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

Loomworks change request — Stele extraction Phase 2 (move the actor constructors; split orchestration/helpers.py; reference the ActorRef type, do not move it) — CR-2026-105 — v0.2

Version. 0.2 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.

v0.2 amendment (this revision). Step 0 pre-flight passed clean (engine 306b526 = CR archived on the Phase 2 branch base; all line coordinates, the importer set, the lookalike, the suite baseline, and alembic 0083 matched the CR with zero divergence). Execution then surfaced one divergence — not in the move, in the CR's per-step suite prediction. v0.1 §4 split the move (Step 2.1) from the importer repoints (Steps 2.2/2.3) and predicted that Step 2.1 alone would leave "import failures from those 6 files… confined to exactly those 6 files." That prediction is mechanically wrong: the four production routers import the moved symbols at module top and are themselves transitively imported across the suite, so removing the constructors before repointing the routers makes the application un-importable and pytest aborts at collection (33 collection errors, zero tests run) — not a runnable suite minus 6. v0.2 corrects the prediction (recorded, not smoothed, in §4.0 below) and restructures Steps 2.1–2.3 into a single move-and-repoint commit, restoring the Phase 1 mechanic (each move bundled with its repoints in one commit, so every commit lands on a green suite). Steps 2.4 and 2.5 are unchanged. No change to scope, destination, the split boundary, or the import partition — only to the step granularity and its verification expectation.

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.


Plain-language summary

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.


1. Scope of Phase 2

In scope — split out of orchestration/helpers.py, move into stele/:

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

Explicitly NOT in scope (later phases, unchanged from the brief and Phase 1 §7 handoff):

2. The naming correction (recorded, not smoothed)

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:

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.

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

3.1 The two constructors and their importers

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 |

3.2 Identity set vs outside callers

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

3.3 The split boundary inside 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):

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.

3.4 The lookalike — do not conflate

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.

3.5 Label call sites — the brief's "~93" is stale and untouched regardless

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.

3.6 Halt condition (carried from Phase 0/1)

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.

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

4.0 The v0.1 prediction error, corrected (recorded, not smoothed)

What v0.1 §4 predicted. v0.1 ran the move as Step 2.1 (move the two constructors out of helpers.py, repoint only the identity-set unit test) and predicted the suite at that point would show "import failures from those 6 files… confined to exactly those 6 files" — i.e. a runnable suite minus the six outside callers, then Steps 2.2/2.3 would repoint those six and restore green. The "6 files" count was carried from §3.1/§3.2 (six outside-caller files repoint).

What is actually true (verified in execution on branch cr-2026-105-stele-extraction-phase-2, move staged, uncommitted). Removing the two constructors before repointing the production routers makes the application un-importable. The four production routers (orchestration/router.py, orchestration/routers/converse.py, orchestration/routers/inbox.py, orchestration/routers/composition.py) import the moved symbols at module top, and those routers are themselves transitively imported across much of the suite. So pytest does not run-and-fail six test files — it aborts at collection with 33 collection errors (Interrupted: 33 errors during collection), zero tests run. Every one of the 33 is import-path-only (ImportError: cannot import name 'actor_from_person'/'actor_from_companion' from 'loomworks.orchestration.helpers'); there are zero logic failures and zero non-actor errors. Root-cause grouping: router.py → 17 test modules, inbox.py → 11, converse.py → 4, tests/test_stepup_commit_gate.py → 1 (direct helpers import). The failure shape is exactly what a clean move predicts (100% import-path, all tracing to the two moved symbols); the failure scope is a collection abort, not "6 files."

Why v0.1 got it wrong. v0.1 reasoned about importer count (7 importers, 6 outside callers — correct) but not about import topology: module-top imports in collection-critical routers make symbol-removal-before-repoint abort collection. The "6 files" number is right for what repoints; it is wrong as a suite-state prediction between the move and the repoints. The tree-truth pull (stele-phase-2-tree-truth-v0_1) recorded the importers and their import locations but did not model the collection-abort consequence of removing them mid-split.

Why this did not happen in Phase 1. Phase 1's completion record (§"Same Phase 0/1 discipline") states the mechanic plainly: "bundle each move + its repoints in one commit." Phase 1's four-commit series did exactly that — each commit moved a symbol and repointed all its importers together (1.2 = row models + 18/44 repoints, 1.3 = registry + 36/86, 1.4 = credentials + 5/8), so the suite was green at every commit. v0.1 of this CR departed from that mechanic by splitting the move (2.1) from the repoints (2.2/2.3). That split is the sole cause of the un-runnable intermediate state.

The correction. Restore the Phase 1 mechanic: the move and all its repoints are one commit. Steps 2.1, 2.2, and 2.3 collapse into a single move-and-repoint commit (new Step 2.1 below). This is more faithful to move-not-duplicate, not less — "no window where both copies exist" is fully satisfied by one commit, and the added property is that the commit lands on a green suite (independently revertible-to-green, which the split 2.1 was not). Steps 2.4 and 2.5 are unchanged.

4.1 Step 2.1 (collapsed) — Move the two constructors and repoint every importer, in one commit

Off main at the Step 0 archival commit (306b526), on branch cr-2026-105-stele-extraction-phase-2. All of the following land as one commit:

(a) Create src/loomworks/stele/actor.py. Holds: a fresh module docstring (the actor-constructor contract surface — constructors that label a recorded action with who performed it; references ActorRef, owned by the memory substrate); from loomworks.memory.base import ActorRef; from loomworks.stele.registry import Person; actor_from_person (body byte-identical to helpers.py:48-60); actor_from_companion (body byte-identical to helpers.py:63-84).

(b) Split orchestration/helpers.py. Remove actor_from_person (:48-60) and actor_from_companion (:63-84). Trim the moved-away half of the module docstring (:1-19), leaving only the project-membership half. Import audit: the ActorRef import (helpers.py:26) leaves with the constructors iff neither OperatorProjectNotFoundError nor verify_project_membership uses it; Person (helpers.py:30) stays (the verifier takes person: Person); keep AsyncSession, UUID, get_membership_for_person_on_engagement. OperatorProjectNotFoundError and verify_project_membership remain, behaviour unchanged.

(c) Repoint all 7 importers to from loomworks.stele.actor import …. Import path only; no call-site logic changes; function-local imports stay function-local (path changes, location does not):

(d) Full suite + DB-truth. Expect 2920 passed / 46 skipped / 0 failed — collection clean, green at this commit (the whole point of the collapse). DB-truth against playground_dev: a sign-in turn through converse exercising a person-actor stamp, confirming the relocated stele.actor.actor_from_person produces the same ActorRef.

Commit (one): Phase 2 step 2.1: move actor constructors to stele/actor.py and repoint all importers. Do not push.

> Per move-not-duplicate, the move, the origin-removal, and all repoints are one commit — there is never a window where both copies exist, and never a window where the suite is un-runnable. This is the Phase 1 mechanic restored (v0.1's 2.1/2.2/2.3 split is superseded by this single step).

Halt condition for this step (carried from §3.6): if any importer needs more than an import-line change, if either staying symbol's behaviour would have to change, or if the suite cannot be made green by the move + repoints alone — stop and report rather than commit.

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:

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:

Halt and report. (Verification-only — no commit, matching Phase 1's Step 1.5.)

5. What must not happen in Phase 2

6. Halt and report

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.

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

8. Reversibility

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.2 — 2026-06-14