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).
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.
In scope:
display_name from the passkey-complete request through add_passkey_complete into the existing add_credential call (which already accepts the param).remove_credential primitive (delete-side; none exists today), a DELETE /me/security/passkeys/{id} host route, and a last-credential guard at the primitive/service layer.PersonRow.totp_secret), and host route(s) under me_security.py (begin + confirm).Out of scope (named so the build does not drift):
OneTimeSecretsDisplay — Operator Layer / frontend, not the engine repo.PendingAddPasskeyStore multi-worker hazard — pre-existing deployment-hardening item, not credential-lifecycle work.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.
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.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.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.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:
stele/credentials.py add_credential (the create-side, as the template for the delete-side) and the WebauthnCredential model rows it writes.get_credential_by_credential_id, list_credentials_for_person, update_sign_count) — so remove_credential matches the module's real session/flush idiom.list_credentials_for_person shape the guard will count against, and the real PersonRow.totp_secret column (stele/models.py) the guard will check for IS NOT NULL.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 :278 → registry.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.
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.
display_name; complete-without-name still works (stays NULL — backward compatible).
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).
(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).
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):
display_name is non-NULL in the row.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).
main == origin/main between phases; intermediate main commits may be local-only until a merge pushes them. Check the real ref before any push/merge.test_me_security.py, test_webauthn_authentication.py) build login-capable persons without onboarding_complete. 5A/5C touch test_me_security.py — new tests there must not sign those persons in through a heal-firing route unless heal is intended to run. Build new persons with onboarding_complete=True where a complete account is the premise.main after the full series is verified.loomworks-record) and archive onto the branch (engine docs/phase-crs).DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-108 — Stele Extraction Phase 5 — Credential Lifecycle — v0.1 — 2026-06-15