Change Request identifier. CR-2026-094
Version. 0.2
Date. 2026-05-29
Status. Executed and accepted. Version 0.2 records the additive domain-expert amendment adopted during the build (see the Amendment record below). Supersedes version 0.1, preserved alongside.
Author. Claude.ai (Change Request drafting layer). Operator: Marvin Percival.
Executing agent. Claude Code 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).
Version 0.1 resolved decision D3 by treating domain_expert as a single-value designation under a precedence order (operator, then contributor, then domain_expert), so a person who held both operator and domain_expert on an engagement surfaced only as "(Operator)" — the recognized-expertise marker was suppressed whenever a base role was also held. During the build, the Operator decided this suppression worked against D3's intent: the point of the marker is to make recognized expertise visible, and the one case where it most matters (a domain expert who is also the operator) was exactly the case that hid it.
Version 0.2 makes the domain-expert marker additive. The surface label is now a base role plus a domain-expert marker when present:
domain_expert is never a base role; it is always the additive marker. Base-role precedence between operator and contributor stays operator before contributor.
This changed three things in the implementation, all carried out and accepted during the build:
_resolve_designation in conversation_turns.py) now returns a base role (operator, contributor, or none) plus a boolean domain-expert marker, rather than a single designation string. This is the read-time join described in §8, widened to carry both values from one query.contributor_designation field plus a new is_domain_expert boolean — the smallest change that lets the Operator Layer compose the full label. Applied to both the engine read view and the conversation-history response schema.turnAuthorSourceLabel) composes "(Operator · Domain Expert)" and the other combinations above.The prior version 0.1 single-value resolution is preserved in the version 0.1 document alongside this one, per the corrections-preserved discipline: the trajectory (suppression considered, set aside in favour of additive recognition) is recorded, not smoothed over.
The amendment touches the D3 row in §2, the actor-representation note in §4, the designation resolution in §8, and acceptance criterion 5 in §11. Those sections below carry the amended text; where a passage describes the version 0.1 single-value behaviour, it has been updated to the additive model.
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. Version 0.2 (amended): the marker is additive — it surfaces alongside the base role (operator or contributor) rather than being suppressed under it, so "{name} (Operator · Domain Expert)" renders the recognition even when a base role is held. domain_expert is never a base role. No authority change, no workflow. Recognition flow stays deferred. (Version 0.1 treated it as a single value suppressed under a higher base role; see the Amendment record.) |
| 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, as amended in version 0.2 to compose a base role plus an additive domain-expert marker:
operator base, no expert marker → "{name} (Operator)".contributor base, no expert marker → "{name} (Contributor)".operator base with the domain-expert marker → "{name} (Operator · Domain Expert)".contributor base with the domain-expert marker → "{name} (Contributor · Domain Expert)".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 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).
A version 0.1 assumption corrected during the build. Version 0.1 stated the contributing designation "is already available" at the operator-turn call site. The build found this optimistic: the converse auth path calls verify_project_membership, which only confirms an active membership exists — it returns nothing and does not resolve which designation the person holds. So the designation is not cheaply available at write time and is not on the turn row.
Resolution (as built). The designation resolves at read time, by joining memberships to membership_designations keyed by (person identifier, engagement identifier), filtered to active rows. Read-time rather than denormalized because designation is current-state — a person's standing can change — so resolving at read time avoids a stale label written onto a past turn. Every turn returned by get_recent_turns (and by the conversation-history endpoint) shares one (person, engagement) scope, so the designation resolves once per read, not once per turn — one join, not an N-plus-one.
Version 0.2 (additive) shape. The resolution returns a base role (operator, contributor, or none — operator before contributor) plus a boolean domain-expert marker (true whenever domain_expert is held, even stacked with a base role). These reach the Operator Layer as the contributor_designation field (the base role) plus an is_domain_expert boolean, and the Operator Layer composes the final label ("(Operator · Domain Expert)", "(Contributor)", "(Domain Expert)", and so on). The choice between read-time join and a denormalized column was a performance call, not a semantic one; the read-time join was chosen and is confirmed single-query.
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).All criteria below were met; see the Step 6 acceptance gate and live authenticated check.
conversation_turns carries actor columns; the migration applies and reverses cleanly. Met (migration 0074, round-trip clean).actor_kind="companion" (no longer indistinguishable-by-role-only). Met (confirmed live).actor_kind="person" and the operator designation reaches the surface. Met (confirmed live: "Marvin Percival (Operator)").domain_expert membership's turn carries the recognized-expertise marker, additively (version 0.2): stacked with a base role it renders "(Operator · Domain Expert)" / "(Contributor · Domain Expert)"; alone it renders "(Domain Expert)". Met (confirmed live: a stacked operator-and-domain-expert membership read back as "(Operator · Domain Expert)").memory_events seam pattern. Met.
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.Version 0.2 (2026-05-29). Records the additive domain-expert amendment adopted during the build (see the Amendment record). Changes D3 (§2), the Step 5 label rule (§5), designation resolution (§8 — also correcting the version 0.1 assumption that the designation was available at the call site; it resolves at read time), and acceptance criterion 5 (§11), and marks all acceptance criteria met against the Step 6 gate and live authenticated check. The version 0.1 single-value resolution is preserved in the version 0.1 document alongside this one.
Version 0.1 (2026-05-29). Initial Change Request. Executes source-identification scoping note version 0.2 (decisions D1 through D5 resolved). Grounded in live code re-verified at engine d5d57ee on 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 — version 0.2 — 2026-05-29