session.resolve; thin both host dependencies; route the person actor path through actor_from_person) — CR-2026-106 — v0.2
Version. 0.2
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.
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, principal out, session_invalid on failure. But get_current_person today does more than that: 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 primitive resolves token→principal; the policy (require second-factor; person-must-exist) stays in the host stub as a thin wrapper, so Stele's contract stays the clean shape the integration design names while 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 the Person (or raising the existing SessionInvalid / not-found signal). The mechanics that already live in stele/session.py (decode_session, 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), and keeps the host-policy checks that are not Stele's contract: require totp_verified; 401 if the cookie is missing; 401 if the person is gone. get_current_person_optional keeps its name, signature, and Person | None return, applying the same totp_verified policy but returning None (not raising) at each failure. Both call the new Stele resolve operation for the token→principal step. 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 (decided: return-based). The Stele primitive resolve_session returns Person | None, not a raise:
SessionInvalid from decode_session — is caught inside the primitive and reported as None. A token that decodes but whose person no longer exists also yields None. The primitive 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 the 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 primitive must catch that internally (try/except → None), or the tolerant path would still receive an exception. Step 3.1 builds the catch into resolve_session; Step 3.2's wrappers then deal only in Person | None, never in SessionInvalid.
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; keep totp_verified 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; apply totp_verified as None |
| 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; findings drove this v0.2 amendment (§2.1). On resuming under v0.2, re-archive: copy the v0.2 CR to docs/phase-crs/cr-2026-106-stele-extraction-phase-3-v0_2.md alongside v0.1, commit alone (mirrors the Phase 2 v0.2 re-archive). Then begin Step 3.1. Baseline confirmed at Step 0: suite 2920/46/0, alembic 0083, HEAD a3c51aa, no branch.
Step 3.1 — Add resolve_session (returns Person | None) to stele/session.py. Add the resolve operation realizing session.resolve: given a token, decode it (decode_session) and load the principal (get_person_by_id), returning the Person. Catch SessionInvalid internally and return None for a bad/missing/expired token; return None if the decoded person no longer exists. The primitive never raises HTTPException and never propagates SessionInvalid — it reports Person | None. The totp_verified policy is not in this primitive (it stays in the host wrappers). 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). Full suite (no caller changed yet — purely additive; expect green). Commit: Phase 3 step 3.1: add resolve_session (token to Person-or-None) to stele/session.py.
Step 3.2 — Thin both resolver wrappers (one commit). Rewrite both functions to call stele.session.resolve_session for the token→principal step, each keeping its own failure policy:
get_current_person (:565) — call resolve_session; raise 401 on None (cookie-missing, bad token, or gone person all now arrive as None); keep the totp_verified → 401 gate. Name, signature, return (Person) unchanged.get_current_person_optional (:474) — call resolve_session; return the None on absence; apply the totp_verified policy by returning None when unverified. Name, signature, return (Person | None) unchanged. Its duplicated decode-load (:489-509) 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; and an _optional route returning None for no/invalid cookie). 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 resolve_session operation added to stele/session.py (returns Person | None, catches SessionInvalid); both thinned wrappers (get_current_person raises on None, get_current_person_optional returns None, names/signatures/returns unchanged, totp_verified policy preserved on each); 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, 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.2 — 2026-06-15