CR identifier. CR-2026-094
Version. 0.1
Date. 2026-05-29
Status. Drafted; awaiting Operator approval.
Author. Claude.ai (CR drafting layer). Operator: Marvin Percival.
Executing agent. Claude Code (CC) on DUNIN7-M4.
Repositories. loomworks-engine (substrate — all of this CR's work) and loomworks (Operator Layer — surface labels). No Workshop (loomworks-ui) work.
Baseline. Engine HEAD d5d57ee (verified current 2026-05-29, tree clean modulo untracked docs/inspections/). Operator Layer HEAD c2ca4db (composer-upload-port shipped, tag composer-upload-port-v0_1).
Companion documents.
loomworks-source-identification-scoping-note-v0_2.md (in loomworks-record/change-requests/) — authoritative scope; decisions D1–D5 resolved. This CR executes that scope.loomworks-foray-integration-grounding-investigation-v0_1.md (in loomworks-record/investigations/) — the live dunin7-foray SDK contract; the FORAY-completeness section below references it rather than re-deriving.loomworks-person-layer-discovery-v0_2.md, phase-14-cr-person-layer-v0_1.md — the person/membership/designation model this builds on.
Verbatim code grounding (verified at baseline d5d57ee, 2026-05-29).
ActorRef — src/loomworks/memory/base.py:17 (four kinds contributor/agent/person/companion at :66; Phase 45 capability_ref :73, approval_mode :74; frozen).Provenance — src/loomworks/memory/base.py:89 (PROV-aligned; wasAttributedTo: ActorRef).append_event — src/loomworks/memory/events.py:118 (takes actor: ActorRef; denormalizes actor_id :169 / actor_kind :170 / actor_instruction_version :171; full provenance JSONB; _foray_reserved_emit seam import :26, call :235).ConverseTurnRow — src/loomworks/orchestration/models.py:53, table conversation_turns (:71); columns id/person_id/engagement_id/role/content/classified_intent/input_mode/completeness_check_prefix/created_at; no actor column.record_turn — src/loomworks/orchestration/conversation_turns.py:77; single ConverseTurnRow(...) instantiation :105; ConverseTurn view :40; _row_to_turn :73; get_recent_turns :120.src/loomworks/orchestration/routers/converse.py: operator turn :940, companion turn :966 (companion attributed to operator's own person.id with role="companion", no ActorRef).src/loomworks/api/routers/seed_conversation.py: _append_operator_turn :412, _append_companion_turn :445 (writes to candidate-engagement event log, not conversation_turns).
What this does. Today Loomworks records "who said what" two different ways. Assertions in Memory carry a rich, PROV-grounded actor record (ActorRef — kind, identity, instruction version, full provenance). Conversation turns carry only a person_id plus a role that is always "operator" or "companion". The companion (AI) turn is written with the Operator's own person_id, distinguished only by the literal string "companion". And the Operator Layer surface labels turns from role alone — so a Contributor's turn renders under the Operator's name. This CR unifies the conversation-turn path onto ActorRef, so conversation turns record source the same honest way assertions do, and the surface shows the four source-kinds truthfully.
What changed from the scoping note. Nothing material — the CR executes scoping note v0.2's resolved decisions (D1–D5). Live-code re-verification (2026-05-29, HEAD d5d57ee) confirmed the write path is unchanged from the scope's grounding: one table, one writer, two call sites, no actor column.
What Operator decision is needed. Approval to execute. All design decisions (D1–D5) were resolved in the scoping note.
In scope: carry an ActorRef (or its denormalized actor fields) onto conversation_turns; record companion turns as the companion kind with the Companion's own identity; carry actor-kind + designation through the read view to the Operator Layer surface labels (CenterPane, ChatView) so the four source-kinds render honestly; give domain_expert a provenance marker; carry the FORAY reserved-location seam onto the conversation-turn write path; make the held-then-commit contribution→commit chain representable. FORAY-complete-and-seam-marked per option (a).
Out of scope (decided): FORAY integration / dunin7-foray dependency / JSON construction; the Domain Expert recognition flow; practice-consensus as a fifth source-kind; the seed-conversation turn surface (D2); OVA enforcement (seams placed, not built).
Shape. Engine-primary (one migration, the write-path change, the read view, the seam). Operator Layer second (surface labels). Two checkpoints: engine, then surface. Treated as a Memory write-path change — tested against production-session behavior, not just a green suite.
Source identification makes Loomworks able to answer, reliably and visibly, who or what asserted each thing, and under what authority — across all of Memory, not just the assertion path. It is the first piece of the Memory-completion arc and the load-bearing half of the "human commit-authority" moat: the enforcement half (AI cannot commit) is already real and defense-in-depth; the visible-provenance half (who-said-what shown honestly) is what this builds.
The problem in one sentence: conversation turns are Memory-population events recorded with a provenance scheme far poorer than the assertion path's, and the surface discards even what little they carry. This CR closes that gap by unifying the conversation-turn path onto the existing ActorRef model.
The change is additive, not a refactor. The conversation-turn write path references no actor model today — there is no competing actor-resolution logic to unwind, only an absence to fill. This is the central fact that keeps the build small and safe.
All five decisions from scoping note v0.2 are adopted without modification.
| Decision | Resolution |
|----------|-----------|
| D1 — Surface labeling depth | Honest name + designation: a Contributor's turn renders as "{name} (Contributor)" / "{name} (Domain Expert)"; AI turns as the Companion. Matches "only show what is available." |
| D2 — Seed-conversation surface | Out of scope. First pass covers conversation_turns only. seed_conversation.py (candidate-engagement event-log turns) is a separate mechanism, unified later. |
| D3 — domain_expert meaning (first pass) | A recognized-expertise marker carried on the source record / surface label when the contributing membership holds the domain_expert designation. No authority change, no workflow. Recognition flow stays deferred. |
| D4 — FORAY-completeness depth | Option (a): data complete and seam-marked; no dunin7-foray dependency; verified per-field against the grounding note's SDK contract. |
| D5 — AI source-kind granularity | Conversation path uses the companion ActorRef kind; agent remains the bearer-token path on the assertion side. Surface shows both as the Companion to the Operator; the kind distinction is preserved in the substrate. |
d5d57ee, tree clean (only untracked docs/inspections/, pre-existing).c2ca4db, tag composer-upload-port-v0_1.ActorRef, append_event, and the seed-conversation path all verified at the file:line locations in the grounding block above.
CC confirms at execution time and records findings in docs/phase-impl-notes/ on a source-identification-step-0 branch (branched from engine d5d57ee):
d5d57ee (or report drift). Current Alembic head (record it — it sets the new migration's down_revision).conversation_turns, or a changed record_turn signature — either would reshape this CR).docs/phase-crs/ and update the CR identifier.If Step 0 surfaces material drift, CC halts and surfaces it for an amendment cycle rather than proceeding.
The scoping note settled that conversation turns unify onto ActorRef; this CR settles how the actor is represented on the row. Two construction options were weighed; the chosen one mirrors the assertion path so the two Memory-population paths converge rather than diverge again.
Considered and set aside — store a full serialized ActorRef JSONB blob on the row. Simplest to write. Set aside because it diverges from how append_event represents actors (denormalized columns + provenance JSONB), reintroducing a third representation shape rather than converging on the existing one, and because the denormalized columns are what make actor-kind queryable and surfaceable without parsing JSON on every read.
Chosen — mirror append_event's denormalization. Add denormalized actor columns to conversation_turns matching the assertion path: actor_id (UUID), actor_kind (text, the ActorRef.kind value), actor_instruction_version (int, nullable). The richer ActorRef fields (display_name, capability_ref, approval_mode) live in a provenance JSONB column if and when needed; first pass carries display_name for the surface label and leaves capability_ref/approval_mode for the held-then-commit chain (§6). This makes conversation-turn provenance structurally identical to assertion provenance — the convergence the scoping note's unification decision intends.
The companion-turn identity fix (D5). Today the companion turn is written with the Operator's person.id and role="companion". Under this CR, the companion turn is written with actor_kind="companion". Per the ActorRef companion kind's existing semantics (base.py docstring): the companion kind's id carries the person's UUID (the Companion's identity is bound to the person — there is no separate companions table), and the Companion's name is the person's companion_name preference. So actor_id remains the person's UUID, but actor_kind now correctly says companion, not a role string standing in for identity. This is the precise gap the unification closes: the AI turn gains a distinct kind without inventing a separate identity store.
role is retained, not removed. role ∈ {operator, companion} stays on the row — it is load-bearing elsewhere (the recursion guard reads it; classified_intent is keyed to companion turns). actor_kind is added alongside role, not as a replacement. role describes the turn's conversational position; actor_kind describes the source. They are related but distinct, and conflating them was the original defect.
Engine work first (Steps 1–4), then Operator Layer surface (Step 5), then acceptance (Step 6). Each step is its own commit. The conversation-turn write path is a Memory write path — every step's tests mirror production session behavior (the transaction envelope, real commit/rollback), not the green-suite-only pattern that has masked production bugs before.
conversation_turns
Add nullable columns actor_id (PG UUID), actor_kind (Text), actor_instruction_version (Integer, nullable), and actor_display_name (Text, nullable) to conversation_turns. Nullable so existing rows are valid (no backfill required for historical turns; they retain person_id + role and read as "kind unknown / legacy" — see §7 read view). down_revision = the Alembic head Step 0 records. New head recorded for the acceptance gate.
Commit: migration only.
record_turn accepts and writes the actor
Extend record_turn (conversation_turns.py:77) to accept an actor: ActorRef parameter and write the denormalized columns on the ConverseTurnRow instantiation (:105): actor_id=actor.id, actor_kind=actor.kind, actor_instruction_version=actor.instruction_version, actor_display_name=actor.display_name. role continues to be passed and written as today. The caller owns the transaction (unchanged). Mirror append_event's denormalization exactly so the two paths are structurally identical.
Tests: production-session-envelope tests confirming the actor columns persist and read back; an operator-turn and a companion-turn case.
Commit: write-path change + tests.
ActorRef at each call site
In converse.py:
ActorRef(kind="person", id=person.id, display_name=<operator display name>) — the person-auth kind (a human acting through the Phase 14 session-cookie path), carrying the contributing membership's designation context for the surface (see §8 for how designation reaches the label). Pass it to record_turn.ActorRef(kind="companion", id=person.id, display_name=<companion_name preference>) and pass it. This is the D5 fix — the AI turn now carries actor_kind="companion".
Contributor distinction. When the acting person holds the contributor (not operator) designation on the engagement, the operator-turn ActorRef still uses kind="person" (the auth path), but the designation — needed for the "(Contributor)" label — is resolved from the membership and carried to the surface via the read view (§7) and the label logic (§8). The ActorRef.kind records the auth path; the designation records the role-on-this-engagement. Both are needed; they answer different questions ("how did they authenticate" vs. "what is their standing here").
Tests: each call site constructs the expected ActorRef; companion turn carries kind="companion"; a contributor-acting case carries the contributor designation through.
Commit: caller changes + tests.
ConverseTurn (the Pydantic view, conversation_turns.py:40) and _row_to_turn (:73) to surface the actor fields (actor_kind, actor_display_name, and the contributing designation) so the Operator Layer can label honestly. get_recent_turns (:120) selects the new columns.append_event's _foray_reserved_emit — a no-op seam at the record_turn write site emitting a reserved-location event (conversation.turn or similar, matching the memory.{event_kind} pattern) carrying the turn id and actor kind. No-op today; the seam point where FORAY attestation attaches when integration opens. This satisfies D4's seam-marking for the conversation-turn path so both Memory-population paths are seam-marked (the assertion path already is).
Tests: the read view surfaces actor fields; the reserved-location seam fires (as a no-op, asserted by the same pattern the memory_events seam test uses).
Commit: read view + seam + tests.
In loomworks (Operator Layer): change the turn author-label logic in CenterPane and ChatView from role-only to the actor representation surfaced by the read view (§7). Per D1:
operator designation → "{name} (Operator)".contributor designation → "{name} (Contributor)".domain_expert designation present → "{name} (Domain Expert)" (D3 marker).actor_kind="companion") → the Companion's name.actor_kind) → fall back to the current role-based label (no migration of historical turns; they label as they do today).Plain-terms discipline: the labels use "Operator", "Contributor", "Domain Expert", "Companion" — methodology nouns, first-class on the surface. No model names, no AI disclosure on the Companion label. Tests: each designation renders its honest label; companion renders as the Companion; legacy turn falls back; a Contributor no longer renders as "(Operator)". Commit (Operator Layer repo): surface labels + tests.
actor_kind="person" and the honest label; drive a companion turn and confirm actor_kind="companion"; if a contributor membership can be arranged in dev, confirm a contributor turn labels as "(Contributor)" not "(Operator)".Per the grounding note (§5): the held-then-commit ceremony maps to a FORAY attestation chain — an AI/agent contribution is one source event; the Operator's commit is a second that references it, preserving the "AI contributed, human committed" trajectory.
This CR's obligation is representability, not building a new workflow: the actor representation on conversation turns plus the existing assertion-path provenance must be sufficient that the contribution→commit chain can be derived. The companion-kind contribution turn (Step 3) and the existing human-commit enforcement on the assertion path already provide the two ends of the chain; the Phase 45 capability_ref/approval_mode fields on ActorRef carry the delegation provenance when a commit runs through the delegation flow. No new commit workflow is built here — the chain is made representable and seam-marked, so a future FORAY integration can attest it. This is the interrogability requirement: a qualified person asking "how would the contributed-then-committed chain be attested?" gets a coherent, code-grounded answer.
Per D4 and the grounding note's real dunin7-foray v3.0.0 contract: the SDK's actor surface is a single triggeredBy string plus an opaque payload. "FORAY-complete" means Loomworks holds, natively and durably, enough to derive both at any time. The per-field check for conversation turns:
| FORAY needs (derivable at emit time) | Where the Loomworks-native datum lives after this CR |
|---|---|
| triggeredBy (actor string) | Derivable from actor_kind + actor_display_name + actor_id on the turn row. |
| Structured actor (payload) | actor_id / actor_kind / actor_instruction_version (+ display_name, designation) — denormalized columns + read view. |
| The act (contribution vs commit) | Turn is a contribution event; the commit is the separate assertion-path attested act (§6). |
| The subject | The turn's id (and, for committed personal-memory facts, the resulting assertion id). |
| Timestamp | created_at on the turn row. |
| Authority / mandate | actor_kind (auth path) + designation (standing) + Phase 45 capability_ref/approval_mode for delegation-flow writes. |
No gap requires new capture beyond the actor columns this CR adds. The reserved-location seam (Step 4) is the point where this derivation feeds a future emit. No obfuscation work (caller-side, deferred to the integration phase per the grounding note).
The "(Contributor)" / "(Domain Expert)" label needs the acting person's designation on the engagement, which is not on the ActorRef (the ActorRef.kind records the auth path, not the engagement standing). Resolution: at the operator-turn call site (Step 3), the contributing membership's designation is already available (the converse auth path resolves the person's membership). Carry the designation onto the turn (either as an additional denormalized column contributor_designation, or resolved at read time in get_recent_turns by joining the membership).
Construction decision for CC at Step 0/Step 4: prefer the read-time join if the membership is cheaply joinable (keeps the turn row lean and the designation always-current); fall back to a denormalized contributor_designation column if the join is expensive on the hot read path. CC reports which it chose and why. Either satisfies D1; the choice is a performance call, not a semantic one.
Consistent with the standing requirement that the FORAY/OVA story survive interrogation by a qualified person:
dunin7-foray dependency, emit/attest wiring, JSON construction.seed_conversation.py) — D2; unified later.role; no rewrite of the past, consistent with corrections-preserved discipline).conversation_turns carries actor columns; the migration applies and reverses cleanly.actor_kind="companion" (no longer indistinguishable-by-role-only).actor_kind="person" and the operator designation reaches the surface.domain_expert membership's turn carries the recognized-expertise marker.memory_events seam pattern.
On clean acceptance: engine tag source-identification-v0_1 at the engine head; Operator Layer tag source-identification-v0_1 at the OL head. Annotated tags (DUNIN7 convention).
Engine: ~10–16 new tests (migration apply/reverse; write-path persistence for person/companion/contributor; read-view surfacing; reserved-location seam; production-session-envelope cases). Operator Layer: ~5–8 new tests (each designation label; companion label; legacy fallback; contributor-not-operator regression). Estimate, not a contract; CC reports actuals at close.
ActorRef.kind) and the engagement standing (designation) answer different questions and both are load-bearing for honest provenance; conflating them (as role did) is the original defect. Worth a named principle.personal_engagement_id on /me; project-less recall) — cleanup, not blocking.
v0.1 (2026-05-29). Initial CR. Executes source-identification scoping note v0.2 (D1–D5 resolved). Grounded in live code re-verified at engine d5d57ee 2026-05-29 and the FORAY integration grounding note. Six-step build (engine then Operator Layer), two checkpoints, conversation-turn path treated as a Memory write path.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks Source Identification — Change Request — v0.1 — 2026-05-29