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

CR-2026-110 — Stele Extraction Phase 6b — 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.


Plain-language summary

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.


Settled scoping decisions (Operator-confirmed, 2026-06-16)

| # | 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 | BPrincipal 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. |


The seam (from grounding, verified 2026-06-15)

src/loomworks/stele/registry.pyclass Person(BaseModel) carries 6 host columns alongside 4 principal fields:

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):

  1. Person view + _row_to_person (registry.py ~39/44/48/53) — the 6 host fields.
  2. get_person_by_email (registry.py:179) — queries PersonRow.email, order_by created_at limit 1.
  3. 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.


Mechanism (locked in 6a as decision 4c)

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.


Build steps

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.

Step 2 — Re-home the three stele sites.

Step 3 — Remove host fields from Person / PersonRow mapping, surface real readers.

Step 4 — Re-home consumers per module, descending weight.

Step 5 — Confirm Person shim retired or reduced.


Invariants (must hold throughout)


Definition of done


What this does NOT do (contract phase, separate CR)

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