Version. 0.1
Date. 2026-06-14
Author. Marvin Percival (DUNIN7 Operator) with CC (executing agent) on DUNIN7-M4, supervised from Claude.ai.
Status. Completion record. Phase 1 of the Stele extraction is complete and pushed to engine origin/main. Markdown primary (technical consumer). Records what moved, the gating persons.id FK / Base decision and how it was locked, the four-commit series, the Branch 1 no-schema proof, the database-truth result, the two carry-forwards for Phase 6, and the Phase 2 handoff.
Governing CR. CR-2026-104 v0.1 (loomworks-record; the grounding draft, tree-truth at engine e4656b4). The D1/D2 decision lock is recorded here, not folded into the v0.1 draft — the draft stays the tree-truth-at-e4656b4 record it is; this completion record carries the decision, as the Phase 0 record carried Phase 0's.
Phase 1 moved the rest of the core identity pieces into the stele/ module: the identity record (PersonRow), the two credential row models (WebauthnCredentialRow, RecoveryCodeRow), the principal view (the registry — Person, get_person_by_id, and siblings), and the WebAuthn credential store (credentials.py). With Phase 0's session/webauthn/recovery already moved, all the identity pieces now live in stele/. Every move was a pure rename with importers repointed — no logic changed, sign-in behaves exactly as before.
The load-bearing part of Phase 1 was not the moves; it was a decision: how the credential and recovery rows' foreign key to persons.id should be handled, given that standalone Stele must eventually own its own principal table. The decision was split into two linked parts and locked before any code moved: the direction is own-its-own-table (standalone Stele will ship its own principal table), and the timing is Branch 1 — make the code-move now under one shared database Base with zero schema change, and defer the actual physical table split to Phase 6 where the standalone packaging is built. This kept Phase 1 the low-risk slice the body-of-work brief promised, while settling the direction the brief named as the gating question.
No schema or data changed (proven three ways). The full suite stayed green at 2920 passed / 46 skipped / 0 failed, and a real-database sign-in plus credential read confirmed identity now resolves from stele/. Two threads were found and deferred to Phase 6 by design: the persons table's outward foreign key to engagements, and the integration-design narrowing of the Person view. Phase 2 (the actor constructors) is next.
Brief v0.4 §5B named the persons.id foreign-key / Base decision as the gating question for the standalone destination, and said Phase 1's decision is that decision. CC's tree-truth pull at e4656b4 made it precise: it is two linked decisions, not one.
Tree-truth that framed it (verified at e4656b4). persons/models.py defined one DeclarativeBase. Eight tables registered on it; four carry a ForeignKey("persons.id") — two move to Stele (webauthn_credentials, recovery_codes), two stay (memberships, organization_memberships, both Loomworks governance). Plus persons.personal_engagement_id → engagements.id points outward from the moving persons table to the staying engagements table. So giving PersonRow a separate metadata would make foreign keys cross the Stele/persons boundary in both directions — that two-way crossing was the whole of the difficulty.
D1 — direction (end-state). Does standalone Stele ship its own principal table (FKs point inward), or declare persons.id as a host-supplied contract (FKs point at a host table)? Locked: own-its-own-table. Reason: the Claude Code Miami standalone bar is "a stranger clones it and reaches a passing test run and a working sign-in by cloning," which host-supplied-contract defeats (the clone would not be self-sufficient — the stranger would have to stand up a host table first). This matches brief v0.4 §5B's surfaced recommendation.
D2 — timing (mechanism for Phase 1). The brief proposed pulling the physical identity/account table split forward to Phase 1; the integration design §5.2 deferred that physical split to the service extraction; the brief's own Phase-1 row called the moves "low-mechanical." Locked: Branch 1 — shared-Base code-move now, physical split deferred to Phase 6. Branch 1 introduces one shared DeclarativeBase both persons/ and stele/ import (one MetaData, so every FK resolves by table name exactly as before — zero schema change, zero data migration, no behaviour change), moves the definitions to stele/, and schedules the physical persons → principals + host-account split (separate metadata, data migration) for Phase 6 de-engine. The alternative (Branch 2 — Stele-own-Base + physical split now) was set aside: it is a real data migration touching live playground_dev data, behaviour-adjacent, and the highest-risk option of the extraction; doing it at Phase 6 — where the host-account table and standalone metadata are built together — is the right home.
Why the split into D1/D2 matters (corrections-not-smoothed). The brief framed the FK question as one decision. CC's tree-truth found it is two: the direction (own-table) can and should be decided now, while the physical split (the data migration) belongs at the de-engine phase. This separation is what let Phase 1 stay low-risk while still settling the gating direction — neither the brief's "pull it forward" nor the integration design's "defer it" was simply overruled; the direction was pulled forward and the physical mechanism was deferred, satisfying both. Both branches and the §4G / §5.2 / §5B tension are preserved here per discipline.
All four remaining identity pieces moved into src/loomworks/stele/, plus the shared Base relocated to a neutral home:
Base → src/loomworks/identity_base.py — one DeclarativeBase both persons/ and stele/ import, so a single MetaData spans both modules. This is a documented, deliberate exception to the engine's per-subsystem-base convention, scoped to the identity cluster, interim until the Phase 6 physical split gives Stele its own metadata. (Recorded here so a future reader sees it as the locked-D2 arrangement, not drift from convention.)PersonRow → stele/models.py.WebauthnCredentialRow, RecoveryCodeRow → stele/models.py.persons/registry.py (Person, _row_to_person, create_person, get_person_by_id, get_person_by_email) → stele/registry.py. The Person view moved verbatim — no narrowing.persons/credentials.py → stele/credentials.py (now loomworks.stele.credentials, distinct from the top-level loomworks.credentials KEK package, which did not move).
With Phase 0's session.py / webauthn.py / recovery.py already in place, the stele/ module now holds the complete core identity surface.
Engine repo (DUNIN7/loomworks-engine).
origin/main: f1cf170 (fast-forward push e4656b4..f1cf170; local == origin).src/loomworks/stele/ — __init__.py, models.py, registry.py, credentials.py, session.py, webauthn.py, recovery.py. Shared base at src/loomworks/identity_base.py.0083, unchanged — no migration added or touched this phase (Branch 1 invariant).
Phase 1 commit series (all on origin/main):
| Commit | Step | Move | Importer repoints (prod / test / script) |
|---|---|---|---|
| 6d45526 | 1.1 | shared Base → loomworks.identity_base | 1 (test harness; mixed line split) |
| 8f5c005 | 1.2 | row models → stele/models.py | 18 / 44 / 0 (2 mixed lines split; 1 multi-line block) |
| 83cd9b1 | 1.3 | registry → stele/registry.py | 36 / 86 / 0 (0 mixed) |
| f1cf170 | 1.4 | credentials → stele/credentials.py | 5 / 8 / 0 (0 mixed) |
Step 1.5 (full regression + database-truth) added no commit — it was verification only, no code change. Phase 1 is four commits, not five (Phase 0 was five: skeleton + three moves + isolation harness).
Move, not duplicate held throughout: every move a pure rename (git R), every importer change an import-line repoint — no importer's logic, control flow, or behaviour changed. No halt condition was hit at any step.
The new difficulty Phase 1 carried that Phase 0 did not: persons/models.py held both moving and staying classes, so its importers were repointed selectively per-symbol, and lines mixing a moving and a staying symbol were split (the moving symbol → stele, the staying symbol kept on persons.models):
Base, PersonRow (test harness, Step 1.1) — split.MigrationEventRow, PersonRow (persons/migration.py, tests/test_data_migration.py, Step 1.2) — split; MigrationEventRow stayed on persons.models.tests/test_me_security.py) — repointed whole.organizations.py, memberships.py, the OrganizationRow / OrganizationMembershipRow lines) — correctly left on persons.models.The registry (Step 1.3) and credential-store (Step 1.4) moves were clean whole-module swaps — no mixed lines — because callers import those as cohesive units.
Name-collision (Step 1.4) — confirmed clean. The moved loomworks.stele.credentials (person WebAuthn store) is distinct from the top-level loomworks.credentials (KEK home, untouched, reference line unchanged). The repoint patterns were anchored to loomworks.persons.credentials, which structurally cannot match the KEK package — so the separation is proven by construction, not merely observed.
Branch 1 invariant — no schema or data change — confirmed three ways:
git diff e4656b4..f1cf170 -- migrations/ is empty (no migration added or touched).0083 unchanged; live playground_dev alembic_version = 0083 unchanged.
FK resolution across the shared MetaData — confirmed. One metadata object across persons.models / stele.models / identity_base; configure_mappers() succeeds; the boundary-crossing FKs resolve both ways (webauthn_credentials / recovery_codes → persons; memberships / organization_memberships → persons; persons.personal_engagement_id → engagements stub).
Suite: 2920 / 46 / 0 at f1cf170 — no regression.
Database-truth (against playground_dev, session token never printed):
get_current_person resolved a known person via stele.session.decode → stele.registry.get_person_by_id (identity record + registry now from stele/).stele.credentials.list_credentials_for_person returned the person's passkey through the moved store.stele.recovery counts read OK.GET /me/security/passkeys with session → 200, without → 401 (full ASGI stack exercising stele.registry + stele.credentials against the real DB).Both are consequences of the locked D1 = own-table direction; both are correctly out of scope for the Branch 1 code-move and land where the physical split happens.
stele.models.PersonRow carries a foreign key to engagements.id, whose stub (_EngagementRow) stays in persons.models — so stele.models is not importable in true isolation without persons.models registering that stub. Harmless in-engine (the app always loads persons.models); it surfaced only in a standalone script and the isolation harness, which CC hardened to register the stub. This is an outward-pointing FK from the identity cluster to a staying Loomworks table — exactly the kind of thread the Phase 6 de-engine step cuts. Under D1 = own-table, Phase 6 resolves it: either the standalone principals table drops personal_engagement_id (personal-engagement becomes host-account data, consistent with integration design §5.2's host-owned-lifecycle framing) or it becomes a host-supplied-contract FK.exhaustion_preference, spend_pause) from Stele's Principal view. Phase 1 moved Person verbatim — those fields are still present — because narrowing the view is a behaviour-touching change, not a move. The narrowing belongs at Phase 6 with the physical split, where the host-account table that will own those fields is built.
Phase 2 — the actor constructors. Moves the actor construction (how a recorded action is labelled with who performed it) into stele/. Per the brief's phase table this is "low, contained." The integration design (§3.3) names the constructors: actor.for_principal, actor.for_companion (live), and actor.for_agent (to build, later — the agent effort, not Phase 2). The ActorRef type is owned by the memory substrate and is referenced, not moved (integration design §5.2) — Phase 2 moves the constructors that produce it, not the type.
Carry-forward cautions for the Phase 2 CR:
f1cf170 before moving; bundle each move + its repoints in one commit; verify against database truth, not suite-only.get_current_person are separate phases (2 and 3) — Phase 2 moves the constructors; Phase 3 points "who is signed in?" at Stele. Do not pull Phase 3 forward.Standalone-distribution note. With D1 = own-table now locked, the integration design §5.2 (which currently says the identity/account split is "deferred to the service extraction") is one version stale on the direction — the direction is decided (own-table by Phase 6), only the physical execution is deferred. This is a doc-sync to fold into the next integration-design version; not urgent, not on the execution path.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Extraction Phase 1 Completion Record — v0.1 — 2026-06-14