DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/loomworks-source-identification-cr-v0_1.md

Source Identification — Change Request — Unify Conversation-Turn Provenance onto ActorRef

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.

Verbatim code grounding (verified at baseline d5d57ee, 2026-05-29).


Plain-language summary

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.


1. Purpose

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.


2. Scoping decisions adopted

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


3. Pre-flight and baseline (Step 0)

3.1 Baseline (verified 2026-05-29)

3.2 Step 0 — CC pre-flight before any change

CC confirms at execution time and records findings in docs/phase-impl-notes/ on a source-identification-step-0 branch (branched from engine d5d57ee):

  1. Engine HEAD still d5d57ee (or report drift). Current Alembic head (record it — it sets the new migration's down_revision).
  2. Engine test count + skipped at baseline (record for the acceptance gate).
  3. Operator Layer test count at baseline.
  4. Confirm the verbatim locations in the grounding block still hold; flag any drift loudly (especially any actor column added to conversation_turns, or a changed record_turn signature — either would reshape this CR).
  5. Confirm the next CR number against engine 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.


4. The actor representation on conversation turns (the core decision)

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.


5. Step-by-step build

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.

Step 1 — Migration: add actor columns to 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.

Step 2 — Write path: 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.

Step 3 — Callers: construct the right ActorRef at each call site

In converse.py:

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.

Step 4 — Read view + reader + FORAY seam

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.

Step 5 — Operator Layer surface labels (CenterPane, ChatView)

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:

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.

Step 6 — Acceptance gate


6. Held-then-commit as an attestation chain (representability, not workflow)

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.


7. FORAY-completeness checklist (option (a), against the real SDK)

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


8. Designation resolution for the surface label

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.


9. Interrogability and seam hardening (throughout)

Consistent with the standing requirement that the FORAY/OVA story survive interrogation by a qualified person:


10. Out of scope (decided; do not build)


11. Acceptance criteria

  1. conversation_turns carries actor columns; the migration applies and reverses cleanly.
  2. A companion turn persists with actor_kind="companion" (no longer indistinguishable-by-role-only).
  3. An operator turn persists with actor_kind="person" and the operator designation reaches the surface.
  4. A Contributor's turn renders as "(Contributor)", not "(Operator)" — the spec §13 defect closed.
  5. A domain_expert membership's turn carries the recognized-expertise marker.
  6. The conversation-turn reserved-location FORAY seam fires as a no-op, matching the memory_events seam pattern.
  7. The held-then-commit chain is representable (§6) — verified by a qualified-reading check, not new workflow.
  8. Engine and Operator Layer suites green at baseline + new tests; production-session-envelope tests pass; typecheck/lint at baseline.
  9. Live authenticated check (Step 6) confirms the labels render honestly end to end.

12. Tags

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


13. Test-count estimate

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.


14. Methodology candidates surfaced (for v0.21 consolidation)


15. Carry-forward / follow-ups (not in this CR)


Changelog

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