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).
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:
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.)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.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).
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.
In scope:
display_name from the passkey-complete request into the existing add_passkey_complete call (substrate already accepts and persists it; the route omits it).remove_credential primitive (hard db.delete; first delete in stele/), a DELETE /me/security/passkeys/{id} host route, and a last-passkey guard at the primitive/service layer blocking removal when it is the person's only passkey.PersonRow.totp_secret), and host begin+confirm route(s) under me_security.py.
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).
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.)get_current_person. Every new route depends on get_current_person (requires totp_verified).
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.
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.
display_name on the row; complete-without-name → still works, stays NULL (backward compatible).
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.
totp_secret set — this is the corrected behaviour, test it explicitly); one of several passkeys → allowed.
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).
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:
pyotp.random_base32() → return secret + provisioning URI (issuer_name="Loomworks"); does not write the live column.pyotp.TOTP(secret).verify(code, valid_window=1)); on pass, encrypt_for_scope(SCOPE_TOTP, secret, …) → write PersonRow.totp_secret; await db.flush(). Verify sits here (constraint 4). No commit. On fail, no write — old secret intact.secret_key the same way the contributor commit_totp_secret and mint_principal do.
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)
Prove the real paths in the real database, through real get_db_session (stub only the WebAuthn crypto verify as the suite does):
totp_secret is set (the corrected behaviour — this is the case v0.1 would have wrongly allowed). A person with two passkeys → revoke one, sign in with the other.display_name is non-NULL in the row.main == origin/main between phases; 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) — v0.2 supersedes the v0.1 already filed at change-requests/; both stay (trajectory preserved).
| # | 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) |
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) → flush → refresh → 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