orchestration/helpers.py; reference the ActorRef type, do not move it) — CR-2026-105 — v0.1
Version. 0.1
Date. 2026-06-14
Status. Change request — the third executable phase of the Stele extraction, drafted against the post-Phase-1 tree (engine origin/main f1cf170; suite baseline 2920 passed / 46 skipped / 0 failed; alembic head 0083, unchanged). Phase 2 moves the two actor constructors — the helpers that label a recorded action with who performed it — out of orchestration/helpers.py into stele/. This is a file split, not a whole-module move: helpers.py also holds a project-membership exception and verifier that stay. The ActorRef type is owned by the memory substrate and is referenced, not moved. Markdown primary (technical consumer: Claude Code). Halt-before-push; per-step commits; verify against engine-database truth, not the suite alone.
Decisions this CR rests on (settled, carried from Phase 0/1): (1) Stele's interface is a host-agnostic, in-process-first / service-ready API contract. (2) The contract is complete (passkey-revoke, TOTP-rotate arrive Phase 5). (3) Sign-up split is approach B (Phase 4, not here). (4) Move, not duplicate — one implementation throughout; safety from small reversible slices. (5) White-label / multi-language hooks thread into user-facing ops in later phases. (6) Phase 0 and Phase 1 are complete and pushed (engine f1cf170): the full core identity surface (session.py, webauthn.py, recovery.py, models.py, registry.py, credentials.py) lives in stele/ under a shared Base at loomworks.identity_base; the gating persons.id FK / Base decision is locked (D1 = own-its-own-table, D2 = Branch 1, physical split deferred to Phase 6).
Source grounding. The Stele body-of-work brief v0.4 (§5 phase table, Phase 2 row = "Move the actor construction … Low, contained"); the integration design v0.2 (§3.3 the actor constructors; §5.2 ActorRef referenced-not-moved); the Phase 1 completion record v0.1 (§7 Phase 2 handoff and its cautions); and a tree-truth verification at f1cf170 recorded in stele-phase-2-tree-truth-v0_1 and §3 below. This CR is drafted against that pull, not from memory — the Phase 0 / Phase 1 grounding discipline.
This moves the two small functions that stamp a recorded action with who did it — one for a person, one for that person's Companion — into the Stele module, where the rest of the identity code now lives. The functions are tiny and produce a value (an ActorRef) whose type belongs to the memory substrate; Phase 2 moves the constructors, not the type — the type is referenced by import, exactly as the integration design requires.
The one real care-point: the file these functions live in today, orchestration/helpers.py, also holds two unrelated things — a project-membership error and a project-membership check — that must stay where they are. So this is a split, not a whole-file move: extract the two actor functions into Stele, leave the project-membership pieces in orchestration/, and fix up the imports on both sides. This is the same hazard Phase 1 met when persons/models.py held both moving and staying classes; the discipline is identical (split per-symbol, repoint each importer, change no behaviour).
Two myths from the brief get corrected here against the live tree. First, the brief's names (actor.for_principal / actor.for_companion) do not exist as code — the real symbols are actor_from_person and actor_from_companion. Second, "zero external callers" is false for these constructors: there are 6 callers outside the identity set (4 production routers + 2 tests), all of which repoint. And the brief's "~93 actor-label call sites" is stale — the real surface is 228 actor= kwargs and 73 inline ActorRef( constructions, none of which Phase 2 touches; Stele slots in underneath them unchanged. No schema change, no behaviour change. If anything looks off at the halt, the offending step reverts as a single commit.
In scope — split out of orchestration/helpers.py, move into stele/:
actor_from_person (orchestration/helpers.py:48-60) — (person: Person) → ActorRef(kind="person", id=person.id, display_name=person.display_name). This is the brief's "for_principal" / integration design §3.3 actor.for_principal.actor_from_companion (orchestration/helpers.py:63-84) — (person: Person) → ActorRef(kind="companion", id=person.id, display_name=person.companion_name). This is the brief's "for_companion" / integration design §3.3 actor.for_companion. The companion actor's id is the principal's UUID, by construction (no separate companions table).
Destination. A new module in the Stele package holding the two constructors. Default placement: src/loomworks/stele/actor.py. [CC verifies at Step 0 pre-flight item 4] — confirm stele/actor.py does not already exist and the name does not collide; if the Stele package convention favors folding these into an existing Stele module (e.g. alongside the registry, since both take a Person), CC reports the alternative at pre-flight and the Operator picks. Default = a dedicated stele/actor.py, because the constructors are a distinct contract surface (integration design §3.3 names "Principal → actor" as its own operation group) and a dedicated module keeps the Phase 3 actor.for_agent constructor a clean later addition to the same file.
References (do not move) — the ActorRef type. ActorRef is defined at memory/base.py:17 and owned by the memory substrate (integration design §5.2). The moved constructors import it: from loomworks.memory.base import ActorRef. The import travels with the constructors; the type does not move. The response-layer mirrors (ActorRefResponse at api/schemas.py:2426, ActorRefSchema at api/schemas.py:4179) are not Stele's and are untouched.
Stays in orchestration/helpers.py (NOT moving this phase):
OperatorProjectNotFoundError (helpers.py:33-46) — a project/membership domain exception. Project-membership is Loomworks governance, not Stele identity.verify_project_membership(...) (helpers.py:86+) — project-membership verification.Explicitly NOT in scope (later phases, unchanged from the brief and Phase 1 §7 handoff):
actor.for_agent / agent actor construction — does not exist as a helper today; agent actors are built inline at 64 ActorRef(kind="agent", …) sites. Phase 2 moves none of them. The agent constructor is the "Later" agent-identity build, not Phase 2.get_current_person / session-resolution → Stele — Phase 3. The auth-path actor resolution in api/deps.py (get_resolved_actor :912, _resolve_cookie_actor :996, get_committing_resolved_actor :1073) builds person/agent/contributor ActorRefs in the "who is signed in → actor" path. That is Phase 3, not Phase 2. Do not pull it forward.persons → principals + account split — Phase 6.
Brief v0.4 and integration design §3.3 name the constructors actor.for_principal / actor.for_companion / actor.for_agent. These names do not exist as code symbols at f1cf170. The live engine has:
actor_from_person — the brief's "for_principal".actor_from_companion — the brief's "for_companion".
Phase 2 uses the real symbol names (actor_from_person, actor_from_companion) and does not rename them — a rename would be a behaviour-adjacent change touching all 6 callers' call sites, not just their import lines, and renaming is out of scope for a move phase (Phase 0/1 discipline: import lines may change, symbols and behaviour may not). The dotted contract names (actor.for_principal) remain the integration-design contract vocabulary; the underscore names are the Python realization. This divergence is a doc-sync note for the next integration-design version (§7), not an execution-path change.
f1cf170) — blast radius and the split boundary
Move-not-duplicate means every importer of a moved symbol gets an import-line repoint (the Phase 0/1 mechanic). The new difficulty Phase 2 carries — identical in class to Phase 1's persons/models.py — is that helpers.py is a mixed module: it holds both moving (the two constructors) and staying (the project-membership exception + verifier) symbols. The split must be precise.
Importer files: 7 (4 production + 3 test + 0 script). All are outside helpers.py.
actor_from_person:
| File | Import line | Call site(s) | Note |
|---|---|---|---|
| orchestration/routers/inbox.py | :48 | :290 | module-top import |
| orchestration/routers/converse.py | :53 | :1155 | module-top import |
| orchestration/routers/composition.py | :41 | :150 | module-top import |
| orchestration/router.py | :1099 | :1201 | function-local import |
| tests/test_stepup_commit_gate.py | :26 | :257, :274 | module-top import |
| tests/test_engagement_creation_walk_guards.py | :252, :332 | :282, :289, :337 | function-local imports |
actor_from_companion:
| File | Import line | Call site(s) | Note |
|---|---|---|---|
| orchestration/router.py | :70 | :293, :2053, :2221 | module-top import |
| orchestration/routers/converse.py | :52 | :1190 | module-top import |
| tests/test_actor_from_companion.py | :16 | :38, :46, :54, :61 | dedicated unit test — identity set |
orchestration/helpers.py (defines both — but Phase 2 moves the definitions out, so helpers.py loses these symbols) + tests/test_actor_from_companion.py (the dedicated unit test — repoints its import to stele/actor.py).orchestration/router.py, orchestration/routers/converse.py, orchestration/routers/inbox.py, orchestration/routers/composition.py, tests/test_stepup_commit_gate.py, tests/test_engagement_creation_walk_guards.py.
> "zero external callers" is FALSE for Phase 2. Read it as "zero outside the identity set" (the Phase 0 lesson) — and here that count is 6, not zero. Every one repoints to the new stele/ path. (All production callers are within orchestration/; no non-orchestration subsystem imports these constructors.)
helpers.py (the central hazard)
orchestration/helpers.py holds, in order:
| Lines | Symbol | Disposition |
|---|---|---|
| :1-19 | module docstring (half about each side) | split — author a new docstring for stele/actor.py; trim the moved-away half from the helpers.py docstring |
| :22-30 | imports (see partition below) | partition |
| :33-46 | OperatorProjectNotFoundError | stays |
| :48-60 | actor_from_person | moves |
| :63-84 | actor_from_companion | moves |
| :86+ | verify_project_membership(...) | stays |
Import partition (verified at f1cf170):
stele/actor.py (the constructors' needs):from loomworks.memory.base import ActorRef (helpers.py:26)from loomworks.stele.registry import Person (helpers.py:30) — both sides need Person; it moves and stays (re-imported on each side).orchestration/helpers.py (the verifier's needs):from loomworks.persons.memberships import get_membership_for_person_on_engagement (helpers.py:27-29)from sqlalchemy.ext.asyncio import AsyncSession (helpers.py:24)from uuid import UUID (helpers.py:22)from loomworks.stele.registry import Person (helpers.py:30) — verify_project_membership takes person: Person at :88, so Person stays imported here too.
Person is the shared import: it is needed by both the moving constructors and the staying verifier, so after the split both stele/actor.py and orchestration/helpers.py import Person from loomworks.stele.registry. This is partition, not duplication of logic — each module imports the type it uses.
[CC verifies at Step 0 pre-flight item 2] — exact line numbers for the docstring span, the two functions, the exception, and the verifier against the live helpers.py, since a Phase-1-era edit could have shifted them by a line or two. The split boundary (which symbol goes where) is fixed by this CR; the exact line coordinates are confirmed at pre-flight.
api/routers/seed_conversation.py:115 defines _companion_actor() building ActorRef(kind="agent", id=_COMPANION_ACTOR_ID, display_name="Loomworks companion"). This is not actor_from_companion — it builds kind="agent" (a fixed system-companion agent id), lives in a different module, and is one of the 64 inline agent sites. Phase 2 moves only helpers.actor_from_companion (kind="companion", bound to person.id). Do not touch _companion_actor.
The brief's "~93 places that use the identity to label an action" matches no single live pattern at f1cf170:
| Pattern | Count |
|---|---|
| \bactor= (kwarg passing an actor to an action) | 228 |
| ActorRef\( (inline actor constructions) | 73 |
| \bactor_ref= | 2 |
None of these are touched by Phase 2. Moving the two helpers constructors changes only their 7 importers (§3.1). The 228 actor= / 73 ActorRef( surface "slots in underneath them" unchanged — exactly the brief's intent. The CR records the real numbers (228 actor=, 73 ActorRef() and supersedes the stale "~93" so no one goes hunting for 93 sites that do not exist as a coherent set.
Halt and report if: any importer needs more than an import-line change (a logic edit); the split would require editing either staying symbol's behaviour; or the suite cannot be made green by repointing alone.
Step 2.1 — Create stele/actor.py and move the two constructors. Create src/loomworks/stele/actor.py (or the pre-flight-confirmed destination). Move actor_from_person (:48-60) and actor_from_companion (:63-84) into it, byte-identical bodies. Add the moving imports (ActorRef from memory.base; Person from stele.registry) and a fresh module docstring. In the same commit, remove the two functions from orchestration/helpers.py, trim the moved-away half of its docstring, and prune any import that helpers.py no longer uses only because the constructors left (audit: ActorRef — if verify_project_membership and the exception do not use it, the ActorRef import leaves with the constructors; Person stays, still used by the verifier). Repoint the dedicated unit test tests/test_actor_from_companion.py:16 to stele/actor.py in this same commit (it is the constructors' identity-set test). Full suite. Commit: Phase 2 step 2.1: move actor constructors to stele/actor.py.
> Per move-not-duplicate, the move and the removal-from-origin are one commit — there is never a window where both copies exist (the Phase 1 mechanic: each move + its origin-removal + its repoints bundle together).
Step 2.2 — Repoint the 4 production callers. Repoint to from loomworks.stele.actor import …:
orchestration/router.py — two imports: actor_from_companion at module-top :70, and actor_from_person at function-local :1099. Repoint both; the function-local one stays function-local (move only the path, not the import's location).orchestration/routers/converse.py — two imports: actor_from_companion :52, actor_from_person :53.orchestration/routers/inbox.py — actor_from_person :48.orchestration/routers/composition.py — actor_from_person :41.
No call-site logic changes — only the import path. Full suite + DB-truth (a sign-in turn through converse exercising a person-actor stamp against playground_dev, confirming the relocated constructor produces the same ActorRef). Commit: Phase 2 step 2.2: repoint production actor-constructor importers.
Step 2.3 — Repoint the remaining test callers. Repoint:
tests/test_stepup_commit_gate.py:26 (actor_from_person).tests/test_engagement_creation_walk_guards.py — two function-local imports at :252 and :332 (actor_from_person); keep them function-local, change only the path.
(tests/test_actor_from_companion.py was already repointed in Step 2.1.) Full suite. Commit: Phase 2 step 2.3: repoint test actor-constructor importers.
Step 2.4 — Fix the stale non-importer mentions (accuracy, non-breaking). These do not break the suite (no import), but become inaccurate after the move:
orchestration/__init__.py:13 — module-map comment lists actor_from_person under helpers.py; update to reflect helpers.py now holds only OperatorProjectNotFoundError + verify_project_membership, and that the actor constructors live in stele/actor.py.tests/test_voice_provenance.py:326 — docstring names both helpers; update the reference to the new home.
Full suite (still green; these are comment/docstring edits). Commit: Phase 2 step 2.4: sync stale comments/docstrings to new actor-constructor home.
Step 2.5 — Full-suite regression + database-truth. Run the complete suite (target: 2920/46/0, unchanged — no test added or removed, only repointed). Verify against playground_dev:
kind="person", id, display_name through the relocated stele.actor.actor_from_person (sign-in → converse path).kind="companion", id=person.id, display_name=companion_name through stele.actor.actor_from_companion.0083 unchanged on repo and live DB; this phase touches no models).orchestration/helpers.py still exports OperatorProjectNotFoundError and verify_project_membership and they behave unchanged (a project-membership check still resolves).Halt and report. (Verification-only — no commit, matching Phase 1's Step 1.5.)
0083.memory/base.py, referenced by import. The response-layer schema mirrors are not touched.actor.for_agent / the 64 inline agent sites do not move. No agent constructor is created or relocated. _companion_actor in seed_conversation.py is not touched.api/deps.py actor-resolution (get_resolved_actor, _resolve_cookie_actor, get_committing_resolved_actor) is the auth-path / get_current_person surface — do not pull it into Phase 2.actor_from_person / actor_from_companion keep their names; only their module path changes.actor=, 73 ActorRef() are not touched. Stele slots in underneath them.
After Step 2.5, halt before any push. Report: the destination module chosen (stele/actor.py or the pre-flight alternative); confirmation the two constructors moved byte-identical and the two project-membership symbols stayed; the per-file importer-repoint list (6 outside callers + the identity-set unit test) with confirmation no call-site logic changed; the import-partition result (which imports moved, which stayed, Person on both sides); the comment/docstring sync; the no-schema-change confirmation (alembic 0083); and the full-suite + database-truth (person-actor and companion-actor stamps from stele/) result. On the Operator's review, push Phase 2; Phase 3 (get_current_person / session-resolution → Stele) is the next CR.
actor.for_principal / actor.for_companion are realized in code as actor_from_person / actor_from_companion (and that actor.for_agent is unbuilt — 64 inline sites, no constructor). Fold alongside the §5.2 own-table direction-sync the Phase 1 record already named.actor=, 73 ActorRef() in the next brief revision, with the note that the label surface is untouched by the extraction by design.
Every step is its own commit; the offending step reverts as a single commit with no effect on the others. There is no schema change to unwind and no parallel copy to reconcile — a revert puts the moved constructors back in helpers.py and restores the prior import paths. The split is reversible by re-merging the two functions into helpers.py and reverting the import partition.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks change request — Stele extraction Phase 2 (the actor constructors) — CR-2026-105 — v0.1 — 2026-06-14