credentials.py; settle the persons.id FK / Base decision) — CR-2026-104 — v0.1
Version. 0.1
Date. 2026-06-14
Status. Change request — the second executable phase of the Stele extraction, drafted against the post-Phase-0 tree (engine origin/main e4656b4; suite baseline 2920 passed / 46 skipped / 0 failed). Phase 1 moves the identity records (PersonRow), the principal view (the registry: Person, get_person_by_id, …), and carries the two credential row models (WebauthnCredentialRow, RecoveryCodeRow) and persons/credentials.py into stele/ — the pieces Phase 0 (CR-2026-103 v0.3) reassigned here because they are entangled in the shared persons/models.py Base. Phase 1's load-bearing element is not the moves — it is the persons.id foreign-key / Base decision (brief v0.4 §4G/§5B), which this CR surfaces explicitly and which the Operator must lock before Step execution. Markdown primary (technical consumer: Claude Code). Halt-before-push; per-step commits; verify against engine-database truth, not the suite alone.
Decisions this CR rests on (settled): (1) Stele's interface is a host-agnostic, in-process-first / service-ready API contract. (2) The contract is complete (passkey-revoke, TOTP-rotate arrive Phase 5). (3) Sign-up split is approach B (Phase 4, not here). (4) Move, not duplicate — one implementation throughout; safety from small reversible slices. (5) White-label / multi-language hooks thread into user-facing ops in later phases. (6) Phase 0 is complete and pushed (engine e4656b4): recovery.py, webauthn.py, session.py live in stele/; the isolation harness passes; sign-in verified against playground_dev.
Source grounding. The Stele body-of-work brief v0.4 (§4G, §5B the FK/Base decision; §5 the re-sequenced phase plan; Phase 1 row = "the packaging foundation"); the integration design v0.2 (§5.2: "on day one Stele owns the whole persons table physically with lifecycle columns nullable-and-host-owned; the physical identity/account table split is deferred to the service extraction" — brief §5B proposes pulling this forward); the Phase 0 completion record v0.1; and a tree-truth verification at e4656b4 (importer counts, FK map, Base structure) recorded in §3 below.
persons.id foreign key / shared Base (must be locked before Step 1.1)
Brief v0.4 §5B states this decision and Phase 1's Base/FK decision are the same decision, and that the CR "should state the decision explicitly, present both branches, and record which was chosen and why." It has two linked parts.
e4656b4)
persons/models.py defines one DeclarativeBase (Base, line 35). Eight tables register on it. Four carry a ForeignKey("persons.id"):
| Table (ORM row) | FK → persons.id? | Phase 1 disposition |
|---|---|---|
| webauthn_credentials (WebauthnCredentialRow) | yes | moves to Stele |
| recovery_codes (RecoveryCodeRow) | yes | moves to Stele |
| memberships (MembershipRow) | yes | stays (Loomworks governance) |
| organization_memberships (OrganizationMembershipRow) | yes | stays |
Plus persons.personal_engagement_id → engagements.id (line 91): persons moves, engagements (_EngagementRow) stays.
So if PersonRow is given a separate metadata, foreign keys cross the persons/Stele boundary in both directions: governance rows that stay still point into the moved persons table, and the moved persons table still points out to the staying engagements table. That two-way crossing is the whole of the difficulty.
> Does standalone Stele ship its own principal table (FKs point inward), or declare persons.id as a contract the host must satisfy (FKs point at a host-supplied table)?
Recommendation (brief §5B, surfaced not pre-decided): own-its-own-table — the Miami bar is "a stranger reaches a passing run and a working sign-in by cloning," which host-supplied-contract defeats.
The brief (§5B) proposes pulling the physical identity/account table split forward to Phase 1; the integration design (§5.2) currently defers that physical split to the service extraction; and the brief's own Phase-1 row calls the moves "low-mechanical." These pull in different directions. Two branches:
Base code-move now; physical split deferred (recommended). Introduce one shared DeclarativeBase that both persons/ and stele/ import; move the PersonRow/credential-row/registry/credentials.py definitions to stele/. One MetaData, so every FK resolves by table name exactly as today — zero schema change, zero data migration, no behaviour change (the Phase-0 discipline that kept the move phases safe). D1's own-table direction is locked, and the physical persons → principals + host-account split (separate metadata, data migration) is scheduled for Phase 6 de-engine. This keeps Phase 1 the low-risk slice the brief's table promises and matches integration design §5.2 — now with the direction decided rather than open.Base + physical split now. Give Stele its own MetaData; split the persons table into principals (identity columns) + a host account table (lifecycle columns) via a hand-written data migration; handle the two-way cross-metadata FKs. Reaches the own-table physical end-state at Phase 1 — but it is a real data migration touching live playground_dev data, behaviour-adjacent, and the highest-risk option of the extraction. (Note: alembic target_metadata = None — migrations are hand-written, so no autogenerate breaks either way; Branch 2's cost is the data migration and cross-metadata FK wiring, not autogen.)Recommendation: lock D1 = own-table, and execute D2 = Branch 1 — separate the direction (decide now) from the physical split (do it at the de-engine phase, where the host-account table and standalone metadata are built together). Per corrections-not-smoothed, both branches and the §4G/§5.2/§5B tension are recorded; the Operator locks D1 and D2 before Step 1.1. The step plan below assumes Branch 1; §2's note states how the steps change under Branch 2.
In scope — move into stele/ (Branch 1: definitions move, shared Base, FKs unchanged):
PersonRow (persons/models.py:65) → stele/models.py.WebauthnCredentialRow (:239), RecoveryCodeRow (:341) → stele/models.py. (Their ForeignKey("persons.id") resolves intra-Stele once PersonRow is alongside them; the staying governance rows' FKs to persons.id resolve via the shared MetaData.)persons/registry.py (Person, _row_to_person, create_person, get_person_by_id, get_person_by_email) → stele/registry.py.persons/credentials.py (WebauthnCredential, _row_to_credential, add_credential, list_credentials_for_person, get_credential_by_credential_id, update_sign_count) → stele/credentials.py. Name-collision caution: this becomes loomworks.stele.credentials, distinct from the top-level loomworks.credentials package (the KEK home, which never moves — §3 of CR-2026-103).Base — relocate the DeclarativeBase to a shared module both persons/ and stele/ import (so one MetaData spans both). stele/recovery.py's existing from loomworks.persons.models import RecoveryCodeRow repoints to the moved row.
Stays in persons/ (NOT moving this phase): OrganizationRow, OrganizationMembershipRow, MembershipRow, MembershipDesignationRow, MigrationEventRow, _EngagementRow; signup.py, login.py, migration.py, memberships.py, organizations.py, presence.py, personal_engagement.py; the person-scoped TOTP internals; and the account-lifecycle columns on the persons table (host-owned, nullable — integration design §5.2).
Explicitly NOT in scope (later phases, unchanged from the brief): actor constructors (Phase 2); get_current_person pointing at Stele (Phase 3); the sign-up split (Phase 4); recovery completion + passkey-revoke + TOTP-rotate (Phase 5); de-engine/packaging/SDK + the physical persons→principals+account split (Phase 6); docs-with-runnable-examples + reference UI + the WebAuthn full-ceremony close (Phase 7); agent identity (later). The Principal-view narrowing (integration design §5.2 — excluding exhaustion_preference/spend_pause from Person) is a behaviour-touching change and is not part of this move; registry.Person moves unchanged.
If the Operator locks Branch 2 instead: Steps 1.1–1.4 are preceded by a schema step (the principals/host-account split migration + separate MetaData), the FK repoints become cross-metadata wiring rather than name resolution, and Step 1.5's DB-truth check adds a migration round-trip + data-integrity verification. That is a materially larger, higher-risk CR and would warrant its own re-scoped version.
e4656b4) — blast radiusMove-not-duplicate means every importer of a moved symbol gets an import-line repoint (the Phase-0 mechanic, proven across ~113 sites). Counts:
registry (the principal view): 121 importers — 35 production (incl. credit/, api routers, persons/) + 86 test. Whole-module move.from loomworks.persons.models import …): 61 importers — 12 production (incl. credit/ ×6: lifecycle_evaluator, conversion_observer, grants, near_exhaustion, routing, lifecycle; plus api/routers/{login,memberships,me_spend,me_credits}, persons/*, stele/recovery.py, credentials/migrate.py) + 49 test. models.py holds both moving and staying classes, so repointing is selective per-symbol, not whole-module — and several lines mix the two (MigrationEventRow, PersonRow; the 3 parenthesised multi-imports; Base, PersonRow), which must be split (moved symbol → stele, staying symbol → persons). This is more delicate than Phase 0's clean whole-module moves and is the main execution risk.credentials: ~11 importers — ~3 production + 8 test.Halt condition (carried from Phase 0): halt if any importer needs more than an import-line change (a logic edit), or if the suite cannot be made green by repointing alone.
Step 1.1 — Relocate the shared Base. Introduce a shared DeclarativeBase both persons/models.py and stele/models.py import (one MetaData across both modules); repoint Base importers. No rows moved yet; zero schema change. Full suite. Commit.
Step 1.2 — Move the row models. Move PersonRow, WebauthnCredentialRow, RecoveryCodeRow into stele/models.py; selectively repoint the 61 importers per-symbol (splitting mixed lines so staying classes keep pointing at persons/models); repoint stele/recovery.py's RecoveryCodeRow import. Confirm FK resolution unchanged (shared MetaData). Full suite + DB-truth (a row insert/read exercising the moved FKs against playground_dev). Commit.
Step 1.3 — Move the registry. Move persons/registry.py → stele/registry.py; repoint all 121 importers; Person view moved unchanged (no narrowing). Full suite. Commit.
Step 1.4 — Move the credential store. Move persons/credentials.py → stele/credentials.py; repoint the ~11 importers; its WebauthnCredentialRow import is now intra-Stele. Full suite. Commit.
Step 1.5 — Full-suite regression + database-truth. Run the complete suite. Verify against playground_dev that the identity-record path now resolves PersonRow/registry from stele/: sign-in end to end (the Phase-0 check, now also exercising the relocated get_person_by_id), plus a credential-row read for a known person through the moved stele.credentials. Confirm no schema/migration change occurred (Branch 1 invariant: alembic heads unchanged, table definitions byte-equivalent). Halt and report.
Person view is not narrowed; signup.py/login.py/get_current_person logic is untouched (Phases 3/4); no governance rows or flows are edited beyond import lines.persons table keeps its lifecycle columns (host-owned, nullable); the physical split is Phase 6. (Only Branch 2 migrates — and only if the Operator locks it.)credentials/ package.get_current_person, or sign-up — those are later phases. Import-line repoints are permitted and required (the v0.3 distinction: an import may change; behaviour may not).
After Step 1.5, halt before any push. Report: which D1/D2 branch the Operator locked; the symbols moved with per-symbol importer-repoint counts (production/test/script) and confirmation no importer's logic changed; the shared-Base/FK handling and proof FK resolution is unchanged; the no-schema-change confirmation (Branch 1); and the full-suite + database sign-in/credential-read regression result. On review, push Phase 1; Phase 2 (the actor constructors) is next.
Every step is its own commit; the offending step reverts as a single commit with no effect on the others. Under Branch 1 there is no schema change to unwind — a revert simply puts the moved definitions back at their prior path.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks change request — Stele extraction Phase 1 — CR-2026-104 — v0.1 — 2026-06-14