DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path scoping-notes/loomworks-stele-phase-5-scoping-note-v0_1.md

Loomworks — Stele Extraction Phase 5 Scoping Note — v0.1

Version. 0.1 Date. 2026-06-15 Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (scoping) on DUNIN7-M4. Status. Scoping note. Precedes the Phase 5 Change Request. Grounded against the real engine code at main 9be25fd (alembic 0084) via a read-only CC inventory of /me/security/*, the Stele credential primitives, and the sign-in paths, plus a read-only SSO-presence check. No code written; no CR drafted yet. Markdown primary (technical consumer / next-session orientation).


Plain-language summary

What this does. Scopes Phase 5 of the Stele extraction — credential lifecycle — against what is already in the code, so the CR builds only what is missing rather than re-building what the §10.3 account-recovery amendment already landed.

What changed since the Phase 4→5 handoff. The handoff named four Phase 5 operations as if all four were open work. The grounding read shows two of the four are already built and shipping (second-passkey enrollment; recovery-code surfacing with soft-invalidation, both via §10.3). Phase 5's real work is two operations (passkey-revoke; person-scoped TOTP-rotate) plus one small gap (additional passkeys land with no display name).

What decision is needed. Two are already locked in this session (extend /me/security/* in place; last-credential guard counts passkeys + TOTP secret only). One remains: confirm the Phase 5 cut order and the scope line below, then I draft the CR.

In scope. Passkey-revoke (route + Stele remove_credential primitive + last-credential guard at the primitive/service layer); person-scoped TOTP-rotate (route(s) + Stele person-TOTP re-provision primitive); the additional-passkey display_name plumbing gap.

Out of scope. Anything SSO/federated (not built — forward commitment); the OneTimeSecretsDisplay frontend component (Operator Layer, not engine); the two Phase 4 logged limitations (failed-signup UX gap; orphaned claim) unless Phase 5 chooses them; founder-authority Mechanism 3 (bound to A3 in CR-2026-098, a separate thread).


1. Where Phase 5 actually sits (grounded, not assumed)

The Phase 4→5 handoff's Phase 5 row named four operations: second-passkey enrollment, passkey-revoke, TOTP-rotate, recovery-code surfacing. The grounding read against main 9be25fd corrects the framing: two are already built.

| Phase 5 operation | Real state today | §10.3 covered it? | Still missing | |---|---|---|---| | Second-passkey enrollment | Built. POST /me/security/passkeys/begin + /complete, calling add_passkey_begin/add_passkey_completeadd_credential. Works. | n/a | Only the display_name gap (below) | | Recovery-code surfacing | Built. POST /me/security/recovery-codes/regenerate + GET /me/security/recovery-codes status, with soft-invalidation (invalidated_at) in regenerate_recovery_codes. | Yes — landed | Nothing engine-side. OneTimeSecretsDisplay is frontend/OL, not this repo | | Passkey-revoke | Absent. No route, no primitive. Router docstring defers it pending the last-credential guard, which "waits on the recovery arc." | n/a | Full build: route + primitive + guard | | TOTP-rotate (person) | Absent person-scoped. A reset_totp exists but is contributor-scoped (ContributorRow, wrong subject). Person TOTP secret is set only at signup today. | n/a | Full build: route(s) + person-scoped re-provision primitive |

The correction to carry into the CR: Phase 5 is two operations plus one gap, not four operations. The recovery arc that passkey-revoke was waiting on is the §10.3 work — now satisfied, so revoke is unblocked.

2. Locked decisions (this session)

**D1 — Extend /me/security/* in place. The new revoke and TOTP-rotate routes land in the existing host router me_security.py, calling Stele primitives underneath. This follows the host-seam principle the extraction has held throughout: Stele owns the credential primitives; the host owns the lifecycle policy.** The primitives already sit in stele/ (credentials.py, recovery.py); the routes already sit in the host router. Phase 5 adds a remove_credential primitive and a person-TOTP re-provision primitive to stele/, and the routes + the guard to the host. No new router, no surface move. (Operator-locked, 2026-06-15.)

D2 — The last-credential guard counts passkeys + TOTP secret only. The guard answers "does this person have another way in after I remove this passkey?" It counts: registered passkeys, plus PersonRow.totp_secret IS NOT NULL. It does not count recovery codes (a break-glass substitute for the second factor, sitting behind the passkey-gated partial session per the sign-in path — a person with zero passkeys and only recovery codes has no first factor and cannot authenticate). It does not consult SSO (none exists — §4). (Operator-locked, 2026-06-15.)

3. SSO — settled, and why it does not block Phase 5

The seed names three auth paths: passkey, authenticator code (TOTP), organizational SSO. The grounding read settled SSO's real state: not built — a seed commitment ahead of the build (per Phase 14 Decision 7 and the Phase 48 deferral). No SSO/SAML/OIDC route, no IdP-binding column on PersonRow, no non-passkey/non-TOTP session path except the env-gated dev bypass. The three implemented auth paths reduce to two: passkey-assertion + (TOTP or recovery code).

Consequence for Phase 5: the hazard "strip the last passkey but SSO still keeps the person in" cannot occur today, because SSO cannot be the thing keeping a person in. The guard's job is unambiguous (D2). The guard is written to gate on passkey count + PersonRow.totp_secret IS NOT NULL only.

Forward dependency (named, not built). When org-SSO lands in a future phase, the guard's "other ways in" predicate must be revisited to add the SSO binding to the count. Phase 5 records this as a pointer so the future SSO phase finds the guard rather than discovering it by surprise. It is a forward dependency, not a present input — the CR must not write the guard to consult a column or code path that is not there.

4. The four layer hazards carried in as design constraints

CC flagged four hazards of the Phase-4 heal-placement class — value/guard produced in one layer, used in another. Phase 5 designs against each from the start.

  1. The last-credential guard goes at the primitive/service layer, not the router. The existing passkey-complete ownership gate (pending.person_id != person.id → 403) sits in the router above the write; add_passkey_complete does not re-check binding. Any caller bypassing the route bypasses the gate. Phase 5's revoke guard must not repeat this — it must live where the removal happens (the remove_credential primitive or a thin service wrapper), so a caller cannot strip a last credential by going around the route. (This is the direct Phase-4 lesson recurring: a placement is checked against the real code at the placement site.)
  1. Commit boundary stays in the route, flush stays in the primitive. add_credential and regenerate_recovery_codes only flush(); the db.commit() lives in the route handler. Phase 5's revoke and TOTP-rotate must follow the same split — primitive flushes, route commits — or a forgetful caller silently rolls back. The CR specifies flush-in-primitive / commit-in-route explicitly for both new operations.
  1. Second-factor gate is uniform via get_current_person (totp_verified). Every /me/security/* route inherits the totp-verified-session requirement only because it depends on get_current_person. Phase 5's new routes inherit it only if they use the same dependency — the CR requires it for revoke and TOTP-rotate.
  1. TOTP-rotate's verify is a layer-split decision. A person-scoped rotate is generate → verify the new secret → re-encrypt onto PersonRow.totp_secret. The verify-before-commit step is exactly the place a guard/gate sits between produce and use. The CR places the verify in the primitive/service path, not as a router-only check, for the same reason as hazard 1.

5. The three pieces of real work

5A — Passkey-revoke.

5B — Person-scoped TOTP-rotate.

5C — Additional-passkey display_name gap.

6. Out of scope (named, so the CR does not drift into them)

7. Standing disciplines for the Phase 5 CR (carried from Phases 0–4)

8. Open decision for the Operator (one)

Cut order. My lean: 5C → 5A → 5B. Reasoning: 5C (the display_name gap) is the smallest, lands on already-built code, and clears the enrollment path cleanly before touching revoke. 5A (passkey-revoke) carries the load-bearing guard and should be isolated in its own revertable commit. 5B (TOTP-rotate) is the largest (new primitive + two routes + the twin-vs-generalize decision) and goes last so the guard from 5A is already proven before the rotate path leans on the same primitive-layer-gate pattern. Confirm "5C → 5A → 5B" or redirect.


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Extraction Phase 5 Scoping Note — v0.1 — 2026-06-15