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

Loomworks change request — Stele extraction Phase 3 (relocate session→principal resolution into Stele as session.resolve; thin the host dependency; route the person actor path through actor_from_person) — CR-2026-106 — v0.1

Version. 0.1 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. deps.py:get_current_person becomes a thin delegator (same name, signature, return), so its 146 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.

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. Phase 3 lifts that work into Stele (stele/session.py, as resolve_session realizing the contract's session.resolve) and leaves get_current_person as a one-line stub that calls it. Same name, same signature, same return — so all 146 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 deps.py:get_current_person to a delegator. It 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. Its body calls the new Stele resolve operation for the token→principal step. The 146 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.

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 delegator — keep name/signature/return; body calls Stele resolve; keep totp/missing/gone policy | | 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 146 callers depend on get_current_person by name. As long as the stub keeps that name and signature, relocating its body does not change any importer — so no collection abort is expected here (unlike Phase 2, this is not a symbol-removal; the symbol stays put and only its body thins). Step 0 confirms against the live tree.

[CC verifies at Step 0] — exact current line coordinates for all of the above (a Phase-2 edit or drift could shift them); whether get_current_person_optional / require_founder delegate to get_current_person (inherit the thin body) or duplicate the resolution (need their own seam); and that folding resolve_session into stele/session.py does not collide with an existing name.

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

Step 0 — pre-flight (read-only, one archival commit). Archive this CR into the engine at docs/phase-crs/cr-2026-106-stele-extraction-phase-3-v0_1.md (commit alone). Confirm: HEAD 677c46f, tree clean; the §3 line coordinates against live deps.py; whether the _optional/require_founder neighbours delegate or duplicate; that resolve_session (or chosen name) does not collide in stele/session.py; baseline suite 2920/46/0; alembic 0083. Halt and report. Any divergence drives a CR amendment, same as Phase 2.

Step 3.1 — Add session.resolve to stele/session.py. Add the resolve operation: token → decode → load principal → return Person (raising the existing SessionInvalid on bad token; the not-found and totp policy are not in this primitive — they stay in the host stub). Add the one import it needs (get_person_by_id from stele.registry, if not already imported in session.py). Full suite (no caller changed yet — this is purely additive; expect green). Commit: Phase 3 step 3.1: add session.resolve (token to principal) to stele/session.py.

Step 3.2 — Thin get_current_person to a delegator (one commit, with any repoint). Rewrite get_current_person's body to call stele.session.resolve_session for the token→principal step, keeping the cookie-missing → 401, totp_verified → 401, and person-gone → 401 policy in the stub. Name, signature, return unchanged. If _optional/require_founder duplicate the resolution (per Step 0), thin them the same way in this commit; if they delegate, they inherit it free. Full suite + DB-truth (a real sign-in through the stub against playground_dev resolving a known person). Commit: Phase 3 step 3.2: thin get_current_person to delegate session resolution to stele.

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 the stub (policy preserved); (c) an engagement-scoped person turn produces the correct ResolvedActor with the person ActorRef from stele.actor; (d) alembic 0083 unchanged on repo + live DB; (e) 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 operation added to stele/session.py (name, shape); the thinned get_current_person (name/signature/return unchanged, policy preserved); the host seam (what routes to Stele, what stayed); the person-actor route through actor_from_person; confirmation the 146 callers are untouched; the no-schema confirmation (alembic 0083); and the full-suite + DB-truth results (sign-in, totp-reject, 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 operation). Step 3.2 reverts by restoring the original get_current_person body. 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 the host dependency) — CR-2026-106 — v0.1 — 2026-06-15