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.
| Repo | Expected | Found |
|---|---|---|
| DUNIN7/stele | 1077803 | 1077803 ✓ |
| loomworks-engine | 08e89c5 | 08e89c5 ✓ |
| loomworks-record | cb367ab | cb367ab ✓ |
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.
SignupError / PendingRegistrationNotFound / PendingRegistrationOutOfOrder / TotpCodeInvalid; _PendingRegistration; results BeginSignupResult / PasskeyVerifiedResult / SignupCompleteResult; PendingRegistrationStore; _new_registration_id; and the three flow functions begin_signup / complete_passkey / complete_totp_verify._PendingAddPasskey, AddPasskeyBeginResult, PendingAddPasskeyStore, and the two functions add_passkey_begin / add_passkey_complete. ~100 lines, all at the bottom of the file under its own banner comment.§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:
PENDING_TTL (line 46, timedelta(minutes=5)) — a module constant add_passkey_begin reads (line 416), also read by begin_signup.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.**
add_passkey_begin: calls stele.webauthn.begin_registration(config, user_name, user_display_name, exclude_credentials) for the actual challenge; the wrapper adds only an add_id token, a PendingAddPasskeyStore.put, and the result shaping.add_passkey_complete: store.get(add_id) → stele.webauthn.verify_registration(config, credential, expected_challenge) → stele.credentials.add_credential(...) → store.discard. The wrapper adds only the store lookup/discard and the challenge↔credential binding.
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.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)
resolve_current_principal (default resolver over resolve_session, gates totp_verified), extract_token (bearer default), provide_secret_key (Stele-env default → STELE_SECRET_KEY), provide_person_email (default None).provide_db_session, provide_webauthn_config, provide_add_passkey_store, provide_add_passkey_begin, provide_add_passkey_complete. All five fail loud (NotImplementedError → 500, or 503 for an unconfigured secret) — never silent.
§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.WebauthnConfig — three fields: rp_id, rp_name, rp_origin (frozen dataclass).
§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 —
provide_db_session — a request-scoped AsyncSession yielder (a few lines over an async engine).provide_webauthn_config — return a WebauthnConfig(rp_id, rp_name, rp_origin) (one line from config).provide_add_passkey_store — return a pending store (one line: PendingAddPasskeyStore()-shaped).provide_add_passkey_begin / provide_add_passkey_complete — return the add-passkey ceremony callables (the host must have a ceremony to return).
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:
provide_db_session and provide_webauthn_config. The mount collapses to ~5 config values + 2 short providers.
§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.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:
stele.webauthn.begin_authentication + verify_authentication, stele.credentials.get_credential_by_credential_id + update_sign_count;stele.recovery.verify_and_consume_recovery_code, and for TOTP — note a gap: Stele exposes TOTP rotation (person_totp.begin/confirm) and the secret column + kek_decrypt, but no standalone "verify a TOTP code against the stored secret" primitive; the reference app composes it (kek_decrypt(totp_secret) → pyotp.TOTP(secret).verify(code)), as the engine's login.py does;stele.session.issue_session (full session token), with the partial-session shape (assertion-then-2FA) a host concern.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).
Fork 1 — the §4 ceremony (wire vs lift).
add_passkey_* shares exactly 2 trivial one-directional symbols with the signup flow (PENDING_TTL, PendingRegistrationNotFound), is a thin wrapper over stele.webauthn primitives it already calls, carries no host concepts, has only me_security as a caller, and its types are Stele-shaped (with the contract Protocols already in stele.api). Removing it leaves the signup flow fully intact.stele.webauthn; the slot + store disappear; the package becomes self-contained for passkey enrollment; the reference app wires less. The feared persons/signup.py "split" is, on the evidence, a clean deletion of a co-located block (the signup flow is untouched). Interaction note (held-out item): lifting touches only stele.webauthn + persons/signup.py + me_security (which would then call Stele's ceremony or drop the injected one); it does not affect the ../stele editable-dep package path.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