DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-109-stele-extraction-phase-6-v0_1.md

CR-2026-109 — Stele Extraction Phase 6 — Physical persons → principals + host_account Split (Expand) — v0.1

Version. 0.1 Date. 2026-06-15 Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (CR) on DUNIN7-M4. Status. Change Request, drafted, not yet executed. Governs Phase 6 of the Stele extraction — the expand half of the physical table split. Grounded against engine main 8f2665a (alembic 0084) via the read-only Step 0 grounding (CC, 2026-06-15) recorded in §12. Markdown primary (technical consumer). Baseline. engine origin/main 8f2665a, alembic 0084, suite 2933/46/0. Clean tree (.wrangler/ gitignored).


Plain-language summary

What this does. Splits the one persons table into two physical tables: principals (Stele-owned: identity + auth columns, under Stele's own new Base/MetaData) and host_account (host-owned: lifecycle, policy, comms columns, under the host Base). The two share one id — host_account reuses persons.id as its primary key, joined 1:1. This is the schema change the standalone-Stele milestone turns on: after it, the credential and recovery foreign keys point inward at principals, and Stele can be extracted to its own repo (Phase 7) without carrying host columns.

Expand only. This CR lands the expand half of an expand-contract migration: the new tables come up, data is copied, foreign keys re-point, both shapes stay live. The old host columns remain physically present on persons (dead-but-present) so nothing breaks if a read site was missed. Dropping the old columns is a separate later step (the contract), Operator-triggered after the expand half is proven and observed. Phase 6 is therefore fully reversible end to end.

The decisions (locked with the Operator, 2026-06-15):

  1. Two physical tables, not one-table-two-views.
  2. Shared idhost_account.id = persons.id (1:1). The 12 host-side FKs re-target the host table at the same id value — minimal FK churn.
  3. Column homesprincipals: id, display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at. host_account: everything else (email, mobile, companion_name, personal_engagement_id, referred_by, license_tier, account_status, expires_at, previous_status_change_at, exhaustion_preference, near_exhaustion_*, previous_responder_model, spend_pause, onboarding_complete, plus its own created_at/updated_at).
  4. Expand-contract, expand half = this CR.

What the Operator does. Authorize; CC runs Step 0 (read-only re-confirm) and halts for any drift since the grounding; then per-step execution, suite green at each, halt before each push, proven in playground_dev before merge.

Schema change. Yes — this is the second schema change in the extraction (Phase 4's 0084 was the first). New migrations 0085+ (expand). It moves existing data (copy host columns into host_account), which Phase 4 did not — hence expand-contract for reversibility.


1. The split shape (from the real graph, §12)

principals — Stele's own new Base/MetaData (Stele finally gets its own, per the identity_base deferral note). PK id. Columns: display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at. Carries the 2 identity FKs (webauthn_credentials.person_id, recovery_codes.person_id) — intra-Stele, clean.

host_account — host Base (the one persons/models keeps). PK id = the principal's id (shared id, 1:1; FK host_account.id → principals.id). Columns: email, mobile, companion_name, personal_engagement_id, referred_by, license_tier, account_status, expires_at, previous_status_change_at, exhaustion_preference, near_exhaustion_20pct_seen_at, near_exhaustion_10pct_seen_at, near_exhaustion_5pct_seen_at, previous_responder_model, spend_pause, onboarding_complete, own created_at/updated_at. Carries the 12 host FKs (memberships, organization_memberships, engagements.created_by, workspaces.owner, engagement_tags.added_by, saved_filters.owner, person_settings, conversation_turns, companion_notifications, uploaded_files.uploaded_by, credit_grant.claimed_by, persons.referred_by self-ref) — re-targeted to host_account at the same id value (shared id makes this a table-retarget, not an id-translation).

Expand-half reality: during Phase 6, persons keeps ALL its current columns physically. principals and host_account are added; the principal-side columns are also still on persons (read from principals now), and the host columns are copied into host_account (read from host_account now) while remaining present on persons. Nothing is dropped. The old persons row stays a valid, complete row throughout — that is the reversibility guarantee.

The two Bases resolve cleanly because of shared id: host-side FKs point at host_account (intra-host, host Base, resolves by table name in the host MetaData); the 2 identity FKs point at principals (intra-Stele, Stele Base). No cross-metadata FK string-resolution is needed — the shared-id decision is what avoids it.

2. Scope

In scope (the expand half):

Out of scope:

3. Step 0 — read-only re-confirm (no branch, no commit)

The heavy grounding is done (§12). Step 0 here is a thin drift-check, because grounding was thorough and recent.

4. Step 1 — Stele's own Base + the principals table

1.1 — Branch + archive CR. Cut cr-2026-109-stele-extraction-phase-6 from 8f2665a; archive this CR into engine docs/phase-crs/. Commit.

1.2 — Stele's own Base/MetaData. Introduce Stele's own DeclarativeBase (the one identity_base.py defers to Phase 6). Decide placement against the real module layout (read it; report). The principal models (principals, and re-homing webauthn_credentials/recovery_codes mappings) move under it. This is the first step that gives Stele its own MetaData — read identity_base.py's constraints before writing, and preserve how the host Base continues to resolve its own rows. Inspect-first: report the seam before mutating.

1.3 — principals table + migration 0085 (expand: create + copy principal columns). Create the principals table (Stele Base) with the locked principal columns. Migration 0085: CREATE TABLE principals; copy (id, display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at) from persons. persons keeps these columns (expand — both live; principal reads now resolve from principals). Re-point the 2 identity FKs (webauthn_credentials.person_id, recovery_codes.person_id) to principals.id.

5. Step 2 — the host_account table (shared id)

2.1 — host_account model (host Base) + migration 0086 (expand: create + copy host columns). Create host_account under the host Base, PK id with FK → principals.id (shared id, 1:1). Migration 0086: CREATE TABLE host_account; copy all host columns from persons, host_account.id = persons.id. persons keeps these columns (expand — dead-but-present after host reads re-point).

2.2 — Re-point the 12 host FKs to host_account (migration 0087). Re-target each of the 12 host-side FKs from persons.id to host_account.id. Because the id is shared, the FK values are unchanged — only the referenced table changes. The 1 logical reference (audit.events.person_id, no DB FK) re-points in code only, no migration. The self-ref (persons.referred_by) re-points to host_account.

6. Step 3 — re-home reads/writes to the new tables

3.1 — Re-point host-column reads/writes off persons onto host_account. The ~150 host-column sites (§12 0.4) now read/write host_account. Heaviest: expires_at (46), email (34), personal_engagement_id (23). Critically, sever the stele-module sites that touch host columns (expires_at, email, companion_name showed stele in their module list) — Stele must not read host columns after the split (it breaks standalone isolation). Inspect-first per module; report the seam; this is the highest-blast-radius step — consider sub-stepping per module (api/routers, credit, persons, orchestration) with a commit each if the change is large.

3.2 — Re-point principal-column reads onto principals. The principal columns (display_name, totp_secret, first_login_at, last_presence_proof_at) now read from principals. Lower blast radius than host. Sever any host-module site that should now read principal data across the join.

7. Step 4 — the mint_principal seam

4.1 — Mint writes principal columns only. mint_principal (Stele) currently writes onboarding_complete (host), email/mobile (host). After the split, Stele must not write host columns. Move host-column initialization to where the host_account row is created — at the mint→onboard boundary (the host_account row is created at mint time with id = principal.id, but its host-policy columns including onboarding_complete=False and email/mobile are written host-side, not by Stele's mint primitive). Read the real Phase-4 mint/onboard split (§12 0.5) and place the host_account-row creation so the orphan→heal path (Phase 4) still works: an orphan is a principal with a host_account row that onboard completes.

8. Step 5 — the guard/test updates (the Phase 5 lesson: scope the guards)

5.1 — test_person_model. Encodes the row shape (asserts email/mobile/totp_secret round-trip on the person row; the Person pydantic view excludes totp_secret). After the split, email/mobile live on host_account, totp_secret on principals. Update the assertions to the split shape — do NOT fixture-patch them green; re-point them to assert the real new homes. 5.2 — test_stele_isolation. The de-engine harness imports loomworks.persons.models to register host rows on the shared MetaData and builds via create_all on the single Base. After Phase 6, Stele has its own Base — the isolation harness can tighten toward Stele building without the host import. In scope for Phase 6: confirm the harness still passes and note how much closer to true isolation the split brings it; full isolation (harness passes with zero host import) may complete in Phase 7. Report the gap that remains.

9. Verification (against playground_dev, both shapes live)

Prove the expand invariant — both shapes valid — on the real path:

10. Known cautions

11. The locked decisions (for the record)

| # | Decision | Locked value | Set aside | |---|---|---|---| | 1 | Table shape | Two physical tables | One-table-two-views (defers the real split to Phase 7; reintroduces the bundling A avoided) | | 2 | Host PK | Shared id (host_account.id = persons.id, 1:1) | Separate host id (150+ FK re-points + id translation everywhere) | | 3 | Column homes | principals: id/display_name/totp_secret/first_login_at/last_presence_proof_at/created_at/updated_at; host_account: all else | email/mobile-as-principal (contradicts the seed: email is not identity) | | 4 | Migration style | Expand-contract; expand = Phase 6, contract = separate later step | Direct cutover (half-moved window, restore-from-backup rollback) | | 5 | Phase 6 vs 7 | Split first (Phase 6, in-engine, proven), package later (Phase 7) | Brief's bundled de-engine+split-as-one-phase (two kinds of change, one schema, in one phase) |

12. Step 0 grounding report (read-only, CC, 2026-06-15)

(Full report retained from the Phase 6 grounding read. Summary of the load-bearing findings:)


DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-109 — Stele Extraction Phase 6 — Physical persons → principals + host_account Split (Expand) — v0.1 — 2026-06-15