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

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

Version. 0.6 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 changed (version trajectory)

v0.6 (Step 3 moved to model-layer rename-to-find). Scoping the Step 3 re-home surfaced a collision: the mirror triggers (built 1.3/2.1) read the DB columns by name, so v0.2's schema-layer rename (rename the persons columns) would break the triggers and drown the loud-miss signal in trigger noise. Fix (Operator-locked, Option B): apply rename-to-find at the model layer — remove host columns from the PersonRow ORM mapping (app readers break loudly at attribute access) while the DB columns and triggers stay untouched. Same philosophy, layer that doesn't collide. §6 rewritten; §9 raw-SQL backstop carried.

v0.5 (dual-write ordering fix — expand-window sync trigger). The 1.3 pre-build inspection (CC) caught an ordering bug present since v0.1: the credential FK re-point (1.3) was scoped before the mint_principal write-path fix (Step 4), but the re-point depends on the write — between them, a new person + credential would FK-violate and the suite would go red at 1.3. Fix (Operator-locked): migration 0085 adds an AFTER INSERT OR UPDATE trigger on persons mirroring the principal columns into principals (the write-side mirror of expand's read guarantee), retired at contract. The bug and the two rejected alternatives are recorded in §4 1.3. Step 4 gains a note that mint's explicit principals write lands there but the trigger is retired only at contract.

v0.4 (Step 1.2/1.3 sequencing + Step 2.2 lightened). The 1.2 Base-seam inspection (CC) surfaced the ORM-mapping-vs-physical-table seam. The Operator locked Option B: 1.2 lands only the Stele Base scaffold; the principals mapping, the credential-mapping moves, the physical table, and the DB-constraint re-point all land together at 1.3, so ORM and DB are never inconsistent at a commit (overriding CC's recommendation of Option A, which would open an ORM/DB-disagreement window). The same inspection confirmed that in expand the 12 host FKs need no re-point (shared id + persons staying host-side) — Step 2.2 is amended to confirm-and-likely-skip rather than migrate. Plan intent unchanged; sequencing made consistent.

v0.3 (column-count correction). Step 0 drift-check (CC, 2026-06-15) caught that v0.2's §12 summary said "22 columns" while the real model and live DB have 23. No engine drift, no plan hole — the §1 column-home lists already enumerate all 23 (verified column-by-column). v0.3 corrects the count in §12 and records the correction. The plan is unchanged.

What changed from v0.1 (Operator direction, named not smoothed)

v0.1 scoped Step 3 (re-home the ~150 read/write sites) as grep-find-then-re-point: search the columns, re-point each site, trust the grep was complete. The Operator surfaced a stronger instrument: rename-to-find. Rename the old host columns on persons so any reader the grep missed breaks loudly when the suite runs — the suite and the import layer find the misses instead of a grep hoping to. v0.2 rewrites Step 3 around this (§6): copy → rename old columns per module → the suite surfaces every remaining reader → re-point → green → commit, module by module.

Why it is better, in one line: grep finds what you search for; a rename finds what you forgot to search for. A site reading person.expires_at through an alias, a relationship, or getattr survives a grep but dies on a rename — and a silent-miss-now is a production break later. The rename converts "did grep find everything?" (an anxiety) into "the suite is green, so nothing reads the old name" (a proof).

Its limit (recorded honestly): a rename catches readers that go through code the suite exercises. Raw SQL strings naming the column won't fail at import — only when that query runs — so §9's real-playground_dev verification still stands as the backstop for uncovered paths. And a rename makes persons no longer a pure mid-step rollback target (a row read straight from persons would have renamed columns) — but the rename is itself reversible (rename back), nothing is dropped, so no data is lost; the reversibility is slightly weaker but still real (see §10).


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 (scaffold only — Option B). Introduce Stele's own DeclarativeBase in stele/base.py (the one identity_base.py defers to Phase 6). Nothing moves onto it at 1.2 — this step lands the empty Base scaffold only. The principal mappings and the credential-mapping moves are deferred to 1.3 so they land together with the physical table (see the sequencing note below).

> Sequencing decision (Option B, Operator-locked 2026-06-15; amended from v0.3's wording). The 1.2 inspection (CC) surfaced a real seam: a credential FK can only "become intra-Stele" once principals exists as a registered mapping, but the physical principals table doesn't exist until migration 0085. v0.3 implied Option A (define the principals ORM + move credential mappings at 1.2; physical table at 1.3) — CC's inspection recommended A as matching the wording. The Operator overrode to Option B: the inspection's findings changed what we know, and A opens a window where the ORM FK target (principals.id) disagrees with the live DB constraint (persons.id) and the isolation test's create_all would materialize principals on the test DB before the migration. B closes that gap — the principals mapping, the credential-mapping moves, the physical table, and the DB-constraint re-point all land together at 1.3, so ORM and DB are never inconsistent at a commit. Trade accepted: 1.2 is a thin scaffold; the credential mappings don't become intra-Stele until 1.3. (Option C — a throwaway persons stub on the Stele MetaData — was set aside as extra motion for no lasting benefit.) Confirmed by the inspection: with shared id + expand, Stele's MetaData ends fully self-contained (principals + the 2 credential tables, zero outward FK) and no FK crosses the boundary — the clean separation that makes Stele standalone-extractable.

1.3 — principals mapping + credential moves + physical table + migration 0085 (everything principal-side, landing together). On Stele's Base: define the principals mapping (locked principal columns: id, display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at) and move WebauthnCredentialRow/RecoveryCodeRow under Stele's Base with their FKs re-pointed persons.id → principals.id (intra-Stele at the ORM level). Migration 0085 (down_revision 0084): CREATE TABLE principals; INSERT INTO principals SELECT (the 7 columns) FROM persons; re-point the 2 identity DB constraints (webauthn_credentials.person_id, recovery_codes.person_id) to principals.id. persons keeps these columns (expand — both live; principal reads now resolve from principals). ORM and DB land consistent in this one step (the Option-B guarantee).

> Dual-write ordering fix — the expand-window sync trigger (Operator-locked 2026-06-15; fixes a bug in v0.1–v0.4). The ordering bug, named: v0.1–v0.4 scoped the credential FK re-point at 1.3 but the mint_principal write-path fix (which makes mint write a principals row for new persons) at Step 4. That ordering is wrong — the re-point depends on the write-path. Between 1.3 and Step 4, mint_principal still writes only persons, so any new person given a credential FK-violates (the credential points at a principals row that doesn't exist). Conftest truncates persons each test and never populates principals, so the migration's INSERT…SELECT copies an empty table — the suite would go red at 1.3 and stay red until Step 4, breaking suite-green-at-every-commit. The CR's expand rationale (§31) covered missed reads (dead-but-present columns) but not the write side. The fix: migration 0085 adds an AFTER INSERT OR UPDATE trigger on persons that mirrors id + the 7 principal columns into principals (upsert). This is the write-side mirror of what expand already does for reads — both shapes kept live by the DB, every writer (mint, test helpers, raw SQL) auto-gets its principals row, the re-pointed FK always resolves, zero app-code change, suite stays green. **The trigger fires on INSERT and UPDATE — INSERT so new persons get a principals row; UPDATE so a change to a principal column on persons during the expand window keeps the principals copy in sync (an INSERT-only trigger would let the two shapes drift on updates, silently breaking the "both shapes valid" guarantee). The trigger is documented in 0085 as an expand-window mirror, retired at the contract phase** (when mint_principal writes principals directly and persons's principal columns are dropped). Step 4's clean app-level mint_principal refactor still happens as scoped — the trigger only makes the in-between window safe. (Options considered and set aside: Option 2 — pull the principals-write into mint_principal at 1.3 — drags Step 4's seam forward, touches multiple test helpers, and misses raw-SQL/test-helper person creation, grep-find's weakness in write form. Option 3 — defer the FK re-point past Step 4 — contradicts "re-point at 1.3" and leaves credentials pointing at persons longer.)

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 — The 12 host FKs in expand (likely NO migration — confirm at the step). Inspection finding (1.2, CC): with shared id and persons staying host-side in expand, the 12 host FKs already target persons.id, persons stays on the host Base, so they remain intra-host and resolve unchanged — no re-point is needed in expand. The re-target to host_account (and the referred_by self-ref, and the audit.events logical ref) is a contract-phase concern, not expand. v0.3 scoped a migration 0087 here; the inspection shows expand likely needs none. Confirm at this step against the real schema: if the host FKs genuinely need no change while persons persists, skip the migration and record why; only re-point if the real code shows a host FK that must move during expand. This makes Step 2 lighter than v0.3 anticipated — the host side barely moves in expand, which is the point of shared id.

6. Step 3 — re-home reads/writes to the new tables (MODEL-LAYER rename-to-find, per module)

The mechanism (v0.6 — model-layer, not schema-layer). v0.2 scoped rename-to-find at the schema layer (rename the DB columns on persons). That collides with the mirror triggers built in Steps 1.3/2.1: the triggers read NEW.<col> by the real DB column name, so renaming a DB column breaks the trigger too — and a broken trigger fires on every persons write, drowning the "which app site missed" signal in trigger noise. v0.6 moves rename-to-find to the model layer: remove the host columns from the PersonRow ORM mapping (they live on HostAccountRow now). Any app code reading person.<host_col> breaks loudly at attribute access (AttributeError / mapper error). The DB column stays — so the mirror trigger, reading the real DB column, is completely undisturbed. This catches exactly the readers rename-to-find was meant to catch (ORM-attribute access is how nearly all ~150 sites read these), leaves the most delicate object in the phase (the triggers) untouched, and is the same philosophy at the layer where it doesn't collide. (Operator-locked 2026-06-15, Option B; Option A — rename DB columns + rewrite the trigger each batch — was set aside as five raw-SQL rewrites of the load-bearing mirror; Option C — grep-find now, loud-break deferred to contract — abandons rename-to-find's loud-miss guarantee for Step 3.)

3.1 — Re-home host-column reads onto host_account, per module, model-layer rename-to-find. The ~150 host-column sites (§12 0.4) re-point to read host_account. Order by §12 blast-radius, stele-module sites FIRST (severing Stele's reads of host columns is the load-bearing isolation requirement, and smallest to reason about; then api/routers, credit, persons, orchestration). For each module batch:

3.2 — Principal-column reads from principals. The principal columns (display_name, totp_secret, first_login_at, last_presence_proof_at) — decide whether app reads should now resolve from principals or stay reading PersonRow (which still has them during expand). Lean: leave principal reads on PersonRow during expand (the columns are still there, the mirror keeps them identical, and re-homing them is mostly intra-Stele and lower-value than the host re-home). Re-home principal reads to principals only where it serves the Stele-isolation goal (e.g. Stele's own code should read principals, not persons). Report what was re-homed and what was left. Same model-layer technique if a forced break is wanted (remove a principal column from PersonRow), but principal columns are fewer and the isolation pressure is lower — use judgement.

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. Mint also writes the principals row directly here (it stops relying on the 1.3 expand-window trigger to mirror it) — but note the trigger stays in place through the rest of expand as the safety net for any other persons writer; it is only retired at the contract phase, not at Step 4. Step 4 makes mint's principals write explicit and app-level; the trigger's removal is deferred to contract.

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) | | 6 | Step 3 re-home mechanism | Rename-to-find, per module (rename old columns → suite surfaces every reader → re-point) | Grep-then-re-point (v0.1 — finds only what you search for; misses aliases/relationships/getattr; a silent-miss-now is a production-break-later) |

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.6 — 2026-06-15