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

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


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


Correction trajectory (preserved, not smoothed)

  1. Delivery mechanism — full path. Leaned option 2 (separate 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."
  2. The "zero added query" error (corrected in this version). Claimed the combined return is free because the row is loaded once. The boundary inspection (2026-06-16) showed the loaded row lives inside Stele (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.
  3. The single-query options, considered and rejected.
  1. 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.
  2. Dependency name. Leaned 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.

Settled Step 3 decisions

| # | 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 PersonPrincipal; Person view + functions removed; no twin reaches main. | | D4 | Dependency rename | get_current_personget_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. |


The real Step 3 surface (grounding, 2026-06-16)

Resolution chain: get_current_person (deps.py) → resolve_session (session.py) → get_person_by_idselect(PersonRow)…scalar_one_or_none()_row_to_personPerson. 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.


Build steps (Step 3)

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.

Step 3.1–3.6 — Strip host fields one at a time (D5 order). Per field in order personal_engagement_idcompanion_nameemailmobileexhaustion_preferencespend_pause:

Step 3.7 — Retire the Person name (D3/D4).

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.3 — 2026-06-16