Person-view split — v0.3
Version. 0.3
Date. 2026-06-16
Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (scoping) on DUNIN7-M4.
Status. Steps 1–2 built, pushed, green on branch stele-phase-6b-person-view-split (23d4bb5). This version corrects the Step 3 delivery cost (v0.2 stated it wrong) and locks the option-1 wiring. Worklist for Step 3 execution. Markdown primary (technical consumer: CC executes).
Against. branch stele-phase-6b-person-view-split 23d4bb5 (Steps 1–2). Base was engine main def8b04.
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 combined CurrentUser dependency assembled host-side, and retires the Person name at the view and function layer (replaced by Principal).
What changed from v0.2. v0.2's decision D1 claimed the combined return costs "zero added query" because the per-request row is already loaded and cached. That was wrong, and is corrected here. The boundary forbids Stele exposing the host data it loads — building HostAccount inside Stele's resolution would make stele/ import from persons/, the inversion the whole extraction prevents. So Stele projects the principal and the host columns are unreachable through it; the host side loads host_account separately. The true cost is one extra cached, indexed PK lookup per authenticated request during expand, becoming the natural two-table read (or join) at contract. v0.3 records this and the full delivery-shape trajectory.
Decision needed. None outstanding. Step 3's delivery shape is settled (option 1, below). This version is for the record and to drive CC execution.
Out of scope (contract phase, separate CR). Dropping persons host columns · re-targeting the 12 host FKs · retiring the mirror triggers · renaming PersonRow the ORM row (survives 6b; rename/relocation is contract-phase) · re-examining personal_engagement_id's host classification given hot-path use.
get_current_host_account dependency) on boundary grounds → saw personal_engagement_id's ~16 hot reads → leaned hybrid (cheap principal-side personal_engagement_id, host-dependency for the rest), accepting a boundary blur → Operator asked why values can't be resolved once and reused → grounding showed the full row is already loaded once and cached per request, so the per-read cost never existed → retired the hybrid and the blur, moved to "resolve-once, expose-both."get_person_by_id) and Stele cannot expose host data without importing persons/. Stele projects the principal; the host columns on the loaded row are unreachable through Stele and are discarded. The host side must load host_account separately. True cost: one added cached PK lookup during expand, not zero.deps.py issues its own combined query, sidestepping resolve_session): one query, but creates a second resolution path and hollows Stele's resolve_session primitive — the standalone contract. Rejected.Callable[[PersonRow], T] projector into get_person_by_id so the already-loaded row builds the host object): boundary-clean (a TypeVar projector names no host type) and genuinely one query — but only during expand. At contract, persons drops its host columns; the loaded row no longer carries them; the projector can't build HostAccount; you are back to two loads and you rip the generic machinery out. Cost to add: first-use Generic/TypeVar/Callable in Stele's session+registry, an optional projector threaded through 8 call sites (most not needing host data), a Generic[T] ResolvedSession — all to save one cached PK lookup that is given back at contract. Rejected as expand-only scaffolding.Person retirement scope. Operator chose path 1 — name retired, replaced by Principal. Class-name rename is ~177 annotation sites (separate from the ~4 function-rename sites). Operator confirmed full collapse inside 6b — no Person/Principal twin reaches main.get_current_actor → caught: "actor" is load-bearing (ActorRef, Step-2 actor_from_*) — a collision. Settled get_current_user (CurrentUser with .principal, .host_account). User = the authenticated requester; distinct from actor = who acts in a conversation.
| # | Decision | Settled |
|---|---|---|
| D1 | Delivery mechanism — option 1, two clean loads | resolve_session returns Principal only (Stele stays minimal, no generics). deps.py (host-side, may import both stele/ and persons/) calls resolve_session for the principal + payload, loads host_account host-side via a new get_host_account_by_id in persons/, and assembles CurrentUser(.principal, .host_account). Cost: one extra cached, indexed PK lookup per authenticated request during expand; at contract this is the natural principals + host_account two-read (or join), so the shape survives contract unchanged — no rework. |
| D2 | Two typed objects, not re-fused | CurrentUser exposes .principal: Principal and .host_account: HostAccount as separate typed projections. A single all-fields object would be the Person shim renamed — forbidden. |
| D3 | Person name retired (path 1, full collapse in 6b) | Class Person → Principal; Person view + functions removed; no twin reaches main. |
| D4 | Dependency rename | get_current_person → get_current_user, returns CurrentUser. Folds into the ~177-site annotation sweep with the class rename — no extra spread. |
| D5 | Strip order | Per-field, hottest first: personal_engagement_id (~16) → companion_name (~6) → email/mobile/exhaustion_preference/spend_pause (~5, mostly the memberships.py response dict). Each: strip → rename-to-find → re-point to …host_account.<field> → suite green → commit. |
| D6 | API shape held constant | No API response/request schema changes. Person is never a response_model; no /person routes; self-surface is /me/*. memberships.py:349–356 keeps its exact JSON keys, sourced from the host projection. Class/dependency renames are internal-only. |
Resolution chain: get_current_person (deps.py) → resolve_session (session.py) → get_person_by_id → select(PersonRow)…scalar_one_or_none() → _row_to_person → Person. The PersonRow is local to get_person_by_id and discarded — only the view escapes. resolve_session never holds the row (this is why an injected-projector seam would have to live in get_person_by_id, not resolve_session — see trajectory item 3).
resolve_session callers: 5, all in deps.py (get_current_person, get_current_person_optional, get_current_contributor, + two more). None read host fields themselves; host reads happen downstream in the ~26 handlers. get_current_contributor needs no person-host data.
get_person_by_id direct callers: 3 — room_gate.py:208, session.py:156 (via resolve_session), auth_dev.py:82.
Host-field reader surface — ~26 real person.<host> reads: personal_engagement_id ~16 (hot), companion_name ~6, email 2, mobile/exhaustion_preference/spend_pause 1 each. The one response consumer is memberships.py:349–356 (5 host fields → response dict; keeps keys).
Write-then-read-stale: no new surface. Host-field writers (onboard.py, credit/lifecycle.py) target their own PersonRow, never the frozen view.
Class-name surface: Person type ~177 references, overwhelmingly Depends(get_current_person) annotations; never a response_model. Rename is pure-annotation, zero API impact.
Each step: build, suite to green, halt before push. Inspect-and-report first on the 3.0 wiring before mutating. Per-field commits keep each rename-to-find break-wall readable.
Step 3.0 — Combined return (get_current_user / CurrentUser), option-1 wiring.
get_host_account_by_id in persons/ (host-side loader; queries host_account — or persons during expand — by id; mirrors get_person_by_id).resolve_session returns Principal (rename ResolvedSession.person → principal; 5 host-side read sites in deps.py). Stele stays minimal — no generics, no projector.deps.py: get_current_person → get_current_user; assembles CurrentUser(principal=<from resolve_session>, host_account=<from get_host_account_by_id>). All combined-shape wiring host-side; stele/ imports nothing from persons/.Person still carries host fields this step; nothing stripped yet. Suite green. Commit. Halt.
Step 3.1–3.6 — Strip host fields one at a time (D5 order).
Per field in order personal_engagement_id → companion_name → email → mobile → exhaustion_preference → spend_pause:
Person/Principal view (and its mapping).…host_account.<field> off CurrentUser. memberships.py re-points to the host projection, response keys unchanged.
Step 3.7 — Retire the Person name (D3/D4).
Person → Principal across ~177 annotation sites; collapse the Step-1 Principal view and hollowed Person into one — no twin.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).PersonRow 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/.0085 → principals, 0086 → host_account).persons keeps all 23.stele/ imports nothing from persons/. (Option 1 holds this without generics, the durable reason it was chosen over the projector.)memberships.py keys unchanged.Principal view (Stele) and HostAccount view (host-side) are the read path for their fields.get_current_user returns CurrentUser(.principal, .host_account); principal from Stele's resolve_session, host account loaded host-side in deps.py.memberships.py response keys unchanged.Person name retired at view + function layer; one Principal view, no twin on main; get_principal_by_id etc.PersonRow row name survives (contract-phase).playground_dev routes drive 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's host classification given hot-path identity-adjacent use.deps.py (host-side) — not a generic rewrite of resolve_session. Until profiled, the two-load shape stands (it is the contract reality regardless).
DUNIN7 — Done In Seven LLC — Miami, Florida
CR-2026-110 — Stele Extraction Phase 6b — Person-view split — v0.3 — 2026-06-16