Files
pad/internal/server/testdata
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
..