Version. 0.3
Date. 2026-06-14
Status. Change request, twice amended. v0.3 corrects a latent contradiction in v0.2: it described Phase 0 as "no edits to load-bearing code," but the three files it keeps in Phase 0 (recovery.py, webauthn.py, session.py) are imported by ~113 sites (≈20 production, 87 test, 6 script), including persons/login.py, persons/signup.py, and api/deps.py where get_current_person lives. Moving a file under "move, not duplicate" requires repointing every importer — so v0.2 as written had no executable path (repoint → forbidden edits to load-bearing modules; don't repoint → broken suite). v0.3 resolves this by distinguishing two senses of "touch load-bearing code": a logic/behaviour edit (forbidden in Phase 0 — that is what Phases 3 and 4 do) versus an import-path repoint (a mechanical, behaviour-preserving rewrite of an import line, the unavoidable completion of any file move, which IS in scope). Phase 0 does the second across all importers of a moved symbol; it does not do the first anywhere. The coupling map's "zero external callers" is now understood precisely: zero callers outside the identity set (no non-identity subsystem imports these), which is what made them safe to move — but they are heavily imported within the identity surface, and those imports get repointed. The v0.2 row-model/credentials.py→Phase 1 and TOTP→Phases 3/4 reassignments are unchanged and correct. Markdown primary (technical consumer: Claude Code). Halt-before-push; per-step commits; verify against engine-database truth.
Decisions this CR rests on (all settled): (1) Stele's interface is a host-agnostic API contract, in-process first, service-ready. (2) The contract is complete (defines passkey-revoke and TOTP-rotate, which arrive in a later phase). (3) Sign-up split is approach B — mint-then-onboard with a heal path (Phase 4, not this CR). (4) Move, not duplicate — relocate code in small reversible slices; one implementation throughout; safety from small reversible steps, not a parallel fallback. (5) White-label/multi-language hooks (tenant_context, locale) thread into user-facing operations (later phases, not this CR).
Source grounding. The Stele extraction scoping note v0.1 (Phase 0 definition), the integration design v0.2 (the contract), and the Claude Code coupling map (engine c210715). The coupling map established that the pieces below have zero external callers — that is what makes Phase 0 the safe first slice. v0.2 adds the tree-truth verification at 60e7cfa (the coupling-map anchor plus the Step 0.1 skeleton), which confirmed the zero-caller claim and corrected the file-separability assumption.
> Amendment note (v0.1 → v0.2), per corrections-preserved discipline. v0.1 §1 listed five pieces as moving into stele/ in Phase 0: the WebAuthn credential storage + WebauthnCredentialRow, the recovery logic + RecoveryCodeRow, the WebAuthn ceremony, the person-scoped TOTP internals, and the session encode/decode. That list was built from the coupling map, which read each piece as a separable file. The tree-truth check before Step 0.2 found otherwise: (i) WebauthnCredentialRow and RecoveryCodeRow are defined in the shared persons/models.py alongside PersonRow (Phase 1, explicitly not moving) under a single shared Base with ForeignKey("persons.id") — moving them is a metadata/Base-split decision, not a file move; (ii) the person-scoped TOTP internals are not a file (persons/totp.py does not exist) but inline logic inside signup.py (Phase 4) and login.py (Phase 3) — both load-bearing and out of scope for Phase 0; (iii) persons/credentials.py's functions import WebauthnCredentialRow, so they cannot cleanly precede the row model's move. The v0.1 boundary is not wrong as an eventual destination — every piece still moves into stele/ — but its phasing assumed file-separability the tree does not have. v0.2 reassigns the two interwoven pieces (and credentials.py) to the phases that already own the modules they live in, and holds Phase 0 to the three clean file-moves. The v1 list is preserved here as the superseded phasing.
> Amendment note (v0.2 → v0.3), per corrections-preserved discipline. v0.2 kept three files in Phase 0 — recovery.py, webauthn.py, session.py — and described the phase as "pure relocation of standalone modules … no edits to load-bearing code," with §2 authorizing import updates only among the moved files and into still-persons/ symbols. The tree-truth importer count run before Step 0.2 showed those three files are imported by ~113 sites (recovery.py: 3 production + 5 test; webauthn.py: 10 production + 10 test; session.py: 7 production + 72 test + 6 script), and the production importers include persons/login.py, persons/signup.py, and api/deps.py (get_current_person). Under "move, not duplicate," relocating a file means repointing all its importers — so v0.2 had no executable path: repointing edits the modules §3 says not to touch, and not repointing breaks the suite §2 requires green. v0.2's framing was right that Phase 0 changes no behaviour; it was wrong to equate "no behaviour change" with "no edits to load-bearing files," because an import-line rewrite is an edit to the file but not to its behaviour. v0.3 draws that line explicitly (§2, §3): logic edits to load-bearing modules are forbidden and deferred to Phases 3/4; import-path repoints are the mechanical completion of a move and are in scope. The destination is unchanged; v0.3 only makes the move executable.
This is the first, safest step of giving Stele a clean boundary. It creates the Stele module and moves into it three self-contained identity files — the recovery-code logic, the WebAuthn ceremony, and the session encode/decode. No subsystem outside identity imports these, which is what makes them safe to move: no feature outside the auth surface is affected. They are imported within the identity surface (by login, sign-up, and the dependency that resolves the current person), so moving each file means rewriting those import lines to point at the new location — a mechanical, behaviour-preserving change. No behaviour changes anywhere; the logic of login, sign-up, and get_current_person is untouched — only where they import from changes. This is the ideal slice to prove the module structure and to stand up the test harness that exercises Stele's moved logic in isolation. If anything looks off at the halt, each move reverts as a single commit. The identity records, the credential row models, the person-TOTP internals, and the actor constructors do not move in this phase — those are later CRs.
**In scope — move into the new stele module (clean standalone files; zero callers outside the identity set, confirmed at 60e7cfa; importers within the identity surface are repointed per §2):**
persons/recovery.py — generate_recovery_codes, store_recovery_codes, verify_and_consume_recovery_code, regenerate_recovery_codes, and the ride-along helpers hash_recovery_code, count_unused_recovery_codes, count_active_recovery_codes). Note: RecoveryCodeRow is NOT in this file — it lives in shared persons/models.py and does not move this phase (see reassignment below). recovery.py imports the row from persons.models; that import stays pointing at persons/ after the move.persons/webauthn.py — begin_registration, verify_registration, begin_authentication, verify_authentication, plus the config/challenge dataclasses).persons/session.py — encode_session, decode_session, issue_session, the SessionPayload type), referencing the shared key-encryption key left in the top-level credentials/ package (the KEK does NOT move — see §3).
For each of the three: move the file into stele/, then repoint every importer of its symbols to the new stele/ path — this includes the ~113 import sites across production, test, and script code (see Step 0.2 for the per-file counts). Repointing is an import-line rewrite only; no importer's logic changes. Imports into still-persons/ symbols (e.g. WebauthnCredentialRow, RecoveryCodeRow, PersonRow) stay pointing at persons/ — those rows do not move this phase. Run the suite per move; it must stay green.
Reassigned out of Phase 0 by the v0.2 boundary correction (these still move into stele/ eventually — at the phase that owns the module they currently live in):
WebauthnCredentialRow and RecoveryCodeRow → Phase 1. Both are defined in the shared persons/models.py alongside PersonRow (which Phase 1 moves), under one shared Base with ForeignKey("persons.id"). They are siblings of PersonRow under the same metadata; the shared-Base/cross-base-FK decision belongs to Phase 1, made once for all the identity rows together. Moving them in Phase 0 would be a behaviour-affecting schema-layer change, which §3 forbids.persons/credentials.py — add_credential, list_credentials_for_person, get_credential_by_credential_id, update_sign_count) → Phase 1. They import WebauthnCredentialRow; they cannot cleanly precede the row model's move. They travel with the row model in Phase 1. (Name-collision caution carried to Phase 1: persons/credentials.py — the person WebAuthn store, which moves — is a different thing from the top-level credentials/ package — the KEK home, which stays. §3.)persons/totp.py. The secret-generation and pyotp-verify logic is inline inside signup.py (generate + verify-in-complete_totp_verify — Phase 4) and login.py (verify-in-verify_totp_for_partial_session — Phase 3). Both are load-bearing and explicitly out of scope for Phase 0. The internals get extracted into stele/ as part of the phases that already touch those files, so the load-bearing modules are edited once, not twice. (Boundary caution preserved: the person-scoped TOTP internals are distinct from contributors/totp.py + api/routers/totp.py, the engagement-scoped contributor surface, which stays put permanently.)Explicitly NOT in scope this phase (later CRs — unchanged from v0.1):
PersonRow and the registry (Person, get_person_by_id) — Phase 1 (now also carrying the two credential row models and credentials.py, per the correction above).get_current_person pointing at Stele — Phase 3.
> Superseded v0.1 in-scope list, preserved per corrections-not-smoothed. v0.1 listed in Phase 0: the WebAuthn credential storage + WebauthnCredentialRow (credentials.py), the recovery logic + RecoveryCodeRow (recovery.py), the WebAuthn ceremony (webauthn.py), the person-scoped TOTP internals, and the session logic (session.py). v0.2 keeps recovery.py (functions only), webauthn.py, and session.py in Phase 0; moves the row models and credentials.py to Phase 1; moves the TOTP internals to Phases 3/4. The destination is unchanged; only the phasing is corrected.
Step 0.1 — Create the module skeleton. Create the stele module/package inside the engine (confirm the engine's module layout and place it consistently — e.g. src/loomworks/stele/). Empty package with its __init__. Commit. No behaviour change.
Step 0.2 — Move the three files, repointing all importers, one file at a time. Move, in order: persons/recovery.py, then persons/webauthn.py, then persons/session.py into stele/. The importers were counted at 60e7cfa (the verification that produced this v0.3): recovery.py — 3 production (api/routers/me_security.py, persons/login.py, persons/signup.py) + 5 test; webauthn.py — 10 production (api/deps.py, api/app.py, routers claim/me_security/login/signup, engagement/commit_attestation.py, persons/credentials.py, persons/login.py, persons/signup.py) + 10 test; session.py — 7 production (api/deps.py where get_current_person lives, routers login/signup/me_reactivate/auth_dev, persons/login.py, persons/signup.py) + 72 test + 6 script.
For EACH file:
stele/.stele/ path — production, test, and script. This is an import-line rewrite only. No importer's logic, control flow, or behaviour changes — get_current_person's body, the sign-up flow, and the login flow are untouched; only their import statements change. Imports into still-persons/ symbols (WebauthnCredentialRow, RecoveryCodeRow, PersonRow) stay pointing at persons/.Re-scoped halt condition (v0.3): the original "unexpected external importer → halt" no longer applies — external importers are now expected and repointed. Halt instead if repointing an importer would require more than an import-line change — i.e. if any call site cannot be made to work by rewriting its import alone and would need a logic edit. That would mean the symbol's surface changed in the move (it should not — this is relocation, not refactor) and needs a decision. Also halt if the suite cannot be made green by import repointing alone.
Do NOT move credentials.py, the row models, or the TOTP internals — reassigned to later phases per §1.
Step 0.3 — Wire the KEK reference. The session and credential logic encrypt under the shared key-encryption key, which stays in credentials/ (it is also used by api-keys, system-config, and the credential store — it is shared infra, not Stele's). Confirm the moved Stele code references the KEK from its existing location and does not copy or move it. Commit if any import path adjustment is needed.
Step 0.4 — Stand up the isolation test harness. Write a test that exercises the moved Stele pieces without booting the rest of the engine — encode and decode a session (fully self-contained in stele/session.py); run a WebAuthn registration ceremony against a test config (stele/webauthn.py); and exercise the recovery functions (stele/recovery.py) — generate, store, verify-and-consume one. Boundary note (v0.2): RecoveryCodeRow and WebauthnCredentialRow do NOT move this phase — they remain defined in persons/models.py under the shared Base. So the harness creates the tables from that shared metadata (the rows the stele/ functions operate on still live in persons/ until Phase 1), provides a test KEK and a test config, and does not import engagements, memory, or credit. This is still the proof that Stele's moved logic exercises in isolation (the Operator's "minor ecosystem" requirement); the row models join stele/ in Phase 1, at which point the harness can create Stele-owned tables. Commit.
Step 0.5 — Full-suite regression. Run the complete engine test suite and confirm nothing broke (nothing should — no external caller was touched). Verify against the engine database that sign-in still works end to end (the login path still imports these symbols, now from stele/ — confirm the import-path updates are complete and correct). This is the database-truth check, not suite-only. Halt and report results.
credentials/; Stele references it.contributors/totp.py + api/routers/totp.py) is not Stele and never moves. (v0.2: the person-scoped TOTP internals also do not move this phase — they are inline in signup.py/login.py and reassigned to Phases 3/4 per the §1 correction. Do not extract them in Phase 0.)PersonRow, the registry, the actor constructors, get_current_person, or sign-up — those behaviour changes are later phases (Phase 1 for the registry/rows, Phase 2 for the actor constructors, Phase 3 for get_current_person, Phase 4 for sign-up). Repointing their import lines to the moved stele/ files is permitted and required — that is the mechanical completion of the move (§2), not a logic edit. The distinction is the v0.3 correction: an import statement may change; the code's behaviour may not.
After Step 0.5, halt before any push. Report: the module location, the files moved (with the importer-repoint count for each and confirmation no importer's logic changed), the KEK-reference handling, the isolation test result (proof Stele's moved logic exercises in isolation), and the full-suite + database sign-in regression result. On the Operator's review, push Phase 0; then Phase 1 (the identity records and registry, now also carrying the credential row models and credentials.py) is the next CR.
Every step is its own commit. If the halt review finds a problem, the offending step reverts as a single commit with no effect on the others. There is no parallel copy to reconcile — a revert simply puts the moved file back at its prior path. This is the safety model: small, reversible slices, not a fallback implementation.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks change request — Stele extraction Phase 0 (stand up the module, move the zero-caller pieces) — CR-2026-103 — v0.3 — 2026-06-14