Commit Graph

747 Commits

Author SHA1 Message Date
xarmian daed6bb441 fix(server): removeUnusableWorkspace reports a failed delete as kept (BUG-3479) (#1870)
Its contract is nil when the workspace is gone, an error when it was kept,
but the branch where its own soft-delete FAILED returned nil. Every caller
then reported a removal that had not happened while a live, ownerless
workspace held its slug.

- That branch now returns an error naming the failed removal.
- Cloud auto-create (both arms) logs that the workspace could NOT be removed
  instead of leaving 'is being removed' as the last word.
- Create workspace's seed failure says in its 500 that the half-made
  workspace survived and holds its name.
- addOwnerOrCompensate's ABSENT arm now returns the KEPT error, which its
  callers already treat as a failure; the bundle keep path reads the row back
  (BUG-2709) and the interrupted-import rollback does too (BUG-3475).

A test per caller with a SQLite trigger refusing the soft-delete; each fails
on the old nil.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 23:39:39 -04:00
xarmian a23edba856 feat: built-in update count in the bootstrap, and accepted updates record replaced fields (TASK-3462 U3a) (#1868)
* test(server): the bootstrap counts built-ins with an update on offer (TASK-3462 U3a, red)

* feat(server): bootstrap carries builtin_updates, a count of built-ins with an update on offer (TASK-3462 U3a)

update_available plus diverged, among the items the caller sees in full:
the library listing's own visibility, through one shared helper. omitempty,
so a workspace with nothing on offer is byte-identical to before.

* feat(mcp): agents mention built-in updates once and never apply them; tool surface 0.71 (TASK-3462 U3a)

The skill (both copies) and the MCP server instructions describe the new
builtin_updates bootstrap count: mention it once per session, and leave each
update for a person to accept. ADDITIVE bump to 0.71, since the bootstrap
every door returns gained a member and the instructions changed. Golden wire
bytes regenerated: only the version and the instructions text moved.

* test(server): an accepted built-in update records the field values it replaced (TASK-3462 U3a, red)

* feat(server): an accepted built-in update names the field values it replaced (TASK-3462 U3a)

Version history keeps bodies only, so the summary of the update now lists
each non-body field whose value it changes, as key: old → new (JSON,
bounded), including typed convention metadata. Fields the update leaves
unchanged are not listed.

* test(server): every template seed and every library activation reads current at once (TASK-3462 U3a)

The guard the night-43 review ran by hand: the registry's hash must equal
what each seeding door stores. A seed that stores drifted text turns it red
(update_available straight after seeding).

* test(server): replaced-fields summary ignores numerically equal values and truncates on a rune boundary (TASK-3462 U3a, codex r1, red)

* fix(server): replaced-fields summary compares canonical numbers and cuts on a rune boundary (TASK-3462 U3a, codex r1)
2026-10-07 21:29:37 -04:00
xarmian 250b5c2c03 fix(server): an interrupted import rolls back, and its outcome can be asked for (BUG-3475) (#1867)
* fix(server): an interrupted import rolls back, and its outcome can be asked for (BUG-3475)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 21:03:24 -04:00
xarmian 698fe2ff6e fix(server): a collab revalidation tick that errors makes the conn read-only (BUG-3474) (#1866)
* test(server): a revalidation tick that errors leaves the collab conn writable (BUG-3474, red)

A real fault: workspace_members is renamed, so the tick's membership lookup
fails with a plain error. After the first failing tick, a frame still
persists. Red 3/3 on SQLite.

* fix(server): a collab revalidation tick that errors makes the conn read-only, not closed (BUG-3474)

Lead ruling: fail closed on WRITE, keep the connection. Both error branches
of the tick (GetItem, and a plain error from authorizeCollabAccess) now call
SetConnWritable(false) before logging. The next clean tick restores write if
the role still allows it. The test faults each branch for real (renamed
workspace_members, renamed items).

* test(server): a credential re-check store error keeps the collab conn writable; recovery is checked by content (BUG-3474, codex r1, red)

* fix(server): an unknown credential re-check makes the collab conn read-only and skips the tick (BUG-3474, codex r1)

streamCredentialStillValid reads a store error as valid (right for SSE,
which has no write), so the collab tick went on to set write from the role.
The collab loop now switches on credentialLiveness: invalid closes as
before; unknown sets read-only, logs at warn and re-arms.
2026-10-07 19:45:14 -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 b3cf4f5292 fix(server): an ordinary open backlog fills Up next and pad project ready (BUG-3453) (#1854)
A moved-in backlog of open tasks and bugs with no priority set, bugs in
their initial status `new`, answered "No ready items found": only the
literal status `open` counted, and an open item outside an active plan
needed a high/critical priority (BUG-1082's "without flooding", though the
list is capped at three).

Such items now fill the slots the existing tiers leave empty, from a
fallback tier below every item admitted before, so a workspace that
filled three slots gets the same list. The tier admits items whose
collection is not a system collection and declares `priority` or
`severity` (lead-ruled; across every built-in template that is Tasks,
Features, Backlog and Bugs), whose done field is not terminal, that are
not in progress and not blocked. It ranks by priority, else severity,
then age.

`ready`'s help and the MCP `ready` description said it returns the full
actionable backlog; it returns the same capped top three as `next`, and
now says so. The empty state says why and points at `pad project stale`.
ListCollectionsMinimal also returns is_system.

ToolSurfaceVersion 0.70 (behavior bump on the v0.49 grounds).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 08:36:58 -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 cba6379c4d test: the stalled-transport test waits for the worker to be inside Publish before clearing the transport (BUG-3464) (#1850) 2026-10-07 05:09:33 -04:00
xarmian e774268d63 fix(plugin): drop the pinned version so installs follow the commit (BUG-3463) (#1848)
A version in plugin.json pins every installed copy until the string
changes (code.claude.com/docs/en/plugins/loading, "Versions and
updates"). 0.3.3 stood from 2026-08-27 while 8 commits changed plugin/,
so marketplace installs kept the August skills. With no version, a
relative-path plugin in a git-hosted marketplace is versioned by its
commit, and `claude plugin update` picks up every change.

Measured in a scratch CLAUDE_CONFIG_DIR against a git marketplace with a
commit to plugin/ after install: pinned 0.3.3 answered "already at the
latest version (0.3.3)" and kept the old copy; unpinned updated
be0122112e5a -> d6312f1d8c6d with the change in the cache.

A guard test refuses a version in plugin.json or the marketplace entry.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 03:33:30 -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 f74f626fcb fix(server): register-with-code claims the invitation atomically; a gone invitation refuses and rolls the account back (BUG-3438) (#1834)
* test(server): register-with-code through an invitation gone before its claim joins nothing (BUG-3438)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(server): a claim refused before commit skips the reconcile read; a failed rollback answers 500; docs name the admin case (BUG-3438, codex r9)
2026-10-06 15:48:30 -04:00
xarmian 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 10a1683b83 fix: the access epoch tracks the live collection set; deleted collections leave the local index; the open-children guard ignores them (BUG-3428 phase 2) (#1826)
* test(server): the epoch tracks the live collection set; the guard ignores deleted collections' children (BUG-3428 phase 2, red)

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 01:33:24 -04:00
xarmian 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 a1c36fca04 fix(server): the upgrade diff states what a widened delegated access grants (BUG-3427) (#1817)
A widened delegated-access row told the owner the change was recorded and
granted nothing until delegated access is enabled (TASK-3399), but U5b-2
opened delegated sign-in. appAdmitDelegated grants the lesser of what the
person consented to and what the manifest offers now, and a consent is
capped at the level offered when it was given, so the row now says that
existing sign-ins keep their consented access and people who sign in after
approval can consent to the new level. Stale 'refused until TASK-3399'
comments in app_api.go and install_clients.go are corrected.

Tests pin the detail string and the behaviour behind it (a read consent
stays read after the widening; a write consent given after it writes).
Mutants: the old string, and admission granting the manifest's level, each
go red.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 19:45:01 -04:00
xarmian 9da6714ffc fix(server): access_epoch hashes the set the item doors filter by (BUG-3347) (#1812)
* test(server): access_epoch must track the effective set for item-grant callers (BUG-3347, red)

* fix(server): access_epoch hashes the set the item doors filter by (BUG-3347)

* fix(server): a degraded SSE visibility snapshot does not move the access epoch (BUG-3347, codex r1)

* test(server): pin the visible-collections degraded site alone (BUG-3347)
2026-10-05 17:22:05 -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 7b95dae4f5 fix(oauth): another client's replay of a spent refresh token or code revokes nothing (BUG-3400) (#1807)
* fix(oauth): another client's replay of a spent refresh token or code revokes nothing (BUG-3400)

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

* fix(oauth): pin the confidential-client case and document BUG-3400's residuals

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 13:22:23 -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 2ef63e3b25 fix(oauth): introspection describes only the caller's own client's access tokens (BUG-3398) (#1791)
* fix(oauth): introspection describes only the caller's own client's access tokens (BUG-3398)

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

* test(oauth): two clients share the test redirect URI (BUG-3398)

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

* test(server): the census names the shared introspection caller reader (BUG-3398)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 11:51:01 -04:00
xarmian 95ae3adbd9 fix(apps): an installed app learns its workspace from the redeem, an unscoped /me and every webhook (BUG-3416) (#1797)
* fix(apps): an installed app learns its workspace from the redeem, an unscoped /me and every webhook (BUG-3416)

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

* fix(apps): redeem resolves the live workspace before it spends the code (codex r1 on BUG-3416)

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

* fix(apps): redeem holds the workspace row FOR SHARE, so a soft delete in flight lands before it or after its commit (codex r2 on BUG-3416)

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

* fix(apps): redeem locks the workspace before the install, the order account deletion and purge take (codex r3 on BUG-3416)

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

* fix(apps): redeem's workspace read takes no lock; a database failure is a fault, not a bad code (lead ruling, codex r4 on BUG-3416)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 09:16:24 -04:00
xarmian a04e35a67d fix(apps): every list in an apps JSON response is a list, never null (BUG-3417) (#1796)
* fix(apps): every list in an apps JSON response is a list, never null (BUG-3417)

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

* fix(apps): upgrade confirm items and private-origin allowed lists are lists; census widened (codex r1 on BUG-3417)

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

* test(apps): the null-list census drives the full manifest and the remaining doors (codex r2 on BUG-3417)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 08:49:03 -04:00
xarmian 5279c49140 fix(store): account deletion and workspace purge lock comments root first, and retry a residual deadlock (BUG-3395) (#1792)
* fix(store): account deletion and workspace purge lock comments root first (BUG-3395)

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

* test(store): seed the bot's membership as provisioning does (BUG-3395)

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

* fix(store): retry the account deletion and purge transactions on a deadlock; the depth bound fails closed (codex r1 on BUG-3395)

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

* test(store): a retried deadlock fails the lock-order cases, so the retry cannot mask a broken order (BUG-3395)

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

* test(server): the account_deleted audit event is written once across a retry (codex r2 note, BUG-3395)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 07:01:22 -04:00
xarmian 9ae4a0be45 feat(apps): upgrade review and draft activation in Settings → Apps (TASK-3413, SPEC-6 U9b) (#1789)
* feat(apps): the install view lists the items its pack provisioned (source_pack = origin@version), for draft activation (TASK-3413, SPEC-6 U9b)

* refactor(apps): the per-artifact review is its own component, shared by install and upgrade (TASK-3413, U9b)

* feat(apps): the upgrade review (diff by review/auto, released companions, new drafts, the reviewed hash) and the drafts panel with per-item activation (TASK-3413, SPEC-6 U9b)

* test(apps): each upgrade diff line sits in exactly one group (TASK-3413, U9b mutant U2)

* test(apps): e2e for the upgrade review and draft activation through page.route (TASK-3413, U9b)

* fix(apps): Activate addresses the item by id, not slug; the origin prefix is compared in characters; the one-live-install-per-origin rule is documented (TASK-3413, U9b codex r1)

* fix(apps): Activate moves focus to its row and announces it; the draft list retries after a failure; the upgrade's loading line is a status (TASK-3413, U9b codex r2)

* fix(apps): activation moves focus only when it was lost, and to the panel heading when the row is gone (TASK-3413, U9b codex r3)

* test(apps): an app token, service or a delegated owner's, cannot activate its own draft artifact (§2 step 7; TASK-3413, U9b lead ruling)
2026-10-05 06:09:12 -04:00
xarmian 9da2f17a5b test(collab): rate-bucket tests freeze their own bucket instead of racing the refill (BUG-3410) (#1788)
* test(collab): bucket tests freeze their own bucket instead of racing the refill (BUG-3410)

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

* test(collab): the non-upgrade test checks its frozen bucket was charged too (codex r1 on BUG-3410)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 05:08:38 -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 ede0c22fc2 feat(apps): Settings → Apps: install review, install-code handoff, and lifecycle controls (TASK-3413, SPEC-6 U9a) (#1785)
* feat(apps): GET /workspaces/{ws}/apps lists the workspace's installs for the owner, and says when apps are off (TASK-3413, SPEC-6 U9a)

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

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

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

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

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

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

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

Migrations 125 / pg 099.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migrations 124 / pg 098.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migrations 123 / pg 097.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: census the delegated consent's form readers, the app-grant revoke's user agent, and four binding columns that carry no caller text (TASK-3399, U5b-1)
2026-10-04 19:22:21 -04:00
xarmian aadf3c09d6 feat(apps): app-owned webhooks, held until redeem hands out the secret (TASK-3408, SPEC-6 U10a) (#1778)
* feat(apps): app-owned webhooks, held until redeem hands out the secret (TASK-3408, SPEC-6 U10a)

One webhooks row per install (app_install_id), created from the manifest
at provisioning, kept in step by a reviewed upgrade, deleted by uninstall.
Its signing secret is handed to the app only by redeem, which rotates it
and stamps secret_delivered_at; until then the hook is HELD (lead ruling,
day 86) and the owner sees "needs a new install code to activate".
Installs provisioned before U10 are backfilled at start, held.

App hooks stay off every owner door: ListWebhooks (the owner list and the
owner dispatcher), the scoped get/test and delete, and the plan count.
Events resolve to THIS install's companions only (via_app).

GET /workspaces/{ws}/apps/{installID} and the lifecycle responses carry
the hook's status. Delivery is U10b.

Migrations 122 / pg 096.

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

* fix(apps): the webhook backfill re-decides under the install lock from the stored manifest (codex r1, TASK-3408)

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

* test(apps): NUL census baseline for the app webhook columns (TASK-3408)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-04 18:21:57 -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 37066f97be feat(apps): app attachment routes: item-scoped upload, metadata, and a streamed download re-admitted before its first byte (TASK-3401, SPEC-6 U6c) (#1776)
* feat(apps): app attachment routes: item-scoped upload, metadata and a streamed download re-admitted before its first byte (TASK-3401, SPEC-6 U6c)

* test(server): an app attachment read refuses a subject whose membership no longer reaches the item (TASK-3401, U6c)

* test(server): U6c tests reach the item re-check, the upload's edit right and the download's type decision (TASK-3401, U6c)

* test(server): a deletion before the first byte reaches the attachment re-check (TASK-3401, U6c)

* fix(apps): U6c codex r1: authorize before storage, a transport write deadline, the credential last in re-admission, and a live companion check in the fence (TASK-3401, U6c)

* fix(apps): U6c codex r2: a truncated upload is 400, a re-admission fault is 500 not 401, HEAD on the download, and unknown variants refused (TASK-3401, U6c)

* fix(apps): U6c codex r3: the upload re-admits the subject before its row (StageUpload/Insert, no store callback), and variant is null on originals (TASK-3401, U6c)

* fix(apps): U6c codex r4: a token-state or settings read failure during admission is a fault (500), not a denial (TASK-3401, U6c)

* fix(apps): U6c codex r5: a credential that dies mid-upload answers 401 invalid_token, a permission change 403 (TASK-3401, U6c)

* fix(apps): U6c codex r6: the served variant is re-checked at the gate, and a settings read fault at the first gate is a 500 (TASK-3401, U6c)

* test(server): a viewer's upload is refused before its body is read (TASK-3401, U6c)

* test(server): account for the app upload's raw body reader in the body-reader census (TASK-3401, U6c)
2026-10-04 15:14:27 -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 5e2accd179 feat(apps): app write routes: items create/update, comments create/update/delete, the write rule, SSE and watch parity (TASK-3401, SPEC-6 U6b) (#1773)
* docs(store): the per-call users read in the bot ceiling is accepted; how to remove it if profiled (TASK-3401)

* feat(server): app write routes: items create/update, comments create/update/delete, the write rule ahead of the editor fast path, SSE and watch parity (TASK-3401, U6b)

* test(server): app write routes: census, read-token refusal, write rule, refusals, disable race, SSE/watch parity (TASK-3401, U6b)

* test(server): the write-rule test gives the bot a role and a companion control, so only the rule can refuse (TASK-3401, U6b)

* fix(apps): an app write's status notification comes from its own transaction, never the handler's earlier read (TASK-3401, U6b codex r1 P2)

* fix(apps): an app item write's response is built from its own transaction: slug, schema, via_app, creator and ETag read before commit, nothing read after (TASK-3401, U6b codex r2 P2 x2)

* fix(apps): app comment writes publish the item and actor display their own transaction read, not the handler's earlier read (TASK-3401, U6b codex r3 P1+P3)

* docs(server): name the second layer behind the app comment same-item check (TASK-3401, U6b)
2026-10-04 12:22:00 -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
xarmian 282a42f69f feat(apps): the installed-app API door, ceiling and read routes (TASK-3401, SPEC-6 U6a) (#1771)
* feat(store): a bot's visibility is its install's ceiling, at every caller (TASK-3401)

* feat(server): the app API door, route table, ceiling, and read routes (TASK-3401, U6a)

* test(server): U6a door, ceiling, DTO census and visibility-caller census (TASK-3401)

* test(server): controls for the cross-workspace refusals; role follows access; cross-item parent projects null (TASK-3401)

* fix(server): app responses are re-validated against revocation before they are sent; one users read in the bot ceiling (TASK-3401, codex r1)

* fix(server): re-admit an app request and replay its authorizations before the response is sent (TASK-3401, codex r2)

* fix(server): re-admission rechecks apps availability and fails closed without a re-check holder; collection re-checks memoized (TASK-3401, codex r3)

* test(server): re-validation fails closed without a re-check holder (TASK-3401)

* docs(server): the re-check memo is sequential, not a snapshot (TASK-3401, codex r4)

* chore(server): drop the unused write-rule helper until U6b's first caller (TASK-3401)

* style(server): gofmt after dropping the helper (TASK-3401)

* fix(server): app item lists page by window (has_more, next_offset), not by page length (TASK-3401, Rook review)
2026-10-04 09:51:29 -04:00
xarmian ca158c22d8 feat(apps): app install staging: apps/1 manifest, fetch policy, pending reservations, preview (TASK-3397, U8a) (#1770)
SPEC-6 U8a (DOC-3371 §1, §2 steps 1-5, §9). Strict apps/1 parsing;
an https/no-redirect fetch policy screened at dial time with an admin
private-origin list; pending-install reservations under the owner lock
and the instance advisory lock, holding the full charge until the fetch
ends; the owner preview with every artifact normalized by the importer's
own code (normalizeArtifact, prepareCreateFields, both extracted
behaviour-preserving and pinned by a parity golden); apps_enabled and
the admin apps settings; a background sweep of expired pending
installs; migration 118/092. The preview is advisory: it may refuse
what the import would accept, never the reverse; U8b re-checks
atomically.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-04 07:19:07 -04:00
xarmian e5262160e1 feat(oauth): install clients, service tokens and the issuance barrier for installed apps (TASK-3394, SPEC-6 U5a) (#1769)
* feat(store): install clients, the issuance barrier and app_token_bindings (TASK-3394)

* feat(oauth): install-client service tokens, in-process app-token introspection, public introspection hardening, A0 token gate (TASK-3394)

* test(store): the issuance barrier serializes with disable, rotate and re-enable on Postgres (TASK-3394)

* test(server): pin each install-token refusal by its own hint; bearer introspection of another token (TASK-3394)

* fix(oauth): a grant carries the epoch read before client auth; the install-caller check reads credentials as fosite does (TASK-3394, codex r1)

* fix(oauth): classify the token request from the body fosite parses, multipart included (TASK-3394, codex r2)

* test(server): account for the install-client token rules' form readers in the body-reader census (TASK-3394)

* chore(store): renumber the install-client migrations to 117 / pg 091 after #1768 (TASK-3394)
2026-10-04 05:58:33 -04:00
xarmian 526001c199 feat(appstore): app attachment upload, read rule and caps behind FencedTx (TASK-3396, U7a) (#1768)
SPEC-6 U7a (DOC-3371 §4 Attachments, day-85 revisions): the store, blob
and appstore layers of app attachments. Constant-work blob writes
(FSStore.Stage/Commit/Abort), the upload hash guard moved into
attachments.InFlight and shared with the app store, FencedTx upload with
exact install caps recounted under the workspace seq lock plus an
in-process admission reservation, the app read rule, variant inheritance
of uploader and via_app, the AppAttachment DTO, and migration 116/090
(attachments.via_app). The HTTP route lands with U6 or a U7b on top.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 21:31:44 -04:00
xarmian b572f0739c feat(auth): the bot principal for installed apps: users.kind, kind gates on every sign-in door and credential, people-only counts (TASK-3392) (#1766)
* feat(store): the bot principal: users.kind, CreateAppUserTx, kind gates on every credential mint and claim, people-only counts, install-gated app projection (TASK-3392)

* test(store): record users.kind in the NUL census baseline (store-written, two values) (TASK-3392)

* feat(server): kind gates at the sign-in doors, admin 404 and member 409 for a bot, apps array on the member list, reserved domain refused (TASK-3392)

* docs+web: the members apps array (TS type, CLAUDE.md); people-only admin member count test (TASK-3392)

* test(server): the U3 owner-webhook strip test plants an install (TASK-3392)

* test(server): forgot-password and resend refuse a bot before attempting a mint (TASK-3392)

* test(server): a usable base URL so the resend door would really mint (TASK-3392)

* test(server): the session doors' own kind checks, with the bot already resolved (TASK-3392)

* fix: stored reset/verification tokens, PAT rotation, grants and OAuth bearers refuse a bot (TASK-3392, codex r1)

* docs: SPEC-6 §11 Q2 is decided (separate Apps section, no seat); the switches stay pinned (TASK-3392)

* docs(server): name the two layers in front of CLI approve's kind check (TASK-3392)

* test(appstore): the projection's install gate joins the reviewed door helpers (TASK-3392, rebased on #1765)
2026-10-03 19:41:18 -04:00
xarmian 6066d42115 feat(outbox): freeze an app-projection block into item and comment events (TASK-3389) (#1763)
* feat(outbox): freeze an app-projection block into item and comment events (TASK-3389)

SPEC-6 U3. A connected app's webhook payload must describe an event as it
was when it happened, built from nothing but what the mutation froze. Every
single-subject item and comment event now carries an `app_projection`
block, computed on the mutation's own transaction:

- items: creator (account id, display name, kind), collection_id, and the
  field values projected by the event-time schema (declared keys only,
  never relation or multi_relation);
- comments: creator, collection_id, item_id, and a parent_comment_id kept
  only when the parent is on the same item;
- comment.deleted computes its block before reapTombstonesTx can remove the
  parent, so the parent check sees it.

The comment payload is built by marshaling the comment and adding the block
as a key: models.Comment has its own MarshalJSON, which an embedding wrapper
would promote, silently dropping the block.

Account deletion scrubs the block by path: creator.user_id and the display
beside it. Projected field values are the item's own data and are not
walked.

The block is for app delivery only. The outbox drain strips every
app_projection key at deliverOutboxUnit, the one place a payload reaches an
owner webhook (singles and folded bulk batches alike). The block is written
for every event for now; the one-installed-app gate lands with the installs
table (DOC-3371, U4/U5).

* fix(store): a data-shape problem degrades the app projection to partial, never fails the write (TASK-3389)

* fix(store): a fields blob with trailing data projects partial; cap the partial-warning registry (TASK-3389, codex r3)
2026-10-03 16:20:54 -04:00
xarmian cc85c2f9da feat(auth): social claim of a never-verified account, provider subjects, cloud-only sidecar routes (TASK-3351) (#1760)
* feat(auth): sidecar OAuth routes are cloud-only; provider subjects bind on next sign-in (TASK-3351)

U1: oauth-login, oauth-link and oauth-unlink answer only in cloud mode. A
self-hosted install carrying a cloud secret created accounts and minted
sessions on a caller's word about an email.

U2: user_oauth_identities (SQLite 111 / PG 085) binds a provider's stable
account id to one pad account. oauth-login and oauth-link take an optional
subject. The first sign-in carrying one binds it, and a different one is
refused 403 oauth_subject_mismatch, naming the recovery route (password reset,
then relink). A subject bound to another account creates nothing. Unlink
forgets the binding. The subject is checked at the door (no NUL trigger),
and the four columns are recorded in the NUL baseline.

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

* feat(auth): a provider that vouches for an address claims the never-verified account (TASK-3351)

U3: oauth-login for an existing account whose provider is not linked used to
refuse. Now, if the account has never been verified and the provider vouches
for the address, it is claimed:
- Google and Apple: email_verified is enough.
- GitHub: the sidecar must also send email_primary=true (ruling 2), and
  sidecars today don't, so GitHub claims stay refused until one does.

The claim runs the BUG-3382 core, now shared as claimAccountTx:
- credentials reset and the epoch bumped
- access stripped in workspaces the account doesn't own, and the ones it owns
  soft-deleted (ruling 3)
- an account with billing refused with account_claim_needs_support (ruling 4)

The core now also unlinks every sign-in provider and forgets their bound
subjects; those were linked by whoever held the account. The claimant's
provider is then linked and bound, the display name replaced with the
provider's, and a session minted on the claim's epoch.

The response carries claimed, stripped_workspaces and deleted_workspaces. The
mailbox gets a notice listing them (lead's ruling: the redirect may never
render the response); without email it is logged.

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

* fix(auth): claim, link and bind commit together; serialize provider writes; claim lock order (TASK-3351)

Codex round 1:
- P1: a social claim committed before its provider subject was checked, so a
  sign-in refused on the subject left the account claimed. ClaimAccountByProvider
  now takes the provider and subject and links and binds in the claim's own
  transaction; a mismatch rolls the whole claim back.
- P2: a sign-in that read the link before an unlink could re-bind its subject
  afterwards. Every provider write (link, bind, unlink, claim) now locks the
  account row, and a bind requires the provider to still be linked
  (ErrOAuthProviderNotLinked, answered as oauth_provider_not_linked).
- P2: the social claim locked the account before its verification tokens,
  the reverse of verification, and the two deadlocked on Postgres. A test hook
  holds the claim at its account lock while a consume starts; without the fix
  it fails with 40P01 under test-pg, and with it it passes.

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

* fix(auth): one lock order, account before tokens; a failed provider link stops the sign-in (TASK-3351)

Codex round 2:
- P2: the token-first order adopted in round 1 deadlocked against account
  deletion, which locks the account first. Every path now locks the account
  before its tokens:
  - deletion (unchanged)
  - the social claim (back to account-first)
  - ConsumeEmailVerification and the link claim: each finds the token's
    account without a lock, locks the account, then spends the token
    conditionally
  This also removes an older consume-vs-deletion deadlock. Two tests hold the
  claim, or deletion, at the account lock while the others start. Under
  test-pg, the consume-first order fails both with 40P01 and the claim-first
  order fails the deletion test; the fixed order passes both.
- P2: on a new account, a failed provider link was only logged and the
  sign-in continued, leaving an account that could not sign in with that
  provider again. Any link failure now stops the sign-in.

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

* fix(auth): a link claim checks its token's expiry after waiting for the account (TASK-3351)

Codex round 3: the spend reused a timestamp taken before the account lock,
so a token that expired while the claim waited still claimed the account. The
spend now reads the clock itself. Test: a token expiring in 1s, with the claim
held at the account lock for 2.1s, claims nothing. It fails with the stale
timestamp.

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

* fix(auth): a claim resets the registrant's username and display name (TASK-3351)

Lead's ruling on #1760: the registrant chose the username and display name,
and either can impersonate ("support", a staff name). Nothing live depends on
the username once the claim has deleted the account's own workspaces. The
shared claim core (both the link claim and the provider claim) now resets
both, in the claim's transaction under the account lock:
- the name: the provider's name, or the address's local part
- the username: generated from that name with GenerateUsername, and unique
  among other accounts
The handler's post-claim rename moves into the core.

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

* fix(auth): a claimed account's username follows the username rules and survives a lost race (TASK-3351)

Codex round 5:
- P2: the regenerated username could be reserved (support@ gave "support",
  a provider name "Console" gave "console"). The store cannot import the
  server's ValidateUsername, so the server installs it on the store
  (SetUsernameValidator), and candidates that fail it are skipped.
- P2: a suffix could push a 39-character base past the limit. Each candidate
  now cuts the base to fit its suffix.
- P2: another account taking the chosen username before the claim wrote it
  failed the claim with a unique-index violation. The claim now retries, up
  to three times. Its transaction rolled back whole, the link claim's token
  included.
Tests: reserved and length (store, with a stub validator); the real
validator through the social claim (server); the lost race, Postgres only,
through a hook that takes the username before the write. Each test fails
without its fix.

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

* fix(auth): a claim falls back to a random username when no candidate from the name will do (TASK-3351)

Codex round 6: an email fallback the generator leaves invalid (foo--bar), or
a name whose first 1,000 candidates are all held, failed the claim. After the
derived candidates, the claim now tries random user-xxxxxxxx names under the
same rules and uniqueness check. The test fails without the fallback.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 12:15:04 -04:00
xarmian 98871c7e01 fix(auth): a verification link verifies only for its account's session; the mailbox owner can claim (BUG-3382) (#1758)
* fix(auth): a verification link verifies only for its account's session; the mailbox owner can claim (BUG-3382)

Registration mails the verification link to the address, and the address
may belong to someone other than whoever registered it. Clicking it used to
verify the squatter's account in place, keeping the squatter's password and
sessions.

- POST /auth/verify-email now verifies only when the request is signed in as
  the account the token names. Otherwise it answers 409 verify_needs_session
  with {email, can_claim} and leaves the token unspent.
- New POST /auth/verify-email/claim {token}: the mailbox owner claims a
  never-verified account. One transaction spends the token, resets every
  credential (an unusable password, 2FA, sessions, PATs, OAuth, CLI
  handoffs, reset and verification tokens), marks the address verified,
  strips memberships, grants, watches and tabs in workspaces the account
  does not own, and soft-deletes the ones it owns (TASK-3351 ruling 3). An
  account with a billing customer is refused with
  account_claim_needs_support (ruling 4). The answer carries a
  set-password path and the affected workspace names.
- users.credential_epoch (SQLite 110 / PG 084): bumped by a password change,
  a disable and a claim. Every session mint is fenced on the epoch read
  when the credential was checked (login, 2FA via the challenge token, CLI
  approval, reset completion, rotation, OAuth login), so a sign-in checked
  before a claim mints nothing after it (401 credentials_changed).
- The web verify page offers "Sign in to verify" (back to the link), and,
  for an unverified address, a confirmed claim listing what was removed.
- A regression test shows that an unverified account's cookie cannot use
  the cookie-fallback routes.

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

* fix(auth): fence password writes on the checked epoch; own verify bucket; switch account (BUG-3382)

Codex round 1:
- A password change (PATCH /me) or a reset completion that started before
  a claim could still overwrite the claim's reset. UserUpdate.ExpectedEpoch
  makes the write conditional on the epoch its authority was checked under:
  the current password's read, or the reset token's spend. The spend now
  reads the epoch in the same statement, and a test hook puts a claim
  between the spend and the read-back. A mismatch writes nothing
  (credentials_changed).
- verify-email and its claim move to their own EmailVerify bucket (10/hour),
  so opening the link, refreshing and claiming no longer spends the
  PasswordReset budget the claimant needs to set a password.
- The verify page, signed in as a different account, signs out before
  sending the user to sign in. A plain /login link bounced straight back.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 09:48:19 -04:00