session.resolve; seam all five decode-load sites onto it; route the person actor path through actor_from_person) — CR-2026-106 — v0.4
Version. 0.4
Date. 2026-06-15
Status. Change request — the fourth executable phase of the Stele extraction, drafted against the post-Phase-2 tree (engine origin/main 677c46f; suite baseline 2920 passed / 46 skipped / 0 failed; alembic head 0083, unchanged). Phase 3 moves the session→principal resolution — the per-request work of turning a session cookie into "who is acting" — into Stele as the integration design's session.resolve operation. The host dependencies (get_current_person and get_current_person_optional) become thin wrappers over the Stele primitive, keeping their exact names, signatures, and returns — so their callers do not move. The phase also draws the host seam (Stele resolves the principal; engagement membership / can_commit / bearer paths stay host-side) and routes the inline person-ActorRef build through the actor_from_person constructor Phase 2 moved. Markdown primary (technical consumer: Claude Code). Halt-before-push; per-step commits, each move bundled with its repoints in one commit (Phase 2 lesson); verify against engine-database truth, not the suite alone.
v0.2 amendment (this revision). Step 0 pre-flight (engine a3c51aa = CR archived) confirmed the CR's line coordinates, destination (resolve_session name free in stele/session.py), suite baseline, and alembic 0083 — and surfaced one material divergence the CR's halt list named: there are two resolver functions, not one. get_current_person (deps.py:565) raises 401 on failure; get_current_person_optional (deps.py:474) DUPLICATES the resolution (its own cookie-decode-load at :489-509) but returns None on each failure instead of raising — that tolerant behaviour is its whole reason to exist. v0.1 assumed only the raising function needed the seam and that the optional neighbour would "inherit the thin body for free." That is wrong for get_current_person_optional — it cannot inherit a raising delegator. (require_founder at :621 does delegate via Depends(get_current_person) and inherits the thin body free, as v0.1 expected.) The correction (recorded, not smoothed, in §2.1 below): the Stele resolve primitive is shaped return-based — it returns Person | None (catching SessionInvalid internally and reporting a bad/missing token as None, not an exception). Both wrappers then become thin and delegate to the same primitive: get_current_person raises 401 on None; get_current_person_optional returns the None. This is truer to the integration design's session.resolve (resolve reports what it found; the host decides whether absence is fatal — the same host-policy boundary the CR already draws for the totp_verified gate). Step 3.2 now thins both wrappers. No other change: scope, destination, the host seam, the person-actor route, and the no-schema invariant all hold. The v0.1 caller figure "146" is corrected to the live loose-grep 147 (1 def + 74 Depends(get_current_person) + 2 Depends(get_current_person_optional) + prose/_optional mentions) — a counting-method artifact, not a real caller change; no caller moves either way.
v0.3 amendment (this revision). Before any Step 3.1 code was written, a self-contradiction inside v0.2 was caught: v0.2 said the resolve operation returns just the person (Person | None) and that the host wrappers still apply the second-factor (totp_verified) gate and that the wrappers never re-decode the token. These three cannot all hold. The reason is concrete: the totp_verified flag lives only inside the session payload ({person_id, totp_verified, created_at, expires_at}), not on the Person record. So if the resolve operation hands back only the person, it has already consumed and discarded the payload — and the wrapper has no way to see totp_verified to apply the gate, short of decoding the token a second time (which v0.2 forbade). v0.2 was internally inconsistent: one place said "the host acts on the totp_verified field in the payload" (host has the payload), another said the operation returns only the person (host does not). The correction (recorded, not smoothed, in §2.2 below): the resolve operation returns the person and the session payload together, as a small named result, ResolvedSession (.person, .payload), or None when there is no valid session. The wrappers then see both: absence → raise-or-return-None; present → read payload.totp_verified host-side to apply the gate. This is the truest form of the contract — session.resolve hands the host everything the session contains (who you are, and the session's state), and the host decides what to do with it. It removes the contradiction, keeps the second-factor policy host-side (Stele never enforces it), and requires no second decode. v0.3 replaces every "Person | None" resolve-shape reference with the ResolvedSession | None shape; all other v0.2 content (the two-resolver finding, the host seam, the person-actor route, no-schema) holds unchanged. This is a security-critical gate — the result shape was corrected precisely so the gate cannot be silently dropped.
v0.4 amendment (this revision). A read-only grounding read of the real auth code (before writing any Step 3.1 code) confirmed the v0.3 design holds against the live engine — decode_session(token: str, …) → SessionPayload; SessionPayload carries person_id and totp_verified; both wrappers read the cookie, decode, gate on totp_verified, load the person, exactly as v0.3 assumes — and found that the decode-load step Phase 3 extracts appears at five sites, not two. The line-coordinate checks in v0.1/v0.2 saw the two functions they looked for; the grounding read of every decode_session caller found three more, of which two are in-class with what Phase 3 extracts: _resolve_optional_person (deps.py:173) — a third tolerant person resolver, near-identical to get_current_person_optional; and _resolve_cookie_contributor (deps.py:715) — the contributor sibling of _resolve_cookie_actor, doing the same decode-load before its contributor-membership work. (The two remaining decode_session callers — get_current_person and _resolve_cookie_actor — were already in scope; tests calling decode_session directly exercise the primitive and are unaffected.) The scope correction (recorded, not smoothed, in §2.3 below): Phase 3 seams all five decode-load sites onto resolve_session, leaving zero inline decode-load duplicates behind. v0.3 (and v0.1/v0.2 before it) would have shipped resolve_session plus two un-seamed copies of the very thing it exists to absorb — the drift the extraction removes. The Operator's standing direction — we defer too much — applies exactly here: the earlier draft's instinct to leave _resolve_cookie_contributor out ("it's a contributor, not a person") confused the decode-load step (which is identical across actor kinds and is Phase 3's subject) with actor construction (contributor/agent building, which genuinely stays host-side). Phase 3 seams the decode-load on all five; it does not touch any actor-construction or membership logic. The contributor-membership and bearer/agent actor builds stay exactly where they are. v0.4 adds the two sites to scope (§1, §3.7), splits the wrapper-thinning into person resolvers (Step 3.2) and the contributor resolver (Step 3.2b, with an inspect-first pass since its decode-load is followed by membership work that must stay put). All v0.3 content (the ResolvedSession shape, the host seam, the person-actor route, no-schema) holds unchanged.
Decisions this CR rests on (settled, carried from Phase 0/1/2): (1) Stele's interface is a host-agnostic, in-process-first / service-ready API contract. (2) Move, not duplicate. (3) Bundle each move with its repoints in one commit — never split a move from its repoints (the Phase 2 v0.1→v0.2 lesson: a module-top-imported symbol removed before its importers repoint aborts collection). (4) Phase 0/1/2 complete and pushed (engine 677c46f): the core identity surface plus the actor constructors live in stele/. (5) Destination for session.resolve: stele/session.py (Operator-locked; the module already owns decode_session, COOKIE_NAME, SessionInvalid). (6) Consolidation decided: route — the person actor path goes through stele.actor.actor_from_person; contributor/agent paths stay inline.
Source grounding. Integration design v0.2 (§3.2 session.resolve contract: {token} → {principal}, error session_invalid; §4 host seam: "host-specific concerns never cross into Stele"; §"realize the contract" naming Phase 3 = session.resolve under the host dependency); the Phase 3 scoping note v0.1; the Phase 2 completion record v0.1 (§7 Phase 3 handoff); and CC's read-only pre-flight at 677c46f (§3 below). Drafted against that pull, not from memory.
Today, deps.py:get_current_person does the real work of sign-in resolution: read the cookie, decode the session, check the second-factor flag, load the person, hand it back. A sibling, get_current_person_optional, does the same decode-load work but returns None instead of raising — for routes where "not signed in" is allowed rather than an error. Phase 3 lifts that shared resolution work into Stele (stele/session.py, as resolve_session realizing the contract's session.resolve) and leaves both functions as thin wrappers: the raising one raises 401 when Stele reports no principal, the tolerant one returns the None. Same names, signatures, returns — so all callers keep working untouched.
Two things ride along. First, the boundary: the engagement-scoped resolvers (_resolve_cookie_actor, get_resolved_actor) blend "who is this person" (Stele's job) with "what can they do in this engagement" — membership, can_commit (the host's job). Phase 3 splits them: Stele resolves the principal, the host layer does the engagement/membership/commit reasoning on top. Second, the consolidation: one spot still hand-builds the person ActorRef instead of calling the actor_from_person constructor Phase 2 just moved — Phase 3 routes it through the constructor. Only the person path; contributor and agent stay inline.
One policy care-point, surfaced not buried. The integration design's session.resolve contract is narrow — token in, session result out, absence on failure. But get_current_person today does more than resolve: it also rejects a session whose second-factor isn't verified (totp_verified), and returns 401 if the person no longer exists. Those are auth-policy checks, not pure session mechanics. This CR keeps them, and is explicit about where they sit: the Stele operation resolves token→ResolvedSession (person + payload), and the policy (require second-factor; person-must-exist) stays in the host wrappers, which read the second-factor flag off the returned payload. Stele's contract stays the clean shape the integration design names; the engine's existing behaviour is preserved exactly. No behaviour change to any caller.
No schema change (alembic 0083). No caller moves. Each step bundles its repoints; the suite is green at every commit.
In scope:
stele/session.py. Add a Stele operation realizing session.resolve (integration design §3.2): given a session token, decode it and load the principal, returning a ResolvedSession (.person, .payload) — or None when there is no valid session (the SessionInvalid raised by decode_session is caught internally and reported as None). The mechanics that already live in stele/session.py (decode_session, SessionPayload, COOKIE_NAME, SessionInvalid) and stele/registry.py (get_person_by_id) are the building blocks; Phase 3 composes them into the resolve operation inside Stele rather than in deps.py.resolve_session and reads result.payload.totp_verified from the returned payload (none re-decodes the token):get_current_person (:565, returns Person) — raise 401 on None (missing cookie / bad token / gone person) and on unverified second factor.get_current_person_optional (:474, returns Person | None) — return None on absence or unverified second factor._resolve_optional_person (:173, the third tolerant person resolver found in the grounding read) — same tolerant shape as _optional; thin it identically.All callers are untouched.
_resolve_cookie_contributor (:715) does the same decode-load before its contributor-membership work. Route its decode-load through resolve_session; leave its contributor-membership reasoning exactly in place. (Step 3.2b, inspect-first — see §4.)_resolve_cookie_actor (:996-1070) and get_resolved_actor (:912-993): the session-decode + person-load step routes through Stele's resolve; the engagement membership, can_commit, and bearer contributor/agent reasoning stay in deps.py (host-side). No membership/commit logic crosses into Stele.actor_from_person. Replace the inline _ActorRef(kind="person", id=person.id, display_name=person.display_name) build in _resolve_cookie_actor (:1063-1067) with a call to stele.actor.actor_from_person(person). Behaviour-identical by construction (the constructor produces exactly that ActorRef).Explicitly NOT in scope:
_resolve_cookie_contributor, the _ActorRef builds (person/contributor/agent), and the can_commit logic all stay host-side. Phase 3 seams the decode-load at all five sites; it does not touch anything downstream of it. (The person _ActorRef at :1063-1067 is the one exception — routed through the existing actor_from_person constructor per item 5; that is a route to an already-extracted constructor, not new actor-construction work.)_ActorRef builds (:975 kind="contributor", :981 kind="agent") — host/Later concerns; no constructor to route to (agent constructor is the "Later" build).get_committing_resolved_actor (:1073-1096) — a thin gate over get_resolved_actor (403 if agent or not can_commit); no Stele coupling, untouched.require_founder (:621) — delegates via Depends(get_current_person); inherits the thinned body for free (confirmed at Step 0). Not separately rewritten.get_current_person_optional (:474) — is in scope (Step 3.2); it duplicates the resolution and gets its own thin wrapper over the shared primitive (see §2.1). It is not out of scope, correcting v0.1.
The integration design's session.resolve is deliberately narrow:
> session.resolve — Request {token} → Response {principal}; Error session_invalid.
get_current_person today does three things on top of that narrow resolve:
session.resolve; it moves to Stele.totp_verified — reject a session that hasn't cleared second factor. Auth policy. Stays host-side (in the stub).
Why the split matters. If the totp_verified gate moved into Stele's session.resolve, Stele's contract would no longer match the integration design (which says resolve returns the principal for any validly-decoded token; the second-factor state is a field in the session payload — {person_id, totp_verified, created_at, expires_at} — for the host to act on, not for resolve to enforce). Keeping the gate in the host stub keeps Stele's contract clean and preserves the engine's exact current behaviour. The stub is the right home for "this engine requires verified second factor before you're considered signed in."
Example. A session decodes fine but totp_verified=False (user passed passkey, hasn't done TOTP). Stele's resolve_session happily returns the principal — the token is valid. The host stub sees totp_verified=False and returns 401 — this host requires second factor. A different host (or Stele standalone) could choose differently. That's exactly why the gate is host-side: it's policy, not session mechanics.
What Step 0 found. v0.1 assumed get_current_person was the only resolver and its optional neighbour would inherit a thin delegator for free. Step 0 found two resolvers with different failure behaviour:
get_current_person (deps.py:565) — raises HTTPException(401) on missing cookie, unverified TOTP, or gone person. The "you must be signed in" path.get_current_person_optional (deps.py:474) — duplicates the cookie-decode-load (its own copy at :489-509) but returns None at each failure instead of raising. The "signed-in is optional here" path. It does not call get_current_person.
A raising delegator cannot serve the tolerant caller — the whole point of _optional is not to raise. So v0.1's "one thin stub" is insufficient.
The shape that serves both (the corrected shape is in §2.2). v0.2 first proposed a bare Person | None return. That shape is superseded by v0.3 because it discards the session payload the second-factor gate needs — see §2.2. The principle v0.2 got right and v0.3 keeps: the operation is return-based, not raising — it reports presence-or-absence, and the host (not Stele) decides whether absence is fatal:
SessionInvalid from decode_session — is caught inside the operation and reported as absence (None). A token that decodes but whose person no longer exists also yields None. The operation never raises HTTPException; it reports presence-or-absence.get_current_person calls it and raises 401 on None (plus the totp_verified policy gate, also a raise).get_current_person_optional calls it and returns None (applying the same totp_verified policy by returning None when unverified).
Why return-based, not a flag. A raise_on_invalid: bool flag would push the host's raise-or-tolerate policy into the Stele primitive. Return-based keeps the boundary the CR already draws: the primitive reports what it found; the host decides whether absence is fatal. This is the same split as the totp_verified gate (§2) — resolve is mechanics, raise-vs-None is policy. It also matches the integration design's session.resolve shape ({token} → {principal}), with the engine's SessionInvalid becoming the in-process realization of the contract's session_invalid — caught at the wrapper boundary, not propagated to callers.
The one mechanical care-point. decode_session raises SessionInvalid today. The operation must catch that internally (try/except → absence), or the tolerant path would still receive an exception. Step 3.1 builds the catch into resolve_session; the wrappers then deal only in ResolvedSession | None (§2.2), never in SessionInvalid.
ResolvedSession (v0.3 correction, recorded not smoothed)
What v0.2 got wrong. v0.2 specified resolve_session returning Person | None. But the second-factor flag the wrappers must check — totp_verified — lives only inside the session payload ({person_id, totp_verified, created_at, expires_at}), never on the Person. So a bare-person return throws away the exact fact the gate needs. v0.2 then asked the wrappers to apply the gate anyway and forbade them from re-decoding the token — an impossible set. (Confirmed: registry.Person has no totp_verified field.)
The corrected shape. resolve_session returns a small named result:
ResolvedSession # a tiny frozen result: .person (Person), .payload (SessionPayload)
resolve_session(token) -> ResolvedSession | None
None = no valid session (missing/bad/expired token, or the decoded person is gone). SessionInvalid is caught inside and turned into None.None ResolvedSession carries both the loaded Person and the SessionPayload that decoded it — so the wrapper can read result.payload.totp_verified to apply the gate without decoding twice.
Why a named result, not a bare (person, payload) pair. This is a security-critical call site. A named result (.person, .payload) reads unambiguously where the gate is applied; a bare tuple (result[0] / result[1]) invites a position mix-up at exactly the spot where a mistake weakens authentication. The named result also gives the contract a stable shape for when Stele goes standalone — session.resolve returning a ResolvedSession is part of its public face.
Why this is the truest contract, not just a fix. session.resolve should hand the host everything the session contains — who you are (person) and the session's state (payload: when issued, when it expires, whether second factor cleared). The host then decides what to do with that state. The bare-person shape hid the state and forced the impossible re-decode; ResolvedSession exposes it for the host to act on. Stele reports; the host decides — the same mechanics-vs-policy boundary §2 draws for the totp_verified gate, now made consistent.
Example. Marvin signs in with his passkey but hasn't entered his authenticator code yet, so totp_verified=False. resolve_session returns a ResolvedSession — the token is valid, the person loaded; it does not judge the second factor. get_current_person reads result.payload.totp_verified, sees False, and raises 401 — this host requires the second step. get_current_person_optional reads the same and returns None. Stele never made that call; the host did. A standalone Stele with a different policy could decide otherwise — which is the whole point of keeping the gate host-side.
What the grounding read found. The decode-load step — read token → decode_session → load principal — that Phase 3 lifts into resolve_session appears at five sites in deps.py, not two:
| # | Function | decode @ | Kind | v0.3 status | v0.4 |
|---|---|---|---|---|---|
| 1 | get_current_person (:565) | :597 | raising person | in scope | seam (Step 3.2) |
| 2 | get_current_person_optional (:474) | :500 | tolerant person | in scope | seam (Step 3.2) |
| 3 | _resolve_optional_person (:173) | :195 | tolerant person | missed | seam (Step 3.2) |
| 4 | _resolve_cookie_actor (:996) | :1026 | engagement person | in scope | seam (Step 3.3) |
| 5 | _resolve_cookie_contributor (:715) | :753 | contributor | missed | seam (Step 3.2b) |
v0.1/v0.2's line-coordinate checks found the two functions they went looking for; the grounding read of every decode_session caller found the other three (one already in scope as the engagement resolver, two missed).
Why all five, not three (the over-deferral correction). The earlier drafts' instinct was to leave _resolve_cookie_contributor out because "it's a contributor resolver, not a person one." That instinct confused two different things:
ActorRef (person / contributor / agent) and the membership/can_commit reasoning that follows. This genuinely stays host-side, and Phase 3 does not touch it.
_resolve_cookie_contributor does the same decode-load as the person resolvers, then does contributor-membership work after. Phase 3 seams the decode-load (the part it owns) and leaves the contributor-membership (the part the host owns) exactly in place — the identical host-seam cut already made in _resolve_cookie_actor. Leaving it inline would ship resolve_session plus a hand-rolled duplicate of it — the precise drift the extraction exists to remove. So Phase 3 seams all five, ending with resolve_session as the single decode-load chokepoint and zero inline decode duplicates behind it.
The line that still holds. Seaming all five does not widen Phase 3 into contributor or agent territory. The contributor-membership reasoning, the bearer paths, the kind="agent" inline builds, and the (unbuilt) agent constructor all stay exactly where they are. Phase 3 moves the decode-load and nothing downstream of it. "Stele resolves who; the host decides what-here" is unchanged — there are simply five host functions asking Stele who instead of three.
677c46f, from CC pre-flight) — what moves, what stays
| Symbol | Location | Disposition |
|---|---|---|
| get_current_person | deps.py:565-618 | thin to wrapper — keep name/signature/return; call resolve_session; raise 401 on None; read payload.totp_verified for the gate |
| get_current_person_optional | deps.py:474 (dup decode-load :489-509) | thin to wrapper — keep name/signature/return (Person\|None); call resolve_session; return None on absence or unverified payload.totp_verified |
| require_founder | deps.py:621 | untouched — delegates via Depends(get_current_person), inherits the thin body |
| session decode + person load (inside the above) | deps.py:565-618 | move to stele/session.py as the resolve operation |
| _resolve_cookie_actor | deps.py:996-1070 | seam — decode/load → Stele resolve; membership/can_commit stay; person ActorRef → actor_from_person |
| inline _ActorRef(kind="person") | deps.py:1063-1067 | route through stele.actor.actor_from_person |
| get_resolved_actor | deps.py:912-993 | seam — cookie path's decode/load → Stele resolve; bearer + membership stay |
| inline _ActorRef(kind="contributor") / (kind="agent") | deps.py:975, :981 | stay inline (host/Later) |
| get_committing_resolved_actor | deps.py:1073-1096 | untouched |
| 145 Depends(get_current_person) call sites | across ~46 files | do not move |
Already-Stele imports in deps.py (from Phase 1): stele.registry.{Person, get_person_by_id}, stele.session.{COOKIE_NAME, SessionInvalid, decode_session}. Phase 3 adds the new resolve operation to stele/session.py and imports it; it does not re-import what's already pointed at Stele.
Topology check (the Phase 2 lesson applied). The callers depend on get_current_person / get_current_person_optional by name. As long as both wrappers keep their names and signatures, relocating their resolution body does not change any importer — so no collection abort is expected here (unlike Phase 2, this is not a symbol-removal; the symbols stay put and only their bodies thin). Step 0 confirmed this against the live tree (147 loose-grep occurrences, no caller moved).
[Resolved at Step 0] — line coordinates matched §3; get_current_person_optional duplicates (own seam, in scope per §2.1); require_founder delegates (untouched); resolve_session name is free in stele/session.py; get_person_by_id is not yet imported there (Step 3.1 adds it). The §2.1 two-resolver correction is the v0.2 amendment.
Step 0 — pre-flight (read-only) + grounding read — COMPLETE. Archived v0.1 (a3c51aa); the two-resolver finding drove v0.2 (§2.1); a self-contradiction in v0.2's resolve shape drove v0.3 (§2.2); and a read-only grounding read of the real auth code drove v0.4 (§2.3 — five decode-load sites, not two). All before any Step 3.1 code. The v0.2 archive sits at engine 87ebbd6; v0.3 archived at 38e440c-era (record). On resuming under v0.4, re-archive: copy the v0.4 CR to docs/phase-crs/cr-2026-106-stele-extraction-phase-3-v0_4.md alongside v0.1/v0.2/v0.3, commit alone. Then begin Step 3.1. Baseline confirmed: suite 2920/46/0, alembic 0083; Phase 3 branch cr-2026-106-stele-extraction-phase-3 exists off 87ebbd6, no code yet. Grounded facts (from the read): decode_session(token: str, *, secret_key, now) -> SessionPayload; SessionPayload(person_id: UUID, totp_verified: bool, created_at, expires_at); both person wrappers read request.cookies.get(COOKIE_NAME) then pass the token string to decode_session.
Step 3.1 — Add ResolvedSession + resolve_session to stele/session.py. Add a small frozen result type ResolvedSession carrying .person (Person) and .payload (SessionPayload). Add the resolve operation realizing session.resolve: given a token, decode it (decode_session → payload), load the principal (get_person_by_id), and return ResolvedSession(person, payload). Catch SessionInvalid internally and return None for a bad/missing/expired token; return None if the decoded person no longer exists. The operation never raises HTTPException and never propagates SessionInvalid — it returns ResolvedSession | None. The totp_verified policy is not applied here (it stays in the host wrappers; the operation merely carries the flag in .payload). Add the one import it needs: from loomworks.stele.registry import get_person_by_id (confirmed absent from session.py at Step 0; no circular import — registry imports models, not session). The signature is grounded (not guessed): resolve_session(token: str, *, secret_key: str, now: datetime, db) -> ResolvedSession | None, mirroring decode_session's param style plus the db/session the principal load needs. Report the exact final signature used. Full suite (no caller changed yet — purely additive; expect green). Commit: Phase 3 step 3.1: add ResolvedSession + resolve_session to stele/session.py.
Step 3.2 — Thin the three person resolvers (one commit). Rewrite all three to call stele.session.resolve_session, each keeping its own failure policy and reading the second-factor flag from the returned payload:
get_current_person (:565) — call resolve_session; raise 401 on None (cookie-missing, bad token, or gone person all arrive as None); then raise 401 if result.payload.totp_verified is False. Return result.person. Name, signature, return (Person) unchanged.get_current_person_optional (:474) — call resolve_session; return None on absence; return None if result.payload.totp_verified is False; else return result.person. Name, signature, return (Person | None) unchanged. Its duplicated decode-load (:489-509) is replaced by the resolve_session call. Neither wrapper re-decodes the token._resolve_optional_person (:173) — the third tolerant person resolver (found in the grounding read; near-identical to _optional). Thin it identically: call resolve_session; return None on absence or unverified second factor; else return result.person. Its inline decode-load (:195-area) is replaced by the resolve_session call.
require_founder (:621) delegates via Depends(get_current_person) and is untouched (inherits the thin body). Full suite + DB-truth (a real sign-in through get_current_person resolving a known person against playground_dev; both tolerant resolvers returning None for no/invalid cookie; and a totp_verified=False session rejected by get_current_person and yielding None from both tolerant resolvers). Commit: Phase 3 step 3.2: thin the three person resolvers onto stele resolve_session.
Step 3.2b — Seam the contributor resolver's decode-load (one commit, inspect-first). _resolve_cookie_contributor (:715) does the same decode-load (:753) before its contributor-membership work. Inspect first: before editing, confirm the exact line where the decode-load ends and the contributor-membership work begins, and report it — this is the one site where the decode-load is followed by logic that must stay put, so the seam line matters. Then route only the decode-load through resolve_session; leave every line of contributor-membership reasoning exactly as it is. If the inspection shows the decode-load is entangled with the membership work in a way that can't be cleanly separated by an import-and-call, STOP and report rather than forcing it (it would become a behaviour-adjacent edit, out of scope). Full suite + DB-truth (a contributor cookie path still resolves the contributor and its membership unchanged). Commit: Phase 3 step 3.2b: seam _resolve_cookie_contributor decode-load onto stele resolve_session.
Step 3.3 — Seam the engagement resolvers + route the person actor (one commit). In _resolve_cookie_actor and get_resolved_actor's cookie path: route the decode/load step through Stele's resolve; leave membership / can_commit / bearer paths in place. Replace the inline person _ActorRef (:1063-1067) with stele.actor.actor_from_person(person). No membership/commit logic moves to Stele. Full suite + DB-truth (an engagement-scoped person turn through converse producing the same ResolvedActor, with the person ActorRef now from actor_from_person). Commit: Phase 3 step 3.3: seam engagement resolvers to stele resolve; route person actor through actor_from_person.
Step 3.4 — Full-suite regression + database-truth. Suite 2920/46/0 (no test added/removed; callers unchanged). DB-truth against playground_dev: (a) sign-in resolves a known person through the thinned get_current_person; (b) a totp_verified=False session still 401s at get_current_person (policy preserved); (c) both tolerant person resolvers (get_current_person_optional, _resolve_optional_person) return None for a missing/invalid cookie and the Person for a valid one; (d) an engagement-scoped person turn produces the correct ResolvedActor with the person ActorRef from stele.actor; (e) a contributor cookie path still resolves the contributor and its membership unchanged; (f) zero inline decode_session calls remain in deps.py — grep confirms all five sites now route through resolve_session (the single decode-load chokepoint); (g) alembic 0083 unchanged on repo + live DB; (h) deps.py still holds the host concerns (membership, can_commit, bearer paths, actor construction) unchanged. Halt and report. (Verification-only — no commit, matching Phase 1/2.)
totp_verified / person-exists policy does not move into Stele. Stele's session.resolve stays the narrow contract shape; policy stays host-side, read off the returned payload._resolve_cookie_contributor's membership work, the _ActorRef builds, can_commit, and the bearer/agent paths are untouched.0083.decode_session calls in deps.py. All five decode-load sites route through resolve_session. If any remains inline at Step 3.4, the phase is incomplete — report it.
After Step 3.4, halt before push. Report: the ResolvedSession result type and resolve_session operation added to stele/session.py (returns ResolvedSession | None, catches SessionInvalid, carries .person + .payload, exact signature used); the three thinned person resolvers (get_current_person, get_current_person_optional, _resolve_optional_person — names/signatures/returns unchanged, gate read off payload, no re-decode); the contributor decode-load seam (_resolve_cookie_contributor — the inspected seam line, decode-load routed, membership untouched); the engagement-resolver seam + person-actor route through actor_from_person; confirmation zero inline decode_session calls remain in deps.py (all five sites on resolve_session); confirmation callers are untouched; the no-schema confirmation (alembic 0083); and the full-suite + DB-truth results (sign-in, totp-reject, both tolerant resolvers None/Person, engagement-scoped actor, contributor path unchanged). On review, push Phase 3; Phase 4 (the mint↔onboard sign-up split, approach B) is the next CR.
session.resolve is realized in code as stele.session.resolve_session (or chosen name), and that the host wraps it with the totp_verified / person-exists policy — the contract is the narrow resolve; the policy is the host's. Fold alongside the §3.3 contract-name-vs-code-symbol sync the Phase 2 record already named.stele/session.py does not reach back into host config; the residue cut is Phase 6's.
Each step is its own commit. Step 3.1 is purely additive (revert removes the unused resolve_session). Step 3.2 reverts by restoring the three person resolvers' original bodies. Step 3.2b reverts by restoring _resolve_cookie_contributor's inline decode-load. Step 3.3 reverts by restoring the engagement resolvers' inline decode/load and the inline person ActorRef. No schema to unwind, no parallel copy. The phase is reversible commit-by-commit.
DUNIN7 — Done In Seven LLC — Miami, Florida
Loomworks change request — Stele extraction Phase 3 (session.resolve under all five decode-load sites) — CR-2026-106 — v0.4 — 2026-06-15