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.
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:
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.personal_engagement_id, no benign default), not to defer it.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).
Recorded across versions; this version adds the Step 3 execution arc.
CurrentUser assembled host-side in deps.py, two clean loads (durable to contract).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.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.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).personal_engagement_id first) to "prove on high-traffic."companion_name to lead.companion_name came in at 83 failures despite fewer source sites — proving the test fallout is field-independent (driven by the converse cascade + shared _make_person helpers), not a function of source count. The reorder bought the source thread, not a calmer test surface.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 bc1e7f1→1396552 amend), rather than claim a tactic that was not proven.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.")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.
| # | 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 handler → Depends(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). |
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_email → Principal) |
| 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.
Step 3.3 — strip the four remaining host fields (email, mobile, exhaustion_preference, spend_pause).
Person view + drop from the builder's Person (same commit, M5); rename-to-find; re-point readers by site class (M2); host-path readers ride the thread (M3) or CurrentUser (leaves); migrate tests via the shared helper (M4).email/mobile read mostly in already-migrated leaves (me_route) + credit/email — moderate. exhaustion_preference/spend_pause — small, meaningful defaults, mostly memberships.py.playground_dev per touched route; commit; halt before push.
Step 3.7 — retire the Person name (D3/D4 from v0.3).
Person → Principal across ~177 annotation sites; collapse the Step-1 Principal view and the now-principal-only Person into one — no twin reaches main.get_person_by_id → get_principal_by_id; get_person_by_email/create_person renamed/re-typed (~4 sites); _row_to_person → _row_to_principal (exists).get_current_person / ResolvedSession.person (the shim) — all readers are on get_current_user / the thread by now.get_principal_and_person_by_id (the combined projector added in 3.0 because Person can't derive Principal) collapses to principal-only here.make_person_and_host rides this rename with the rest.PersonRow (ORM row) keeps its name — contract-phase.playground_dev, not the suite alone. Suite green. Commit. Halt.Step 3.8 — boundary + invariant verification.
stele/ imports nothing from persons/. No migration; triggers and DB columns untouched. Full suite green. Commit. Halt.0085 → principals, 0086 → host_account).persons keeps all 23.stele/ imports nothing from persons/ (the thread lives host-side in orchestration/; CurrentUser assembled in deps.py).playground_dev per touched route.Principal view (Stele) and HostAccount view (host-side) are the read path for their fields.CurrentUser; memberships.py keys unchanged.Person name retired at view + function layer; one Principal view, no twin on main; shim (get_current_person/ResolvedSession.person) retired; get_principal_by_id etc.PersonRow row name survives (contract-phase).playground_dev drives clean.main, recorded, branch retired (6a close pattern).PersonRow host-side; drop persons host columns; re-target 12 host FKs to host_account; retire mirror triggers as an ordered pair.persons/host_account_view.py imports HostAccountRow from stele/models.py — resolves when the row relocates host-side.personal_engagement_id= kwarg to their own local builders without reading it back (they did not break). A clean follow-on consolidation onto the shared helper — deliberately out of 6b scope (do not churn passing tests).personal_engagement_id classification~~ — closed (confirmed host; see trajectory item 8).deps.py (host-side), not a generic resolve_session rewrite. Until profiled, the two-load shape stands.
DUNIN7 — Done In Seven LLC — Miami, Florida
CR-2026-110 — Stele Extraction Phase 6b — Person-view split — v0.4 — 2026-06-16