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

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

Version. 0.1 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 recorded in loomworks-stele-phase-5-scoping-note-v0_1 (loomworks-record 53733db). 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).


Plain-language summary

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

Cut order. 5C (display_name gap) → 5A (passkey-revoke) → 5B (TOTP-rotate). Smallest-and-on-built-code first; the load-bearing guard isolated in 5A; the largest operation (5B) last, after the primitive-layer-gate pattern is proven in 5A.

Locked decisions carried in. Extend /me/security/* in place (host owns lifecycle policy, Stele owns primitives). The last-credential guard counts passkeys + PersonRow.totp_secret IS NOT NULL only — not recovery codes, not SSO. SSO is not built (forward commitment); the guard records it as a forward dependency, not a present input.

What the Operator does. Authorize this CR; then CC runs Step 0 (read-only) and halts for one decision — twin-vs-generalize for the TOTP-rotate primitive — which can only be made against the real contributor-TOTP bodies CC reads in Step 0. Nothing branches or commits until that halt is resolved.

No schema change anticipated. 5A/5B/5C add routes and primitives; none adds a column. (Phase 4's 0084 was the first and so far only schema change in the extraction.) If 5B's design surfaces a need for a column, that is a halt-and-surface, not a silent migration.


1. Scope

In scope:

Out of scope (named so the build does not drift):

2. Design constraints (the four layer hazards, carried from the scoping note)

These are not steps; they are constraints every step is checked against. All four are the Phase-4 "value/guard produced in one layer, used in another" class.

  1. Guard at the primitive/service layer, never the router. The existing passkey-complete ownership gate sits in the router above the write; add_passkey_complete does not re-check binding. The 5A last-credential guard must live where the removal happens, so a caller cannot strip a last credential by going around the route.
  2. Flush-in-primitive / commit-in-route. Existing primitives (add_credential, regenerate_recovery_codes) flush only; the route commits. 5A's remove_credential and 5B's re-provision flush only; their routes commit. Specified explicitly per step.
  3. Second-factor gate via get_current_person. Every new route depends on get_current_person (requires totp_verified), inheriting the second-factor-verified-session requirement. Required for both new routes.
  4. TOTP-rotate verify sits in the primitive/service path. Rotate is generate → verify the new secret → re-encrypt. The verify-before-commit is a produce/use seam; it goes in the primitive path, not as a router-only check.

3. Step 0 — read-only pre-flight (no branch, no commit)

CC runs this and halts at the end for the one Operator decision. Confirm baseline, then ground the two operations against real code.

0.1 — Baseline.


cd /Users/dunin7/loomworks-engine
git rev-parse --short HEAD
git status --porcelain
alembic current

Expected: 9be25fd, clean (ignore untracked .wrangler/), 0084 head. Report actuals; flag any drift. Do not run the suite yet if the tree is clean and matches — the scoping inventory already established the surface; a baseline suite run is optional confirmation, not required by Step 0.

0.2 — Ground 5A (revoke). Read the real verbatim body of:

Report: the exact delete idiom the module uses elsewhere (if any), and the precise guard predicate in real-column terms.

0.3 — Ground 5B (TOTP-rotate), the decision-bearing read. Read the real verbatim bodies of contributors/totp.py: setup_totp, generate_totp_secret, commit_totp_secret, and reset_totp. For each, report: its exact signature, what subject it operates on (ContributorRow vs anything person-shaped), what column it writes, and what crypto it calls (encrypt_for_scope/SCOPE_TOTP/secret_key threading). Then read the real signup-side person-TOTP set path (signup.py around :278registry.create_person) to see how the person TOTP secret is encrypted and stored today. Halt-and-surface decision (the one Operator decision in Step 0): from those real bodies, report whether the person-scoped re-provision primitive should be (i) a person-scoped twin of the contributor functions (new functions, person subject, PersonRow.totp_secret column) or (ii) a generalization of the existing contributor functions over the subject. Give a lean with the real reason (e.g. "twin — the contributor functions are ContributorRow-typed end-to-end and generalizing would touch the contributor reset path, widening blast radius beyond Phase 5"). Do not pick; surface and halt.

0.4 — Ground 5C (display_name). Read the real add_passkey_complete body (signup.py ~:397–420) and the PasskeyCompleteRequest model. Confirm the exact place the display_name=None is passed and the request field to thread from. Report the one-line change site.

Step 0 output: the three grounded reports + the twin-vs-generalize lean. Then halt. The first executing step (1.1) archives this CR into the engine and cuts the branch — only after the Operator resolves the twin-vs-generalize decision.

4. Step 1 — 5C: additional-passkey display_name gap (smallest, on built code)

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

1.2 — Thread display_name. Add an optional display_name field to PasskeyCompleteRequest (or whichever request model the complete route binds — confirmed in 0.4); thread it through add_passkey_complete into the existing add_credential(display_name=…) call. Primitive already accepts it — this is pure plumbing, no primitive change.

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

2.1 — Stele remove_credential primitive. Add to stele/credentials.py, matching the module's real session/flush idiom (from 0.2). Deletes the WebauthnCredential row for (person_id, credential_id). Flush only — no commit (constraint 2). Signature derived from the real reader idiom, not invented.

2.2 — The last-credential guard, at the service/primitive layer (constraint 1). Before the delete: count the person's real passkeys (list_credentials_for_person) and check PersonRow.totp_secret IS NOT NULL. Refuse the removal (raise a typed error) if removing this credential would leave zero passkeys AND no TOTP secret. The guard lives with the removal, not in the route — a caller bypassing the route still hits the guard.

2.3 — DELETE /me/security/passkeys/{id} route. Under me_security.py, get_current_person-gated (constraint 3). Resolves the credential, confirms it belongs to the current person (ownership check at the route is fine as a first gate, but the guard in 2.2 is the authoritative one — both present, matching the Phase-4 lesson that the authoritative guard sits at the write). Calls remove_credential; route commits (constraint 2).

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

(Built per the twin-vs-generalize decision resolved at the Step 0 halt. The steps below assume twin as the lean; if the Operator picks generalize, 3.1 changes from "new person-scoped functions" to "generalize the contributor functions over subject" and the test surface widens to cover the contributor reset path — the CR is amended to v0.2 before 3.1 in that case.)

3.1 — Stele person-TOTP re-provision primitive(s). Person-scoped (writes PersonRow.totp_secret), encrypting via the same encrypt_for_scope(SCOPE_TOTP, …, secret_key) path the signup set-path uses (from 0.3). Two-phase: a begin (generate new secret, return provisioning data — does not yet write the live column) and a confirm (verify a supplied code against the new secret, then re-encrypt + write PersonRow.totp_secret). The verify sits in this primitive path (constraint 4). Flush only (constraint 2).

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)

Per the standing discipline, prove the real paths in the real database, through real get_db_session (stub only the WebAuthn crypto verify as the suite does):

Invariant to re-confirm: no person ends a revoke with zero passkeys and no TOTP secret (the guard holds in the real database, not just the suite).

8. Known cautions carried from Phase 4

9. Pending Operator authorizations (none on the execution path until each is reached)


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