Commit Graph

221 Commits

Author SHA1 Message Date
xarmian 527fa47e0f fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) (#2275)
* fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019)

models.User no longer carries the secret. GetUserQ and its siblings, the
member and backlink reads and the mention-recipient check therefore cannot
fail on a secret that will not decrypt. checkSecondFactor loads it through the
new TOTPSecretForVerify after the limiter charge: no secret refuses the TOTP
branch (an empty one never verifies), a decrypt failure logs once without
secret material and fails closed, and the recovery-code branch is untouched.

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

* fix(server): the undecryptable-secret path logs once; the read tests reach the row (BUG-4019 r1)

- checkSecondFactor writes its 500 directly: writeInternalError logged the
  same error a second time. T2 counts every ERROR record except the request
  logger's per-request line.
- T1's Stripe lookup now matches the undecryptable user (it looked up an
  unknown customer and could not go red), and ListUsers and SearchUsers are
  pinned to return the row with its 2FA flag.
- The appstore boundary allowlist no longer says GetUserQ decrypts, and
  drops the deleted decryptUserTOTP.
- Two comments no longer claim a single reader of the column.

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

* fix(server): 2FA on with no secret behind it warns the operator (BUG-4019 r2)

An enabled row whose secret is empty cannot verify a code, so the user is
held to recovery codes; checkSecondFactor now logs one warning naming the
user. T5 asserts it. The door tests assert exactly 500, not any 5xx, and
the test placeholder helper drops its unused argument.

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

* fix(server): the empty-secret warning claims only what is true at that instant (BUG-4019 r3)

user.TOTPEnabled is the request's snapshot, and a disable landing after it
also reads as no secret, so the warning no longer says 2FA is enabled; it
says there was no secret to verify a code against. T5 asserts the new text.

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

* test(store,server): a stored secret that decrypts to empty is no secret (BUG-4019)

The decrypted=="" guard in TOTPSecretForVerify was unexercised: the empty
case wrote '' and returned at stored=="" first, and encrypt("") returns ""
rather than sealing it, so dropping the guard left every test green. The
store test now writes a hand-sealed empty plaintext to an enabled row and
expects ErrNoTOTPSecret; a server test refuses a code computed from "" on
that row.

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

* test(server): the BUG-4019 test header names the 2FA paths, not one check (BUG-4019 r5)

EnableTOTP also decrypts the stored secret (to compare) and fails on one
that will not decrypt, so the header no longer says the second-factor check
is the only thing that fails. TOTPSecretForVerify remains the only reader
that returns the plaintext. Comment only.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
2026-10-21 15:57:29 -04:00
xarmian 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 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 9af942a169 feat(store): push notification schema and settings store (TASK-3937 part A) (#2265)
* feat(store): push notification schema and settings store (TASK-3937 part A)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 18:11:13 -04:00
xarmian dd412a022d feat(needs): GET /me/needs, the cross-workspace needs-you inbox and its router (TASK-3920) (#2253)
* feat(store): reads behind the cross-workspace needs inbox (TASK-3920)

GuestWorkspaceIDs and GetWorkspacesByIDs for the fan-out; NeedsCandidateItems
(visibility scope in SQL), OpenMentionsForItems, NeedsSnoozesForItems and
NeedsMarkersForItems as batches, WorkspaceOwnerIDs, LatestAssignmentTimes.
Snoozes are joined through items.workspace_id and read only for ids the caller
already scoped by visibility (day-19 rulings on TASK-3920).

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

* feat(needs): the routing rule, row types and cursor for the needs inbox (TASK-3920)

Resolve applies asked, mentioned, assigned, admin_fallback in strict exclusive
order over pure inputs (TestResolve_Order). WaitingSince is the earliest open
claim. Score.Value is an int 0-100; Scorer takes the viewer and scope. The
cursor carries as_of, view and ws and is position only.

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

* feat(server): GET /me/needs, the cross-workspace needs-you inbox (TASK-3920)

Fans out over the caller's member and guest workspaces (token allow-list, and a
token bound to one workspace stays in it), reads candidates under the caller's
visibility, routes them with needs.Resolve, applies the caller's snoozes
(joined through items.workspace_id), and pages a total order. degraded is set
only on a provider error. Documented in docs/api.md.

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

* fix(server): classify the needs inbox's route and lifecycle call (TASK-3920)

Adds GET /me/needs to the compress census and its IsTerminalItem call to the
PLAN-3535 lifecycle classification, and drops an unused helper and test field
the linter found. Follows the previous commit; both ride the same PR.

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

* fix(needs): a mentioned row is built from the caller's own mentions; attention flags read only caller-visible items (TASK-3920)

The mentioned rule built one routing for every mentioned person, so a
caller's row carried the earliest mention of anyone and other people's
commenters and comment ids. Resolve now builds each person's view from
their own mentions (Routing.For), and the handler takes the caller's.

The attention read evaluated the whole workspace before visibility, so an
error from an item the caller cannot see set degraded. A new
Runner.WorkspaceFlagsForItems filters decision rows to the given ids
before any state read; the handler passes the caller-visible ones.

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

* fix(needs): only surfaced needs_human rows set a claim; clamp a forged future as_of (TASK-3920)

The needs_human claim time was filled from every latest decision row, so a
candidate whose row did not surface (below threshold, a no, stale) got a false
needs_human reason whenever the workspace had any truly flagged item. Record
and apply it only for flagged items. A cursor as_of later than now is clamped
to now so the defer snapshot cannot be forged forward.

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

* test(needs): restricted-scope store test; document the display-name assignee match (TASK-3920)

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 15:23:54 -04:00
xarmian 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 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 ffc11387e1 feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617) (#2228)
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 07:02:00 -04:00
xarmian 9dc679e786 feat(server): per-user catch-up digest and read cursor (TASK-3615, PR A) (#2219)
* wip(store): workspace_read_cursors table and cursor store (TASK-3615, PR A part 1)

The per-user, per-workspace read cursor behind the catch-up card. seen_at
moves only on Mark caught up (Dave, 10-17) and never backwards; reading never
writes (lead ruling, day 12). Untested checkpoint commit; the digest
endpoint, PUT /me/seen and tests follow.

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

* wip(server): catch-up digest store query, GET /catch-up and PUT /me/seen (TASK-3615, PR A part 2)

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

* test(server): catch-up digest sections, window, cursor, visibility, needs_you, agents (TASK-3615, PR A)

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

* fix(store): read activity metadata as text on Postgres in the catch-up queries (TASK-3615)

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

* test(server): catch-up fixtures for reopen, abandoned, own and unrelated comments, old cursor, done-but-blocked, scoped needs_you (TASK-3615)

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

* fix(server): needs_you skips reference items; classify the catch-up and blocker lifecycle sites (TASK-3615, PLAN-3535)

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

* fix(store): catch-up window's lower bound is inclusive, so a same-second event is re-shown, never hidden; document the endpoints (TASK-3615)

Review round 1 (hunter, Low): an event stamped in the cursor's second could
have happened after the press and was never shown.

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

* test(server): classify GET /catch-up as compress in the route census (TASK-3615)

Its response carries refs, titles, display names and timestamps: no token,
code, signed URL or secret.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 10:09:13 -04:00
xarmian 5caf11f829 fix(collab): a REST content replacement fences tabs seeded from the old body, as a restore does (BUG-3746) (#2134)
* fix(collab): a REST content replacement fences tabs seeded from the old body, as a restore does (BUG-3746)

A prune-and-replace content write (content route and patch route, via
PruneAndApply) pruned the op-log and replaced items.content, but stamped
no seed boundary. A browser that fetched body A before the write and
joined after it, with since=0 and its old content_seq, was admitted,
seeded A into the empty document and flushed it over B. Join already
force-refreshes a client whose content_seq predates the last restore
(BUG-2264); replacements now stamp that same boundary: MarkRestoreBoundary
on the write (durable last_restore_seq, in its tx) and SetLastRestoreSeq
in memory, under the item lock Join takes, so no join lands in between.

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

* test(collab): the BUG-3746 boundary test uses a collab-enabled server (BUG-3746)

testServer has no collab manager, so the PATCH took the ordinary write
path, never reached PruneAndApply, and the test failed with the fix in
place (CI on #2134). Production always runs with collab; the test now
does too. The seeded op-log row is dropped: the boundary does not depend
on it.

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

* test(collab): the patch route has its own seed-boundary leg (BUG-3746)

The patch route prunes and replaces through its own PruneAndApply call,
wired separately from the content route, so a future edit dropping
fenceSeedsBefore there would pass the content-route test. Its twin
asserts the boundary moves past the seeded body's seq to the patch's.

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

* test(collab): a REST replacement admits a tab seeded at the new seq and refuses one seeded before it, through a real join (BUG-3746)

Heron, GO-blocking on #2134: the fence must not over-fence, or every fresh
tab after a REST replace would be force-refreshed, possibly in a loop. Per
route (CONVE-19), after the replacement a tab joining with
content_seq = the new seq gets op_log_cursor, and one at seq - 1 gets
force_refresh.

Mutant: the in-memory boundary stamped one past the write fails both admit
legs ("the fence over-fences"). Counterfactual: with the fence removed, both
refusal legs fail.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 08:39:02 -04:00
xarmian a6f96107f4 feat(lease): an opaque worker id holds item leases, so two agents under one name do not share one (TASK-3613) (#2102)
A lease was "ours" when its free-text label and the account matched, and the
label was the agent's display name, so names carried correctness: two
unregistered Claude Code sessions on one account both held as "claude-code",
and two stateless remote MCP clients shared their bare client name.

A lease is now held under a WORKER ID plus the account (lead rulings on the
item); the label is display only.

- Format: "wk_" + 32 lowercase hex (128 random bits), minted by the CLIENT.
  The server checks the shape (400 invalid_worker_id) and always matches it
  together with the account; a worker id with no signed-in user is refused.
- Store: one predicate, leaseOurs, shared by claim, release and the
  next/ready foreign filter, COALESCEd so its negation is exact. A
  worker-bound lease is ours iff worker id + account match; a name-only one
  keeps the label + account rule, and a worker refreshing its own name-only
  lease binds it. A name-only caller never matches a worker-bound lease,
  even under the same label (the collision fixed). Migrations SQLite 142 /
  PG 112 add items.lease_worker_id (NUL-excluded: format-validated in the
  store before any SQL).
- Server: the worker id comes from the body, then X-Pad-Worker, then a
  ?worker_id query, then the remote MCP caller. The claim answers with the
  caller's own worker_id; reads and the 409 show only the SHORT id
  ("wk_3f2a"); a name-only caller colliding under its own name is told how to
  get a worker id.
- CLI: `pad session register` mints the id into the session's registry
  record, keeps it when the same process re-registers (a /clear: same pid
  and start token) and mints a new one for a new process; older records get
  one lazily; $PAD_WORKER_ID overrides; X-Pad-Worker is sent on every call;
  --worker-id on claim, release, project next and ready (the local-stdio MCP
  path for worker_id).
- MCP: the remote default holder label is the bare client name (dropping
  BUG-2772's "#xxxxxxxx" suffix); the worker id is derived from the account
  and the MCP session; an explicit `worker_id` (pad_item claim/release,
  pad_project next/ready) overrides it and is the resume path across
  connections. ToolSurfaceVersion 0.97 (additive). The catalog text pays for
  itself: tools/list is 4 bytes smaller (the stale holder text is fixed and
  the ttl text trimmed).
- Docs: a claim/release section in docs/api.md (it had none), the
  docs/mcp.md Leases paragraph and changelog, SKILL.md, CLI help. The web UI
  renders no lease today; the TS type gains the short worker for a future
  badge.

Tests: store worker-id rules; server (two workers under one name, account
scoping, the name-only 409 and its message, adopting one's own name-only
lease, 400s, next agreeing with the claim across a matrix); MCP (bare-name
holder, the claim's worker_id, resume via worker_id in claim and next,
malformed refused, session-derived ids stable and distinct); CLI (the id
survives a re-register by the same process, a new process gets a new one,
lazy mint, PAD_WORKER_ID, SetWorkerID). The two CLI rules each kill a
mutant.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The new reference-item test fails on d290fb37c.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 02:49:27 -04:00
xarmian 493498c977 feat(items): idempotency keys for comments, notes and decisions; idempotency_target_gone is structured (TASK-3585) (#2068)
* fix(mcp): idempotency_target_gone is a structured code on both transports (TASK-3585)

A replayed create whose item has since been deleted answers 410
idempotency_target_gone. Remote MCP had no 410 arm, and the CLI wrote no
stdio marker for it, so it reached agents as a generic error. It is now
allow-listed, mapped on the remote 410 arm, and marked on stdio, with a
hint shared between internal/cli and internal/mcp.

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

* feat(items): idempotency keys for comment create (TASK-3585)

POST .../items/{ref}/comments accepts op_key (body) or Idempotency-Key.
The key's answer (the comment id) is recorded inside
CreateCommentWithActivity's transaction, so a retry whose first answer
was lost returns that comment instead of a twin. A refused comment
releases its key; a replay of a comment deleted since answers 410
idempotency_target_gone. `pad item comment --op-key` (a fresh key per
invocation by default) retries once behind the idempotency_keys
capability; MCP comment forwards op_key on both transports.

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

* feat(items): idempotency keys for note and decision appends (TASK-3585)

PATCH /workspaces/{ws}/items/{ref} accepts op_key (body) or
Idempotency-Key, but only on an append-only update
(append_implementation_note and/or append_decision, plus source); a key
with any other member is refused 400. The answer (the item id and the
minted entries) is recorded in the update's own transaction, so a retry
whose first answer was lost echoes the first entries in `appended`
instead of appending twice. The built-in and patch callers, which reach
this handler with their own key, are untouched.

`pad item note|decide --op-key` (fresh per invocation by default) retry
once behind the idempotency_keys capability; MCP note/decide forward
op_key. ToolSurfaceVersion 0.91 -> 0.92 (additive; also records the 410
now being structured). docs/api.md covers create, comment and append keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 14:38:15 -04:00
xarmian c46ecc4cf8 feat(items): idempotency keys for item create (TASK-3585) (#2065)
A create carries op_key (body) or Idempotency-Key (header). The key is
claimed before the write and its answer (the created item's id) is stored
inside the create's own transaction, so a retry whose first answer was
lost returns that item instead of creating a twin. A refused create
releases its key; a key reused for a different create is 422
idempotency_key_reused; one still in flight is 409 idempotency_in_flight.
A replay whose item has since been deleted answers 410
idempotency_target_gone.

CLI: `pad item create --op-key`, a fresh key per invocation by default,
and one same-key retry of a 5xx or transport error, only when the server
advertises idempotency_keys. MCP: pad_item create accepts op_key on both
transports (remote maps it into the body; stdio passes --op-key).
ToolSurfaceVersion 0.90 -> 0.91 (additive).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 13:06:44 -04:00
xarmian 7ec0191d45 feat(items): idempotency keys for patch writes (TASK-3585) (#2062)
* feat(items): idempotency keys for patch writes; an unconfirmed patch resolves only through its own tab (TASK-3585)

A patch may carry an idempotency key (Idempotency-Key header, or op_key).
The key is claimed per (workspace, user, key) before any write-path guard,
with a lease, and its answer is recorded inside the write's own
transaction, guarded by the claim token: done holds exactly when the write
committed, so an expired claim committed nothing and may be taken over. A
retry of the same request replays the answer (Idempotent-Replayed); a key
reused for another request is 422; a live claim is 409 in flight; a
refused write releases the key.

When an open editor received the patch and never confirmed it, the key
records the patch id and a boot-scoped reference to that connection. A
retry goes only to that connection, whose tab remembers the patches it
applied and re-acks one it is sent again; with it gone the answer is
patch_outcome_unknown with the diff since, and nothing is re-planned, since
an insert-shaped edit would keep its anchor elsewhere.

The CLI sends a key per invocation and retries once with it on a
transport error or 5xx. MCP 0.90 adds op_key and three codes.

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

* fix(cli): retry a keyed write only when the server advertises idempotency_keys (TASK-3585)

An older server ignores op_key, so the CLI's single same-key retry of a
5xx could apply a patch twice against a server that serves patch but not
keys. The retry now requires the idempotency_keys capability.

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 06:20:09 -04:00
xarmian bc329fb7f4 feat(items): agent body writes are version-checked by default, phase 1: count, warn, replace (PLAN-3555) (#2054)
Phase 1 of the ruling that agent content writes carry the version the agent
read. Nothing is refused yet; phase 2 (428 at the agent doors) follows a week
of these counts.

- server: `replace` request member; pad_content_writes_unguarded_total
  {door,kind}; warnings.unguarded_content_write on an agent door's unguarded
  content write; a seq update_conflict on a content write (applier route
  included) carries content_write, changed_at, changed_by_kind and a
  "do not resend your body" message.
- CLI: `pad item update --replace` (refused with a token or without a body);
  a stderr warning on an unguarded body write; the merge line on conflict.
- MCP 0.86 (additive): pad_item.update `replace`; the content-conflict hint
  on both transports; the update description leads with read, edit, write
  with the seq.
- Docs and prompts: SKILL.md (both copies), instructions.md, api.md,
  mcp.md, deployment.md, README, update help, plan and onboard playbooks.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 05:29:53 -04:00
xarmian c0e66c6700 fix(models): the fallback terminal lists cover every "won't fix" spelling (BUG-3546) (#2033)
A collection whose status option is the hyphenated wont-fix, with no
terminal_options declared, never closed those items: the fallback lists
spelled it only wontfix / won't fix. Seen in the APM workspace, where plans
needed --force to close over wont-fix children.

DefaultTerminalStatuses and NegativeTerminals now list wontfix, wont-fix,
won't fix and won't-fix: exact spellings rather than a normalizing compare,
because the list also feeds SQL IN (...) filters, which match exactly. The
web mirrors (types/index.ts) carry the same values, and
TestTerminalListsMatchWeb parses them and fails on any drift; until now a
comment asked for that. The CLI and board colour the new spellings as
negative terminals. Pad's own web Bug Tracker template, which uses wont-fix,
now declares terminal_options [fixed, wont-fix] and abandoned_options
[wont-fix].

childProgress.test.ts pinned DEFAULT_ABANDONED_STATUSES to the old literal;
its expected value now includes the new spellings.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 18:59:25 -04:00
xarmian a55d15dbea feat(ci): an advisory dependency-age check (cmd/depages) (TASK-1391) (#2014)
* feat(ci): an advisory dependency-age check (cmd/depages) (TASK-1391)

Companion to Dependabot's cooldown (TASK-1390), which holds a release for 7
days before PROPOSING it but cannot see a version added by hand. cmd/depages
dates every Go module (go.sum), npm package (web/package-lock.json, registry
packages) and action pin (`uses: owner/repo@<sha>`) a change adds, from the Go
module proxy, the npm packument and the GitHub commits API, and fails when
one is younger than --days and not allowed in .github/dep-age-allow.txt
(which requires a reason per line). --base <ref> checks only what is new
since the ref; a dependency that cannot be dated is a warning, not a failure.

CI runs it on pull requests as "Dependency age (advisory)", against the
merge commit's first parent. NOT a required check (lead ruling): red means
wait or allow with a reason.

Rewritten rather than promoted: the /tmp scanner the task names is gone. Its
edge cases are kept (pseudo-versions included, scoped npm names, sub-actions
sharing a repo SHA, the full packument for per-version times). A real run
against main as of 2026-09-01 dated 185 dependencies and flagged one:
golang.org/x/net v0.60.0, #1922's security bump, the case the allowlist is for.

* fix(ci): depages covers aliases, composite actions and lockfile versions; the token stays on GitHub (TASK-1391)

Codex round 1:
- An npm alias is checked as the package it installs (the entry's name).
- A lockfile older than v2 is refused instead of read as empty.
- Every YAML file under .github is scanned, so a local composite action's
  uses: pins count too. docker:// images are out of scope and documented.
- GH_TOKEN is sent only to api.github.com, whatever DEPAGES_GITHUB_API says.
- The base-diff test now covers a go.sum module, a bumped npm package and a
  composite action's new pin in one change.

* fix(ci): depages scans action.yml anywhere in the repo; its README tables the coverage (TASK-1391)

Codex round 2: a local composite action can live outside .github. Every
action.yml / action.yaml in the repository is scanned too (node_modules and
.git skipped). Not changed, with the reasons in the README's coverage table:
docker:// images (no SHA, no publish time) and dating a pin by its commit
(the threat is fresh code, and an old commit is old code).

* feat(ci): depages prints the allowlist line to paste for each young version (TASK-1391)

Lead, on the first real run flagging #1922's x/net security bump: the job's
output now says the check is advisory and prints, per flagged version, the
exact .github/dep-age-allow.txt line with a <reason> placeholder.

* test(models): classify cmd/depages' getJSON in the raw-decode guard (TASK-1391)

CI: TestReservedFieldDecodeSitesAreClassified found cmd/depages/main.go's
getJSON decoding into an interface. It decodes registry and GitHub API answers
into their own structs, never a collection definition; allow-listed with that
reason, and the receipt count goes 26 -> 27.
2026-10-10 04:48:59 -04:00
xarmian 47ca9e521a fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) (#2009)
* fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B)

Lead ruling: a repeated or empty field key or select option is a broken
schema, and the web renders fields and options as lists keyed by them, so
one repeat blanked the view (BUG-3538). PR A made the web render such a
schema; this stops new ones being written.

- Collection create and update refuse, with 400 validation_error naming
  it, a schema in which a field has no key, two fields share a key, or a
  select field lists an empty option or the same option twice
  (models.ValidateSchemaKeysAndOptions, beside ValidateAbandonedOptions).
  WRITE check only: a stored schema with a repeat still reads, and an update
  that sends no schema still applies. Live census on :7777: 0 of 213
  collections affected.
- Dynamic Client Registration registers a repeated redirect URI once (it is
  unauthenticated, and the connected-apps page lists URIs keyed by value).
- ListWorkspaceTags groups tags as TEXT: on SQLite json_each kept [1] and
  ["1"] apart while both scanned to "1".
- Pins that convention commands and surfaces are deduped on read: they are
  already (normalizeItemConventionMetadata); the census verdict that said
  otherwise was wrong.

Not done, deliberately: refusing repeated app-manifest event names or
companion field keys. appmanifest.Parse also re-reads a STORED manifest on
upgrade, so a new refusal there would fail a read of an installed app; PR A
made both web lists render repeats.

ToolSurfaceVersion 0.82 -> 0.83: pad_collection create/update refuse calls
they used to accept (the v0.43 / v0.48 grounds). docs/api.md states the 400.

* fix(server): hold companion and template schemas to the key/option rule (TASK-3539)

Codex round 1: writers that bypass the collection handlers could still store
a schema that repeats or empties a key or option.
- An app's companion collection schemas are checked in the install/upgrade
  preview, on the NEW manifest beside the reserved-key check, and refused
  422 invalid_manifest. Not in appmanifest.Parse, which also re-reads an
  installed app's stored manifest.
- Every template and default collection schema the repo ships is checked by
  a test (they seed through the store, not the handlers).
- The legacy-schema test also reads and creates items in a collection stored
  with a repeat.
Workspace and artifact import, and the library option widening (which
already dedupes), are unchanged: refuse on write only.

* docs(mcp): the v0.83 entry in docs/mcp.md's version history (TASK-3539)

Codex round 2: the current-version labels said v0.83, the history began at
v0.82.

* docs(mcp): docs/mcp.md's intro names the current catalog, v0.83 (TASK-3539)

Codex round 3: the intro still said v0.81, stale since before this change.

* fix(server): the option check covers select and multi_select only (TASK-3539)

Codex round 4: it refused empty or repeated options on every field type, but
only a select or multi_select's options are a rendered list. Another type can
carry a legacy options list (the old field DSL put a third part there), and
refusing it would block an unrelated schema save. Tested both ways.

* test(collections): the template schema check is independent of the validator (TASK-3539)

Codex round 5: calling ValidateSchemaKeysAndOptions from the template test was
circular. It now uses its own check. Also records, at the write, why the
library option widening is not held to the validator: it only appends missing
words (never a repeat), and refusing would fail an item create over a legacy
schema.
2026-10-10 03:04:20 -04:00
xarmian 560ab27b33 feat(server): reference items stop counting as open work (PLAN-3535 PR 2) (#2000)
The items of a collection with tracks_work=false are left out of:
- parent progress (GetItemProgress / GetAllItemProgress SQL, and
  childProgressState for the restricted recompute and the dashboard);
- the open-children close guard (all four doors share runOpenChildrenGuard);
- a default report (resolveReportCollections: !tracksWork replaces IsSystem);
- the dashboard's active items, stalled / overdue / orphaned / blocked /
  content attention, suggested_next and by_role (store and visible recount),
  and so next / ready / stale and the bootstrap dashboard;
- standup and changelog completed work and in-progress, server and CLI;
- the attention decision set's eligibility;
- the admin items_open count.
Sidebar and summary totals, item lists, search, links, the graph, stars and
reminders keep counting every item.

A workspace bundle written before the key existed imports its system
collections as reference, as the migration marked them.

Guards: a three-phase behavioural fixture (work / reference / deleted) over
every excluded surface, each proven sensitive; and a name-based
classification of every lifecycle-helper call site in Go.

MCP ToolSurfaceVersion 0.82 (behaviour bump, v0.49 grounds).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 22:31:16 -04:00
xarmian f5a00d5b13 feat: a collection can be reference material, not work (PLAN-3535 PR 1) (#1996)
* feat: a collection can be reference material, not work (PLAN-3535 PR 1)

The switch, with no counting change yet (PR 2 server, PR 3 web).
CollectionSettings.tracks_work: false makes a collection REFERENCE
material; absent means work, today's behaviour, so old rows, old writers
and a client/server skew are unchanged. One Go predicate
(models.CollectionTracksWork) and one TS twin (collectionTracksWork).

- New collections: the creator's value, else system collections are
  reference and others are work iff their done field can finish. New
  template Docs collections are created as reference (Dave).
- Migration 135 / PG 107: existing Conventions and Playbooks become
  reference; everything else, Docs included, stays work.
- Settings are written wholesale, so an update whose settings omit the
  key KEEPS the stored value (CarryTracksWork), for every writer.
- A typed top-level `tracks_work` on POST/PATCH sets just that key
  server-side. CLI: --tracks-work=true|false (a string flag, so false
  survives stdio MCP). MCP: pad_collection tracks_work, v0.81. A
  non-boolean is refused 400; the CLI checks the response, since an
  older server ignores the member.
- Web: the Display tab's "Track as work" toggle; the editor now starts
  its settings write from the STORED settings (buildCollectionSettings),
  so keys it does not render (content_template, tracks_work) survive.

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

* fix(store): carry tracks_work from the locked row; order the SQLite migration's JSON checks (PLAN-3535, codex)

The update now computes the carried settings from the row it re-read under
the write lock, so a concurrent settings write cannot be reverted by a carry
read before the lock. The SQLite migration evaluates json_type/json_extract
only inside a CASE guarded by json_valid, so malformed settings on a system
row cannot abort it.

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

* fix(models): a collection whose options the default terminals close is work (PLAN-3535)

DefaultTracksWork required declared terminal_options, but every lifecycle
reader falls back to DefaultTerminalStatuses when a done field declares
none, so a `status: open, done` collection finishes items and was being
created as reference. Work now means one of the done field's options is
terminal by the same resolver the readers use. Found by PR 2's store tests,
whose fixture collection is exactly that shape. Also corrects api.md's
MCP version (0.81).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 21:47:50 -04:00
xarmian 4226e8c5ef feat(activity): the collection and actor filters run on the server, and the web/CLI badge says which (TASK-2219) (#1948) 2026-10-09 06:36:13 -04:00
xarmian 136fac6609 feat(import): a partly imported workspace is marked until its owner keeps or deletes it (TASK-896) (#1923)
* feat(import): a partly imported workspace is marked until its owner keeps or deletes it (TASK-896)

A bundle import that hits a DATA error after pad-export.json was imported
keeps the partial workspace (the rule is now written in docs/api.md:
transport failure, validation reject and pre-creation refusal roll back;
only a post-mint data error keeps). The 400 that said "kept" was the only
record, and a client may never read it.

- workspace_import_status (side table, SQLite 131 / PG 104): instance-
  local, never exported, purged with the workspace.
- The keep path writes {status: partial, note}. The note is SANITIZED:
  a fixed category and a correlation id (imp-...); the raw error goes to
  the server log under that id.
- GET /workspaces/{slug} carries import_status (omitempty); the note only
  for owners and instance admins. The list carries the status only.
- PATCH /workspaces/{slug} {"clear_import_status": true} clears it
  (owner-only, like every workspace PATCH).
- pad workspace list marks it "partly imported" (text only; the JSON
  mirrors the MCP workspace-list resource).
- Web: a banner on the workspace landing page and settings, with the
  reason for owners, "Keep it" (clear) and "Delete workspace".

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

* fix(import): a rollback that cannot remove the workspace marks it too; the docs name what keeps (TASK-896, codex r1)

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

* fix(import): only members who are admins read the note; the banner's note fetch is fenced on identity (TASK-896, codex r2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 22:22:06 -04:00
xarmian ee288cbebd fix: item lists refuse a filter key that names no field (BUG-3480) (#1880)
* fix: item lists refuse a filter key that names no field (BUG-3480)

The item list endpoints read every query parameter they did not know as
a field filter, so a parameter the server never had (the web Library
page's `all`, WebMCP's `collection`) answered 200 with an empty list.
A filter key must now be declared by a schema in scope (the
collection's, or any visible collection's on the workspace-wide list),
or be stored by an item in scope, which is honoured and named in the
X-Pad-Undeclared-Filter-Keys header (printed to stderr by the CLI).
Otherwise 400 validation_error, "invalid list filter ...". A key that
is not a field key is refused too; the store used to drop it and
answer the unfiltered list. WebMCP pad_item:list now lists a
collection through its path. ToolSurfaceVersion 0.73.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: type the listByCollection mock so svelte-check can index its calls (BUG-3480)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 10:43:33 -04:00
xarmian 7b68125a30 feat: schema edits say what they do to items; removing an option is never a rename (TASK-2188, TASK-2187) (#1877)
* feat: schema edits say what they do to items, and removing an option is never a rename (TASK-2188, TASK-2187)

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

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

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

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

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

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

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

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

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

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

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

codex r1.

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 03:59:26 -04:00
xarmian deec62235e feat(decision): executable conventions, the server half (TASK-3119 U1a) (#1859)
* wip(decision): conventions set (TASK-3119 U1a)

* chore(eval): executable-conventions eval through the production question path (TASK-3119 U0)

Measures, before any chip exists, how often the decision provider says a
hand-labelled item breaks a convention at the 0.9 threshold: per
convention precision and recall (all cases and real-only), the false
'Breaks' rate of a behaviour convention, and the calls and tokens spent.
State is decision.BuildItemState, the question a decision.Noul worded as
the U1 contract says, the model decision.DefaultModel.

The hand labels were posted to TASK-3119 before any score existed; the
trail comment carrying them is dropped from every state, and the dry run
refuses a state that still carries them. Reads TYPESAFE_API_KEY only.

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

* chore(eval): -links and -only for the conventions eval (TASK-3119 U1a)

-links builds each state as the conventions set does, with the item's
links (from the dump: the parent link only; blocking links are not in
pad item show's JSON). -only runs one convention. Used for the lead's
U1a gate: CONVE-1286 rerun with links.

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

* fix(decision): liveness before EACH split call (TASK-3119 U1a, codex r1)

A split set makes several provider calls; the liveness check ran once
before them, so an item or workspace deleted between calls was still
sent in the later ones. It now runs immediately before every call. A
test deletes the item from inside the first call and the second is not
sent; checking only once fails it.

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

* fix(decision): decision_check has no default; allow-list the eval's decode (TASK-3119 U1a)

A default "on" was written into every new convention: imports gained a
decision_check field (artifact import parity) and an activated built-in
no longer matched its seed (TASK-3462 would read every one as diverged).
Absent already reads as on, so the field carries no default, in the
template and both migrations.

The conventions eval's readJSON is a raw decode the reserved-field guard
counts; allow-listed with its reason, like the attention harness.

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 22:23:29 -04:00
xarmian 1d94d038a4 fix: the first-run launchpad follows an onboarding agent live (BUG-3447) (#1837)
* test: collection create publishes an event; the launchpad and connect banner follow agent writes live (BUG-3447)

* fix(web,server): collection create publishes collection_updated; the launchpad and connect banner follow agent writes live (BUG-3447)

* test(web): a debounce armed in one workspace does not fire into the next (BUG-3447, codex r1)

* fix(web): the live reload and the banner re-check fire only for the workspace they were armed in (BUG-3447, codex r1)

* test(web): an event during the first load or check still triggers the live refresh (BUG-3447, codex r2)

* fix(web): an event before the first load or check commits still triggers the live refresh (BUG-3447, codex r2)

* test(web): a failed quiet re-check that overtook the first check leaves the banner showing (BUG-3447, codex r3)

* fix(web): a failed quiet re-check from the unknown state falls back to showing the banner (BUG-3447, codex r3)

* test: an agent's collection is agent activity (dashboard, banner, e2e incl. the Agent-connected step) (BUG-3447, lead)

* fix: a collection records its source; an agent-created collection is agent activity for the launchpad and the banner (BUG-3447, lead)

* test(e2e): the launchpad follows an onboarding agent from outside the tab, red on main for each outcome (BUG-3447, lead)

* test(store): collection provenance survives export/import; the census sees the new column (BUG-3447, codex r5)

* fix(store): collection provenance travels in the workspace bundle, validated on import; census records the column (BUG-3447, codex r5)

* test(store): an unrecognised bundle source is stored as '' (BUG-3447)
2026-10-06 18:31:29 -04:00
xarmian a91fcce7f9 fix: blank workspaces accept the library's own triggers and scopes (BUG-3446) (#1836)
* fix: a blank workspace's conventions and playbooks take the library's own triggers and scopes (BUG-3446)

The blank template seeds Conventions with trigger [always] / scope [all]
(Playbooks [manual] / [all]) and nothing widened them, so all 18 library
conventions were refused on every door: CLI and MCP library activate, the
web Activate button, and the onboard playbook's literal B3 create.

A create in a system collection declaring the convention or playbook
artifact kind now adds a missing trigger/scope value to the select's
options when the LIBRARY itself uses that word
(collections.LibraryOptionVocabulary, computed from the library). The write
goes through UpdateCollection with its concurrency token, ahead of
validation, and is reported in warnings.options_added (CLI: stderr) and in
the created activity's metadata, which the item timeline renders. Any other
value is refused as before. Editors may trigger it (lead ruling). The
onboard playbook's B3 now says how an owner adds an invented trigger.

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

* chore(mcp): ToolSurfaceVersion 0.67 for BUG-3446 (additive)

Lead ruling: a create in a system conventions/playbooks collection now
adds a library trigger/scope word instead of refusing it, and its result
gains an omitempty warnings.options_added. No tool name, enum or param
moved; tools/list golden bytes are unchanged, initialize moves for the
version and the instructions note.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 16:48:50 -04:00
xarmian 2f3cf402b1 fix(timeline): 'commented on update' only for a comment an update carried (BUG-3437) (#1832)
* fix(timeline): 'commented on update' only for a comment an update carried (BUG-3437)

TimelineCommentCard showed 'commented on update' whenever the comment had
an activity_id, and every comment has one: a comment or reply written on
its own links to its own 'commented' activity, so a plain
'pad item comment' read as if it were attached to a change.

The store's comment list join (the one that already reads the agent name
off the linked activity) now also reads its action into
Comment.LinkedActivityAction (json:"-"), and the timeline sets an
additive, omitempty TimelineEntry.comment_on_update when that action is
anything but 'commented'. The card keys the label on it. The comments
API, the CLI and MCP keep their shape (the new field is never serialized
outside the timeline, which MCP does not expose).

Tests: the server test writes a standalone comment, a reply and an update
carrying a comment through the real routes (web, CLI and MCP share the
comments route) and checks the flag, with the old rule and an always-false
mutant each red; the web test pins the card and the entry list's
pass-through, each red with its half reverted.

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

* test(timeline): a comment that arrives live keeps 'commented on update' (BUG-3437)

The label comes from the server-set comment_on_update. Comments that arrive
while the timeline is open come through the SSE refresh, which re-reads the
timeline endpoint (nothing builds a comment entry client-side), so they carry
the flag. This pins it: red if the entry list stops passing the flag.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 12:57:14 -04:00
xarmian 8c8deeeb89 fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)

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

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

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

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

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

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

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

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

* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
2026-10-06 12:16:32 -04:00
xarmian 1082c4aa16 fix(store): reads exclude soft-deleted collections' items (BUG-3425) (#1820)
* test(store): ListItems, its search and the graph edges must exclude soft-deleted collections (BUG-3425, red)

* test(server): graph nodes exclude soft-deleted collections; the account export keeps them (BUG-3425)

* fix(store): ListItems, its search and the graph exclude soft-deleted collections; the account export opts back in (BUG-3425)

* test(store): role counts exclude soft-deleted collections; search honours the opt-out (BUG-3425, codex r1, red)

* fix(store): role counts exclude soft-deleted collections, as the role lanes do (BUG-3425, codex r1)

* docs(store): doneFiltersForWorkspace's comment no longer names GetRoleBreakdown as counting deleted collections (BUG-3425, codex r2)

* fix(store): keep SQL comments out of the role-count query (an apostrophe broke PG rebind); BUG-3333 expects a deleted collection's doc unlisted (BUG-3425)
2026-10-05 21:18:56 -04:00
xarmian f6938b33a5 feat(webhooks): owner webhooks keep per-endpoint delivery state; 408/425/429 are retried (TASK-3409) (#1809)
* fix(webhooks): 408, 425 and 429 are transient on the owner and app delivery paths, through one shared RetryableStatus (TASK-3409)

* feat(webhooks): owner hooks keep per-endpoint delivery state: a retry goes only to endpoints still owed, a 4xx is final for the event, and endpoints still owed when retention gives up are counted as dropped (GET /webhooks, pad webhook list) (TASK-3409)

* fix(webhooks): an owner endpoint deactivated while an event is still owed to it is recorded and counted as dropped, not silently skipped (TASK-3409, codex r1)

* fix(store): the automatic deactivation settles the endpoint's owed deliveries as dropped and counts them, in its own transaction (TASK-3409, codex r2)
2026-10-05 14:44:50 -04:00
xarmian db8fb3aabf fix(store,server): an item write re-checks its fields under the seq lock when the schema it was validated against has moved (BUG-3407) (#1806)
* fix(store): an item write re-validates the keys it sets against the schema read under the workspace seq lock, so a schema change between the handler's validation and the lock no longer lets a forbidden value in (BUG-3407)

* fix(store): the fenced app write takes the workspace seq lock before reading the companion schema it validates against (BUG-3407, the app path)

* fix(store): a move re-validates its destination fields under the seq lock too (BUG-3407, codex r1)

* fix(store): the re-check judges reserved system metadata by no schema, as the handlers do, so a grandfathered declaration refuses nothing (BUG-3407, codex r2)

* refactor(store): drop a redundant reserved strip; UnmarshalItemFieldSchema already makes it, and the comment says why that decoder (BUG-3407)

* fix(apps): BUG-3407 becomes an optimistic schema compare: handlers pass the exact schema bytes they validated against, the store re-checks the set keys only when the schema under its lock differs, refusals answer 400; a census holds every field-writing handler to it (lead ruling, day 86)

* fix(store): with no ValidatedSchema the store does not decode the fields blob at all, so its own guards and contract are untouched for such callers (BUG-3407)

* fix(store): a stored fields blob that is not an object carries nothing, so a full write repairing it is compared as all-set rather than refused (BUG-3407, BUG-3163 repair path)

* fix(store,server): a supplied schema takes the seq lock and its movement is decided under the locks; the content-route and collab-snapshot paths answer the schema refusal with 400 (BUG-3407, codex r3)
2026-10-05 12:44:17 -04:00
xarmian 41be1bf113 feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) (#1787)
* feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c)

Items (the creating app), comments and version rows carry via_app (the
install id) and via_app_name (the bot's name, else the origin; kept after
uninstall) on the web API, the MCP get / list summary / history summary
projections, and the TS types, with WorkspaceApp naming the members apps
array. ToolSurface 0.65, additive.

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

* fix(apps): attribution on search, /changes, playbook show and restore; chunked lookup (codex r1 on U9c)

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

* fix(apps): attribution on the role board, a comment edit's response and the account export (codex r2 on U9c)

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

* docs: README tool catalog at v0.65 (TASK-3413)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 04:49:25 -04:00
xarmian bec344822a feat(apps): app upgrade: staged preview with a classed diff, one-transaction confirm that re-derives it (TASK-3397, SPEC-6 U8b2) (#1777)
* docs(store): the barrier's early state refusal is load-bearing for the uninstall lock order (TASK-3397)

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

* feat(apps): U8b2 upgrade diff: classes, release of removed companions, the additive-field rule (TASK-3397)

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

* feat(apps): U8b2 upgrade preview and confirm: staging with upgrade_of, diff in the preview, one-tx apply that re-derives and compares (TASK-3397)

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

* test(apps): U8b2 upgrade end to end; null and absent JSON are the same declaration (TASK-3397)

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

* test(apps): U8b2 upgrade lock order against a fenced write, moved-install refusals, upgrade records never cross the install route (TASK-3397)

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

* fix(apps): U8b2 codex round 1: refuse drifted companions, bump the epoch on restrictive upgrades, check existing data and the done field for additive fields, union duplicate event names (TASK-3397)

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

* fix(apps): U8b2 codex round 2: archived items' values count for the additive-field check (TASK-3397)

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

* test(apps): U8b2 registers its two schema DEFINITION decodes with the reserved-field guard; NUL baseline for upgrade_of (TASK-3397)

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

* chore(apps): drop the unused buildAppPreview wrapper (lint) (TASK-3397)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-04 16:16:29 -04:00
xarmian 213ecc0fe3 feat(apps): app install lifecycle: two-phase disable and rotate, re-enable, uninstall with the tombstone (TASK-3397, SPEC-6 U8c) (#1775)
* feat(apps): app install lifecycle: two-phase disable and rotate, re-enable, uninstall with the tombstone (TASK-3397, U8c)

Phase 1 (BeginInstallTeardown) holds the install row FOR UPDATE, moves the
install to disabling or uninstalling, bumps auth_epoch and revokes every
client grant; the delivery drain is a TODO(U10) no-op; phase 2 re-takes the
row and requires the phase-1 state, so a repeated call resumes. Rotate mints
a new install code; re-enable never moves the epoch back. UninstallAppTx is
one child-first transaction: the client (connections by request_id first),
the bot's membership and credentials, the bot DISABLED but kept for
attribution (unlike the account-deletion purge), and the install row kept as
the tombstone. Owner doors: POST .../apps/{installID}/disable|enable|rotate|uninstall.

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

* fix(apps): U8c codex round 1: rotate bumps the epoch again, teardown kills unconsumed install codes, uninstall takes the bot rows first (TASK-3397)

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

* test(apps): U8c fence test lives in internal/store, the one package besides appstore that may open a fence; gofmt (TASK-3397)

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

* test(apps): U8c issuance racing uninstall cannot deadlock, red if the barrier lock-reads the user before its state check (TASK-3397)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-04 14:41:45 -04:00
xarmian 52dc41b96a feat(apps): app install provisioning, install codes and redeem (TASK-3397, SPEC-6 U8b) (#1772)
* feat(apps): app install provisioning, install codes and redeem (TASK-3397, U8b)

Confirm re-normalizes the staged pack, compares it with the reviewed
preview, and provisions in ONE store transaction that re-checks every
state-dependent fact: the install row, companion collections (via_app),
the bot (editor/viewer, collection_access specific over the companions),
the install client, the artifacts as owner drafts stamped with their pack
and digests, a one-time install code, and the pending record's deletion.
A refusal names the artifact or collection, the field and the holder, and
writes nothing.

Redeem is mounted outside the auth stack with a 4 KB whole-body cap and
per-address and per-install limiters; it consumes the code and rotates the
client secret. Owners can issue a new code to recover a lost redeem.

* fix(apps): U8b codex round 1: re-check the destination schema in the provisioning tx, a dead install code never charges the install limiter, a reserved companion slug is refused (TASK-3397)

* refactor(apps): U8b provisioning re-derives the normalization inside its transaction under locks (TASK-3397)

Replaces the per-fact re-checks (codex rounds 1-2 kept finding facts the list
missed) with a re-derivation: ProvisionAppInstall takes the pending row, the
workspace lock, then on Postgres FOR SHARE on every collection row and the
owner's membership row, re-runs the preview normalization on the transaction
through the caller's derive function, locks the relation targets it resolved,
and derives again; the caller compares each pass with the reviewed digests.
SQLite is covered by BEGIN IMMEDIATE.

The normalization path takes a Queryer (relation passes, unique check, slug
probe, kind lookup); the pool versions delegate to it. Parity is pinned over
the 8 importer fixtures plus a relation-bearing case captured before the
refactor, the derivation is proven write-free under the write-capture
harness, and the lock table is pinned on both dialects.

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

* fix(apps): U8b codex round 3: compare the reviewed changes list, lock the caller's visibility rows, take items before the membership (TASK-3397)

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

* docs(apps): U8b lock table matches the round 3 order (TASK-3397)

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

* refactor(apps): U8b locks break dependency cycles, not every read (TASK-3397)

Lead ruling, day 86, superseding "lock every read": the visibility locks
added in round 3 created deadlock cycles with member removal and account
deletion and could never cover an inserted grant or a cached role (codex
round 4). Provisioning now takes the pending row, the workspace lock and FOR
SHARE on the workspace's collection rows, reads the owner role plainly, and
derives once. The serializability argument for each raced writer class is in
the lock comment. The two deadlock scenarios stay as tests.

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

* fix(apps): U8b codex round 5: lock the confirming owner's users row first, account deletion is a real cycle (TASK-3397)

Account deletion reads the bot and membership provisioning writes and
deletes the owner provisioning read, and its FK cascade from users reaches
the pending row. Provisioning now takes the owner's users row FOR SHARE
before the pending row; deletion's first statement locks the same row, so
the two serialize whole in either order. Tested through the real deletion in
both orders.

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

* test(apps): U8b account-deletion cycle test uses the canonical owner (TASK-3397)

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

* feat(apps): U8b adoption re-stamps via_app, only from an uninstalled install (TASK-3397)

Lead ruling, day 86 (from the #1771 review): companions are via_app =
install, so an adopted companion is re-stamped to the new install inside the
provisioning transaction. Any holder that is not uninstalled is refused
(app_already_installed, naming the state). Lock 3 becomes FOR NO KEY UPDATE
so the re-stamp never upgrades a share lock.

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

* test(apps): U8b redeem stays outside the app API token router (TASK-3397)

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

* feat(apps): bot lookups key on app_installs.bot_user_id, the address as the agreement check (TASK-3397)

The U5/U8b agreement: whichever landed second switches the issuance barrier,
AppPrincipalForInstall and the members list from the bot's address to the
bot_user_id provisioning sets, keeping the address as an agreement check.
An install without bot_user_id (made before provisioning) falls back to the
address.

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

* chore(apps): drop a dead assignment in diffChanges (lint) (TASK-3397)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-04 11:59:42 -04:00