Version. 0.4
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).
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.
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).
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 (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).
test_stele_isolation.py in this commit if its create_all now builds principals on the test DB — confirm the harness still passes and note how much closer to Stele-only isolation the split brings it (full isolation may complete in Phase 7).0085 to playground_dev and playground_test.playground_dev: COUNT(persons) == COUNT(principals); every persons.id has a principals row; the 2 identity FKs resolve to principals; a real credential read still resolves.stele/base.py if not already from 1.2, the principal model, the credential model moves, the migration); commit "Phase 6 (1.3): principals mapping + credential moves + migration 0085 (expand)". Halt before push.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 — 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.
playground_dev: all 12 FKs now reference host_account; no orphaned rows (shared id guarantees every referent still resolves).
The mechanism (v0.2): for each module's worth of host columns, rename the old columns on persons (a _relocated suffix) so any site still reading the old name breaks at import or at test-run. Run the suite. Fix the compiler-/test-found breaks by re-pointing them to host_account. Green → commit. Move to the next module. The suite finding the readers replaces grep hoping to. This directly de-risks the highest-blast-radius step.
3.1 — Re-home host-column reads/writes onto host_account, per module, rename-to-find. The ~150 host-column sites (§12 0.4) re-point to host_account. Order the modules by the §12 blast-radius (do the 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:
persons to <col>_relocated (data already copied to host_account in Step 2.1; the rename makes the old name unreachable).AttributeError / mapper error / failed query).host_account across the join.stele-module sites (expires_at/email/companion_name showed stele in their module list) must re-point such that Stele no longer reads host columns at all — not "reads them from host_account," but "does not read them." If a Stele site genuinely needs a host value, that is a seam to surface (Stele shouldn't depend on host policy) — STOP and report rather than wiring Stele to host_account.playground_dev real-path verification is the backstop — drive the real routes for each re-homed module, not just the suite.
3.2 — Re-point principal-column reads onto principals. The principal columns (display_name, totp_secret, first_login_at, last_presence_proof_at) read from principals. Lower blast radius than host. Same rename-to-find option if useful (rename the principal columns on persons, let the suite surface the readers) — but principal reads are fewer and mostly intra-Stele, so grep-then-re-point may suffice; use judgement and report which was used. 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._relocated, a row read straight from persons has renamed (not missing) columns — persons is no longer a pure rollback target mid-Step-3. The rename is reversible (rename back), nothing is dropped, no data lost. Before Step 3 (through Step 2) persons is a pure rollback target; from Step 3 the rollback is rename-back. Acceptable: the rename is what buys the loud-miss-detection that makes the re-home safe.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) |
| 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) |
(Full report retained from the Phase 6 grounding read. Summary of the load-bearing findings:)
8f2665a, clean, alembic 0084 (dir + both playgrounds).principals gets 7 — id, display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at; host_account gets the other 16 plus its own created_at/updated_at. All three near_exhaustion_ are homed host-side. Verified column-by-column against the CR text, 2026-06-15.)* 7 were surfaced UNCERTAIN and decided by the Operator: 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.4 — 2026-06-15