DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-108-stele-extraction-phase-5-v0_2.md

CR-2026-108 — Stele Extraction Phase 5 — Credential Lifecycle — v0.2

Version. 0.2 Date. 2026-06-15 Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (CR) on DUNIN7-M4. Status. Change Request, drafted, not yet executed. Governs Phase 5 of the Stele extraction. Grounded against engine main 9be25fd (alembic 0084) via the read-only inventory in loomworks-stele-phase-5-scoping-note-v0_1 (loomworks-record 53733db) and the read-only Step 0 pre-flight (CC, 2026-06-15) recorded in §11. Markdown primary (technical consumer — CC executes from this; code review reads it). Governing scoping note. loomworks-stele-phase-5-scoping-note-v0_1.md (loomworks-record 53733db). Baseline. engine origin/main 9be25fd, alembic 0084, suite 2921/46/0. Clean tree (one untracked .wrangler/ tooling dir, ignorable).


What changed from v0.1 (named, not smoothed)

v0.2 incorporates three decisions locked after the Step 0 read-only grounding (CC, 2026-06-15). Step 0 surfaced one planned decision (5B twin-vs-generalize) and two unplanned findings that overturned v0.1's design:

  1. The 5A guard predicate is corrected — and this supersedes scoping-note decision D2. v0.1 (and the scoping note's D2) specified: block revoke iff passkey_count == 1 AND totp_secret IS NULL — i.e. a person with a TOTP secret could remove their last passkey, because TOTP was treated as "another way in." CC's read of the real login flow overturned this. Production login is passkey-assertion → partial session → then TOTP or recovery code → full session. TOTP is a second factor layered on a passkey-gated partial; there is no TOTP-only entry, and no SSO. So removing a person's last passkey locks them out even with a TOTP secret. The corrected guard is stricter: block revoke of the last passkey unconditionally (passkey_count == 1), regardless of TOTP. D2's "passkeys + TOTP" predicate stands as the superseded position; the real-login-correct floor is one passkey. (This is the Phase-4 lesson recurring: an instruction written against a design site, overturned by reading the real code at the placement site.)
  1. 5A uses hard-delete, not a soft-delete column. v0.1 left the mechanism open. Decision: hard db.delete(row) — the first delete idiom in stele/. A revoked passkey is a dead credential with no live query that needs a tombstone (unlike recovery codes, whose redemption path filters on invalidated_at). This keeps Phase 5 at alembic 0084 — no migration. Credential-revocation audit, if ever wanted, is a Memory-layer provenance-thread concern, not a soft-delete column.
  1. 5B uses a person-scoped twin, not a generalization. The planned Step 0 decision. The shared crypto is already a single helper both paths call (encrypt_for_scope/SCOPE_TOTP); the contributor functions gate on row.kind != "human" and engagement-routing that persons lack; and generalizing would make stele/ depend on contributors/, inverting the layering. Twin matches existing locality (mint_principal already inlines the person-TOTP encrypt in registry.py).

Plain-language summary

What this does. Builds the two credential-lifecycle operations genuinely missing — passkey-revoke and person-scoped TOTP-rotate — plus the one small display_name gap on the already-built additional-passkey path. It does not rebuild second-passkey enrollment or recovery-code surfacing; both already shipped (the latter via §10.3).

Cut order. 5C (display_name) → 5A (passkey-revoke) → 5B (TOTP-rotate).

Locked decisions carried in. Extend /me/security/* in place (host owns lifecycle policy, Stele owns primitives). The last-credential guard blocks removal of the last passkey unconditionally (corrected from v0.1 — see above). Hard-delete, no migration, stays at 0084. 5B is a person-scoped Stele twin. SSO is not built; the guard records it as a forward dependency, not a present input.

What the Operator does. Authorize v0.2; CC executes from Step 1 (Step 0 is already done — §11). Per-step commits, suite green at each, halt before each push.

No schema change. All three sub-steps add routes and primitives; none adds a column. Phase 5 stays at 0084. If 5B's build surfaces a real need for a column, that is a halt-and-surface, not a silent migration.


1. Scope

In scope:

Out of scope: SSO/federated (not built; forward commitment); OneTimeSecretsDisplay (Operator Layer/frontend); the two Phase 4 logged limitations; founder-authority Mechanism 3 (A3 thread); the PendingAddPasskeyStore multi-worker hazard (deployment-hardening item).

2. Design constraints (the four layer hazards)

  1. Guard at the primitive/service layer, never the router. The 5A last-passkey guard lives where the removal happens, so a caller bypassing the route still hits it.
  2. Flush-in-primitive / commit-in-route. remove_credential and the 5B re-provision flush only; their routes commit. (Matches the module's established idiom — update_sign_count and the contributor TOTP functions all flush, caller commits.)
  3. Second-factor gate via get_current_person. Every new route depends on get_current_person (requires totp_verified).
  4. TOTP-rotate verify sits in the primitive/service path. Generate → verify → re-encrypt; the verify-before-commit is a produce/use seam, placed in the primitive, not as a router-only check.

3. Step 0 — read-only pre-flight — COMPLETE (§11)

Step 0 ran read-only (CC, 2026-06-15): baseline confirmed (9be25fd, clean, 0084); 5A/5B/5C grounded against real code; the three decisions above locked. Full report in §11. No branch, no commit. Execution begins at Step 1.1.

4. Step 1 — 5C: additional-passkey display_name gap

1.1 — Branch + archive CR. Cut cr-2026-108-stele-extraction-phase-5 from 9be25fd; archive this CR (v0.2) into engine docs/phase-crs/. Commit.

1.2 — Thread display_name. Per the grounded change site (§11, 0.4): add optional display_name: Optional[str] to PasskeyCompleteRequest (me_security.py:87); pass display_name=body.display_name in the add_passkey_complete(...) call (me_security.py:210). add_passkey_complete and add_credential already accept and persist it — no substrate change.

5. Step 2 — 5A: passkey-revoke (the load-bearing guard, isolated)

2.1 — Stele remove_credential primitive. Add to stele/credentials.py. Fetch the row via the module's real reader idiom (select(WebauthnCredentialRow).where(...)scalar_one_or_none(), matching get_credential_by_credential_id); hard db.delete(row); await db.flush(). No commit (constraint 2). First delete idiom in stele/ — written to the module's fetch-then-mutate-then-flush precedent.

2.2 — The last-passkey guard, at the service/primitive layer (constraint 1). Before the delete: count the person's real passkeys via list_credentials_for_person. Block (raise a typed error) iff this credential is the person's only passkey (passkey_count == 1) — unconditionally, regardless of totp_secret. This is the corrected predicate (see "What changed from v0.1"): the real login flow requires a passkey to mint even the partial session, so the last passkey is the authenticator floor.

2.3 — DELETE /me/security/passkeys/{id} route. Under me_security.py, get_current_person-gated (constraint 3). Resolve the credential; confirm it belongs to the current person (route-level ownership check as the first gate; the 2.2 guard is the authoritative one at the write). Call remove_credential; route commits (constraint 2).

6. Step 3 — 5B: person-scoped TOTP-rotate (twin, last)

Built as a person-scoped twin in stele/ (decision 3). Mirrors the contributor two-step shape, subject PersonRow, column PersonRow.totp_secret, identical crypto (encrypt_for_scope(SCOPE_TOTP, …, EnvKeyEncryptionKeyProvider(secret_key=secret_key))) — the same call mint_principal already uses for the person at signup (§11, 0.3).

3.1 — Stele person-TOTP re-provision twin. New functions in stele/ (beside PersonRow/credentials.py/recovery.py), not a generalization of the contributor functions. Two-step:

3.2 — Host route(s) under me_security.py. A begin route (returns provisioning data) + a confirm route (verifies the code, route commits the re-encrypted secret). Both get_current_person-gated (constraint 3).

7. Verification (against playground_dev, not the suite alone)

Prove the real paths in the real database, through real get_db_session (stub only the WebAuthn crypto verify as the suite does):

8. Known cautions carried from Phase 4

9. Pending Operator authorizations

10. The three decisions (locked, for the record)

| # | Decision | Locked value | Superseded position | |---|---|---|---| | 1 | 5A guard predicate | Block last passkey unconditionally (passkey_count == 1) | v0.1 / D2: passkey_count == 1 AND totp_secret IS NULL (TOTP treated as fallback — overturned by real login flow) | | 2 | 5A delete mechanism | Hard db.delete (no migration; stays 0084) | Soft-delete column (revoked_at) considered, set aside (audit value low, migration cost real) | | 3 | 5B primitive | Person-scoped twin in stele/ | Generalize contributor functions over subject — set aside (layering inversion, hypothetical third subject) |

11. Step 0 grounding report (read-only, CC, 2026-06-15)

0.1 Baseline. HEAD 9be25fd ✅; tree clean (untracked .wrangler/ only) ✅; alembic 0084 head ✅. No drift → suite not run.

0.2 — 5A (revoke). add_credential idiom: db.add(row)flushrefresh → return. Readers: select(...).scalars().all() / scalar_one_or_none(). update_sign_count: fetch → mutate → flush (no commit — caller owns boundary). No delete idiom anywhere in stele/ — revoke is the first. WebauthnCredentialRow columns: id, person_id, credential_id (unique), public_key, sign_count, transports, display_name, created_at — no soft-delete column. PersonRow.totp_secret: nullable encrypted Text. Guard contradiction surfaced: real login is passkey-assertion → partial → TOTP/recovery → full; TOTP cannot mint a partial alone; no SSO. → last passkey is the floor; CR's OR-predicate overturned.

0.3 — 5B (TOTP-rotate). Contributor generate_totp_secret/commit_totp_secret/setup_totp/reset_totp are all ContributorRow-scoped, gate on kind != "human", write row.totp_secret/updated_at, crypto via encrypt_for_scope(SCOPE_TOTP, …). Person TOTP set only at mint (mint_principal, registry.py) with the identical crypto call; no person-side rotate exists. Lean: twin — shared crypto already factored; contributor guards (kind, engagement-routing) don't apply to persons; generalizing inverts stele/contributors/ layering.

0.4 — 5C (display_name). PasskeyCompleteRequest (me_security.py:87) has only add_id + credential — no display_name. add_passkey_complete (signup.py:397) already carries display_name: Optional[str] = None and threads it to add_credential (:421). The None leaks at the route call (me_security.py:210), which omits the arg. One-line change site: add the field to the request model; pass body.display_name at the route call. No substrate change.


DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-108 — Stele Extraction Phase 5 — Credential Lifecycle — v0.2 — 2026-06-15