DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/phase-16-memory-contribution-ui/phase-16-cr-memory-contribution-ui-v0_1.md

DUNIN7-M4 — INFRASTRUCTURE CHANGE REQUEST

CR-2026-028 — Phase 16: Memory Contribution Through the UI (v0.1)

Version. 0.1 Date. 2026-04-26 Author. Claude (drafting) / Marvin Percival (approving). Target. /Users/dunin7/loomworks (substrate) and /Users/dunin7/loomworks-ui (frontend) on DUNIN7-M4 (MacMini). Baseline reference. Substrate: tag phase-15-loomworks-universal-commons. 1064 tests, 2 environment-gated skips. Frontend: tag phase-15-loomworks-universal-commons. Lint + tsc + build clean. Priority. Standard (sequential phase). Confidential. Internal DUNIN7. Supersedes. No prior Phase 16 CR — v0.1 is the first. Companion to. loomworks-phase-16-scoping-note-v0_1.md (six settled decisions S1–S6); current-status-manifest-v0_13.md; methodology what-dunin7-is-building-v0_17.docx; playground-spec-v0_10.docx (Sec-A, Sec-B); playground-reference-design-v0_1_2.docx (S-1, S-2, S-3); loomworks-brand-README-v0_1.mdloomworks-design-md-v0_1.mdloomworks-brand-guide-v0_15.html (brand system). Status. Pre-execution CR. Ready for Operator review and approval.


Contents


1. Executive summary

Phase 16 is the first phase where a person contributes knowledge to an engagement through the product surface. After Phase 16, a signed-in Contributor opens the Loomworks engagement, navigates to the Memory room, contributes an assertion in any of three modes (typed text, voice recording with transcription, or uploaded PDF with text extraction), commits it, and sees it in Memory with full provenance.

The work has six substantive components.

Component A — Substrate: contribution infrastructure. The AddAssertionRequest schema gains a default for grammar_element so that person-contributed knowledge does not require specification-authoring classification. Person-session auth is verified on assertion endpoints, with ActorRef resolution mapping persons to kind="person". A file upload endpoint and filesystem storage pattern are established for binary originals (audio files, PDFs). An assertion list endpoint is added for the Memory room display.

Component B — Substrate: transcription skill. A bounded structural transformation that accepts an audio file and produces text via the OpenAI Whisper API. Runs server-side, called by the contribution endpoint after file upload.

Component C — Substrate: PDF extraction skill. A bounded structural transformation that accepts a PDF file and produces text via pdfplumber. Runs server-side, called by the contribution endpoint after file upload.

Component D — Frontend: room switcher and lobby. The engagement overview page at /engagement/[id] becomes a lobby showing seed, designations, and room badges with counts. Navigation to rooms with content. The room switcher component appears in the header of every engagement room page, establishing the four-room navigation model.

Component E — Frontend: Memory room. A new page at /engagement/[id]/memory with the env-memory tint (#E4E6E8). Displays all assertions in the engagement's Memory with provenance (contributor, timestamp, state, version, source mode). Contribution surface for creating new assertions.

Component F — Frontend: contribution surface. Three input modes on the Memory room: text (textarea), voice (MediaRecorder API for browser recording, or audio file upload), and PDF (file upload). Each mode produces an assertion through the contribution endpoint. Single-assertion commit per S2.

Phase 16 is complete when the acceptance test suite passes and the Operator has confirmed Checkpoint D. Both repos are tagged phase-16-memory-contribution-ui.


2. Grounding

Phase 16 is grounded in three layers, consulted in order. Claims in this CR cite the layer they rest on.

Layer 1 — Methodology document v0.17. "Memory comes in many forms" is the methodology-level requirement Phase 16 realizes. The assertion lifecycle (held → committed), provenance requirements, and non-erasure discipline are all methodology-grounded. The methodology names Memory as accumulated engagement knowledge; assertions as the unit of contribution; provenance as non-negotiable. Phase 16 makes this accessible through the product surface.

Layer 2 — Reference Design v0.1.2. S-1 (journey is information) grounds the decision to display full provenance on every assertion — who contributed, when, how, the assertion's lifecycle state. S-2 (considerations accumulate) grounds the non-erasure display: retracted assertions remain visible with their retraction rationale, not hidden. S-3 (Memory is weighted, source-identified) grounds the source-mode display: an assertion produced from voice transcription carries that lineage visibly.

Layer 3 — Playground Spec v0.10. Specific requirements:


3. Prerequisites

3.1 Pre-flight checks

Before any work begins, CC confirms:

  1. uv run pytest -v shows 1064 passed, 2 skipped.
  2. Frontend: npx next lint && npx tsc --noEmit clean.
  3. The Loomworks engagement exists with UUID 00000000-0000-0000-0000-000000000002, visibility 'automatic', five committed assertions.
  4. Both persons exist with correct Loomworks memberships (Operator: 'operator'; test person: 'contributor').
  5. Person-session auth works: a session cookie grants access to Loomworks engagement endpoints.
  6. OPENAI_API_KEY is set in the environment (or .env file).

4. Construction decisions this CR closes

This section resolves the eight notes for the CR drafter (N1–N8) from the CR drafting handoff v0.1. Each resolution is final for Phase 16.

4.1 N1 — grammar_element and normative_force on person-contributed assertions

Resolution: make grammar_element optional with a default of "definition".

The existing AddAssertionRequest (Phase 3, Section 12.2) requires grammar_element: Literal["definition", "constraint"] with no default. Phase 16 changes this to:


class AddAssertionRequest(BaseModel):
    content: str
    grammar_element: Literal["definition", "constraint"] = "definition"
    normative_force: Literal["required", "recommended", "optional"] = "required"
    source_mode: Literal["text", "voice", "pdf"] | None = None
    source_file_id: UUID | None = None

The change: grammar_element gains a default of "definition". This is backward compatible — all existing callers already provide the field explicitly. The default serves person-contributed assertions, where the UI does not expose grammar classification. The normative_force default remains "required" for backward compatibility; the frontend sends "optional" explicitly for person contributions.

Two new optional fields are added:

Why not a new endpoint or a contribution_mode discriminator. Adding a separate simplified endpoint would duplicate the assertion creation logic. Adding a contribution_mode field that controls whether classification is required adds conditional validation complexity. A default on grammar_element is the smallest change that achieves the goal: persons never see grammar classification, and no existing behavior changes.

What the person sees. Nothing about grammar elements or normative force. The UI sends: content (the text), grammar_element: "definition" (hardcoded by the frontend), normative_force: "optional" (hardcoded by the frontend), source_mode (from the input mode), and source_file_id (if a file was uploaded).

4.2 N2 — Person-authenticated assertion creation

Resolution: verify and adapt the existing auth resolution.

Phase 14 step 7 rewired all engagement-scoped endpoints to accept person-session auth alongside agent bearer tokens. The auth middleware resolves a session cookie to a person and checks membership on the engagement. The Phase 3 assertion endpoints use get_current_contributor for Add, Revise, Retract, and Relate, and get_committing_contributor for Commit.

Phase 16 requires:

  1. Verify that get_current_contributor (or its Phase 14 replacement) resolves correctly for person-session auth on the assertion endpoints. Phase 14's middleware attaches the resolved Person and Membership to the request context; the assertion endpoints need to read from this context.
  1. ActorRef mapping. For person-authenticated assertion operations, the ActorRef must use kind="person" and the person's UUID:

# Person-authenticated
ActorRef(kind="person", id=person.id, display_name=person.display_name)

# Agent-authenticated (unchanged)
ActorRef(kind="agent", id=contributor.id, display_name=contributor.display_name)
  1. Commit authorization. Phase 3's get_committing_contributor checks kind="human" and commit_authority=True. For person auth, the Commit check becomes: the person has a membership on this engagement with the "contributor" designation (S4 — any Contributor can contribute, which includes committing their own assertions). The person is inherently human — no kind check needed. This replaces the bearer-token-era commit_authority flag for person-authenticated commits.
  1. Backward compatibility. Agent bearer token auth continues to work as before. The middleware's dual-path resolution (session cookie for humans, bearer token for agents) handles this per Phase 14 Section 10.3.

CC should verify during Step 2 (Section 19) whether the current get_current_contributor dependency already handles person-session auth. If it does, the ActorRef construction is the only change. If it does not, the dependency must be updated to resolve from the request context's Person object for session-authenticated requests.

4.3 N3 — File upload and binary storage

Resolution: uploaded_files table + filesystem storage + upload endpoint.

Phase 16 introduces the first binary file storage in the substrate. The pattern is:

Database table: uploaded_files. An operational table (like contributors — not a Memory object in the event log). File metadata is infrastructure for retrieval, not engagement knowledge.

| Column | Type | Constraint | |--------|------|------------| | id | UUID | PRIMARY KEY | | engagement_id | UUID | NOT NULL, FK to engagements.id | | uploaded_by_person_id | UUID | NOT NULL, FK to person.id | | original_filename | VARCHAR(512) | NOT NULL | | content_type | VARCHAR(128) | NOT NULL | | file_size_bytes | BIGINT | NOT NULL | | storage_path | TEXT | NOT NULL | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |

Unique constraint on (engagement_id, id).

Filesystem storage. Files are stored at data/files/{engagement_id}/{file_id}{ext} relative to the repository root. The data/ directory is gitignored. The engagement_id subdirectory provides natural partitioning. The file_id plus original extension avoids filename collisions.

The data/files/ directory must be created at application startup if it does not exist (in the FastAPI lifespan, alongside database initialization).

Upload endpoint.


POST /engagements/{eid}/files
Content-Type: multipart/form-data
Body: file (binary)
Auth: person-session (membership with 'contributor' required) or agent bearer token
Response: { "file_id": UUID, "original_filename": str, "content_type": str, "file_size_bytes": int }

Accepted content types for Phase 16:

Rejected content types return 415 Unsupported Media Type.

File size limit. 50 MB per file. Enforced at the endpoint level. Returns 413 if exceeded.

Why a separate upload endpoint rather than inline in the assertion creation. Binary file upload (multipart/form-data) and JSON assertion creation are different content types. Separating them keeps the assertion endpoint clean (JSON only) and allows the file to be uploaded, processed (transcribed/extracted), and the result reviewed before the assertion is created. The flow is: upload file → process (transcribe/extract) → create assertion with source_file_id referencing the upload.

4.4 N4 — Transcription implementation: OpenAI Whisper API

Resolution: external API via the OpenAI Whisper API.

The transcription skill calls the OpenAI Whisper API (POST https://api.openai.com/v1/audio/transcriptions) with the uploaded audio file. This is the correct choice for Phase 16 because:

Implementation.


# src/loomworks/skills/transcription.py

import httpx
from pathlib import Path

async def transcribe_audio(file_path: Path, api_key: str) -> str:
    """Transcribe an audio file using the OpenAI Whisper API.

    This is a skill (bounded structural transformation):
    audio file in, text out. No registered actor, no instruction set,
    no LLM judgment — deterministic pipeline.

    Args:
        file_path: Path to the audio file on disk.
        api_key: OpenAI API key.

    Returns:
        Transcribed text.

    Raises:
        TranscriptionError: If the API call fails or returns an error.
    """
    async with httpx.AsyncClient(timeout=120.0) as client:
        with open(file_path, "rb") as f:
            response = await client.post(
                "https://api.openai.com/v1/audio/transcriptions",
                headers={"Authorization": f"Bearer {api_key}"},
                files={"file": (file_path.name, f)},
                data={"model": "whisper-1", "response_format": "text"},
            )
        if response.status_code != 200:
            raise TranscriptionError(
                f"Whisper API returned {response.status_code}: {response.text}"
            )
        return response.text.strip()

Configuration. The OPENAI_API_KEY environment variable is read at application startup and stored on app.state.openai_api_key. If the key is not set, voice-mode contribution is unavailable — the endpoint returns 503 with a message indicating transcription is not configured. This does not affect text or PDF modes.

Error handling. Transcription failures (API errors, timeout, invalid audio format) return 422 to the frontend with a descriptive error message. The uploaded file is retained regardless — the person can retry transcription or contribute the content manually.

4.5 N5 — PDF text extraction: pdfplumber

Resolution: pdfplumber for PDF text extraction.

pdfplumber is pure Python (wraps pdfminer.six), installable via pip install pdfplumber --break-system-packages, with no system-level dependencies. It handles structured content (tables, columns, headers) better than pypdf and does not require poppler system packages like pdftotext.

Implementation.


# src/loomworks/skills/pdf_extraction.py

import pdfplumber
from pathlib import Path

def extract_pdf_text(file_path: Path) -> str:
    """Extract text from a PDF file using pdfplumber.

    This is a skill (bounded structural transformation):
    PDF file in, text out. No registered actor, no instruction set.

    Args:
        file_path: Path to the PDF file on disk.

    Returns:
        Extracted text, pages separated by double newlines.

    Raises:
        PDFExtractionError: If the file cannot be read or contains no text.
    """
    pages = []
    with pdfplumber.open(file_path) as pdf:
        for page in pdf.pages:
            text = page.extract_text()
            if text:
                pages.append(text)
    if not pages:
        raise PDFExtractionError("PDF contains no extractable text.")
    return "\n\n".join(pages)

Why pdfplumber over the alternatives. pypdf produces lower-quality extraction on complex layouts (multi-column, tables). pdftotext (poppler) requires apt install poppler-utils or brew install poppler — a system dependency that may not be present on M4. pdfplumber is the middle path: good quality, pure Python, no system dependencies.

Error handling. Extraction failures (encrypted PDF, scanned image PDF with no text layer, corrupted file) return 422 to the frontend. Scanned PDFs without a text layer produce "PDF contains no extractable text" — OCR is not in Phase 16 scope.

4.6 N6 — Assertion list endpoint

Resolution: add GET /engagements/{eid}/assertions with pagination and state filter.

The Phase 3 CR defines individual assertion endpoints but no list endpoint. The Memory room needs to display all assertions in an engagement's Memory. Phase 16 adds:


GET /engagements/{eid}/assertions
Query params:
  state: Literal["held", "committed", "retracted"] | None  (filter; None = all states)
  limit: int = 50  (max 200)
  offset: int = 0
Auth: person-session (membership required) or agent bearer token
Response: {
  "assertions": list[AssertionResponse],
  "total_count": int,
  "limit": int,
  "offset": int
}

The response uses the existing AssertionResponse schema from Phase 3 (Section 12.2), extended with the Phase 16 metadata fields:


class AssertionResponse(BaseModel):
    assertion_id: UUID
    version: int
    content: str
    grammar_element: Literal["definition", "constraint"]
    normative_force: Literal["required", "recommended", "optional"]
    state: Literal["held", "committed", "retracted"]
    committed_at: datetime | None
    committed_by: ActorRefResponse | None
    retracted_at: datetime | None
    retracted_by: ActorRefResponse | None
    retracted_rationale: str | None
    was_revision_of_version: int | None
    # Phase 16 additions:
    source_mode: Literal["text", "voice", "pdf"] | None
    source_file_id: UUID | None
    created_at: datetime

class ActorRefResponse(BaseModel):
    kind: str
    id: UUID
    display_name: str

created_at is the timestamp of the assertion_added event — when the assertion was first contributed. This is distinct from committed_at (when it was confirmed). The Memory room displays both.

The list is ordered by created_at descending (newest first) by default. The Memory room can request committed state to show the confirmed Memory, or held state to show the person's pending contributions.

Implementation. The list queries current_memory_objects for objects where object_type="assertion" and engagement_id matches, with optional state filter. This uses the materialized view that Phase 1 established — no event-log scan needed.

4.7 N7 — Room switcher and four-room navigation

Resolution: room switcher in the engagement header; unbuilt rooms show count without link.

The room switcher is a component that appears in the top bar of every engagement room page (Memory, and future rooms). It shows four room indicators — one per methodology operation — each with the room's environment tint and an assertion/object count.

Composition:

Routing:

| Room | Route | Phase 16 status | |------|-------|-----------------| | Memory | /engagement/[id]/memory | Built. Clickable. | | Manifestation | /engagement/[id]/manifestation | Not built. Count shown, not clickable. | | Shaping | /engagement/[id]/shaping | Not built. Count shown, not clickable. | | Rendering | /engagement/[id]/rendering | Not built. Count shown, not clickable. |

The room switcher fetches counts from a new summary endpoint (see Section 11, GET /engagements/{eid}/room-summary).

Where it appears. In the header of every engagement room page, below the horizontal lockup and above the room content. It does not appear on the lobby page — the lobby has its own room badges (Section 14).

Next.js routing. The /engagement/[id]/memory route is a new page component. Future rooms will be added as new page components at their respective routes. The room switcher component receives the current route as a prop to highlight the active room.

4.8 N8 — Lobby conversion scope

Resolution: lobby shows seed, designations, and room badges with navigation.

The engagement overview page (/engagement/[id]) becomes the lobby. The conversion:

Retained:

Added:

Removed:

Room summary endpoint. The lobby and the room switcher both need room counts. A new endpoint provides this:


GET /engagements/{eid}/room-summary
Auth: person-session (membership required) or agent bearer token
Response: {
  "memory": { "assertion_count": int, "held_count": int, "committed_count": int },
  "manifestation": { "version": int | null },
  "shaping": { "count": int },
  "rendering": { "count": int }
}

For Phase 16, only memory counts are populated from real data. Manifestation, Shaping, and Rendering return zero/null — they reflect whatever the substrate already has from API-created objects in prior phases, if any.


5. Settled decisions — consumed from the scoping note

These are the six scoping decisions. Not subject to relitigation.

| Decision | Settlement | |----------|-----------| | S1 | Three input modes: text, voice (with transcription), PDF (with extraction). All three in Phase 16. | | S2 | Single-assertion commit. One at a time. No batch. | | S3 | Memory is its own page at /engagement/[id]/memory. Four-room model established. | | S4 | Any Contributor can contribute. Membership with 'contributor' designation required. | | S5 | Engagement overview becomes lobby. Room badges, not inline content. | | S6 | Transcription implementation is a CR-level decision. Resolved: OpenAI Whisper API (Section 4.4). |


6. Schema: new table and model changes

6.1 Alembic migration — uploaded_files table

One new migration creates the uploaded_files table per Section 4.3. No columns are added to existing tables — the source_mode and source_file_id fields live in the Assertion memory object's JSONB payload, not in a separate column.

The migration also creates the data/files/ directory if it does not exist (using os.makedirs with exist_ok=True).

6.2 Assertion model changes

The Assertion memory object type (Phase 3, Section 7) gains an optional metadata dict:


class Assertion(MemoryObject):
    object_type: Literal["assertion"] = "assertion"
    content: str
    grammar_element: Literal["definition", "constraint"]
    normative_force: Literal["required", "recommended", "optional"] = "required"
    state: Literal["held", "committed", "retracted"] = "held"
    committed_at: datetime | None = None
    committed_by: ActorRef | None = None
    retracted_at: datetime | None = None
    retracted_by: ActorRef | None = None
    retracted_rationale: str | None = None
    was_revision_of: MemoryRef | None = None
    # Phase 16 addition:
    metadata: dict[str, Any] = Field(default_factory=dict)

The metadata dict carries source-mode information for assertions created through the UI:


# Text mode — no metadata needed (source_mode recorded for display)
{"source_mode": "text"}

# Voice mode
{
    "source_mode": "voice",
    "source_file_id": "uuid-of-uploaded-audio",
    "transcription_method": "openai-whisper-api",
    "original_filename": "recording.webm"
}

# PDF mode
{
    "source_mode": "pdf",
    "source_file_id": "uuid-of-uploaded-pdf",
    "extraction_method": "pdfplumber",
    "original_filename": "document.pdf"
}

Assertions created through the API without source_mode have an empty metadata dict. This is backward compatible — existing assertions in the database have no metadata key in their JSONB; the Pydantic default factory produces {}.


7. Person-authenticated assertion contribution (N1, N2)

This section specifies how person-session auth integrates with the assertion endpoints.

7.1 Auth resolution for assertion endpoints

Phase 14's auth middleware resolves requests to either a Person (session cookie) or a Contributor (agent bearer token). The assertion endpoints need a unified interface that works for both:


# src/loomworks/api/deps.py

class ResolvedActor:
    """Unified actor resolution for assertion endpoints.
    Produced by the auth middleware for both person and agent auth."""
    actor_ref: ActorRef
    person_id: UUID | None  # set for person auth
    contributor_id: UUID | None  # set for agent auth
    can_commit: bool  # True for persons with 'contributor' designation; True for agents with commit_authority

async def get_resolved_actor(
    request: Request,
    eid: UUID,
    db: AsyncSession,
) -> ResolvedActor:
    """Resolve the current request to a ResolvedActor.

    Person auth path: extracts person from session, checks membership
    on engagement, builds ActorRef(kind="person", ...).

    Agent auth path: extracts contributor from bearer token, builds
    ActorRef(kind="agent", ...) or ActorRef(kind="human-contributor", ...).

    Raises 401 if no valid auth. Raises 403 if no membership/access.
    """
    ...

The assertion Add, Revise, Retract, and Relate endpoints use get_resolved_actor to obtain the ActorRef. The Commit endpoint additionally checks resolved_actor.can_commit.

For person auth, can_commit is True when the person's membership on this engagement includes the "contributor" designation. This is per S4: any Contributor can contribute, which includes committing. The Operator designation implicitly includes contribution capability — an Operator can always commit.

7.2 Backward compatibility

Agent bearer token auth is unchanged. The get_resolved_actor dependency returns the appropriate ActorRef regardless of auth path. Existing Phase 3 tests that use agent bearer tokens continue to pass.


8. File upload and binary storage (N3)

Specified in Section 4.3. Implementation details:

8.1 Upload handler


# src/loomworks/api/routers/files.py

@router.post("/engagements/{eid}/files", response_model=FileUploadResponse)
async def upload_file(
    eid: UUID,
    file: UploadFile,
    actor: ResolvedActor = Depends(get_resolved_actor),
    db: AsyncSession = Depends(get_db_session),
) -> FileUploadResponse:
    """Upload a file to an engagement.

    Accepts audio files (for voice mode) and PDFs (for PDF mode).
    Stores the file on disk and records metadata in uploaded_files.
    """
    # Validate content type
    # Validate file size (50 MB limit)
    # Generate file_id (UUID)
    # Determine storage path: data/files/{eid}/{file_id}{ext}
    # Write file to disk
    # Insert metadata row
    # Return file_id and metadata

8.2 File retrieval

Files are not served back through the API in Phase 16 (no inline audio playback or PDF preview per the scoping note's "What Phase 16 does not include"). The file's existence and metadata are referenced in the assertion's provenance display. A GET /engagements/{eid}/files/{fid} endpoint is added for future use but is not consumed by the frontend in Phase 16.


GET /engagements/{eid}/files/{fid}
Auth: person-session (membership required) or agent bearer token
Response: StreamingResponse with the file content and original content type

9. Transcription skill — OpenAI Whisper API (N4)

Specified in Section 4.4. The transcription skill is called by the contribution endpoint (Section 12) after file upload.

9.1 Skill location

src/loomworks/skills/transcription.py. This follows the skill-vs-agent distinction: the transcription is a bounded structural transformation (audio in, text out), not an LLM-backed agent with an instruction set. No registered actor, no agent runner dispatch.

9.2 Configuration

The OPENAI_API_KEY is read from the environment at app startup and stored on app.state.openai_api_key. A missing key does not prevent the app from starting — it disables voice-mode contribution only. The contribution endpoint checks for the key before attempting transcription and returns 503 if absent.

9.3 Supported audio formats

The Whisper API accepts: mp3, mp4, mpeg, mpga, m4a, wav, webm. Browser MediaRecorder typically produces webm (Chrome) or ogg (Firefox). The upload endpoint accepts the content types listed in Section 4.3; the Whisper API handles format conversion internally.


10. PDF text extraction skill — pdfplumber (N5)

Specified in Section 4.5. The extraction skill is called by the contribution endpoint (Section 12) after file upload.

10.1 Skill location

src/loomworks/skills/pdf_extraction.py. Same skill-vs-agent distinction as transcription.

10.2 Dependency

pip install pdfplumber --break-system-packages (or uv add pdfplumber). No system-level dependencies.


11. Assertion list endpoint and room summary (N6)

11.1 Assertion list

Specified in Section 4.6. Implementation in src/loomworks/api/routers/assertions.py.

The list query runs against current_memory_objects with filters:


SELECT payload FROM current_memory_objects
WHERE engagement_id = :eid
  AND payload->>'object_type' = 'assertion'
  AND (:state IS NULL OR payload->>'state' = :state)
ORDER BY (payload->>'created_at')::timestamptz DESC
LIMIT :limit OFFSET :offset

Note: created_at on the assertion payload may not exist for pre-Phase 16 assertions (the founding Memory assertions from Phase 15). For these, the created_at falls back to the event's recorded_at timestamp. The implementation should handle this gracefully.

11.2 Room summary

A new endpoint at GET /engagements/{eid}/room-summary returns counts for all four rooms. Implementation in src/loomworks/api/routers/engagements.py (colocated with the engagement endpoints).

The Memory counts query:


SELECT
  COUNT(*) FILTER (WHERE payload->>'state' = 'held') AS held_count,
  COUNT(*) FILTER (WHERE payload->>'state' = 'committed') AS committed_count,
  COUNT(*) AS total_count
FROM current_memory_objects
WHERE engagement_id = :eid
  AND payload->>'object_type' = 'assertion'

Manifestation, Shaping, and Rendering counts query against their respective object types in current_memory_objects. If no objects of that type exist, the count is zero / version is null.


12. The contribution endpoint: three-mode assertion creation

The contribution flow varies by mode but converges on the same outcome: an assertion in the held state with metadata recording the source.

12.1 Text mode

Frontend sends:


POST /engagements/{eid}/assertions
Content-Type: application/json
Body: {
  "content": "Provenance is what makes corrections visible. Without knowing who said what and when, a correction is indistinguishable from an original claim.",
  "grammar_element": "definition",
  "normative_force": "optional",
  "source_mode": "text"
}

The assertion is created with metadata: {"source_mode": "text"}.

12.2 Voice mode

Two-step flow:

Step 1 — Upload audio.


POST /engagements/{eid}/files
Content-Type: multipart/form-data
Body: file=<audio blob>
Response: { "file_id": "abc-123", ... }

Step 2 — Transcribe and create assertion.


POST /engagements/{eid}/assertions/contribute-voice
Content-Type: application/json
Body: {
  "file_id": "abc-123"
}
Response: {
  "assertion": AssertionResponse,
  "transcription": str
}

This is a convenience endpoint that:

  1. Reads the uploaded file from disk.
  2. Calls the transcription skill (Section 9).
  3. Creates an assertion with the transcribed text as content and voice metadata.
  4. Returns both the assertion and the transcription text (so the frontend can display it for review before commit).

The assertion enters the held state. The person reviews the transcription in the UI and commits if satisfied. If the transcription is wrong, the person can revise the assertion content before committing (using the standard Revise transition).

Why a separate endpoint rather than the generic Add endpoint. The voice flow requires server-side processing (transcription) between file upload and assertion creation. Encoding this as a step in the generic Add endpoint would add conditional branching based on source_mode. A dedicated endpoint makes the flow explicit and keeps the generic Add endpoint simple.

12.3 PDF mode

Two-step flow, same pattern as voice:

Step 1 — Upload PDF.


POST /engagements/{eid}/files
Content-Type: multipart/form-data
Body: file=<pdf file>
Response: { "file_id": "def-456", ... }

Step 2 — Extract and create assertion.


POST /engagements/{eid}/assertions/contribute-pdf
Content-Type: application/json
Body: {
  "file_id": "def-456"
}
Response: {
  "assertion": AssertionResponse,
  "extracted_text": str
}

This endpoint:

  1. Reads the uploaded PDF from disk.
  2. Calls the PDF extraction skill (Section 10).
  3. Creates an assertion with the extracted text as content and PDF metadata.
  4. Returns both the assertion and the extracted text.

Same held-state-then-commit pattern as voice mode.

12.4 Error responses

| Condition | Status | Body | |-----------|--------|------| | File not found (invalid file_id) | 404 | {"detail": "File not found"} | | Transcription not configured (no API key) | 503 | {"detail": "Voice transcription is not configured"} | | Transcription failed (API error) | 422 | {"detail": "Transcription failed: <reason>"} | | PDF extraction failed (no text) | 422 | {"detail": "PDF contains no extractable text"} | | File too large | 413 | {"detail": "File exceeds 50 MB limit"} | | Unsupported content type | 415 | {"detail": "Unsupported file type"} | | No membership / wrong designation | 403 | Standard auth error |


13. Frontend: the room switcher and four-room navigation (N7)

13.1 RoomSwitcher component

A new component at components/engagement/RoomSwitcher.tsx.

Props:


interface RoomSwitcherProps {
  engagementId: string;
  activeRoom: "memory" | "manifestation" | "shaping" | "rendering";
  roomSummary: {
    memory: { assertion_count: number; held_count: number; committed_count: number };
    manifestation: { version: number | null };
    shaping: { count: number };
    rendering: { count: number };
  };
}

Rendering rules:

Per "only show what is available": rooms with no content and no built page do not appear in the switcher. Memory always appears because the room page exists. The other three appear only when they have data (which means they were populated via API in prior phases).

Visual treatment:

13.2 Engagement room layout

A shared layout component at components/engagement/EngagementRoomLayout.tsx wraps all room pages:

13.3 Next.js routing

The Memory room page lives at app/engagement/[id]/memory/page.tsx. Server-rendered by default (standing preference). The page component:

  1. Resolves the engagement ID from the route parameter.
  2. Fetches room summary from GET /engagements/{eid}/room-summary.
  3. Fetches assertions from GET /engagements/{eid}/assertions?state=committed (initial load shows committed assertions).
  4. Renders EngagementRoomLayout with RoomSwitcher and the Memory room content.

14. Frontend: lobby conversion (N8)

14.1 What changes on the engagement overview page

The engagement overview page at app/engagement/[id]/page.tsx is restructured:

Before (Phase 15):

After (Phase 16):

Removed:

14.2 Room badge component

A new component at components/engagement/RoomBadge.tsx. Reuses the badge treatment from the dashboard engagement cards (environment tint background, room name, count). The lobby version is slightly larger than the dashboard card version — it's a card-within-a-page, not a badge-within-a-card.

Props:


interface RoomBadgeProps {
  room: "memory" | "manifestation" | "shaping" | "rendering";
  count: number;
  version?: number | null;
  href?: string;  // present only for built rooms
}

15. Frontend: the Memory room

15.1 Page structure

The Memory room page at /engagement/[id]/memory:

15.2 Assertion display

Each assertion is rendered as a card ({components.card}) containing:

15.3 Held assertions — the person's pending contributions

Assertions in the held state created by the current person appear at the top of the assertion list, visually distinguished (subtle vellum background on the card instead of bleached). Each held assertion shows:

This is per S2: single-assertion commit. Each held assertion has its own commit action.

Held assertions created by other contributors are not shown to the current person in Phase 16. The Memory room shows: the current person's held assertions (for them to commit) + all committed assertions (the shared Memory) + retracted assertions (visible with retraction rationale, per non-erasure). This display logic is a frontend filter on the assertion list response.

15.4 Access control display

Per S4 and the "only show what is available" principle:

The check: does this person's membership include "contributor" or "operator" in designations? If yes, contribution surface appears. If no, it does not.


16. Frontend: the contribution surface

16.1 Mode selector

Three mode buttons at the top of the contribution surface, in a horizontal row:

Buttons use {components.button-ghost} style, with the active mode using a subtle {colors.vellum} background. Mode selection is instant (no animation beyond hover).

16.2 Text mode

A <textarea> with {components.input} styling. Placeholder text: "Share what you know..." (per the methodology's framing of Memory as accumulated knowledge, not formal requirements).

Below the textarea: an "Add to Memory" primary button. Clicking it calls POST /engagements/{eid}/assertions with the text content. The assertion enters the held state. The textarea clears. The held assertion appears at the top of the assertion list (Section 15.3) with Commit and Discard actions.

16.3 Voice mode

Two sub-modes, toggled by a small switch:

Record: A record button (microphone icon, {components.button-primary}). Clicking it starts recording via the browser's MediaRecorder API. The button changes to a stop button (square icon, {colors.iron-oxide} background — this is the amend color, appropriate for a "stop" action). A timer shows elapsed recording time. Clicking stop ends the recording.

After recording stops:

  1. The audio blob is uploaded via POST /engagements/{eid}/files.
  2. The file ID is sent to POST /engagements/{eid}/assertions/contribute-voice.
  3. While transcription is in progress, a loading indicator appears ("Transcribing...").
  4. On success, the transcribed text appears in a card with the label "Transcription" and the assertion is created in the held state. The person can read the transcription, then Commit or Revise before committing.

Upload audio file: A file input that accepts audio formats. The same upload → transcribe → create flow as recording, but with a user-selected file instead of a browser recording.

16.4 PDF mode

A file input that accepts application/pdf. After file selection:

  1. The PDF is uploaded via POST /engagements/{eid}/files.
  2. The file ID is sent to POST /engagements/{eid}/assertions/contribute-pdf.
  3. While extraction is in progress, a loading indicator appears ("Extracting text...").
  4. On success, the extracted text appears in a card with the label "Extracted text" and the assertion is created in the held state.

For long PDF extractions, the text is shown truncated with "Show full text" expansion.

16.5 Error handling in the UI


17. Test infrastructure

17.1 Substrate test fixtures

File upload fixture. A test fixture that creates a temporary audio file and a temporary PDF file for upload tests. The audio file is a minimal valid WebM file (or WAV with silence); the PDF is a minimal valid PDF with extractable text. Both are created in a temporary directory that is cleaned up after the test.

Transcription mock. The transcription skill's API call is mocked in tests. The mock returns a fixed transcription string. Tests do not make real API calls to OpenAI. The mock is injected via dependency override on app.state.openai_api_key and a patched transcribe_audio function.

PDF extraction mock. The extraction skill is not mocked — pdfplumber runs against the test PDF fixture directly. This validates the extraction pipeline end-to-end without external dependencies.

17.2 Frontend test approach

No frontend test framework exists (Residue from Phase 14). Frontend verification is: lint + tsc + build clean, plus manual Operator verification at checkpoints.


18. Acceptance test suite

Projected new test count: approximately 60–80 new substrate tests. Frontend: lint + tsc + build clean.

18.1 Substrate test scenarios

test_file_upload — Upload an audio file and a PDF file to the Loomworks engagement. Verify both return file IDs and correct metadata. Verify the files exist on disk at the expected paths. Verify uploading an unsupported content type returns 415. Verify uploading a file exceeding 50 MB returns 413. Verify uploading without auth returns 401. Verify uploading without membership returns 403.

test_file_retrieval — Upload a file, then retrieve it via GET /engagements/{eid}/files/{fid}. Verify the content matches. Verify retrieving a nonexistent file returns 404.

test_transcription_skill — Mock the OpenAI API. Call transcribe_audio with a test audio file. Verify the returned text matches the mock response. Verify that an API error raises TranscriptionError.

test_pdf_extraction_skill — Create a minimal PDF with known text. Call extract_pdf_text. Verify the returned text contains the expected content. Verify that a PDF with no text raises PDFExtractionError.

test_contribute_text — Create a text-mode assertion on the Loomworks engagement via person-session auth. Verify the assertion is in the held state. Verify metadata.source_mode is "text". Verify grammar_element is "definition" and normative_force is the value sent. Commit the assertion. Verify it transitions to committed with committed_by showing kind="person" and the person's UUID.

test_contribute_voice — Upload an audio file. Call POST /assertions/contribute-voice with the file ID (transcription mocked). Verify the assertion is created in the held state with the mocked transcription as content. Verify metadata.source_mode is "voice" and metadata.source_file_id matches. Verify metadata.transcription_method is "openai-whisper-api".

test_contribute_voice_no_api_key — Remove app.state.openai_api_key. Call the voice contribution endpoint. Verify 503 response.

test_contribute_pdf — Upload a PDF file. Call POST /assertions/contribute-pdf with the file ID. Verify the assertion is created with extracted text. Verify metadata.

test_contribute_pdf_no_text — Upload a PDF with no extractable text (image-only). Verify 422 response.

test_assertion_list — Create several assertions (mixed states). Call GET /engagements/{eid}/assertions. Verify all are returned with correct pagination. Call with state=committed. Verify only committed assertions are returned. Verify total_count is correct.

test_assertion_list_pagination — Create 10 assertions. Fetch with limit=3, offset=0, then limit=3, offset=3. Verify correct subsets and total_count.

test_room_summary — Call GET /engagements/{eid}/room-summary on the Loomworks engagement. Verify Memory assertion count matches (five founding + any test-created). Verify Manifestation/Shaping/Rendering are zero/null if no objects exist.

test_person_assertion_actorref — Create and commit an assertion via person-session auth. Verify the committed_by ActorRef has kind="person" and the correct person UUID and display name.

test_agent_assertion_backward_compat — Create and commit an assertion via agent bearer token auth. Verify the existing Phase 3 flow still works unchanged. Verify the ActorRef has kind="agent" or kind="human-contributor" as before.

test_grammar_element_default — Call POST /engagements/{eid}/assertions without grammar_element. Verify it defaults to "definition". Call with grammar_element="constraint". Verify it is accepted. Verify existing tests that provide grammar_element explicitly still pass.

test_contribution_requires_contributor — Attempt to contribute an assertion as a person without "contributor" designation. Verify 403. This validates S4.


19. Order of operations (steps with checkpoints)

Auto-mode posture: Steps 0–4 auto, Checkpoint A. Steps 5–7 auto, Checkpoint B. Steps 8–12 auto, Checkpoint C (interactive). Step 13 auto, Checkpoint D (final).

Substrate steps

Step 0 — Pre-flight and CR archival.

Archive this CR to docs/phase-crs/phase-16-cr-memory-contribution-ui-v0_1.md. Run pre-flight checks (Section 3.1). Confirm baseline. Create branch phase-16-memory-contribution-ui.

Commit: Phase 16 step 0: CR archival and branch creation.

Step 1 — Schema migration: uploaded_files table.

Create Alembic migration for the uploaded_files table (Section 6.1). Create the data/files/ directory in the migration. Apply migration.

Verification: migration applies cleanly. uv run pytest -v still green (1064 passed).

Commit: Phase 16 step 1: uploaded_files schema migration.

Step 2 — Assertion model update and auth resolution.

Update the Assertion memory object type to include the metadata dict (Section 6.2). Update AddAssertionRequest to make grammar_element default to "definition" and add source_mode and source_file_id fields (Section 4.1). Implement ResolvedActor and get_resolved_actor dependency (Section 7.1). Verify person-session auth works on assertion endpoints — if get_current_contributor does not already handle person auth, update it. Write test_person_assertion_actorref.py, test_agent_assertion_backward_compat.py, test_grammar_element_default.py.

Verification: uv run pytest -v green. Person-session auth creates assertions with correct ActorRef. Agent auth unchanged.

Commit: Phase 16 step 2: assertion model update and auth resolution.

Step 3 — File upload endpoint.

Implement uploaded_files model, file storage utilities, and the upload endpoint (Section 8). Implement the file retrieval endpoint. Write test_file_upload.py and test_file_retrieval.py.

Verification: uv run pytest -v green. Files upload and retrieve correctly.

Commit: Phase 16 step 3: file upload endpoint.

Step 4 — Skills: transcription and PDF extraction.

Install pdfplumber dependency. Implement src/loomworks/skills/transcription.py (Section 9) and src/loomworks/skills/pdf_extraction.py (Section 10). Write test_transcription_skill.py and test_pdf_extraction_skill.py.

Verification: uv run pytest -v green. Transcription mock returns expected text. PDF extraction works on test PDF.

Commit: Phase 16 step 4: transcription and PDF extraction skills.

Checkpoint A — Substrate infrastructure complete. File upload, skills, and auth resolution are working. Operator confirms before contribution endpoints are built.

Step 5 — Assertion list and room summary endpoints.

Implement GET /engagements/{eid}/assertions (Section 11.1) and GET /engagements/{eid}/room-summary (Section 11.2). Write test_assertion_list.py, test_assertion_list_pagination.py, test_room_summary.py.

Verification: uv run pytest -v green.

Commit: Phase 16 step 5: assertion list and room summary endpoints.

Step 6 — Voice contribution endpoint.

Implement POST /engagements/{eid}/assertions/contribute-voice (Section 12.2). Write test_contribute_voice.py and test_contribute_voice_no_api_key.py.

Verification: uv run pytest -v green.

Commit: Phase 16 step 6: voice contribution endpoint.

Step 7 — PDF contribution endpoint.

Implement POST /engagements/{eid}/assertions/contribute-pdf (Section 12.3). Write test_contribute_pdf.py and test_contribute_pdf_no_text.py.

Verification: uv run pytest -v green.

Commit: Phase 16 step 7: PDF contribution endpoint.

Checkpoint B — All substrate endpoints complete. Operator confirms before frontend work begins.

Frontend steps

Step 8 — Room switcher and engagement room layout.

Create RoomSwitcher component (Section 13.1) and EngagementRoomLayout component (Section 13.2). These are reusable components consumed by the Memory room page and future room pages.

Verification: lint + tsc clean. Components render in isolation (visual verification).

Commit (frontend repo): Phase 16 step 8: room switcher and engagement room layout.

Step 9 — Lobby conversion.

Update the engagement overview page (Section 14). Remove inline Memory list, Shapings, and Renders sections. Add room badges. Fetch room summary from the new endpoint.

Verification: lint + tsc clean. Lobby shows seed, designations, and room badges. Memory badge links to the Memory room.

Commit (frontend repo): Phase 16 step 9: lobby conversion.

Step 10 — Memory room page.

Create the Memory room page at /engagement/[id]/memory (Section 15). Assertion list display with provenance. Held assertion display with Commit and Discard actions.

Verification: lint + tsc clean. Memory room shows existing founding assertions for the Loomworks engagement.

Commit (frontend repo): Phase 16 step 10: Memory room page.

Step 11 — Contribution surface: text mode.

Add the contribution surface to the Memory room (Section 16). Text mode first — textarea, "Add to Memory" button, creates held assertion. Single-assertion commit flow.

Verification: lint + tsc clean. Operator can type text, add to Memory, see the held assertion, commit it, and see it in committed Memory.

Commit (frontend repo): Phase 16 step 11: contribution surface text mode.

Step 12 — Contribution surface: voice and PDF modes.

Add voice mode (MediaRecorder recording, audio file upload, transcription display) and PDF mode (file upload, extraction display) to the contribution surface (Sections 16.3, 16.4).

Verification: lint + tsc clean. Operator can record audio, see transcription, commit. Operator can upload a PDF, see extracted text, commit.

Commit (frontend repo): Phase 16 step 12: contribution surface voice and PDF modes.

Checkpoint C — All surfaces built. Operator tests the full contribution flow: text, voice, and PDF modes. Verifies lobby shows correct counts. Verifies room switcher works. This checkpoint is interactive.

Step 13 — Implementation notes and tagging.

Create docs/phase-impl-notes/phase-16-implementation-notes-v0_1.md recording: what Phase 16 built, any findings surfaced during execution, any divergences from this CR.

Verification: file exists and is reviewable.

Commit: Phase 16 step 13: implementation notes.

Checkpoint D — Final. Both repos green. Tag both repos as phase-16-memory-contribution-ui.


20. Acceptance gate

Phase 16 is accepted when:

  1. Substrate: all tests pass (1064 existing + ~70 new ≈ 1134, ±20%). 2 environment-gated skips carried.
  2. Frontend: lint + tsc + build clean.
  3. A signed-in Contributor can open the Loomworks engagement lobby, see room badges, navigate to the Memory room.
  4. The Memory room shows the five founding Memory assertions with provenance.
  5. The Contributor can add a text assertion, see it held, commit it, and see it in committed Memory with committed_by showing their name.
  6. The Contributor can record audio, see the transcription, commit the assertion.
  7. The Contributor can upload a PDF, see the extracted text, commit the assertion.
  8. The lobby shows updated Memory count after new assertions are committed.
  9. The room switcher shows Memory as active with correct count.
  10. Agent auth still works on assertion endpoints (backward compatibility).
  11. All five founding assertions are undisturbed.

On acceptance: tag both repos as phase-16-memory-contribution-ui. Write implementation notes.


21. Post-CR state

New residues anticipated


22. Dependencies and related changes

22.1 New dependencies (substrate)

22.2 Existing dependencies carried forward

All existing dependencies unchanged.

22.3 Frontend dependencies

No new frontend dependencies anticipated. MediaRecorder is a browser-native API.

22.4 Environment variables


23. Kickoff prompt for the Claude Code session

> Read the Phase 16 CR at ~/Downloads/phase-16-cr-memory-contribution-ui-v0_1.md. This is Memory contribution through the UI — file upload, transcription skill (OpenAI Whisper API), PDF extraction skill (pdfplumber), assertion list endpoint, room summary endpoint, voice and PDF contribution endpoints, plus frontend: room switcher, lobby conversion, Memory room page, and three-mode contribution surface. Two repos: substrate (/Users/dunin7/loomworks) and frontend (/Users/dunin7/loomworks-ui). Substrate baseline: tag phase-15-loomworks-universal-commons, 1064 tests. Frontend baseline: same tag. Start with Step 0: archive the CR to docs/phase-crs/, run pre-flight checks, confirm baseline, create the branch.


24. What this CR does not specify (deferred)


25. Changes from prior versions

v0.1 (2026-04-26). First version. Full Phase 16 scope per scoping note v0.1: three-mode assertion contribution (text, voice, PDF), file upload and binary storage, transcription via OpenAI Whisper API, PDF extraction via pdfplumber, assertion list endpoint, room summary endpoint, voice and PDF contribution endpoints, room switcher component, lobby conversion, Memory room page, three-mode contribution surface. One Alembic migration (uploaded_files). Thirteen steps with four checkpoints. Eight drafter notes (N1–N8) resolved. No prior Phase 16 CR.


DUNIN7 — Done In Seven LLC — Miami, Florida Phase 16: Memory Contribution Through the UI — CR-2026-028 — v0.1 — 2026-04-26