Version. 0.1
Date. 2026-06-15
Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (scoping/CR) and CC (executing agent) on DUNIN7-M4.
Status. Completion record. Phase 3 of the Stele extraction is complete and pushed to engine origin/main d3acd84. Markdown primary (technical consumer). Records what moved (session→principal resolution), the three-amendment trajectory (v0.1→v0.4, all before code), the grounding-read discipline that drove v0.4, the five-site seam, the no-schema proof, the database-truth result, one recorded minor behaviour change, and the Phase 4 handoff.
Governing CR. CR-2026-106 v0.4 (loomworks-record 76fd8f2; v0.1/v0.2/v0.3 preserved beside it per discovery-trajectory discipline). The v0.1→v0.4 trail is the substance of this record.
Phase 3 moved the sign-in resolution work — turning a session cookie into "this is who is acting" — out of the engine's request layer and into Stele, as a new operation resolve_session in stele/session.py. Five functions in deps.py used to each do this decode-load themselves; now all five call the one Stele operation. The functions kept their names and signatures, so none of their callers moved.
The operation hands back a small result, ResolvedSession, carrying both the person and the session details (including whether the second security factor was completed). The host functions read that and decide what to do: the strict ones raise 401 when there's no valid session or the second factor isn't done; the tolerant ones return nothing. Stele reports who you are and what the session says; the host decides whether to let you in. That boundary — Stele resolves who, the host decides what-here — is the heart of the phase.
The notable part of Phase 3 was not the code; it was that the plan was wrong three times and got corrected three times before any code was written. Each correction came from looking harder at the real engine instead of trusting the plan's picture of it. The record below walks that trail, because it is the lesson of this phase.
Phase 3's CR went through four versions. Three of them were corrections caught before execution — each a case of the plan assuming something the live code contradicted.
v0.1 → v0.2: one resolver became two. v0.1 assumed get_current_person was the only sign-in resolver, and that its optional sibling would inherit a thin delegator for free. Step 0 pre-flight found get_current_person_optional is a separate resolver that returns None instead of raising — it cannot inherit a raising delegator. The fix: shape the Stele operation as return-based (report presence-or-absence; the host decides whether absence is fatal), so both a raising wrapper and a tolerant wrapper can sit over the same operation.
v0.2 → v0.3: the bare-person return couldn't carry the gate. v0.2 specified the operation returning just the person (Person | None). But the second-factor flag the wrappers must check (totp_verified) lives only inside the session payload, not on the person record. So a bare-person return threw away the exact fact the security gate needs — while v0.2 also said the wrappers apply the gate and must not re-decode. An impossible set. The fix: the operation returns a small named result, ResolvedSession (.person + .payload), so the wrapper reads the second-factor flag off the carried payload without decoding twice. This was a security-critical correction — the result shape was changed precisely so the gate could not be silently dropped.
v0.3 → v0.4: two resolvers became five. A read-only grounding read of the real auth code (every decode_session caller, before writing any Step 3.1 code) confirmed the v0.3 design held against the live engine — and found the decode-load step appears at five sites, not two. Two were missed: _resolve_optional_person (a third tolerant person resolver) and _resolve_cookie_contributor (the contributor sibling). The fix: seam all five onto resolve_session, ending with zero inline decode-load duplicates. The Operator's standing direction — we defer too much — drove the decision to include _resolve_cookie_contributor rather than defer it: the earlier instinct to leave it out ("it's a contributor, not a person") confused the decode-load step (identical across actor kinds, and Phase 3's actual subject) with actor construction (which genuinely stays host-side).
The standing lesson. Twice (v0.2 and v0.4), the miss was the same class: the CR scoped against the functions it expected to find by line coordinate, and the engine had more of them. Line-coordinate checks confirm what you look at; they do not surface what you did not think to look for. The grounding read — printing the real bodies of every relevant function and every caller of the primitive — is what closed the gap, and it is the discipline that should precede execution on any extraction phase that touches a widely-used surface. Ground the design in a full read of the real code before drafting the executable steps, not just a coordinate check.
Added to stele/session.py (Step 3.1):
ResolvedSession — a small frozen result carrying .person (Person) and .payload (SessionPayload).resolve_session(token: str, , secret_key: str, now: datetime, db: AsyncSession) -> ResolvedSession | None — decodes the token, loads the principal, returns the result; catches SessionInvalid internally and returns None for a missing/malformed/expired/tampered token or a gone person. Never raises HTTPException, never propagates SessionInvalid, never applies the totp_verified gate — it carries* the flag for the host. One import added (Person, get_person_by_id from stele.registry); no circular import.
Thinned in deps.py — the five decode-load sites now delegate to resolve_session:
get_current_person (:565, raising person) — None → 401; unverified second factor → 401; else resolved.person. Name/signature/return unchanged.get_current_person_optional (:474, tolerant person) — None/unverified → None; else resolved.person. Inline decode-load removed._resolve_optional_person (:173, third tolerant person) — same tolerant shape; inline decode-load removed._resolve_cookie_actor (:996, engagement person) — decode-load routed; membership/can_commit/bearer paths untouched._resolve_cookie_contributor (:715, contributor) — decode-load routed; contributor-membership reasoning untouched.
The host seam, drawn and held. Stele's resolve_session does the decode-load (the who-are-you step). Everything downstream stays host-side: the totp_verified and person-exists policy (read off the carried payload), engagement membership, can_commit, the contributor-membership reasoning, the _ActorRef builds, and the bearer contributor/agent paths. Phase 3 moved the decode-load and nothing below it.
Person actor routed (Step 3.3). The inline _ActorRef(kind="person", …) in _resolve_cookie_actor now calls stele.actor.actor_from_person(person) — the constructor Phase 2 moved. The contributor/agent inline builds (:937/:943) stayed inline (host/Later concerns).
require_founder (:621) was untouched — it delegates via Depends(get_current_person) and inherited the thinned body.
Engine repo (DUNIN7/loomworks-engine).
origin/main: d3acd84 (fast-forward push 677c46f..d3acd84; linear, no merge commit; local == origin).stele/session.py now holds ResolvedSession + resolve_session. deps.py has zero inline decode_session calls — all five sites delegate to Stele (the single decode-load chokepoint).0083, unchanged — no migration this phase.
Phase 3 commit series (all on origin/main):
| Commit | Step | What |
|---|---|---|
| a3c51aa | 0 | archive CR-2026-106 (v0.1) into engine docs/phase-crs |
| 87ebbd6 | — | archive CR v0.2 alongside v0.1 (after the v0.2 amendment) |
| 79e48eb | — | archive CR v0.3 and v0.4 (after the v0.4 amendment) |
| 7d639c2 | 3.1 | add ResolvedSession + resolve_session to stele/session.py (additive) |
| 3a3665c | 3.2 | thin the three person resolvers onto resolve_session |
| 088212b | 3.2b | seam _resolve_cookie_contributor decode-load onto resolve_session |
| d3acd84 | 3.3 | seam engagement resolvers; route person actor through actor_from_person |
Step 3.4 (regression + database-truth) added no commit — verification only, matching Phases 1/2.
_resolve_cookie_contributor was the only site where the decode-load is followed by logic that must stay put (contributor-membership: the candidate creator-pin branch, can_commit from designations, the Contributor synthesis). The CR required an inspect-first pass with a stop-and-report if the decode-load couldn't separate cleanly.
It separated cleanly: the inspection found decode-load at :716–742, contributor-membership beginning at :743, with the payload not referenced after the seam. Only the decode-load routed through resolve_session; every line of membership reasoning was left exactly as-is, and the secret_key 503 check was preserved. Database-truth confirmed a contributor cookie path still resolves the human contributor with commit_authority=True and membership unchanged. The seam held.
No schema or data change. Alembic 0083 unchanged on repo files and live playground_dev. No models or migrations touched.
Zero inline decode-load remaining. grep decode_session src/loomworks/api/deps.py → 0. All five original sites delegate to stele.session.resolve_session.
Suite: 2920 / 46 / 0, green at every commit. Callers untouched (six membership lookups, ten can_commit refs, two bearer contributor/agent _ActorRef builds, three resolve_contributor_by_token — all intact in deps.py).
Database-truth (against playground_dev):
get_current_person resolves the known person (Marvin). ✓totp_verified=False session → 401 at get_current_person (the second-factor gate, preserved through the new payload-carrying result). ✓get_current_person_optional, _resolve_optional_person) → Person for a valid cookie, None for missing/invalid. ✓ResolvedActor(kind="person", id, "Marvin Percival", can_commit=True), the person ActorRef now from stele.actor.actor_from_person, membership unchanged. ✓commit_authority=True, membership unchanged. ✓
get_current_person previously raised 401 with distinct detail strings for different failure cases (missing cookie vs. bad token vs. gone person). With all of those now funnelling through resolve_session returning None, the wrapper raises one 401 for the collapsed None-case. CC confirmed no test asserts the detail strings (tests check status codes, which are preserved), so the suite is unaffected and the auth contract (401 on no valid session) is unchanged.
This is a small observability/UX regression, not a correctness one: a client or debugger that distinguished "no session" from "expired" from "user deleted" loses that signal. It is recorded here, deliberately, rather than smoothed. If distinguishing the reasons later matters, the fix is small — resolve_session would report why it returned None (a reason enum), and the wrapper would map reasons to messages. That reopens the narrow-vs-rich result-shape question settled in v0.3, so it was correctly left out of Phase 3's decode-load scope. Candidate for a small follow-up if needed; not a Phase 3 obligation.
Phase 4 — the sign-up split (approach B). Splits the fused sign-up into Stele-mints-principal → host-onboards. This is the mint↔onboarding seam the integration design names; approach B (mint-then-onboard with a heal path) is the locked decision from Phase 0.
Carry-forward cautions for the Phase 4 CR:
playground_dev, not the suite alone.Doc-sync notes (not on the execution path):
session.resolve is realized as stele.session.resolve_session returning a ResolvedSession(person, payload), with the totp_verified/person-exists policy applied host-side off the carried payload. Fold alongside the §3.3 contract-name-vs-code-symbol sync the Phase 2 record already named.resolve_session lives in stele/session.py and reads db/secret_key/now as parameters (host-supplied), not from engine config — so the resolution thread is cut as the brief intended; the residue cut is Phase 6's.
Branch cleanup. The Phase 3 branch cr-2026-106-stele-extraction-phase-3 was left in place pending this record. With the record filed, it can be deleted; main carries the full series.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Extraction Phase 3 Completion Record — v0.1 — 2026-06-15