DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/stele-extraction-phase-1/loomworks-stele-phase-1-completion-record-v0_1.md

Loomworks — Stele Extraction Phase 1 Completion Record — v0.1

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.


Plain-language summary

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.


1. The gating decision and how it was locked

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.

2. What Phase 1 closed

All four remaining identity pieces moved into src/loomworks/stele/, plus the shared Base relocated to a neutral home:

With Phase 0's session.py / webauthn.py / recovery.py already in place, the stele/ module now holds the complete core identity surface.

3. State after Phase 1 close

Engine repo (DUNIN7/loomworks-engine).

Phase 1 commit series (all on origin/main):

| Commit | Step | Move | Importer repoints (prod / test / script) | |---|---|---|---| | 6d45526 | 1.1 | shared Baseloomworks.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).

4. The move mechanics and the mixed-line discipline

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):

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.

5. Verification

Branch 1 invariant — no schema or data change — confirmed three ways:

  1. git diff e4656b4..f1cf170 -- migrations/ is empty (no migration added or touched).
  2. Alembic head 0083 unchanged; live playground_dev alembic_version = 0083 unchanged.
  3. The three moved table class definitions are byte-identical to their pre-move form.

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_codespersons; memberships / organization_membershipspersons; persons.personal_engagement_idengagements stub).

Suite: 2920 / 46 / 0 at f1cf170 — no regression.

Database-truth (against playground_dev, session token never printed):

6. Carry-forwards for Phase 6 (found this phase, deferred by design)

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.

  1. The engagements-stub thread. 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.
  2. The Person-view narrowing. Integration design §5.2 excludes the account-lifecycle fields (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.

7. Handoff — Phase 2 is next

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:

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