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).
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):
host_account.id = persons.id (1:1). The 12 host-side FKs re-target the host table at the same id value — minimal FK churn.principals: 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).
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.
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.
In scope (the expand half):
principals table under Stele's own Base/MetaData; new host_account table under the host Base (PK = shared id).0085+ : create both tables; copy data (principal columns → principals; host columns → host_account, id = persons.id); add the 2 identity FKs onto principals and re-point the 12 host FKs onto host_account. Old persons columns retained.expires_at/email/companion_name touched by stele code — the split must move those reads host-side).mint_principal seam: it currently writes onboarding_complete (host), email/mobile (host) — after the split it must write only principal columns; host-column initialization moves to where the host_account row is created (onboard / the mint-onboard boundary).test_stele_isolation and test_person_model to the split shape (they encode the de-engine target and the row shape — §12 0.6).Out of scope:
persons. Separate later step, Operator-triggered.The heavy grounding is done (§12). Step 0 here is a thin drift-check, because grounding was thorough and recent.
8f2665a, clean tree, alembic 0084 (dir + both playgrounds).pg_constraint in playground_dev (14 real FKs; the 2 identity / 12 host split) — if any FK changed since grounding, STOP and report.identity_base.py and confirm the single-Base interim is unchanged (Stele's own Base does not yet exist).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.
playground_dev: every persons row has a matching principals row; the 2 identity FKs resolve to principals.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).
playground_dev: every persons row has a matching host_account row at the same id; column values match.
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.
playground_dev: all 12 FKs now reference host_account; no orphaned rows (shared id guarantees every referent still resolves).
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.
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.
playground_dev: a real two-phase signup creates a principals row AND a host_account row at the same id; a forced onboard failure leaves a principal + a host_account-with-onboarding_complete=false orphan that heals on sign-in (the Phase 4 invariant, now across two tables).
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.
playground_dev, both shapes live)Prove the expand invariant — both shapes valid — on the real path:
persons row has a matching principals row and a matching host_account row, all at the same id.principals; the 12 host FKs resolve to host_account; zero orphans.principals, reads host policy from host_account across the join).persons still carries every old column (expand — nothing dropped); a row read directly from persons is still complete and valid (the rollback guarantee).0084 only added a column; Phase 6 copies existing data. Expand-contract is the safety posture: nothing dropped this phase, every step reversible.main == origin/main between phases; check the real ref before push/merge.test_person_model re-points to the new homes, it is not patched.persons host columns is a separate Operator-triggered step after expand is proven and observed. Do not drop any column in Phase 6.
| # | 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) |
(Full report retained from the Phase 6 grounding read. Summary of the load-bearing findings:)
8f2665a, clean, alembic 0084 (dir + both playgrounds).display_name/first_login_at/last_presence_proof_at → principal; email/mobile → host; created_at/updated_at → both. email→host confirms the seed (email is not identity).pg_constraint): 14 real DB FKs — 2 identity (webauthn_credentials, recovery_codes) follow principals; 12 host (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) follow host_account. 1 logical ref (audit.events, no DB FK) re-points in code only.expires_at 46, email 34, personal_engagement_id 23. Two dormant (near_exhaustion_*, previous_responder_model — 0 sites). stele-module touches expires_at/email/companion_name — the split must sever these.mint_principal writes onboarding_complete + email/mobile (host-leaning) — the seam to fix (Step 4). onboard is clean (host columns/rows only).test_no_delete_statements not implicated (no DELETE). test_stele_isolation (de-engine harness) and test_person_model (row shape) encode the target and move with the split — scoped into Steps 5.1/5.2.loomworks.identity_base.Base (Branch-1 interim); its docstring defers "the physical persons → principals + host-account split and Stele's own MetaData to Phase 6." principals → Stele's own new Base; host_account → host Base (must NOT be on Stele's Base, or standalone breaks). Shared id is what lets the two Bases resolve their FKs without cross-metadata wiring.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