DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-106-stele-extraction-phase-3-v0_2.md

Loomworks change request — Stele extraction Phase 3 (relocate session→principal resolution into Stele as 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.


Plain-language summary

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.

1. Scope of Phase 3

In scope:

  1. Relocate session→principal resolution into 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.
  1. Thin both host wrappers to delegators over the Stele primitive. 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.
  1. Draw the host seam in the engagement resolvers. _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.
  1. Route the person actor path through 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:

2. The contract vs the policy (recorded, the central design point)

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:

  1. token → principal — this is session.resolve; it moves to Stele.
  2. require totp_verified — reject a session that hasn't cleared second factor. Auth policy. Stays host-side (in the stub).
  3. person-must-exist (401 if gone)policy/host concern. Stays host-side, though "load the principal" is Stele's; the decision to 401 on a missing principal is the host's.

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.

2.1 Two resolvers, one primitive — the return-based shape (v0.2 correction, recorded not smoothed)

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:

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:

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.

3. Tree-truth (at 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 ActorRefactor_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.

4. Steps (each its own commit; halt before push)

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:

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

5. What must not happen in Phase 3

6. Halt and report

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.

7. Doc-sync notes (not on the execution path)

8. Reversibility

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