session.resolve; thin both host dependencies; route the person actor path through actor_from_person) — CR-2026-106 — v0.3
Version. 0.3
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.
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.get_current_person keeps its exact name, signature, and return type (Person); it calls resolve_session, raises 401 on None (missing cookie / bad token / gone person), and reads result.payload.totp_verified to raise 401 when the second factor is unverified. get_current_person_optional keeps its name, signature, and Person | None return, calling resolve_session and returning None on absence or unverified second factor (not raising). Both read the second-factor flag from the returned payload — neither re-decodes the token. All callers are untouched._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:
get_current_person / get_current_person_optional callers — do not move; both wrappers preserve name and signature._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.
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, one archival commit) — COMPLETE. Archived v0.1 at engine a3c51aa; the two-resolver finding drove v0.2 (§2.1), and a self-contradiction in v0.2's resolve shape drove v0.3 (§2.2) before any code was written. On resuming under v0.3, re-archive: copy the v0.3 CR to docs/phase-crs/cr-2026-106-stele-extraction-phase-3-v0_3.md alongside v0.1 and v0.2, commit alone (mirrors the Phase 2 re-archive pattern). The v0.2 archive at engine 87ebbd6 stays. Then begin Step 3.1. Baseline confirmed at Step 0: suite 2920/46/0, alembic 0083, no branch beyond the archive; the Phase 3 branch cr-2026-106-stele-extraction-phase-3 already exists off 87ebbd6.
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). Mirror the existing module's signature style for what decode_session accepts (token string vs. request); report the exact 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 both resolver wrappers (one commit). Rewrite both functions 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.
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; an _optional route returning None for no/invalid cookie; and a totp_verified=False session rejected by get_current_person and yielding None from _optional). Commit: Phase 3 step 3.2: thin get_current_person and get_current_person_optional 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) get_current_person_optional returns None for a missing/invalid cookie and the Person for a valid one (tolerant behaviour preserved); (d) an engagement-scoped person turn produces the correct ResolvedActor with the person ActorRef from stele.actor; (e) alembic 0083 unchanged on repo + live DB; (f) deps.py still holds the host concerns (membership, can_commit, bearer paths) unchanged. Halt and report. (Verification-only — no commit, matching Phase 1/2.)
get_current_person returns exactly what it returned before, rejects exactly what it rejected (missing cookie, unverified TOTP, gone person). The seam is a relocation of the who-are-you step, not a change to what-you-can-do.totp_verified / person-exists policy does not move into Stele. Stele's session.resolve stays the narrow contract shape; policy stays host-side.can_commit / bearer contributor/agent reasoning does not cross into Stele. Host seam holds.0083.
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); both thinned wrappers (get_current_person raises on None and on unverified payload.totp_verified; get_current_person_optional returns None on either; names/signatures/returns unchanged); confirmation neither wrapper re-decodes the token; the host seam (what routes to Stele, what stayed); the person-actor route through actor_from_person; confirmation the callers are untouched; the no-schema confirmation (alembic 0083); and the full-suite + DB-truth results (sign-in, totp-reject on both wrappers, optional-path None/Person, engagement-scoped actor). 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 both wrappers' original bodies. Step 3.3 reverts by restoring the 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 both host dependencies) — CR-2026-106 — v0.3 — 2026-06-15