Person-view split — v0.1
Version. 0.1
Date. 2026-06-16
Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (scoping) on DUNIN7-M4.
Status. Drafted, awaiting Operator confirmation then CC execution. Single CR (not sub-split). Application-layer only — no migration. Markdown primary (technical consumer: CC executes).
Against. engine origin/main def8b04, alembic 0086, suite 2933 / 46 / 0.
What this does. Splits Stele's fused Person read view into two views — a pure Principal view (identity/auth fields only) and a host-side HostAccount view (the host columns) — then re-homes every site that reads host data off Person onto the right view. This is the work Phase 6a deliberately deferred: 6a split the tables (principals, host_account) but left every read resolving from the fused Person view.
What changed since the handoff. Grounding (2026-06-15) corrected three of the handoff's open assumptions. (1) Blast radius is 76 raw attribute-hits as an upper bound, not ~150 — so this is one CR, not a 6a-style sub-split. (2) The guard suite is clean — nothing polices the Person view's shape, so the split trips no repo-wide invariant (unlike Phase 5's hard-delete tripping the non-erasure guard). (3) Both views are new — only the row models (PrincipalRow, HostAccountRow) exist from 6a; neither read view exists yet.
Decision needed. Confirm the CR as drafted, then it goes to CC. One sub-decision is deferred to CC pre-flight: the exact host-side module path for the new HostAccount view (no host/account module exists at the package root yet) — Build Step 1 states it; CC confirms the path before building.
Out of scope (the contract phase, after 6b). Dropping any persons column, re-targeting the 12 host FKs, retiring the two mirror triggers. 6b is still inside expand. No migration. Triggers and DB columns are untouched throughout.
| # | Decision | Lean → Confirmed |
|---|---|---|
| 1 | One CR or sub-split? | One CR. 76-upper-bound + clean guard suite + loud-break safety net don't carry the 6a a/b weight. |
| 2 | View names | PrincipalRow → Principal, HostAccountRow → HostAccount. Symmetric with existing PersonRow → Person; HostAccount avoids credit-errors Account* collision. |
| 3 | View placement | B — Principal in Stele's surface (stele/registry.py); HostAccount host-side. Honors the 6a Base-ownership boundary at the view layer, so Phase 7's repo cut doesn't re-disentangle a host view from Stele's surface. |
| 4 | get_person_by_email | Host-side lookup. It queries email (host column) as predicate, order_by created_at limit 1 — nothing principal about it. |
| 5 | actor_from_companion | Stele keeps building the ActorRef label; sources companion_name from the HostAccount view across the join. The label is a Stele concern; only its data source is host. |
| 6 | Re-home order | Three stele sites first (isolation requirement), then consumers per-module in descending weight: api/routers → credit → persons → orchestration. Commit per module; suite green at each; halt before push. |
src/loomworks/stele/registry.py — class Person(BaseModel) carries 6 host columns alongside 4 principal fields:
Principal view): id, display_name, created_at, updated_atHostAccount view): email, mobile, companion_name, personal_engagement_id, exhaustion_preference, spend_pause
Populated by _row_to_person(row) → Person.model_validate(row) off PersonRow (stele/models.py:51).
The three stele host-read sites (re-homed first):
Person view + _row_to_person (registry.py ~39/44/48/53) — the 6 host fields.get_person_by_email (registry.py:179) — queries PersonRow.email, order_by created_at limit 1.actor_from_companion (actor.py:57) — reads companion_name for the Companion ActorRef display_name.
Row models (exist from 6a, untouched): PrincipalRow (models.py:233, SteleBase, table principals); HostAccountRow (models.py:151, identity_base.Base, table host_account).
Blast radius (upper bound, raw .<col> grep across api/credit/persons/orchestration): 76 — .email 23, .personal_engagement_id 22, .companion_name 7, .spend_pause 4, .mobile 4, .exhaustion_preference 2. The real subset is whatever rename-to-find breaks loudly; survivors are false positives (contributor rows, request bodies, orgs reading a same-named attribute that isn't a Person).
Guard suite: clean. Only tests/test_engagement_creation_walk_guards.py matches Person + a guard term, and it's an engagement-walk guard, not a view-shape/contract guard. No architectural guard polices the Person view's shape.
Model-layer rename-to-find. Remove a host field from the Person view / PersonRow mapping; the suite breaks every reader of it loudly (AttributeError / mapper error); re-point each to the HostAccount view; green; commit; per module (or field-group). The loud-break is the safety — re-add the removed field to revert (pure Python; no DB change). DB columns and the two mirror triggers stay untouched.
Each step: build, run suite to green, halt before push. Push is a structurally separate explicit instruction. Two-pass on existing-code edits: inspect-and-report first where a step says so.
Step 1 — Author the two views.
Principal view in stele/registry.py (id, display_name, created_at, updated_at), populated from PrincipalRow.HostAccount view host-side (the 6 host fields used by the seam — email, mobile, companion_name, personal_engagement_id, exhaustion_preference, spend_pause — plus id), populated from HostAccountRow.HostAccount. No host/account module exists at package root. CC surfaces the natural home (sibling to where HostAccountRow lives, or a new host-side views module) and confirms with the Operator before building. State the chosen path in the commit message.Person stays as the transition shim this step. Suite green (nothing reads the new views yet). Commit. Halt.Step 2 — Re-home the three stele sites.
_row_to_person / Person callers inside Stele → split onto Principal + HostAccount as each caller needs.get_person_by_email → host-side lookup querying HostAccountRow.email (decision 4).actor_from_companion → source companion_name from HostAccount across the join (decision 5).
Step 3 — Remove host fields from Person / PersonRow mapping, surface real readers.
Person view (and the PersonRow attribute mapping feeding them). The suite breaks loudly at every real reader.Step 4 — Re-home consumers per module, descending weight.
HostAccount (or Principal where it wanted identity), suite green for that module's breakages, commit per module, halt before push.playground_dev, not the suite alone — the model-attribute removal catches ORM readers; any raw-SQL reader of these columns (rare) only fails when run. Drive the real routes per re-homed module.
Step 5 — Confirm Person shim retired or reduced.
Person carries only principal fields (or is removed if no caller still wants the fused object). Confirm against the real caller set — do not force removal if a legitimate combined caller survives; surface it instead.0085 → principals, 0086 → host_account) stay. They keep both new tables in sync with every persons write through contract.persons keeps all 23 columns; host_account and principals keep theirs.Principal view (Stele surface) and HostAccount view (host-side) exist and are the read path for their respective fields.HostAccount, not via a fused Person.playground_dev routes drive clean per re-homed module.Person reduced to principal-only or retired; no host field readable off a fused Stele view.main, recorded, branch retired (per 6a close pattern).
Drop persons's now-duplicate columns · re-target the 12 host FKs to host_account · retire the two mirror triggers as an ordered pair · mint host-column write cleanup. All deferred to the contract phase. 6b stays inside expand.
DUNIN7 — Done In Seven LLC — Miami, Florida
CR-2026-110 — Stele Extraction Phase 6b — Person-view split — v0.1 — 2026-06-16