Version. 0.1
Date. 2026-06-15
Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (scoping/CR) on DUNIN7-M4; CC executing on DUNIN7-M4.
Status. Completion record. Phase 6a (CR-2026-109 v0.7) of the Stele extraction — the expand half, tables of the physical persons split — is built, verified on playground_dev, and pushed to branch cr-2026-109-stele-extraction-phase-6 at def8b04. Ready to merge to engine main. Markdown primary (technical consumer).
Governing CR. cr-2026-109-stele-extraction-phase-6-v0_7.md (loomworks-record change-requests/; archived on the branch). Seven versions (v0.1–v0.7) all live — the correction trajectory is in the CR's "What changed" sections.
What 6a did. Split the one persons table into two physical tables: principals (Stele-owned: identity + auth columns, on Stele's own new Base/MetaData) and host_account (host-owned: lifecycle/policy/comms columns, on the host Base), sharing one id (host_account.id = principals.id = persons.id, 1:1). The credential and recovery foreign keys now point inward at principals — the structural change the standalone-Stele milestone turns on. Both new tables are kept in sync with the still-live persons table by two database mirror triggers (the expand guarantee: both shapes live, nothing dropped, fully reversible).
What 6a deliberately did NOT do. The host-read re-home (~150 sites) and the Person-view split lifted out to a separate CR, 6b, when a Step 3 inspection found Stele's Person view is a fused whole-person view, not a pure-principal one — re-homing the reads is a view-split, a large enough body of work to deserve its own CR. The mint host-column cleanup (Stele stops writing email/mobile/onboarding_complete) is carried to contract (when the triggers retire). Neither is skipped — both are recorded carry-forwards.
Final state. Branch def8b04, suite 2933/46/0, alembic 0086 (two new migrations 0085/0086), expand proof passed end-to-end on playground_dev. Two code commits (1.3, 2.1) + the Base scaffold (1.2); Steps 4/5/6 were verification-only.
The carry-forwards (recorded, not skipped): (1) the Person-view split → CR 6b; (2) the mint host-column cleanup → contract; (3) the host-FK re-target to host_account → contract; (4) the failed-signup UX gap (HTTP 500 on forced onboard failure) → still the Phase-4 logged limitation, not introduced by 6a.
Two physical tables, shared id:
principals (new, Stele's own Base stele/base.py): id, 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), now pointing at principals.id. Zero outward FK — the self-contained identity MetaData that makes Stele standalone-extractable.host_account (new, host Base): PK id = the principal's id, with a DB-level deferred FK → principals.id. The 16 host columns + own created_at/updated_at.Two migrations:
0085 (Step 1.3): CREATE TABLE principals; copy the 7 principal columns from persons; re-point the 2 identity DB constraints to principals.id; AFTER INSERT OR UPDATE mirror trigger on persons populating principals.0086 (Step 2.1): CREATE TABLE host_account; copy the 16 host columns from persons at the shared id; AFTER INSERT OR UPDATE mirror trigger on persons populating host_account; the host_account → principals FK is DEFERRABLE INITIALLY DEFERRED (the two triggers fire in name-sort order, host before principals, so the deferred check resolves at commit once both rows exist).
persons is unchanged in shape — it keeps all 23 columns (expand: nothing dropped). It remains the source of writes; the triggers fan each write out to both new tables.
def8b04)
| Commit | Step | What |
|---|---|---|
| f3f9a66 | 1.1 | Archive CR v0.3 |
| 6734197 | — | Archive CR v0.4 (Option B sequencing) |
| ca91c89 | 1.2 | Stele's own Base/MetaData (scaffold only) |
| de3b24f | — | Archive CR v0.5 |
| a325fcb | 1.3 | principals mapping + credential moves + migration 0085 (expand + sync trigger) |
| c589dd3 | 2.1 | host_account table + migration 0086 (expand + host mirror trigger) |
| 7ef9608 | — | Archive CR v0.6 |
| def8b04 | — | Archive CR v0.7 |
Two code commits (1.3, 2.1) + the Base scaffold (1.2). Steps 3/4/5/6 produced no code — Step 3 records the deferred view-seam, Step 4 folds to a carry-forward (triggers cover it), Steps 5/6 are verification-only.
host_account.id = persons.id, 1:1) — minimal FK churn; the 12 host FKs re-target the table at the same id value, and in expand they don't move at all.email/mobile → host (confirms the seed: email is not identity).persons columns) is a separate later step.This phase had the richest correction path in the extraction. Every change was caught at inspection or pre-build, before damage.
4a — Option B sequencing (v0.4). The 1.2 Base-seam inspection found the credential FK can only "become intra-Stele" once principals is a registered mapping, but the physical table doesn't exist until 0085. v0.3 implied Option A (ORM move at 1.2, table at 1.3) and CC recommended A as matching the wording; the Operator overrode to Option B — the inspection's findings changed what we knew, and A opens a window where the ORM FK target disagrees with the live DB constraint and the isolation test materializes principals early. B lands the mapping + table together at 1.3, ORM and DB never inconsistent. Override of CC's recommendation, on the merits.
4b — the dual-write ordering bug + sync trigger (v0.5). The 1.3 pre-build inspection caught that v0.1–v0.4 scoped the credential FK re-point (1.3) 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: migration 0085 adds the mirror trigger (write-side expand guarantee), retired at contract. A real ordering bug in the CR, caught before it shipped.
4c — model-layer rename-to-find (v0.6). Scoping Step 3 surfaced that the Operator's rename-to-find idea (rename persons columns to force missed readers to break) collides with the mirror triggers — they read the DB columns by name, so a rename breaks the trigger and drowns the signal. The Operator chose Option B: apply rename-to-find at the model layer (remove columns from the PersonRow mapping) — app readers break loudly at attribute access while the DB columns and triggers stay untouched. Same philosophy, layer that doesn't collide.
4d — the 6a/6b split (v0.7). The Step 3.1 inspection found the deepest seam: Stele's Person Pydantic view is a whole-person view carrying six host columns, and the ~150-site host blast radius flows through it. Re-homing those reads is a Person-view split — a large downstream sweep. The Operator chose to do the view-split (yes) but as its own CR, 6b, after closing 6a. This CR narrowed to 6a (the physical split + proof); the view-split and host-read re-home lifted to 6b, driven by model-layer rename-to-find. 6a banks the hard schema work behind a clean merge before 6b opens.
principals and host_account each get an AFTER INSERT OR UPDATE trigger on persons. Both fire on every persons write (including writes touching only the other table's columns — harmless churn, since the new tables are mirrors not sources of truth during expand). Both keep created_at immutable on UPDATE. The host_account → principals deferred FK exists because the two triggers fire in name-sort order (host before principals), so a raw persons insert creates the host_account row before its principals parent — the deferred FK moves the check to commit, by which time both exist. Proven on the real DB.
Carry-forward for contract: the two triggers are a pair with an ordering relationship, not two independent objects. When they retire at contract, they must retire in a way that doesn't leave a window where one fires without the other. The contract CR must treat them as a unit.
All checks passed on playground_dev (alembic 0086), driving the real HTTP path (only WebAuthn crypto verify stubbed):
COUNT(persons) == COUNT(principals) == COUNT(host_account) == 41, all at shared id; zero missing either side.principals, a host column only to host_account (clean separation — neither table carries the other's columns).principals; 12 host FKs still resolve to persons (unchanged in expand); host_account → principals deferred FK satisfied.persons + principals + host_account; a forced onboard failure leaves an orphan (onboarding_complete=False, personal_engagement_id=NULL) that heals on sign-in across both tables (the Phase-4 invariant, now spanning two tables) — login 200, onboarding_complete → True mirrored, personal engagement set.persons retains all 23 columns (expand guarantee).
Real-DB hygiene: the exercise created real accounts and cleaned the full footprint; playground_dev returned to 41/41/41, zero leftovers, alembic 0086. Harness-only errors (httpx re-raise, FK delete-ordering) were fixed in the throwaway harness, none touching the engine.
Note on Check 5: the forced-onboard-fail path returns HTTP 500 (not a graceful error). This is the Phase-4 logged "failed-signup UX gap", pre-existing — 6a preserves the correct heal behavior across the new two-table structure; it does not introduce the 500. Recorded as carried-forward, not a 6a regression.
Person-view split → CR 6b. Split the fused Person view into a pure Principal view + a host view; re-home the ~150 host-read sites via model-layer rename-to-find. Starting inventory: the three stele sites (registry.py Person view, get_person_by_email, actor.py actor_from_companion). Scoped fresh as its own CR after 6a merges.mint_principal still writes email/mobile/onboarding_complete to persons (Stele writing host data — harmless during expand, mirrored to host_account). Can only be severed cleanly when the triggers retire (contract) and the view boundary 6b draws is in place. A direct mint-writes-principals now would double-write with the trigger for no benefit.host_account → contract. The 12 host FKs stay on persons in expand (shared id + persons persists). They re-target host_account at contract, alongside the referred_by self-ref and the audit.events logical ref.cr-2026-109-stele-extraction-phase-6 at def8b04, pushed, in sync with origin.0086 on playground_dev and playground_test.origin/main still 8f2665a (6a not yet merged — the merge is the next step).principals + 2 credential tables, zero outward FK) — the structural precondition for the standalone repo (Phase 7).cr-2026-109-stele-extraction-phase-6 to engine main (check the real origin/main ref first — Phase-4 caution).main at the merge, the two new tables + triggers, alembic 0086, the carry-forwards, and 6b (the Person-view split) as the next slice.Person-view split, starting from the three stele sites.DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Extraction Phase 6a Completion Record — v0.1 — 2026-06-15