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

CR-2026-110 — Stele Extraction Phase 6b — Person-view split — v0.4

Version. 0.4 Date. 2026-06-16 Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (scoping) on DUNIN7-M4. Status. Steps 1–3.2 built, pushed, green on branch stele-phase-6b-person-view-split (f49c63f). This version records the proven Step 3 method (it differs materially from v0.3's plan) and carries the remaining strips + retirement. Markdown primary (technical consumer: CC executes). Against. branch stele-phase-6b-person-view-split f49c63f. Base was engine main def8b04. Suite 2933 / 46 / 0.


Plain-language summary

What this does. Splits Stele's fused Person read view into a pure Principal view (identity/auth) and a host-side HostAccount view (host account state), re-homes every reader onto the right view via a host-side combined CurrentUser dependency and a host-side HostAccount cascade thread, and retires the Person name at the view + function layer (replaced by Principal).

What changed from v0.3. v0.3 planned Step 3 as six independent per-field strips, hottest-first, each switching readers to get_current_user. Execution proved a different and better method, recorded here:

  1. The lever is not the field — it is two pieces of reusable machinery. A host-side HostAccount cascade thread (one param carries all host fields through the converse cascade) and a shared two-function test helper (tests/helpers/identity.py). Once both exist, every remaining field is a mechanical rider.
  2. Strip order does not matter the way "hottest-first" assumed. Hottest = widest = worst surface to prove a tactic on. Reordered to easiest-first — then found field order is not the lever at all (the test fallout is field-independent, driven by shared test helpers + the converse cascade). The decisive sequencing choice was to face the test-helper redesign on the one field that forces it (personal_engagement_id, no benign default), not to defer it.
  3. CurrentUser is for leaf handlers; the HostAccount thread is for hubs/helpers. Not every reader switches to get_current_user — a leaf handler does; a hub handler keeps person and loads host_account in-body; a non-handler helper takes the host value (or HostAccount) as a param.

Decision needed. None outstanding. The method is proven; the remaining strips are mechanical. This version is for the record and to carry the remaining work.

Out of scope (contract phase, separate CR). Drop persons host columns · re-target the 12 host FKs · retire the mirror triggers as an ordered pair · rename PersonRow (survives 6b) · re-examine personal_engagement_id classification (now confirmed correct — see below; the carry-forward is closed).


Correction trajectory (preserved, not smoothed)

Recorded across versions; this version adds the Step 3 execution arc.

  1. Delivery mechanism (v0.2→v0.3). option 2 → hybrid → "resolve-once expose-both" → corrected: the boundary forbids Stele exposing host data, so the cost is one cached PK lookup per authenticated request during expand, not zero. Settled: option 1, CurrentUser assembled host-side in deps.py, two clean loads (durable to contract).
  2. Single-query options considered and rejected (v0.3). Bypass (hollows resolve_session) and injected generic projector (boundary-clean and one-query but expand-only — evaporates at contract, adds first-use generics to Stele). Both rejected; the two-load shape is the contract reality regardless.
  3. Step 3.0 "additive" contradiction (caught at build). The 3.0 kickoff used replace/rename verbs (which belong to 3.7) while also saying "additive." CC caught that a literal replace+rename breaks ~256 sites in one commit. Corrected to additive-add-alongside: ResolvedSession gains principal beside person; get_current_user added beside the unchanged get_current_person; nothing stripped. The Person name retires in 3.7, not 3.0.
  4. me_create_engagement leaf→hub (caught at build). Pre-flight called it a leaf with helpers going to Principal; build found it fans person to a _call_* helper → it is a hub. Reverted the helpers-to-Principal change; applied the hub tactic (keep person, in-body host load).
  5. Strip order: hottest-first → easiest-first → field-is-not-the-lever.
  1. companion_name "zero test edits" was luck, not method (named honestly). It went green with no test migration because its column default ("Companion") matches fixtures — it dodged the test problem. The shared test helper was therefore NOT built on companion_name. The commit message was amended to say so (the bc1e7f11396552 amend), rather than claim a tactic that was not proven.
  2. personal_engagement_id sequenced NEXT, not last (the key call). Because only a field without a benign default forces the test helper, and deferring it behind the trivial fields would only rebuild the test tactic under the hardest field anyway. Done next: source rode the thread (51 failures, zero collateral), and the shared test helper was built and proven here. (CC's closing note had re-defaulted it to "last"; the Operator confirmed "next.")
  3. personal_engagement_id host classification — confirmed, carry-forward closed. The Operator questioned it ("every principal has a personal engagement"). Inspection confirmed host: physically on host_account (migration 0086, never on principals); FK → engagements.id, a pure host/Loomworks substrate Stele has no notion of (a standalone principal cannot carry a FK into host-land); set at onboard (host phase), NULL for a minted-not-onboarded orphan; recorded in CR-2026-109 v0.7 §3. 1:1-with-principal is not identity-data — it is host-provisioned-per-user. Classification holds; the v0.3 carry-forward to re-examine it is closed.

Settled Step 3 method (proven across 3.0–3.2)

| # | Rule | Detail | |---|---|---| | M1 | Combined delivery, two typed objects | get_current_user returns CurrentUser(.principal: Principal, .host_account: HostAccount), assembled host-side in deps.py (principal from Stele's resolve_session; host loaded via get_host_account_by_id in persons/). Two cached PK lookups in expand; the contract reality regardless. Never re-fused into one all-fields object. | | M2 | Reader re-home by site class | Leaf handlerDepends(get_current_user), read user.host_account.<field> / user.principal.<field>. Hub handler (fans person to other consumers) → keep person, load host_account in-body via get_host_account_by_id(id=person.id), read the field. Non-handler helper → take the host value (or HostAccount) as an explicit param; never import get_current_user (a dependency) or HostAccount into Stele. | | M3 | The HostAccount cascade thread | operator_converse loads host_account in-body once and threads it as one param (host_account: HostAccount \| None) through route_intent → the _route_* helpers. Each helper reads host_account.<field>. One param carries every host field — no per-field param explosion. Boundary-clean: orchestration/ is host-side and may import HostAccount from persons/; only stele/ may not. | | M4 | Shared test helper (tests/helpers/identity.py) | Two functions: make_person_and_host(...) -> (Person, HostAccount) (pure in-memory builder; unit tests thread host_account=), and create_person_with_personal_engagement(db) -> (Person, HostAccount, UUID) (DB-backed; sources the real value, asserts host.personal_engagement_id == the create return to prove mirror sync, fails loud on desync). Replaces the per-file _make_person/_setup copies. Coverage preserved — real values asserted, never None-to-compile. | | M5 | Builder ↔ view anti-drift rule | make_person_and_host builds Person to match the view's current shape. Each strip drops the field from the builder's Person construction in the same commit that strips it from the view, so builder and view never drift. Recorded in the module docstring. | | M6 | Per-field commit discipline relaxes once machinery exists | Strict one-commit-per-field existed to keep rename-to-find break-walls readable. With the thread + helper proven, break-walls for the remaining low-count fields are small and readable; the remaining fields may strip together in fewer commits. Decide from the real surface per remaining field. | | M7 | API shape held constant | No response/request schema changes. memberships.py keeps its exact JSON keys, sourced from the host projection. Renames are internal-only (never a response_model; no /person routes). |


Build state (as of f49c63f)

| Step | Commit | State | |---|---|---| | 1 | (in branch) | Principal + HostAccount views authored | | 2 | 23d4bb5 | stele host-reads re-homed (actor_from_companion parameterized; get_person_by_emailPrincipal) | | 3.0 | 73846d4 | CurrentUser delivery path stood up (additive; get_current_person untouched) | | 3.1 | 1396552 | companion_name stripped; HostAccount cascade thread established; leaf handlers via CurrentUser. (No test migration — benign default.) | | 3.2 | f49c63f | personal_engagement_id stripped (rode the thread); shared tests/helpers/identity.py authored + proven; host classification confirmed |

Person view now carries: email, mobile, exhaustion_preference, spend_pause (+ identity). The thread and the shared test helper are both proven.


Remaining build steps

Step 3.3 — strip the four remaining host fields (email, mobile, exhaustion_preference, spend_pause).

Step 3.7 — retire the Person name (D3/D4 from v0.3).

Step 3.8 — boundary + invariant verification.


Invariants (hold throughout)


Definition of done


Carry-forwards to contract phase


DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-110 — Stele Extraction Phase 6b — Person-view split — v0.4 — 2026-06-16