DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/stele-extraction-phase-7/loomworks-stele-phase-7-p7-3-step-0-inspection-report-v0_1.md

Loomworks — Stele Phase 7 P7-3 Step 0 Inspection Report — v0.1

Version. 0.1 Date. 2026-06-19 By. Claude Code (CC) on DUNIN7-M4 — read-only inspection per loomworks-stele-phase-7-p7-3-step-0-inspection-brief-v0_1.md. Nothing changed — no edits, no commits, no migrations to the code trees. Status. Step 0 inspection report grounding the P7-3 scoping note. CC reports facts and surfaces both forks; CC decides neither.

Ground state — HEADs confirmed (at start-state, no drift)

| Repo | Expected | Found | |---|---|---| | DUNIN7/stele | 1077803 | 1077803 ✓ | | loomworks-engine | 08e89c5 | 08e89c5 ✓ | | loomworks-record | cb367ab | cb367ab ✓ |


§A — The persons/signup.py tangle (Fork 1's deciding fact)

persons/signup.py is 450 lines in two halves with a near-zero seam between them.

§A.1 — full surface, grouped.

§A.2 — the dependency lines between the halves: exactly TWO shared symbols, both trivial, both one-directional. The add-passkey half uses nothing from the signup-flow half except:

  1. PENDING_TTL (line 46, timedelta(minutes=5)) — a module constant add_passkey_begin reads (line 416), also read by begin_signup.
  2. PendingRegistrationNotFound (line 54, a SignupError subclass) — raised by PendingAddPasskeyStore.get (lines 376, 379).

Both flow from the signup-flow definitions into the add-passkey half — the dependency is one-directional. The signup-flow half uses nothing from the add-passkey half. It has its own _PendingRegistration / PendingRegistrationStore / result types and never references _PendingAddPasskey, AddPasskeyBeginResult, PendingAddPasskeyStore, or the two add_passkey_* functions. So removing the add-passkey half leaves the signup flow 100% intact — there is no shared mutable state, no shared helper called both ways, no entangled control flow.

**§A.3 — add_passkey_* is a thin wrapper over stele.webauthn primitives it already calls.**

The substantive WebAuthn work is already in Stele; the host wrapper is the pending-store plumbing + the token. No host-specific business logic crosses (no onboard, no host_account, no membership, no engine concepts).

§A.4 — callers: only me_security. add_passkey_begin / add_passkey_complete are called exclusively from api/routers/me_security.py (lines 178, 230; imported 49–50). No signup-flow path calls them (complete_passkey uses verify_registration directly, not add_passkey_*). Confirmed against live, matching P7-2 Step 0.

§A.5 — the stele.webauthn landing site is natural; the types are Stele-shaped. stele.webauthn already holds begin_registration / verify_registration / begin_authentication / verify_authentication + the challenge/result dataclasses (RegistrationChallenge, VerifiedCredentialData, …) + WebauthnConfig. An add-passkey ceremony lands directly beside begin_registration / verify_registration. The three types it would bring — _PendingAddPasskey (person_id: UUID, challenge: bytes, user_handle: bytes, options_json: str, expires_at), AddPasskeyBeginResult (add_id, options_json), PendingAddPasskeyStore (in-process dict) — are identity-only, no host concepts (person_id is the principal id). They are Stele-shaped. Moreover, stele.api already defines the contract shapes for exactly these: the AddPasskeyStore / AddPasskeyBeginResult Protocols and the PasskeyEnrollmentNotFound exception. So lifting would make those Protocols concrete in stele.webauthn, and the two shared symbols become Stele-native (a TTL constant; PasskeyEnrollmentNotFound in place of PendingRegistrationNotFound).

§A read (fact, not decision): the lift is a clean extraction, not a messy split. Lifting add_passkey_ removes a ~100-line, nearly-independent block (2 trivial one-directional shared symbols) and adds it next to the WebAuthn logic it already calls. It does not split the signup flow* — the §4 "reopens the persons/signup.py split" framing overstates it. What stays in persons/signup.py afterward is the intact signup flow (begin_signup / complete_passkey / complete_totp_verify + their store/types). The "split" is really "delete the add-passkey block from signup.py; it was only ever co-located, never entangled."


§B — The live mount surface (what a stranger actually wires)

§B.1 — the eight routes (stele/api.py; resource-relative; host picks the prefix), with the injection slots each pulls:

| Method · Path | Slots pulled | |---|---| | POST /passkeys/begin | resolve_current_principal, provide_person_email, provide_db_session, provide_add_passkey_store, provide_webauthn_config, provide_add_passkey_begin | | POST /passkeys/complete | resolve_current_principal, provide_db_session, provide_add_passkey_store, provide_webauthn_config, provide_add_passkey_complete | | GET /passkeys | resolve_current_principal, provide_db_session | | DELETE /passkeys/{passkey_id} | resolve_current_principal, provide_db_session | | POST /recovery-codes/regenerate | resolve_current_principal, provide_db_session | | GET /recovery-codes | resolve_current_principal, provide_db_session | | POST /totp/rotate/begin | resolve_current_principal, provide_db_session | | POST /totp/rotate/confirm | resolve_current_principal, provide_db_session, provide_secret_key |

§B.2 — the injection slots (9), default class, identity: (signatures verbatim in the P7-2 completion record §3 / CR-2026-115 §3.2; re-confirmed against live api.py)

§B.3 — the public SDK surface: import stele exposes 65 primitives eagerly and FastAPI-free (identity/session/credentials/recovery/TOTP/WebAuthn/KEK + the ORM rows + Base); stele.router and the slots are lazy (PEP-562 __getattr__; FastAPI loads only on access). Host import paths: stele.router, stele.provide_, stele.resolve_current_principal, the AddPasskey protocols, PasskeyEnrollmentNotFound — all identity-equal to the stele.api objects.

§B.4 — mount mechanics: module-level router + dependency_overrides keyed on the slot objects:


app.include_router(stele.router, prefix="/me/security")
app.dependency_overrides[stele.provide_db_session] = my_db_dep   # the exact slot object is the key

§B.5 — delivery seam: extract_token(request) -> Optional[str] ships a neutral Authorization: Bearer default. Cookie delivery is a host override of extract_token (read request.cookies[...]). issue_session is mint-only — the router sets no cookie; a cookie host writes the cookie from the returned token, a bearer host returns the token to the caller.

§B.6 — WebAuthn config: provide_webauthn_config must return a stele.webauthn.WebauthnConfigthree fields: rp_id, rp_name, rp_origin (frozen dataclass).


§C — What a real mount requires (Fork 2's sizing fact)

§C.1 — the config values (env-scaffolding floor): STELE_SECRET_KEY (a Fernet key; STELE_SECRET_KEYS_PREVIOUS optional for rotation); rp_id, rp_name, rp_origin (WebAuthn RP); and a database URL (STELE_DATABASE_URL, postgresql+asyncpg://…, per the migration env.py). ~5 config values.

§C.2 — the code-wiring (the mandatory providers a stranger writes): the 5 raises-until-overridden slots —

The decisive sizing fact — three of the five mandatory slots exist only because the ceremony is host-side. provide_add_passkey_store + the two ceremony slots are the §4 asymmetry's footprint. This is where Fork 1 and Fork 2 meet:

§C.3 — the WebAuthn ceremony requirement (the gap-close surface): the browser front drives the standard ceremony — begin returns options_json → the front calls navigator.credentials.create(options) (enrollment) / navigator.credentials.get(options) (login) → posts the result back to complete. The RP binding (rp_id / rp_origin) must match the serving origin or the browser refuses the ceremony. The reference app's front is minimal HTML/JS: the two navigator.credentials calls + fetch to the mounted routes + base64url encode/decode of the challenge/credential.

§C.4 — the sizing read (fact; Operator sizes the generator): the real mount's size is dominated by Fork 1. Under lift, a working mount is small (2 short providers + ~5 env values) — env-scaffolding-only plausibly suffices, and a skeleton file would save little. Under wire, the mount is substantial (the ceremony is real host code) — a runnable skeleton file (main.py with include_router + the 5 stubbed providers) would meaningfully cut the stranger's work, and the config-gen earns its keep. The two forks are not independent: Fork 1's resolution largely sizes Fork 2.


§D — Reference-app shape signals (reported, not designed)

§D.1 — framework: a minimal standalone FastAPI app. stele.router is a plain APIRouter(tags=["stele-security"]) (api/api.py:258) — mountable in any FastAPI app. No coupling complicates a bare-FastAPI mount (the slots are the only host surface). The reference app is a small FastAPI app — not the engine, not Next.js.

§D.2 — DB: Postgres, via the P7-1 baseline. SQLite is not viable. The Stele models use JSONB (webauthn_credentials.transports) plus LargeBinary and PG_UUID — Postgres-specific. The routes use AsyncSession, and STELE_DATABASE_URL is postgresql+asyncpg://…. The reference app stands up its DB by running Stele's 0001_baseline (the 3-table migration from P7-1) against a real Postgres (a throwaway/docker Postgres; not SQLite — JSONB won't translate).

§D.3 — browser-front minimum: smallest static HTML/JS — for enrollment: fetch begin → navigator.credentials.create → fetch complete; for login: fetch login-begin → navigator.credentials.get → fetch login-verify; plus base64url helpers and token handling (set/read a cookie for the cookie shape, or stash + send a bearer header for the bearer shape). Two delivery shapes → two token-handling paths over the same minted token.

§D.4 — login as well as enrollment (host composition the reference app writes). The router ships credential-management routes (enrollment / recovery / TOTP rotation) but not login orchestration (B-first: signup/login stayed host). A full login is composable from Stele primitives the reference app orchestrates itself:

So the reference app is the host that composes signup + login over the primitives — that composition is real reference-app code the scoping note must design (it demonstrates B-first's "the marquee flows live in the host/reference app," not as package routes).


Closing — the two forks, with the real picture (Operator decides both)

Fork 1 — the §4 ceremony (wire vs lift).

Fork 2 — the config-generator size — is largely decided by Fork 1.

CC has decided neither fork. The facts above are framed for the Operator's call; the P7-3 scoping note is written against this report once both forks are resolved.


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Phase 7 P7-3 Step 0 Inspection Report — v0.1 — 2026-06-19