Commit Graph

2750 Commits

Author SHA1 Message Date
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 423d312b77 feat(store): @mentions enqueue a push notification (TASK-3946) (#2272)
* feat(store): @mentions enqueue a push notification (TASK-3946)

A comment create or edit enqueues a 'mention' notification for each user
the comment_mentions index newly records, keyed mention:<comment>:<user>.
Never on a tombstone, import, cross-workspace copy or the backfill: the
reindex returns the new users and only the create and edit sites enqueue.

The author is never told. A mentioned user who cannot see the item
(non-member, no-grant guest, restricted member outside the collection)
gets nothing (binding rule 645fab20). On create a mention supersedes the
user's comment/reply row; on edit a written mention deletes the user's
still-pending comment/reply row for the comment, and a row already sent,
skipped or expired stays (ruling f0fc5933).

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

* fix(server): an edit's mentions are from the editor (TASK-3946 r1)

The edit handler passes the request's actor to a new UpdateCommentAs, so a
mention a platform admin adds to another user's comment is from the admin,
the admin's own @-mention is skipped, and the author is told when mentioned
(ruling e6a86278). UpdateComment(id, body) stays for tests and attributes
to the author.

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

* test(appstore,store): review the mention helpers into the fence door (TASK-3946)

The fenced comment create and edit now reach the mention enqueue and its
visibility read. Each newly reachable helper joins
allowedStoreMethodsInsideDoor with what it writes, confirmed by reading
it: only enqueueNotificationsCountTx (the existing INSERT) and
enqueueMentionNotificationsTx (the pending comment/reply supersede DELETE)
write, on the fence's tx. The mention site guard follows the
UpdateComment -> updateComment rename.

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

* docs(store): qualify the reply-wins note; GetUserQ decrypts (TASK-3946 r4)

The one-"reply" rule on enqueueCommentNotificationsTx yields to a mention,
and the fence door's GetUserQ entry names the in-memory TOTP decrypt
(ruling 864f5506).

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP
2026-10-21 10:12:15 -04:00
xarmian 94191b2bdc feat(needs): store reads for importance and simplicity scoring (TASK-3959, part 1a) (#2271)
* feat(needs): store reads for importance and simplicity scoring (TASK-3959, part 1a)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
2026-10-21 08:02:26 -04:00
xarmian d76f06d199 feat(server): push device registration and notification settings API (TASK-3938) (#2269)
* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938)

RegisterPushDevice is one transaction: delete rows on either unique key,
evict the oldest at the 20-device cap, insert. RotateUserSessions mints the
replacement, rebinds the caller's own devices, deletes the other sessions,
and fails closed to revoke-all.

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

* feat(server): push device and notification-settings routes, push_available (TASK-3938)

Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT
/me/notification-settings, push_available on /auth/session,
PAD_PUSH_GATEWAY_URL, and all credential-change rotations through
RotateUserSessions. docs/api.md updated.

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

* test(server): classify GET /me/notification-settings in the compress census (TASK-3938)

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

* fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1)

* fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1)

* test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938)

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

* test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938)

Reuse sessions across rounds and widen the count-to-insert window with a
test-owned slow insert trigger, so removing the user-row lock overshoots
the cap on every run.

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

* docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1)

* fix(server): push_available goes to a signed-in caller only (TASK-3938 r2)
2026-10-21 06:49:54 -04:00
xarmian 104f809e67 feat(store): enqueue push notifications at four store sites (TASK-3937 part B) (#2268)
* feat(store): enqueue push notifications at four store sites (TASK-3937 part B)

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-21 03:31:18 -04:00
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

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

* fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1)

r1 rulings (lead, on BUG-3982):
- F1: docs/api.md says details.token_workspace is absent for a deleted pin.
- F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's
  tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes
  only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one
  statement); any other id is the missing-id 404.
- Q1: an MCP tool mapped to a global route (pad_workspace create) is
  refused for a pinned PAT, tested through wireMCP.

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

* test(e2e): the suite token is unpinned (BUG-3982 CI)

global-setup minted the browser suite's token with a workspace_id on a
stale premise (api_tokens once required one). Under BUG-3982 a pinned
token is refused the global routes the UI calls on every page, so the
E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and
/auth/export. task-3415's deliberate pin is kept.

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

* fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3)

GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter,
but the token route gate read "import-status" as a slug and admitted a
pinned token, which could then read the user's import outcome for any
workspace by key. It joins reorder, import and deleted as a refused
segment.

The route walk skipped every route the gate admits, so it could not
catch a route admitted wrongly. Each admitted route must now sit inside
the workspace subrouter or be in a hand-written expected list, and the
list must match what the gate admits.

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

* fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4)

r4 rulings (lead, on BUG-3982):
- On every door that takes a workspace by name (RequireWorkspaceAccess,
  SSE /events?workspace=, search ?workspace=), a pinned token naming any
  other workspace is refused before the name is resolved
  (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug
  get byte-identical refusals. The resolved-ID compares stay behind it.
- The email-restricted share link and the invitation preview read the
  caller through signedInFor: a token pinned to another workspace counts
  as signed out there.

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

* test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4)

The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant
rerun showed that mutant surviving. This direct test, shaped like
TestBUG3982_WorkspaceAccessComparesThePinByID, holds it.

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

* fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5)

r5 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace refuses a pinned token naming any other
  target_workspace before resolving it, so copy and preflight give a
  missing, non-member and member target the same refusal. The OAuth
  allow-list check, reported after the role check, is unchanged.
- The /-/r/ resolver checks the pin by name before resolving; its
  answer was already the same 404.
- Both join the identical-bytes test; the resolver also gets a by-ID
  test, since its resolved-ID compare is now behind the by-name one.

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

* fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6)

r6 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace compares the pin by resolved ID behind its
  by-name check, like RequireWorkspaceAccess, SSE, the resolver and
  collab, with a by-ID test.
- Collab's resolved-ID compare gets the by-ID test the others have.
- docs/api.md: five BUG-3982 sentences appended without a separator
  now end the sentence before them.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00
xarmian 7c9f29b2f3 fix(ci): Go toolchain 1.26.10 (GO-2026-6809) and markdown-it 14.3.3 (TASK-4007) (#2267)
* fix(ci): Go toolchain 1.26.10 for GO-2026-6809, markdown-it 14.3.3 for npm audit (TASK-4007)

Main CI is red on both gates.
- govulncheck: GO-2026-6809 (crypto/x509) is fixed in go1.26.10. The
  go.mod toolchain line and the Dockerfile's golang image move to
  1.26.10. The workflow comments that name the toolchain value follow
  it, and go.mod's comment records the raise.
- Nix: its artifact is built with nixos-26.05's go, which predates
  1.26.10, so GO-2026-6809 joins nix/accepted-advisories.txt the way the
  1.26.9 block did, with the same removal condition.
- npm audit: markdown-it up to 14.3.2 has two high quadratic-time DoS
  advisories (GHSA-vfgh-jcmw-g37q, GHSA-gfr2-rv44-r23c). The overrides
  pin goes ^14.3.0 -> ^14.3.3; the lock moves markdown-it 14.3.2 ->
  14.3.3 and nothing else.

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

* ci(deps): allow markdown-it 14.3.3 under the 7-day age check, a security fix (TASK-4007)

Dependency age (advisory) flagged only markdown-it@14.3.3, published
2026-10-16: the fix for GHSA-vfgh-jcmw-g37q and GHSA-gfr2-rv44-r23c.
pad-merge-pr requires every check at the head to be SUCCESS, so the
advisory red blocked the merge. A security fix cannot wait out the
window. The allow line is the file's own remedy for this case; its
removal after the version ages past 7 days is a filed follow-up.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-21 00:07:12 -04:00
xarmian 9af942a169 feat(store): push notification schema and settings store (TASK-3937 part A) (#2265)
* feat(store): push notification schema and settings store (TASK-3937 part A)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-20 19:06:53 -04:00
xarmian 9d563aaf09 fix(store): collection settings must be a JSON object at every write door (BUG-3985) (#2260)
* fix(store): the collection store doors refuse non-object settings (BUG-3985)

The HTTP decoder already refuses collection settings that are not a JSON
object (IDEA-1488). CreateCollection (via createCollectionTx, which app
provisioning also uses) and UpdateCollection did not, so a Go caller that
never passes through the decoder could store [], "x", 7, null or non-JSON
text. Both now refuse such a value with models.ErrSettingsNotObject
(introduced by BUG-3976) before writing; "" stays the empty sentinel
written as "{}".

Workspace import is unchanged: it already repairs these values to "{}"
(coerceJSONForImport), the IDEA-1488 log-and-coerce posture for restoring
data. No current internal caller writes a non-object value.

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

* fix(server): whitespace-only collection settings are refused over HTTP too (BUG-3985)

flexJSONToString passes a whitespace-only settings string through as the
"" sentinel, so it reached the store. Before the store doors refused it, a
PATCH with {"settings":" "} replaced the stored settings with " " (invalid
JSON on SQLite); a create repaired it to an object. Both now answer 400
validation_error with ErrSettingsNotObject and write nothing; the new HTTP
test pins both doors.

Comments that called the create arm unreachable over HTTP, or named
workspace import as a source of non-object stored settings (import repairs
them to {}), are corrected.

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

* fix(models): whitespace-only collection settings decode as the "" sentinel (BUG-3985)

The previous commit let the store refuse whitespace-only settings, which
turned a create that used to answer 201 (repaired to an object) into a 400.
Instead, CollectionCreate and CollectionUpdate now read a whitespace-only
settings string as "", so create keeps its repair and a PATCH behaves
exactly as "" does: the settings become {} with tracks_work and
awaits_human carried. The store refusal stays as defense in depth.

Settings only. The other fields decoded by flexJSONToString (traits, item
fields and tags, view config) are BUG-3992.

The HTTP tests pin "" and two whitespace shapes on both doors, and the
create handler's comment is restored to "unreachable over HTTP" with the
reason updated.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 18:11:13 -04:00
xarmian 0e051112e5 fix(cli): 2fa disable exits non-zero when the rotated session cannot be saved (BUG-3882) (#2264)
* fix(cli): 2fa disable exits non-zero when the rotated session cannot be saved (BUG-3882)

The disable revokes this CLI's session and hands back a replacement. When
saving it failed, the command warned and exited 0, so a script carried on
with a dead token. It now keeps the warning and returns an error that points
at 'pad auth login'.

No other CLI path saves a rotated token: every other credentials write
already returns its error.

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

* fix(cli): 2fa disable also fails when the server sends no new session (BUG-3882)

The disable has revoked this CLI's session either way, so a 200 without a
replacement token leaves it signed out just as a failed save does. Exit
non-zero there too, pointing at 'pad auth login'.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 17:02:41 -04:00
xarmian bae3601a76 feat(web): the workspace import toasts the unique values it dropped (TASK-3842) (#2259)
* feat(web): the workspace import toasts the unique values it dropped (TASK-3842)

The import's 201 already carries dropped_unique_values (BUG-3825) and the
CLI prints each one, but the web Create Workspace import showed nothing.
It now shows one held toast after an import: a lead sentence, then one
line per drop in the CLI's shape,
  dropped: <item>: <key> "<value>" is taken by <holder>
capped at 10 lines with "and N more".

The toast keeps line breaks (white-space: pre-line), so a server-supplied
string must not carry one: the value is JSON-quoted (the CLI's %q) and a
control character in the item, key or holder is escaped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* docs(web): importSummary's quoting comment says JSON, not %q (TASK-3842)

r1 FIX, comment only: the value is JSON-quoted. That matches the CLI's
%q for ASCII, but not for every non-ASCII rune (U+2028, for example).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* fix(web): import drop lines escape every unsafe class and clip each field (TASK-3842)

r2 class fix:
- Every field, the value included, has its Cc, Cf, Zl and Zp characters
  replaced with \uXXXX. That covers newlines, U+0085, bidi overrides
  such as U+202E, U+2028 and zero-width U+200B. The value is
  JSON-quoted first, because JSON.stringify leaves Cf, Zl and Zp as
  they are.
- Each raw field is clipped to 120 code points plus "…" before it is
  quoted or escaped, so a cut never splits an escape or a surrogate
  pair.

Tests cover U+202E, U+2028, U+0085 and U+200B in all four fields, the
clip and its exact limit, a cut on either side of an escapable
character, and a cut on a surrogate pair.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 16:00:34 -04:00
xarmian 24833331ff test(links): mention linearity tests compare n with 4n, not an absolute bound (BUG-3995) (#2262)
* test(links): mention linearity tests compare n with 4n, not an absolute bound (BUG-3995)

TestExtractMentionsLinearAfterCodeSpans failed CI under -race at 3.08 s
against its 3 s bound (#2259). Both mention linearity tests, it and its
sibling TestExtractMentionsLinearOnSlashRuns, now share
assertMentionsLinear: best of five runs at n and 4n, each after a
runtime.GC(), and the ratio must stay under 8x (about 4x when linear,
about 16x when quadratic). A 30 s per-run backstop stops a regressed scan
from running the suite for minutes. No absolute bound remains.

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

* test(links): the mention linearity helper's comment bounds its claim (BUG-3995 r1)

It said a slow runner "cannot fail" the check and that the backstop stops
a regressed scan "running the suite for minutes". The 30 s per-run
backstop is absolute, and a regressed run measured 1.5 to 3 minutes before
failing. The comment now says what is true: the ratio is independent of
runner speed, and the backstop is about 16x the slowest linear run measured.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 15:58:18 -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 a16d76f9d5 fix(server): a typed settings member over non-object stored settings answers 400 (BUG-3976) (#2256)
PATCH /collections/{slug} with tracks_work or awaits_human merges into the
stored settings. When those are not a JSON object (import, a hand edit, an
old row), the merge failed with a plain error and answered 500. It now
wraps models.ErrSettingsNotObject, and both collection handlers map it to
400 validation_error with the same text. A full settings write still
repairs the row.

Over HTTP only the flags-only update can reach it: the request decoder
already refuses incoming settings that are not an object (IDEA-1488), so
the create mapping is defensive and commented as such. The tests pin the
decoder's 400 for those doors so they cannot regress to a 500.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 15:07:49 -04:00
xarmian 164768dab8 fix(server): an invite with an unknown role answers 400, not 500 (BUG-3861) (#2255)
* fix(server): an invite with an unknown role answers 400, not 500 (BUG-3861)

The invite door passed any role straight to CreateInvitation, so a value
outside owner/editor/viewer reached the invitations CHECK constraint and
answered 500. It now answers the role-change door's 400 ("role must be
owner, editor or viewer") and writes nothing. Both doors that take a
workspace role from the caller call one check, requireKnownWorkspaceRole,
so the answer cannot drift between them.

Population: those two are the only doors that take a workspace role from
request input (non-test Go under internal/ and cmd/); every other writer
passes the literal "owner".

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

* fix(server): check the invite role after the door's existing refusals (BUG-3861 r1)

Every answer the invite door gave before is unchanged; the only one that
changes is the old 500. Pinned by a setup-window case with a bad role.

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

* fix(server): check the invite role just before CreateInvitation (BUG-3861 r2)

The check ran ahead of the already-member 409, so a bad role turned it
into a 400. It now follows every refusal the handler makes; only the store's
own pre-insert refusals (disabled inviter, workspace deleted mid-request)
lose to it. Pinned by an already-member case with a bad role.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 14:30:33 -04:00
xarmian 393d986ea9 fix(auth): a browser-approved CLI token gets its own lifetime (TASK-3975) (#2252)
* fix(auth): a browser-approved CLI token gets its own lifetime (TASK-3975)

The CLI token minted by /auth/cli/sessions/{code}/approve was dated by the
approving web session's created_at, so approving from an 85-day-old session
left the token a 5-day window under the 90-day cap. It is now minted with no
issuedAt: created_at is the approval, 30-day sliding window, 90-day cap from
approval. No TTL lengths change.

The widening is accepted: a stolen near-expiry web session can now mint a CLI
token good for up to 90 days from approval. Step-up-on-approve is filed as its
own idea.

created_at doubled as the sign-in recency anchor for confirm-only account
delete (BUG-3336), so dating the CLI token from approval would have made it
read as a fresh sign-in. CLI-kind sessions can therefore no longer satisfy
confirm-only delete recency (403 reauth_required): approach (b), which keeps
the BUG-3336 property with no migration. Passwordless accounts confirm from
the web. The credential-change rotation keeps the replaced session's
created_at (pinned by a new test); its 7d-vs-30d TTL is BUG-3978, untouched.

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

* fix(auth): only a browser session approves a CLI sign-in (TASK-3975 r2)

With the approval now minting the CLI token with its own lifetime, a
CLI-kind session that could approve could mint its own successor: a CLI
bearer creates a pending code with POST /auth/cli/sessions, approves it
with itself, and receives a new token with created_at = now and a fresh
90-day cap. Repeated before each cap, that chain keeps a CLI credential
(or a stolen one) alive forever. Before this PR the approval inherited
the approver's created_at, so the chain stayed bounded by the original
sign-in. Reproduced on the previous head: an 85-day-old CLI bearer got
200 and a session aged 0s.

The accepted widening is ONE hop from a web session; the chain never
was. /approve now refuses any approver that is not a web-kind session
with 403 session_required, before anything is minted. The web client
approves on its cookie and is the only caller. docs/api.md says so.

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

* test(auth): the rotation cap pin starts from a 7-day web session (TASK-3975)

#2254 (BUG-3978) makes a credential-change rotation keep the replaced
session's own sliding window. TestTASK3975_RotationKeepsParentCap
rotated the 24-hour session oauthOnlyCloud mints, so after that change
the 24-hour window bound before the 90-day cap it exists to pin, and it
failed on the rebased tree (expiry now+24h, want now+5d). The parent is
now a 7-day web session, so the cap five days away is what binds.
Test-only.

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

* docs(api): who may approve a CLI sign-in, by credential (TASK-3975 r4)

A CLI-kind bearer gets 403 session_required, a CLI-kind session sent as
a cookie is no credential (BUG-3350) and gets 401, and an API token gets
403. The sentence said any CLI-kind session got 403, and called the
web-kind check "browser" though a web session sent as a bearer passes.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-20 13:30:31 -04:00
xarmian 27bc097f5a fix(store): refuse writes to read cursors and app installs after migrate-to-pg, and classify every table (BUG-3958) (#2257)
* fix(store): refuse writes to read cursors and app installs after migrate-to-pg, and classify every table (BUG-3958)

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 12:58:18 -04:00
xarmian adbbb89a21 fix(links): wiki-link code-span check walks the spans once per call (BUG-3968) (#2261)
* fix(links): wiki-link code-span check walks the spans once per call (BUG-3968)

ExtractWikiLinks checked every [[...]] match against the fenced and inline
code ranges with isInRanges, which scans from the first range each time, so
a body of many inline code spans followed by many links was quadratic: a
1.6 MB body of that shape took 54 s. It now uses the rangeCursor that
ExtractMentions got in f4af9889e. Matches arrive in increasing order and
both range lists are sorted and disjoint, so the check is O(1) amortised.
isInRanges has no other caller and is removed.

Benchmark, n code spans then n links, ns/op before -> after:
n=10000 ~155M -> ~9M; n=20000 ~650M -> ~22M; n=40000 ~3.2G -> ~43M.

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

* test(links): the wiki-link linearity test compares n with 4n, not an absolute bound (BUG-3968)

An absolute time bound is the shape that flaked on CI for the mentions
twin under -race (BUG-3995). The test now takes the best of three runs at
n and 4n and requires the ratio to stay under 8x: about 4x when linear,
about 16x when quadratic. A 30 s per-run backstop stops a regressed scan
from running the suite for minutes.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 12:42:49 -04:00
xarmian 096bde6954 feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980) (#2258)
* feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980)

Polish from the Connected Apps walkthrough, items 1-6:

1. Console > Admin > Settings gains an Apps section on the existing
   GET/PUT /api/v1/admin/apps (draft-then-Save, read-only on Cloud); the
   Apps tab's off-state copy now names it.
2. The install preview drops the stale "will activate when this server
   supports them" notice: events and item actions are live.
3. An item an app's bot created reads "Created ... by <app name>" instead
   of "by agent"; a person acting through an app keeps "by <person> via
   <app>". created_by storage is unchanged.
4. An install's webhook status is derived as "paused" while the install
   is not active, matching the delivery gate, instead of reading "active".
5. The context-code redeem route is /api/app/v1/context/redeem in both
   code comments that named it under /workspaces/{ws}.
6. docs/apps.md: a developer guide to the manifest, install and redeem,
   tokens, the app API, webhooks, item actions and lifecycle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* docs(apps): r1 FIX-OR-CITE on the four untraced areas (TASK-3980)

Every claim in webhook delivery, install refusals, manifest limits and
comment author_kind is now backed by a file:line or corrected:

- The text limits (title, publisher, description, name, label, path)
  are bytes, not characters.
- id and slug say their start and end rules.
- Reserved slugs refuse with 422 invalid_manifest.
- Unknown keys are refused in the manifest and collection schemas, but
  not inside the free-form config_schema.
- Confirm documents 409 install_review_mismatch.
- Redeem documents its limits: 10 a minute per address, 5 per install.
- author_kind says when each value occurs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* docs(apps): r2 Lows and FIX-OR-CITE on the OAuth paragraphs (TASK-3980)

r2 Lows:
- Apps off: the apps list answers 200 available:false, and every other
  app route answers 404.
- Preview failures add invalid_base_url 400, artifact_fetch_failed 502
  and artifact_destination_missing 422.
- paused takes precedence over the held notice. Enable restores the
  hook's earlier status. Only rotate takes the secret away.

FIX-OR-CITE on delegated consent and OAuth (24 claims):
- Do not discover the token endpoint from the well-known document. It is
  gated on MCP OAuth and describes MCP clients.
- Only reads are re-validated before send; writes and downloads re-check
  at their own points.
- Name the three OAuth doors that stay open with MCP off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:29:59 -04:00
xarmian 2655ad5d60 fix(auth): a credential-change rotation keeps the session's own sliding window (BUG-3978) (#2254)
* fix(auth): a credential-change rotation keeps the session's own sliding window (BUG-3978)

rotateSessionsAfterCredentialChange re-minted every replacement session
with the 7-day web TTL, so a password or 2FA change made from a 30-day
browser-approved CLI token left the CLI with a 7-day one.

The replacement now keeps the replaced session's own renew_ttl_seconds,
the way it already keeps its created_at (BUG-3336). store.SessionInfo
carries it as RenewTTL, read in ValidateSession's existing query. A
legacy row with no window falls back to the web TTL, as before.

A rotation changes no session's lifetime, so nothing is lengthened: a
browser-approved CLI token stays 30 days, a `pad auth login -i` session
stays 7 days, and a web session stays 7 days. The 7-day login -i window
versus the 30-day cliSessionTTL constant is BUG-3983, for a separate
ruling.

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

* fix(auth): a rotated web session's cookies carry its own window (BUG-3978 r1)

The rotation kept the session's window in the database but still set
the session and CSRF cookies to the 7-day web TTL. A browser bootstrap
from /setup mints a 30-day web session (createAuthSession with
cliSessionTTL), so after a credential change its cookies would have
expired at 7 days while the session ran to 30. Both max-ages now come
from the rotation's ttl, as createAuthSession sets them.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-20 07:32:16 -04:00
xarmian 07215d4953 fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970) (#2249)
* fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970)

Dave: "the plugin should not be doing anything when a session is started."
session.start now only registers /pad-pane and restores the pane. It no
longer runs `pad session register` / `should-arm`, no longer spawns
pad-monitor.sh, and no longer starts the 5s refresh timer, which now runs
only while the pane is open.

wake() runs once per session, on the first pad Bash command, a Pad MCP tool
call, or opening the pane. The first pad command waits for registration
(never for should-arm), because register mints the worker id that leases key
on. A .pad.toml push.auto_arm opt-in is therefore read at first use.

Tests: session start runs no process and asks for no timer; non-pad Bash
wakes nothing; wake runs once; an MCP tool wakes it; registration happens
before the first pad command runs. Prose swept (skill, monitor script,
CLAUDE.md, registry comments); docs/plugin-releases.md gets the stuck-0.3.x
remedy.

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

* fix(plugin): a pad command inside a shell string or after an assignment wakes the session (TASK-3970)

Hunter r1 F1: `bash -c "pad ..."`, `sh -c 'pad ...'` and `X=... pad` missed
the first-use match, so that call ran before presence was recorded. The
class now takes quotes and `=`; a false positive only wakes early.

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

* fix(plugin): pad as a whole word, checked against a table of shapes (TASK-3970)

Round r2 found two more missed shapes (Codex: `\pad ...`; hunter: a bare
`pad` before a quote, `)` or `;`). Rather than add a third case, the match
is now `pad` as a whole word, not inside a longer name. watch.test.ts
tables 25 shapes that run pad and 8 that do not, and asserts that
registration ran before each matching command reached the tool
(Codex r2 note).

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

* fix(plugin): a Pad MCP tool wakes the session whatever the server is called (TASK-3970)

Codex r3: the matcher required "pad" in the server part of the tool name,
so a Pad server registered under another name (work-tracker) was missed.
It now matches any mcp__<server>__pad_* tool. The tests table Pad tools
under four server names and four tools that are not Pad's, and add ./pad
and ../bin/pad to the Bash shapes.

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

* fix(plugin): the pane refreshes after a Pad MCP edit under any server name (TASK-3970)

Hunter r4: the pane-refresh matcher still required "pad" in the server
name, so it disagreed with the wake matcher fixed in 25c39f2aa. Both now
take any server. The wake matcher also allows digits in the tool part.
New pane test.

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

* docs(plugin): the file header describes session start and wake (TASK-3970)

Codex r5 nit: the header still said the mod only listens until /pad-pane
runs. Comment-only.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 00:26:16 -04:00
xarmian 1e9b87759e feat(store): @mention index on comments (TASK-3917) (#2243)
* feat(store): comment_mentions index, parser, backfill (TASK-3917)

Index of which humans a live comment @-mentions, written in the comment's
transaction at every writer (create, edit, tombstone, fenced app doors,
import), with a startup backfill. Migrations 154 (sqlite) and 123 (pg).

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

* test(appstore): allow-list reindexCommentMentionsTx in the fence boundary (TASK-3917)

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

* test(appstore): the comment write census permits comment_mentions (TASK-3917)

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

* fix(store): comment mention index r1 fixes: skip imported, cap, parser rules, created_at, guard (TASK-3917 r1)

F1 imported comments are never indexed (import, edit, backfill); F2 cap of
50 names; F3 parser "/" "\" "_" "." non-ASCII rules; F4 guard rewritten with
per-decl statement counts over internal/ and cmd/; F5 created_at follows the
comment (edit time for a newly added mention); F6 FOR KEY SHARE on PG;
F8 backfill logs per-comment failures.

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

* test(store): pin the backfill created_at against an aged comment (TASK-3917 r1)

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

* fix(links,store): follower and prefix rules, cap after resolution, guard ignores comment lines (TASK-3917 r2)

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

* test(links),docs(store): mid-word "_" prefix row; F6 skip comment names what reopens it (TASK-3917 r2)

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

* fix(links): linear URL check for "/" before "@"; store docs and guard test (TASK-3917 r3)

r3 F1: the "/" prefix rule walked back to the previous whitespace for every
match, so "/@a" repeated was quadratic (240 KB took ~10 s, in the comment
write transaction). A forward scan now tracks the run start and the last
"://" once per call. F2: the reindex doc no longer says ONE query. F3: the
guard test legs are isolated. F4: callsReindex documents its fail-open cases.

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

* fix(links): mention code-span check walks the spans once per call (TASK-3917 r4)

isInRanges scanned every code range for every match, so many inline code
spans followed by many mentions was quadratic (360 KB took ~1.1 s). A cursor
over the sorted, disjoint ranges advances with the increasing match position.
ExtractWikiLinks has the same shape and is left for its own item.

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

* test(links): a row where the code-span cursor skips several spans at once (TASK-3917 r5)

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

* docs(links): CJK letter after a mention is a documented limit (TASK-3917 r3 F5, ruling 6250d6ec)

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-19 23:27:16 -04:00
xarmian ce2ca4fcab feat(web): quick add gains Cancel and 'Add and open' buttons (TASK-3972) (#2247)
* feat(web): quick add gains Cancel and 'Add and open' buttons (TASK-3972)

The sidebar quick-add dialog shows Cancel, Add and open, and Add, in the
mobile capture sheet's order. Cancel calls cancelQuickAdd and Add and open
calls submitQuickAdd(true), the same paths Esc and Cmd/Ctrl+Enter take, so
item creation is unchanged. Plain Add still stays in place (TASK-3590).

Each button gets its own class. The two tests that took the first
.quick-add-btn to mean Add now select .quick-add-submit, since the first
match is now Cancel, which is always enabled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* fix(web): the quick-add footer wraps instead of overflowing at 320px (TASK-3972 r1)

r1 found the three buttons ran past the dialog's edge on a narrow screen:
.quick-add-actions did not wrap and .quick-add-buttons would not shrink.
Both now wrap, the buttons stay right-aligned, and labels never break.
Add gets a transparent 1px border, so it is as tall as the bordered
buttons when it sits alone on a row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:52:48 -04:00
xarmian 1bbca0753f fix(web): toasts with an action or link stay ~10s (TASK-3973) (#2245)
A toast that offers an action button or a link now defaults to
ACTIONABLE_DURATION (10s, the same as an error) when the caller passes no
duration, so quick add's "Open" is still there when a person reaches for
it. An explicit duration still wins. Hover and focus still pause the clock.


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

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:48:18 -04:00
xarmian 5eeca6f730 feat(web): the side pane can widen to 90% of the usable width (TASK-3971) (#2246)
paneBounds() caps the pane at 90% of the usable container width instead of
720px, and the list keeps the other 10% in place of its 360px floor. The
default width (the CSS clamp and its JS mirror) is unchanged, and so is the
relaxed pane floor on a cramped container. aria-valuemax follows the new
bound.


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

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:45:40 -04:00
xarmian 94c6d3cc52 fix(brand): the favicon's gaps tighten to match the mark (TASK-3974) (#2248)
pad-mark-small moves from 4px tiles with 2px gaps (gap/tile 0.50) to 4px
tiles with 1px gaps and a 1px inset (0.25), the grid pad-favicon-tile
already used. It stays pixel-aligned: the master's own 0.19 renders its
sub-pixel gaps as smeared half-tones at 16px 1x in Chromium and WebKit,
and this grid stays crisp at 1x, 2x and 3x. icon.svg, favicon-16/32 and
favicon.ico (16/32/48) are re-exported with export.mjs; the outputs of
unchanged masters came out byte-identical, so the export reproduces the
committed set. docs/brand.md records the change and why.


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

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:32:39 -04:00
xarmian 16aaaf5450 feat(collections): awaits_human collection setting (TASK-3919) (#2244)
* feat(collections): awaits_human collection setting (TASK-3919)

A collection whose items are asks of a person (IDEA-3906): the needs-you
router will treat an open, assigned item in it as "asked". Stored as
settings.awaits_human like tracks_work: absent means false, a settings write
that omits it keeps the stored value, a typed member on create and update
sets it alone, and a non-boolean is refused 400. No migration (A3 default).

- models: CollectionAwaitsHuman[JSON], Carry/Validate/Set, on shared bool
  setting helpers that the tracks_work functions now use too
- store: create and update apply it; update carries it under the row lock
- CLI: pad collection update --awaits-human[=false], checked on the response
- web: "Items are asks of a person" toggle on the editor's Display tab
- docs/api.md

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

* fix(collections): read awaits_human per key, not through CollectionSettings (TASK-3919)

A plain bool field made a stored non-boolean awaits_human (an import is not
validated) fail the whole CollectionSettings unmarshal, so a reference
collection read as work. CollectionAwaitsHumanJSON now reads the one key.
Also corrects the --awaits-human comment: the flag shape is not MCP-safe.

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

* test(store): assert the returned settings after turning awaits_human off (TASK-3919)

staticcheck SA4006: the UpdateCollection result was assigned and never read.

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

* fix(cli): pad collection create takes --awaits-human (TASK-3919)

Codex r2 P2: only `collection update` had the flag, so marking a
human-tasks collection took a second call. create now sends the typed
member and, like update, refuses to report success when an older server
ignored it.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-19 19:58:46 -04:00
xarmian e598bc4f64 feat(decision): effort, effort_medium and high_stakes on the attention set, staged; drain-paced backfill (TASK-3921) (#2236)
* feat(decision): effort, effort_medium and high_stakes on the attention set, staged; one-shot backfill (TASK-3921)

- attention set gains effort (Score), effort_medium (Choice) and high_stakes
  (Noul) in the same call; needs_human_decision's text is unchanged.
- QuestionSet.Unsurfaced names the staged keys: WorkspaceFlags and
  SurfacedDecisions skip them (no flag, no state read, no counter); the
  decisions list and endpoint still return every row.
- attention chips only render keys that have a label.
- store: per-set enqueue body extracted (owedDecisionJobTx), OweDecisionJobs
  (chunked, under the installed debounce) and ItemsMissingDecisionKey.
- Runner.BackfillEffort owes the attention set once for eligible items with a
  needs_human_decision answer and no effort answer, marker
  effort_decisions_backfilled written after every chunk; hooked at the end of
  reconfigureDecisionsLocked (boot and later admin enable); makes no provider call.
- eval harness: titlebody now sends the live set; RESULTS.md rows pending.
- docs/api.md and tests updated.

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

* test(decision-eval): keep titlebody as the day-73 control, add titlebody-live; record the TASK-3957 and TASK-3921 runs

- titlebody again sends the day-73 pair (needs_human_decision + blocked), so it
  stays comparable with its 09-22 row as the model-drift control.
- titlebody-live sends the live attention set on the same {title, body} state:
  the TASK-3921 gate (needs_human_decision AUC 0.936, within .02 of 0.939).
- Every run prints the question keys it sends, before -dry too, so a row's
  call shape is in its own log.
- RESULTS.md: rows for the one-question call (TASK-3957) and the
  four-question call (TASK-3921).

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

* fix(decision): effort backfill lifecycle, staged keys off item reads, dry-run guard (TASK-3921; #r1 F1-F4)

- F1: startEffortBackfill checks stopped and does bg.Add under one
  decisionTick.mu hold; BackfillEffort/EffortBackfillCandidates take a ctx
  checked before each candidate read and each chunk; cancel returns ctx.Err()
  and never writes the marker. stopDecisionTick cancels the run.
- F2: reconfigure cancels a running backfill; only a non-nil runner starts a
  new one. A per-run generation replaces the atomic latch so a cancelled run
  cannot clobber its successor.
- F3: item show / --agent / MCP item get use Runner.ItemReadDecisions, which
  drops the set's Unsurfaced keys; the decisions endpoint stays raw. docs/api.md
  updated.
- F4: the file dry run refuses a path with a -wal/-shm sibling or under
  $HOME/.pad (refuseLiveDatabase, tested without the env var).

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

* test(decision): a chunk write that fails in the store writes no marker (TASK-3921; mutant M6)

The two failure tests fail the per-chunk test hook, so the path where
OweDecisionJobs itself returns an error was never exercised: a mutant that
ignores that error and marks anyway survived. The new test breaks the
decision_jobs table from the hook so the error comes back through the store,
asserts no marker, then restores the table and re-runs to completion.

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

* refactor(decision): drop the pre-marker cancel check; a cancel after the last chunk may mark (TASK-3921; M5, lead ruling)

Mutant M5 (that check removed) survived every test, and could not do
otherwise: a cancel can land there only after every chunk was owed, and the
marker means every candidate was ENQUEUED, not answered, so marking then is
true. The check is removed rather than kept as a guard no instrument can
turn red. The doc comments on BackfillEffort, cancelEffortBackfill and
startEffortBackfill now say a cancel before the last chunk writes no
marker and one after it may.

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

* docs(api): a backfill chunk in flight when decisions are disabled may still commit (TASK-3921; r2 L1, lead ruling bdce2116)

docs/api.md said that with no provider "no job row is ever written". That is
false for one window: a disable that lands while the one-shot effort
backfill is inside a chunk's write lets that chunk commit, and its jobs wait
until a provider returns. Ruled docs-only. The disable test's comment now
says it covers a backfill parked before its first chunk, not that window.

Sweep for the claim (CONVE-23): grep of docs, internal, cmd, scripts and
skills (.go, .md) for "no job row", "job row is", "no job is", "no jobs
are written/owed/enqueued" and "never written/owed/enqueued". Two hits are
about this mechanism: api.md:72 (fixed) and the test comment (narrowed).
Unchanged, still true:
- store/decisions.go:27 is about the WRITE DOORS, which still enqueue
  nothing without a resolver.
- runner_test.go:238 is about the nil-provider runner, which never starts a
  backfill.
- effort_backfill_lifecycle_test.go:128 covers a run parked before its
  first chunk.
The other hits are unrelated mechanisms.

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

* fix(decision): pace the effort backfill on drain; dry run opens a copy; docs eligibility (TASK-3921; r3 M1, L2, L4)

- M1: the backfill owes one tick pass of items per chunk (20 default, wired
  from the tick limit) and owes chunk k+1 only once chunk k's own jobs have
  drained: none claimable now, by ClaimDecisionJobs' own predicate
  (new Store.CountClaimableDecisionJobs). Debounced jobs do not hold it.
  The wait polls at the tick interval, honours ctx, and a cancel writes
  no marker; the marker still follows the last chunk being owed.
- L2: TestEffortBackfillDryRunOnFile copies the named file under GOTMPDIR
  and opens only the copy.
- L4: docs/api.md states the real eligibility and the drain pacing.

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

* docs(decision): the Unsurfaced comment names ItemReadDecisions (TASK-3921; r4 L2)

The field's comment listed WorkspaceFlags and SurfacedDecisions as the
readers that skip staged keys, but not ItemReadDecisions, which item show
(and so --agent and MCP item get) has used since the r1 F3 fix. Comment only.

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

* test(decision): break the table before the LAST chunk so the store-failure test pins M6 (TASK-3921; lead ruling)

Since the drain pacing (51c545850), the store-failure test broke the
decision_jobs table before chunk 1 of 3. Under mutant M6 (a failed chunk
write ignored) the drain wait that follows read the same broken table and
failed, so no marker was written by a different mechanism and the test stayed
green: an end-state assertion another path also produced (CONVE-12). Breaking
the table before the last chunk leaves no drain wait after it (the marker does
not wait for the last chunk to drain), so only the chunk's own error check can
keep the marker unwritten.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 13:05:05 -04:00
xarmian ffc11387e1 feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617) (#2228)
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)

Store, REST and the read-time judgment for per-criterion verdicts:
- item_verifications (SQLite 150, PG 120), append-only, with the NUL
  census classified and trigger file 151 generated.
- internal/verification: criterion hash (whitespace-collapsed), the
  removed / changed / superseded / current state, and the advisory
  possibly_stale hint from a linked PR merged after the verdict.
- POST/GET /items/{ref}/verifications: atomic batch, seq-guarded so a
  criterion hash is stored against the body it came from, via resolved
  in-workspace and visibility-checked, activity "verified", item_updated
  SSE.
- github_pr carries merge_sha / merged_at, fetched by pad github link
  and reconcile.

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

* test(verify): router tests, body-order sort, compress census, CLI merge fields (TASK-3617)

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

* test(verify): close mutant holes M5 (re-spacing) and M8 (merged before the verdict); placeholder folds into missing (TASK-3617)

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

* docs(api): the verifications endpoint (TASK-3617)

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

* fix(verify): r1 findings: the retry re-checks access; a moved item is told from a gone via (TASK-3617)

- F1: when the item moves between the criteria read and the write, the
  retry re-runs visibility and edit permission on the fresh item.
- F2: a zero-row insert re-checks the target in the transaction, so a
  Postgres seq race retries (or 404s) instead of answering unknown_via.
- F3: verificationBeforeInsertHook test seam; tests for one move, an
  item that keeps moving (409), and a grant revoked mid-retry (404).

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

* fix(verify): r2 L1, the retry re-resolves via; gate findings (TASK-3617)

- The retry re-runs the via resolution and its visibility check, the same
  re-check F1 added for the item; TestTASK3617_RetryRechecksVia.
- The state-history test asserted by row position, and one batch's rows
  share a created_at, so their order is by random id: assert by criterion.
- nulscan.go's protected-column count (146: 27 JSON, 119 text).

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

* test(store): the scan-cost pin follows the list, 146 columns (TASK-3617)

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

* chore(verify): drop the comment resolveVia's doc comment superseded (TASK-3617 r3)

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

* fix(verify): r4, verifications travel with a workspace export/import (TASK-3617)

- F1: VerificationExport, exported for the bundle's live items and
  imported with the item and via ids remapped, every other value
  verbatim. Each bundle row is checked before its insert (a refused
  statement on Postgres loses the whole import); a row that fails, or
  whose item did not import, is dropped and counted. Tests: a SQLite round
  trip, the migrate-to-pg path (SQLite export into a Postgres import with
  the slug preserved), and a hostile bundle on both backends.
- F2: docs/api.md says a criterion repeated in one request is
  validation_error, as the code answers.
- Q2: gh's mergedAt zero time is read as not merged.

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

* fix(store): item_verifications is a migrated table (TASK-3617 r4 gate)

The export now carries verifications, so the three lists that name what
`pad db migrate-to-pg` copies must name it too: MigratedTables (the NUL
preflight scans it), its export-section test map, and
migratedRefusalTables (the post-migration write refusal covers it).
Swept by item_reminders across cmd/, internal/ and docs/: those three
are the population.

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

* chore(store): renumber TASK-3617 migrations after #2230 (sqlite 151/152, pg 121)

#2230 took sqlite 150 and pg 120 for decision_jobs.not_before. The
verification table is now sqlite 151 / pg 121, and its NUL trigger file
is 152_nul_invariant_triggers_post151 (regenerated, so the trigger names
carry pad_nul152_). References swept by the old file names and the bare
numbers in this branch's diff.

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

* test(verify): an id that is both a criterion and a placeholder is refused (TASK-3617, lead spot mutant)

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
2026-10-19 10:25:43 -04:00
xarmian eb7f8a200c feat(needs): per-user dismiss/defer storage and the needs/snooze routes (TASK-3918) (#2231)
IDEA-3906 half A, unit A2.

- Migrations 153 (SQLite) and 122 (Postgres) add item_needs_snoozes, one
  row per (user, item), FKs cascading, indexed on item_id.
- A deferral holds until defer_until (an RFC3339 time after now and within
  366 days), whatever changes before then (IDEA-3906 A2). A fractional time
  is stored rounded up to the next whole second, never early.
- A dismissal holds until something happens after its marker: a live
  comment by anyone else newer than seen_comment_at, another such comment
  at that same second (seen_comment_ids), or a later status transition
  (MAX(status_transitions.seq); items.seq and updated_at don't move on a
  comment). The user's own human comments never break it; an agent's
  comments under their credentials do, and so does the user's own status
  change. Imported comments are history and never break it.
- The marker comes from the client, clamped to the server's values so it
  can only claim less was seen. No marker means the current state.
- PUT|DELETE /api/v1/workspaces/{ws}/items/{slug}/needs/snooze, beside
  stars and watches: requireItemVisible (404 when hidden) and no edit
  check, so viewers and item-grant guests may snooze. Read-scoped tokens
  are refused by tokenScopeAllows. DELETE is idempotent.
- nulcolumns classifies the table's TEXT columns as server-written.
- A migrate-to-pg marked SQLite file refuses snooze writes: the table joins
  migratedRefusalTables, since models.WorkspaceExport does not carry it.
  TestMigratedDatabaseRefusesNeedsSnoozeWrites drives the store's own verbs
  rather than iterating the list, so removing the entry goes red.
- Workspace purge relies on the FK cascade; the purge census
  (wsChildTables, seedFullWorkspace) now counts item_needs_snoozes.
- docs/api.md documents the endpoint.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 07:02:00 -04:00
xarmian e432d0f5bc feat(decision): debounce decision re-asks with decision_jobs.not_before (TASK-3916) (#2230)
* feat(decision): debounce decision re-asks with decision_jobs.not_before (TASK-3916)

IDEA-3906 half B, unit B2.

- Migrations 150 (SQLite) and 120 (Postgres) add decision_jobs.not_before
  TEXT. A job is claimable only once not_before is NULL or past, in the one
  decisionJobClaimable predicate both the scan and the claim UPDATE use.
- The enqueue sets not_before to the item's latest evaluation for the set
  plus the set's window, or NULL when there is no window, no prior
  evaluation (a first evaluation is never delayed), or the window is past.
  The arithmetic is in Go on decisionTime TEXT, so both backends agree. On
  conflict COALESCE(old, new) keeps an existing not_before, so a stream of
  comments cannot push the re-ask back.
- A superseded completion RECOMPUTES not_before from the latest evaluation
  rather than setting now+window (ruling ae2e0a90): the mid-run write read
  the evaluations before this run's answers landed. A run that stored
  nothing moves nothing, so it never invents a delay.
- Runner.Install wires Store.SetDecisionDebounce: ReaskDebounce (1h) for the
  item-level sets, none for a PerComment set or an unregistered one.
- The dashboard's attention comment says the pending window can now last
  up to an hour (#2229 r3-D1, ruling ce3e9dc5).
- The tick-interval and release comments no longer promise a job is
  claimable at once: a debounced one waits for its window.
- docs/api.md and README updated.

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

* docs: a debounced burst costs at most one call an hour (TASK-3916; #2230 r1 codex Low)

* docs: the debounce is per question set (TASK-3916; #2230 r2 codex Low)

* fix(store): debounce helpers stay inside the app-fence boundary (TASK-3916; #2230 CI TestAppstoreBoundary)
2026-10-19 04:08:03 -04:00
xarmian b1785f8952 fix(decision): retire text-derived blocked, waiting_on_external and conventions_comments; keep a pending answer on the dashboard (TASK-3915, TASK-3924) (#2229)
* fix(decision): retire text-derived blocked, waiting_on_external and conventions_comments; keep a pending answer on the dashboard (TASK-3915, TASK-3924)

IDEA-3906 half B, units 1 and 3.

- The attention set asks one question, needs_human_decision. The
  text-derived blocked Noul is gone: blocked is the dependency graph's
  alone, and the dashboard no longer appends a "its text says it is
  blocked" entry. waiting_on_external flagged nothing on 10-17 and goes too.
- conventions_comments is no longer registered in production. It took 42%
  of provider calls on 10-17 for 7 chip-level answers. The set definition
  and the PerComment machinery stay, since tests and the conventions eval
  gates use them. The web drops the comment-card chips and their store.
- Stored answers to the retired keys and set need no migration: no
  registered question carries their keys, so they read current=false and
  the brief counts them as stale questions.
- WorkspaceFlags pending tolerance: an above-threshold answer whose item has
  moved on is kept while a job is owed for it with no recorded failure and
  the set still applies. Without that, the dashboard entry vanishes for the
  whole wait for the re-ask, which TASK-3916's debounce will stretch to
  an hour.
- The day-73 titlebody eval control keeps the retired blocked question text
  locally, so it still re-runs that shape.
- docs/api.md and README updated.

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

* test(decision): a claimed re-ask still counts as pending; rewording one question keeps the others (TASK-3915 r1)

r1 hunter Lows, all FIXED: the per-key rewording rule had lost its
coverage with the one-question set, so a two-question test set keeps it;
a claimed-but-in-flight job leg is added to the pending test; the
ProductionRegistry comment's run-on join is split.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 00:44:38 -04:00
xarmian 6f8d43e2eb feat(metrics): signup and first-write funnel metrics; TouchUserWrite reports the first write (TASK-3926) (#2227)
* feat(metrics): signup and first-write funnel metrics; TouchUserWrite reports the first write (TASK-3926)

pad_signups_total{method=email,client=app|web} counts a self-serve or invited
registration once it is kept and signed in; admin-created accounts and the
bootstrap admin are not signups, and OAuth signups are pad-cloud's
(pad_cloud_oauth_signups_total, TASK-3925).

TouchUserWrite now reports first: a conditional UPDATE on last_write_at IS
NULL lets exactly one of any number of concurrent first writes see a row
count of 1, then the old throttled update runs. s.touchUserWrite(r, kind)
replaces the four call sites and counts pad_first_writes_total{kind,client}
plus pad_first_write_age_seconds{client} for a human account created within
30 days. client is agent, then app (PadShell UA), then cli, else web.

PLAN-3908 phase 1, unit 2. No migrations.

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

* fix(metrics): a workspace restore is not a first item write; every call site tested through its handler (TASK-3926 r1)

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-18 23:25:22 -04:00
xarmian a0c7d68aa9 build(web): SvelteKit 3 + adapter-static 4 (TASK-3423 PR B) (#1835)
* wip(web): SvelteKit 3.0.1 + adapter-static 4 with the $lib alias (TASK-3423 PR B, WIP)

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

* wip(web): kit 3 migration fixes - goto reset, page.url.href, $app/env, $app/state, cookie v2 (TASK-3423 PR B)

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

* fix(web): pane Back restores scroll through kit 3's snapshot() (TASK-3423 PR B)

PaneHost read page.state one task after popstate; kit 3 applies a popped
entry after an await, so the saved paneScrollTop was never seen. Kit's own
snapshot() keeps the position per history entry, captures it when the entry
is left, drops it on a replace, and restores only on a traversal after the
entry is applied. The controller's replaceState(paneScrollTop) write, the
getPaneScrollTop dep and readPaneScrollTop are gone. The 9 bug-2182 legs pass.

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

* test(e2e): the back-settle latch opens at the Back's popstate, where kit 3 arms it (TASK-3423 PR B)

Kit 3's popstate handler is async, so afterNavigate (where the settle is
armed) now runs after every popstate listener; measured 2-30ms after the
helper's listener. Not yet run green: the box is held.

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

* fix(web): the pane back-settle waits for an in-flight drill instead of minting the popped-to item (TASK-3423 PR B)

Kit 3 resolves a goto to afterNavigate across tasks, so a drill fired inside
the 140ms window could still be in flight when the settle fired; it minted
the popped-to item first (measured: URL still ?item=A at fire time, 2 of 4
runs). The settle now re-arms while navigating.current is set. Unit tests
cover the defer and the cancelled-navigation fallback; both die without
the defer. e2e not yet re-run on this commit.

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

* fix(web): defer the back-settle on navigating.type, not a nonexistent navigating.current (TASK-3423 PR B)

79175a8bc read navigating.current, which kit 3's $app/state navigating does
not have: undefined !== null held always, so the settle deferred forever.
The R14 drill legs passed for that wrong reason and 432 (a drill AFTER the
window still sees A minted) failed 5/5. navigating.type is null exactly
when no navigation is in flight. Measured at this commit: pane-async-race
373 and 432 and capstone 645, 5 repeats each, 15/15 pass.

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

* fix(web): the list's restore-skip is released by the next navigation, not a microtask (TASK-3423 PR B)

Closing the pane from a scrolled column jumped the list (bug-3165 e2e):
the handoff wrote the anchored position at 52ms, then the scroll restore
applied the list entry's saved offset (top 600) from 82ms on (measured with
a scroll-write probe after Escape). Kit 3 calls snapshot.restore after an
await in its navigate, so the queueMicrotask release ran before the
restore it was meant to skip. The skip now belongs to the navigation that
armed it: its own restore consumes it and the next beforeNavigate releases
any remainder. bug-3165 spec: 6/6 desktop legs pass.

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

* fix(web): editor menus unsubscribe from the editor they subscribed to, not the live prop (TASK-3423 PR B)

version-restore-collab (BUG-2264's e2e) blanked the item page after a
restore under live collab, 3/3 on kit 3: the force-refresh remount ran
EditorBubbleMenu's and EditorLinkPopover's effect cleanups, which read the
live editor prop. That read re-entered Svelte's flush and destroyed the
editor mid-transaction (state_unsafe_mutation from ProseMirror's blur
handler), and a null prop then threw on .off, killing the page (stacks
captured with Error.stackTraceLimit=100). The cleanups now close over the
editor their run subscribed to.

Evidence is the e2e: red 3/3 at 347942e17, green 3/3 (2 tests x 3) with
this change alone. A jsdom unit test could not reproduce it: there Svelte
hands a teardown the pre-teardown prop value, so the old cleanup passed
too, and a test that passes either way was not kept.

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

* fix(web): kit 3 in code main added since PR B: $app/env, goto reset in the library tabs (TASK-3423)

* test(web): re-review the identity-gate rows the rebase onto main re-hashed (TASK-3423)

collection page: quickCreateInColumn, the two pane-follow continuations,
installPaneTestHook, loadUrlFilters and updateUrlFilters. Only kit 3 API swaps
reach them (page.url.href, goto reset:false); no await or fence moved.
library page: the tab-switch goto now restores focus to the tab after the
navigation (kit 3 has no keepFocus); its continuation writes no state and is
tabled.

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

* test(e2e): quick capture's Open lands in the title editor under kit 3 (TASK-3423)

The captured item's URL carries ?new=1, which opens it with its title in
the editor. Under kit 2 the follow-up goto's focus reset closed the editor
at once, so the title read as text; kit 3's reset:false keeps it open
(site 3 of the day-87 ruling). Assert the editor's value instead.

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

* test(e2e): login honours ?redirect= and still refuses off-site targets (TASK-3423)

The login page now reads ?redirect= through $app/state's page, and that
value is validateRedirect's input. Nothing exercised the read: the share
and invite specs check only the link that points at /login. Covers the
form path from a signed-out context, the signed-in forward, and three
off-site shapes (absolute, protocol-relative, encoded slash).

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

* test(e2e): name the encoded-slash case by what the guard receives; off-site on the form path too (TASK-3423 r2)

Codex r2: the encoded-slash case is a value that still holds %2f after
searchParams decodes it once; the label and a comment now say so. Hunter
r2 L1: the form sign-in also gets an off-site target. Hunter r2 L2: the
off-site cases wait on one poll for "this origin, past /login", so an
intermediate URL cannot satisfy half of it.

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

* fix(web): the activity page's URL clear uses kit 3's goto options (TASK-3423)

#2226 landed dropFromUrl with goto's noScroll/keepFocus, which kit 3 removed
(they throw in dev and are ignored in prod), and new URL(page.url) on the
$app/state proxy. reset: false keeps scroll and focus as both options did;
page.url.href gives URL a string.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-18 19:03:13 -04:00
xarmian ed559deb4d feat(web): catch-up card, Mark caught up, and See everything into Activity (TASK-3615, PR B) (#2226)
* wip(web): catch-up card on the workspace home, Mark caught up, and See everything into Activity (TASK-3615, PR B)

The card renders GET /catch-up above Active Work, fenced on identity and
keyed on (user, workspace). Mark caught up PUTs /me/seen and refetches.
See everything opens Activity in Audit with ?since= set to the window's
start, so the activity endpoint's since now also accepts an RFC3339
instant (shape item 5, moved from PR A to PR B by the lead's ruling).

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

* test(e2e): catch-up card flow against the real binary (TASK-3615, PR B)

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

* test(e2e): register the catch-up teammate with the admin session, not its token (TASK-3615, PR B)

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

* fix(web): catch-up r1 fixes: since validation matches Go, loads are ordered, a failed refetch keeps the card, a mounted Activity page applies a linked view (TASK-3615, PR B)

- sinceFromUrl range-checks the fields Go's time.Parse checks instead of
  trusting Date.parse, which rolls 2026-02-30 over and accepts T24:00:00
  (and refused offsets Go takes, up to +-24:60). The table's verdicts are
  pasted from go1.26.
- CatchUpCard numbers its loads, so A->B->A cannot let the first A answer
  paint over the second.
- A mark that lands but whose refetch fails keeps the card and says the mark
  worked, instead of clearing it.
- The Activity page applies a changed ?view= on a mounted page, as it
  already did for ?since=, still without persisting it.
- The e2e waits for a new second before Mark, since the window floor is
  inclusive at second precision.

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

* fix(web): clearing the since chip or Clear filters drops since from the URL (TASK-3615, PR B)

Clearing only filterSince left ?since= in the URL, so a reload re-applied a
cleared filter, and re-following the same link changed no URL value, so the
URL effect never re-ran. Both clear paths now navigate (replacing history)
to the URL without since. The shallow replaceState would leave page.url,
and so the derived since, stale. The route has no load function, so the
navigation re-fetches nothing.

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

* fix(web): a clear whose navigation rejects still drops since from the address bar; the e2e reload leg asserts on the request (TASK-3615, PR B)

r3 findings. clearSince falls back to the shallow replaceState when its goto
rejects, so a reload stays cleared. A superseded goto needs nothing, since
the newer navigation decides the URL. The e2e reload leg now checks the
reloaded feed request carries no since, instead of a chip absence that
passes before a late effect could add one.

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

* fix(web): choosing Live or Audit drops view from the URL, so a reload keeps the choice (TASK-3615, PR B)

r4 Medium, the same class as the since fix: a Live or Audit click cleared
viewFromUrl but left ?view= in the URL, so a reload or a revisit re-applied
the linked view over the user's choice. The page reads two URL values, since
and view, and every path that clears or overrides either now drops it from
the URL through one helper.

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

* fix(server): refuse an activity since whose UTC instant passes year 9999 (TASK-3615, PR B)

r5 finding. An offset can carry 9999-12-31 into year 10000, and the store
compares the cutoff as RFC3339 text, so "10000-..." sorted below every row
and opened the window to everything. The handler now refuses it with 400
invalid_since, and sinceFromUrl mirrors the bound so such a link loads the
unfiltered feed instead of an error. The new rows' verdicts come from a Go
probe of the handler's condition. docs/api.md says so.

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

* test(web): census entry for CatchUpCard's keyed sections each (TASK-3615, PR B)

eachKeyCensus.test.ts requires every keyed each to name why its keys cannot
repeat. CatchUpCard's sections come from a four-entry literal with distinct
ids, only filtered.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
2026-10-18 14:42:40 -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 b13e842740 fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879) (#2222)
* fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879)

A failed tool call answers the /mcp POST with 200 and an error result,
so the audit middleware, which classified only the transport status,
wrote result_status=ok for every refused write. MCPAuditLog now installs
an outcome sink on the request context; a tool-handler middleware on
every MCP server pad builds records a failed result's error code (and
details.reason) into it. permission_denied, auth_required and
rate_limited are denied; any other failure is an error; the code is the
error_kind.

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

* test(mcp): unit rows for the tool-outcome sink, codes and middleware order (BUG-3879)

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

* docs: the MCP audit status is the call's outcome, not the transport's (BUG-3879)

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

* fix(mcp): a JSON-RPC error inside a 200 is audited too, via an OnError hook (BUG-3879 hunter r1 M1)

mcp-go refuses an unknown tool, and answers a missing resource or
prompt, before the tool-handler middleware runs, so those calls were
still audited as ok.

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

* test(mcp): a failed tools/list audits as error; the prose names what OnError cannot see (BUG-3879 r2)

Lead ruling R2 pins the unscoped hook; hunter r2 L1 narrows the claim:
a message rejected before method dispatch never reaches OnError.

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

* test(mcp): a refused write's audit kind carries the reason BUG-3864 added (BUG-3879)

After rebasing onto #2218 (BUG-3864), the 403s for a read-only token's
write and a viewer's write carry details.reason, token_scope and role.
recordToolOutcome writes error_kind as <code>:<reason> whenever a reason
is present, as designed, so the two transport rows now expect
permission_denied:token_scope and permission_denied:role. On the old
base those 403s carried no reason, which is what the rows had pinned.

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

* fix(mcp): an unverified email's refused call is audited as denied (BUG-3879 r4)

Since BUG-3864, a write refused because the account's email is
unverified answers with its own MCP code, email_not_verified, rather
than permission_denied. classifyMCPToolOutcome only knew the older
three refusal codes, so after the rebase onto #2218 that refusal was
audited as error. It is denied now, as the classifier's own comment and
this bug's contract already said. The plan and member limits stay
errors (BUG-3894 owns that policy).

The unit rows that paired permission_denied with an email_not_verified
reason described a payload the server never sends; they now use
token_scope, a reason it does send, plus a row for the real code. A new
transport row drives an unverified owner's write on the Cloud fixture
and requires a denied row. docs/api.md and the metric's Help text name
the code.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR
2026-10-18 11:12:41 -04:00
github-actions[bot] 52d13c070d chore(plugin): the marketplace follows v0.18.1 (TASK-3487) 2026-10-18 13:13:35 +00:00
xarmian d8dedc09b9 chore(nix): bump package version to 0.18.1 ahead of the v0.18.1 tag (#2225)
Claude-Session: https://claude.ai/code/session_01NaDMQGkhe25P5VmgjoXudZ
v0.18.1
2026-10-18 07:53:55 -04:00
xarmian c29519f72b fix(web): Manage Billing reaches the Stripe portal, and only for a billing account (BUG-3897, BUG-3898) (#2224)
* fix(web): Manage Billing is a full navigation to the Stripe portal (BUG-3897)

The Pro "Manage Billing" link points at /billing/portal, a pad-cloud
sidecar path with no SPA route. It lacked data-sveltekit-reload, so
SvelteKit's client router took the click and rendered the catch-all
("no dashboard available"): the request never reached nginx's /billing/
route, and the portal's origin check needs the Referer a full navigation
sends. Every other link into a sidecar path already carries the
attribute (the /auth/github and /auth/google links); the export download
is exempt through its download attribute.

The page test mounts the real billing page as a Pro user and requires
the attribute; on main it fails on exactly that assertion.

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

* fix(web): Manage Billing needs a Stripe customer; a portal refusal shows a message (BUG-3898)

A Pro plan granted by an admin has no Stripe customer, so the portal can
only refuse it, and the user saw pad-cloud's raw JSON. The billing page
now reads stripe_customer_id from GET /auth/me (the session payload does
not carry it) and gives a Pro account without one a one-line note in
place of the button. A customer with no live subscription still gets
the portal, for its invoices (R2). Until /auth/me answers, or if it
fails, the button stays.

pad-cloud's portal now answers a refusal by sending the browser back to
/console/billing?portal_error=<code>. The page shows that code's message
(a generic one for any unknown code, never the code itself), clears the
parameter, and drops it unread in the apps, like a checkout return. The
codes are pinned in $lib/billing/portalError.ts and match pad-cloud's
stripe.go: not_configured, unauthenticated, origin, no_billing_account,
stripe_error.

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

* fix(web): the billing-account read follows a plan that becomes Pro during the visit (BUG-3898 r1)

Review round r1: the stripe_customer_id read ran only if the account was
already Pro at mount, so a checkout return confirmed by polling skipped
the gate. It now runs from an effect, once per visit, as soon as the
account is Pro.

Tests: the page tests now apply history.replaceState instead of only
recording it, so they assert the real URL. A new row covers portal_error
alongside a checkout return (both read, both cleared) and a plan that
becomes Pro mid-visit. The BUG-3897 test mocks /auth/me as a customer
explicitly instead of passing through a swallowed TypeError.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR
2026-10-17 22:45:56 -04:00
xarmian d5177e7a1f fix(server): the account export's store reads are cancelled at its deadline (BUG-3632) (#2223)
* fix(server): the account export's store reads are cancelled at its deadline (BUG-3632)

The export stopped waiting for a stalled read at its 60s deadline
(BUG-3626, BUG-3659), but the read ran on: Query and QueryRow on a
*sql.DB or *sql.Tx hand the driver context.Background(), even inside a
snapshot begun with the deadline context, so the query kept its
goroutine and pool connection until the lock behind it let go.

store.WithContext(ctx, q) wraps a *sql.DB or *sql.Tx as a Queryer whose
reads run under ctx, so the existing *Q readers bind to a deadline with
no parallel set of Context variants. The export routes all of its reads
through it: the workspace listing (new GetUserWorkspacesQ), the access
preflight, and every read inside the per-workspace snapshot.
withinExportDeadline stays as the backstop for anything that does not
honour the context.

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

* fix(store): a cancelled guest-grant read reports its error instead of a short list (BUG-3632)

GuestVisibleCollectionIDsQ never checked rows.Err(). Now that the
account export's reads are cancelled at its deadline, a read ended
mid-iteration returned a truncated visible set and no error, which the
export's preflight would read as a restricted workspace (403) rather
than a timeout (503). Both loops now check it.

The store test also pins that a cancelled read's error wraps
context.DeadlineExceeded on both dialects (measured: SQLite returns it
bare, pgx as *pgconn.errTimeout), which is what the export's 503
classification relies on.

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

* fix(server): a read failing past the export deadline reads as the deadline; the preflight keeps its fail-closed seam (BUG-3632)

Review round r2:
- withinExportDeadline: when a cancelled read's own error wins the race
  with ctx.Done(), it is reported wrapping the deadline, so the export's
  503-vs-500 choice no longer depends on how a driver wraps its cancel
  error.
- accountExportVisibleCollections keeps the BUG-3334
  visibleCollectionIDsFault seam that visibleCollectionIDs honours; the
  ctx-bound read had dropped it.
- store.WithContext's comment says how far cancellation reaches per
  driver: modernc SQLite interrupts only a query's first step.
- The store test's idle check compares against the connections in use
  before the read, not zero.

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

* test(store): a cancelled read stops an endless result set at the next row (BUG-3632)

Review round r3 (Codex Medium): modernc SQLite interrupts only a query's
first step, so does iteration over a large result set outlive the
deadline? Measured, it does not: database/sql closes the rows at the
next row boundary once the context is done, on SQLite and Postgres. The
new row iterates an endless recursive CTE under a 200ms deadline and
requires the deadline error and the connection back. With the adapter
dropping ctx it runs past 5s.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR
2026-10-17 19:32:06 -04:00
xarmian 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
github-actions[bot] 5d5f17f54e chore(plugin): the marketplace follows v0.18.0 (TASK-3487) 2026-10-17 17:33:20 +00:00
xarmian d51aba9adb fix(invitations): a member accepting a dead invitation is already in, not told to get re-invited (BUG-3870) (#2217)
* test(invitations): a member accepting a dead invitation is already in (BUG-3870, red)

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

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

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

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
v0.18.0
2026-10-17 11:32:56 -04:00
xarmian b901ec629c feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)

Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.

Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.

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

* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)

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

* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)

Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.

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

* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)

Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.

describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.

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

* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)

Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.

The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 21:14:10 -04:00
xarmian c086457939 fix(web): a shown kept draft is held, so newer kept text cannot replace it (BUG-3863) (#2215)
* fix(web): a kept draft the notice shows is held, so newer kept text cannot replace it (BUG-3863)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 11:08:12 -04:00