Version. 0.1
Date. 2026-06-14
Author. Marvin Percival (DUNIN7 Operator) with CC (executing agent) on DUNIN7-M4, supervised from Claude.ai.
Status. Completion record. Phase 2 of the Stele extraction is complete and pushed to engine origin/main. Markdown primary (technical consumer). Records what moved (the two actor constructors), the v0.1→v0.2 CR amendment (the per-step suite prediction error and the step collapse that fixed it), the mixed-import-block finding, the no-schema proof, the database-truth result, and the Phase 3 handoff.
Governing CR. CR-2026-105 v0.2 (loomworks-record 01a1ab8; v0.1 preserved beside it per discovery-trajectory discipline). The v0.1→v0.2 amendment — collapsing Steps 2.1–2.3 into one move-and-repoint commit, with the prediction error recorded not smoothed — is carried in the CR's §4.0 and summarized in §1 below.
Phase 2 moved the two small functions that label a recorded action with who performed it — actor_from_person (builds an ActorRef with kind="person") and actor_from_companion (builds kind="companion", bound to the person's own id) — out of orchestration/helpers.py and into a new dedicated module, stele/actor.py. The functions' bodies are byte-identical to their pre-move form; no behaviour changed. The ActorRef type they produce is owned by the memory substrate and was referenced, not moved — stele/actor.py imports it from memory/base.py, exactly as the integration design requires.
The care-point: the file these functions lived in, helpers.py, also holds two unrelated project-membership pieces — OperatorProjectNotFoundError and verify_project_membership — that had to stay. So Phase 2 was a split, not a whole-file move: extract the two actor constructors to stele/, leave the project-membership pieces in orchestration/, and partition the imports (the ActorRef import left with the constructors; the Person import stayed on both sides because both the moved constructors and the staying verifier use it). This is the same mixed-module hazard Phase 1 met in persons/models.py; the discipline was identical (split per-symbol, repoint each importer, change no behaviour).
The load-bearing event of Phase 2 was not the move — the move was low-risk and clean. It was a CR prediction error caught in execution and corrected. The CR's v0.1 had split the move into three steps (move first, then repoint production callers, then repoint test callers) and predicted the suite would stay runnable between steps. That prediction was mechanically wrong: removing the constructors before repointing the production routers makes the application un-importable, so the test suite aborts at collection (33 errors, zero tests run) rather than failing six files. The fix — amended into the CR as v0.2 before resuming — collapsed the three steps into one move-and-repoint commit, restoring the Phase 1 mechanic where each move bundles with its repoints so the suite is green at every commit. The prediction error is recorded in the CR, not smoothed over.
No schema or data changed (alembic head 0083 unchanged on repo and live DB). The full suite stayed green at 2920 passed / 46 skipped / 0 failed, and a real-database check confirmed both actor stamps now resolve from stele/. Phase 3 (point get_current_person / session-resolution at Stele) is next.
This is Phase 2's load-bearing event, recorded per corrections-not-smoothed discipline.
What v0.1 predicted. CR-2026-105 v0.1 §4 split the work into three steps: Step 2.1 moved the two constructors out of helpers.py and repointed only the identity-set unit test; Steps 2.2/2.3 then repointed the four production callers and the two remaining test callers. v0.1 predicted that after Step 2.1, the suite would show "import failures from those 6 files… confined to exactly those 6 files" — a runnable suite minus six failing files, restored to green by 2.2/2.3.
What was actually true. Step 2.1's move was correct and verified (constructor bodies byte-identical, import partition right, staying symbols intact). But the suite did not run-and-fail six files — it aborted at collection with 33 errors (Interrupted: 33 errors during collection), zero tests run. Every one of the 33 was import-path-only (ImportError: cannot import name 'actor_from_person'/'actor_from_companion' from 'loomworks.orchestration.helpers'); zero logic failures, zero non-actor errors. Root cause: the four production routers import the moved symbols at module top, and those routers are themselves transitively imported across much of the suite — so removing the symbols before repointing the routers made the application un-importable, and pytest cannot collect any test module that transitively reaches a broken router. Root-cause grouping: router.py → 17 test modules, inbox.py → 11, converse.py → 4, test_stepup_commit_gate.py → 1 (direct helpers import).
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 a collection abort, not a contained set of test failures. The "6 files" number is right for what repoints; it is wrong as a suite-state prediction in the window between the move and the repoints. The tree-truth pull recorded the importers and their locations but did not model the collection-abort consequence of removing them mid-split.
The correction (CR v0.2). Execution halted on the divergence rather than committing a red suite or improvising — the same halt-and-amend discipline Phase 0 used on its boundary corrections. The CR was amended to v0.2 with the prediction error recorded in a new §4.0, and Steps 2.1–2.3 collapsed into a single move-and-repoint commit. This restored the Phase 1 mechanic — the Phase 1 completion record states it plainly: "bundle each move + its repoints in one commit." Phase 1's four-commit series did exactly that and stayed green at every commit; v0.1 of this CR had departed from it by splitting move from repoints, and that split was the sole cause of the un-runnable intermediate state. The collapse is more faithful to move-not-duplicate, not less: one commit fully satisfies "no window where both copies exist," and adds the property that the commit lands on a green suite (independently revertible-to-green, which the split Step 2.1 was not).
The naming correction (carried from v0.1, unchanged). The brief v0.4 and integration design §3.3 name the constructors actor.for_principal / actor.for_companion / actor.for_agent. These do not exist as code symbols. The live constructors are actor_from_person and actor_from_companion; there is no for_agent constructor (agent actors are built inline at 64 sites). Phase 2 used the real symbol names and did not rename them (a rename would touch call sites, not just imports — out of scope for a move). The dotted contract names remain the integration-design vocabulary; the underscore names are the Python realization. This is a doc-sync note (§7), not an execution change.
The two actor constructors moved into a new dedicated module, src/loomworks/stele/actor.py:
actor_from_person(person) → ActorRef(kind="person", id=person.id, display_name=person.display_name) — the integration design's actor.for_principal.actor_from_companion(person) → ActorRef(kind="companion", id=person.id, display_name=person.companion_name) — the integration design's actor.for_companion. The companion actor's id is the principal's UUID by construction (no separate companions table).
stele/actor.py holds a fresh module docstring, the two constructors (byte-identical bodies), and the two imports they need: ActorRef from memory/base.py (referenced, not moved) and Person from stele/registry.py.
Stayed in orchestration/helpers.py, behaviour unchanged: OperatorProjectNotFoundError and verify_project_membership — project-membership is Loomworks governance, not Stele identity. Verified live: the verifier resolves a real membership and raises OperatorProjectNotFoundError on a bogus project.
Did not move (correctly out of scope): the ActorRef type (stays at memory/base.py); the 64 inline kind="agent" construction sites; _companion_actor() in seed_conversation.py (a kind="agent" lookalike, distinct from the moved kind="companion" constructor — not conflated); the 228 actor= / 73 ActorRef( label call sites (Stele slots in underneath them, untouched); and api/deps.py actor-resolution (get_resolved_actor, _resolve_cookie_actor, get_committing_resolved_actor) — the auth-path / get_current_person surface, which is Phase 3, not pulled forward.
Engine repo (DUNIN7/loomworks-engine).
origin/main: 677c46f (fast-forward push f1cf170..677c46f; linear, no merge commit; local == origin).src/loomworks/stele/ now also holds actor.py, alongside __init__.py, models.py, registry.py, credentials.py, session.py, webauthn.py, recovery.py. Shared base at src/loomworks/identity_base.py.0083, unchanged — no migration added or touched this phase (this phase touches no models).
Phase 2 commit series (all on origin/main):
| Commit | Step | What |
|---|---|---|
| 306b526 | 0 | archive CR-2026-105 into engine docs/phase-crs |
| af10f6a | — | archive CR-2026-105 v0.2 alongside v0.1 (after the amendment) |
| 5c06423 | 2.1 (collapsed) | move both constructors → stele/actor.py and repoint all 7 importers, one commit |
| 677c46f | 2.4 | sync stale comments/docstrings to the new actor-constructor home |
Step 2.5 (full regression + database-truth) added no commit — verification only, matching Phase 1's Step 1.5. Phase 2 is four commits: the Step 0 archive, the v0.2 re-archive, the collapsed move-and-repoint, and the comment/docstring sync.
Move, not duplicate held throughout: the constructors' bodies are byte-identical; every importer change was an import-line repoint; no importer's logic, control flow, or behaviour changed (the non-import-line diff is empty). The move and all seven repoints landed as one commit — no window where both copies exist, no un-runnable intermediate state.
The mixed-import-block finding (richer than the CR table showed). The CR's §3.1 importer table treated the production importers as single-symbol import lines (e.g. inbox.py:48, composition.py:41). In execution, 4 of the 6 production importers turned out to pull the actor constructor in a mixed multiline import block sharing the line/block with the staying OperatorProjectNotFoundError / verify_project_membership. These were handled by per-symbol splits (the Phase 1 mechanic applied to importers): the actor constructor's import moved to stele.actor, the project-membership symbols kept their import from helpers. The four:
orchestration/router.py — actor_from_companion module-top :70 (clean line) + actor_from_person function-local block @1097 (mixed split).orchestration/routers/converse.py — mixed block @50 split (actor_from_companion + actor_from_person).orchestration/routers/inbox.py — mixed block @46 split (actor_from_person).orchestration/routers/composition.py — mixed block @39 split (actor_from_person).
The remaining three importers were clean lines: tests/test_actor_from_companion.py:16, tests/test_stepup_commit_gate.py:26, and tests/test_engagement_creation_walk_guards.py:252,:332 (function-local, kept function-local). Verified: no actor_from_* is imported from orchestration.helpers anywhere after the move; the staying symbols remain imported from helpers in all four split files. The halt condition was not tripped — splitting an import block is still an import-line change, and both project-membership symbols' behaviour is unchanged.
This finding did not change the work (it was handled within the import-line-only rule) and did not warrant a further CR amendment (the work matched the CR's intent and rules; only the import-line shape was richer than the table). It is recorded here so a future reader sees the actual import topology.
Import partition (as executed):
stele/actor.py): from loomworks.memory.base import ActorRef, from loomworks.stele.registry import Person.helpers.py: get_membership_for_person_on_engagement (from persons.memberships), AsyncSession, UUID, and Person.Person on both sides — the shared import (both the moved constructors and the staying verifier take a Person); each side imports it from stele.registry. Partition, not duplication of logic.ActorRef audited out of helpers.py — neither staying symbol uses it, so it left with the constructors.
No schema or data change — confirmed. Alembic head 0083 unchanged on repo files and live playground_dev. This phase touches no models and no migrations.
Suite: 2920 / 46 / 0 at 677c46f — collection clean, green, no regression. Identical to the post-Phase-1 baseline.
Database-truth (against playground_dev, no secrets printed):
stele.actor.actor_from_person, fed a person loaded via the relocated stele.registry.get_person_by_id, produced ActorRef(kind="person", id=<operator UUID>, display_name="Marvin Percival") — matching person.id and person.display_name, the exact stamp converse.py:1155 performs.stele.actor.actor_from_companion for the same person produced ActorRef(kind="companion", id=<same UUID>, display_name="Companion") — kind widened to companion, id bound to person.id, display_name the per-person companion name.verify_project_membership resolves the operator's real membership (no raise) and raises OperatorProjectNotFoundError on a bogus project — behaviour unchanged after the split.The two Phase 6 carry-forwards named in the Phase 1 record (the engagements-stub thread; the Person-view §5.2 narrowing) are unchanged by Phase 2 and still land at Phase 6. Phase 2 adds no new schema-level carry-forward.
This phase's doc-sync notes (not on the execution path):
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, noting the label surface is untouched by the extraction by design.
Phase 3 — get_current_person / session-resolution → Stele. Points the "who is signed in → actor" path at Stele: the auth-path actor resolution in api/deps.py (get_resolved_actor :912, _resolve_cookie_actor :996, get_committing_resolved_actor :1073), which builds person / agent / contributor ActorRefs from the session. Per the brief, the ~119 session-resolution callers do not move; the resolution dependency points under Stele.
Carry-forward cautions for the Phase 3 CR:
Branch cleanup. The Phase 2 branch cr-2026-105-stele-extraction-phase-2 was left in place pending this completion record. With the record filed, the branch can be deleted (local and, if pushed, remote); main carries the full series.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Extraction Phase 2 Completion Record — v0.1 — 2026-06-14