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.md → loomworks-design-md-v0_1.md → loomworks-brand-guide-v0_15.html (brand system).
Status. Pre-execution CR. Ready for Operator review and approval.
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.
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:
phase-15-loomworks-universal-commons. 1064 tests, 2 environment-gated skips.phase-15-loomworks-universal-commons. Lint + tsc + build clean.00000000-0000-0000-0000-000000000002, visibility 'automatic', five committed founding Memory assertions, seed v0.4 at seed version 2.OPENAI_API_KEY environment variable configured on M4 for Whisper API access (see Section 9).Before any work begins, CC confirms:
uv run pytest -v shows 1064 passed, 2 skipped.npx next lint && npx tsc --noEmit clean.00000000-0000-0000-0000-000000000002, visibility 'automatic', five committed assertions.OPENAI_API_KEY is set in the environment (or .env file).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.
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:
source_mode — records how the assertion content was produced. "text" for typed input, "voice" for transcribed audio, "pdf" for extracted PDF text. None preserves backward compatibility for API/agent callers. Stored on the Assertion memory object in a new metadata dict.source_file_id — references the uploaded file (audio or PDF) that produced the assertion content. None for typed text. Must reference a valid uploaded file in the uploaded_files table.
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).
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:
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.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)
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.
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.
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:
audio/webm, audio/ogg, audio/mp4, audio/mpeg, audio/wav (browser MediaRecorder produces audio/webm or audio/ogg depending on browser; uploaded files may be other formats).application/pdf.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.
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:
openai-whisper uses the open-source weights which are older.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.
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.
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.
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:
duration-slow with ease-in-out).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.
Resolution: lobby shows seed, designations, and room badges with navigation.
The engagement overview page (/engagement/[id]) becomes the lobby. The conversion:
Retained:
Added:
/engagement/[id]/memory. Other room badges show counts without links (not built yet).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.
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). |
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).
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 {}.
This section specifies how person-session auth integrates with the 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.
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.
Specified in Section 4.3. Implementation details:
# 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
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
Specified in Section 4.4. The transcription skill is called by the contribution endpoint (Section 12) after file upload.
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.
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.
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.
Specified in Section 4.5. The extraction skill is called by the contribution endpoint (Section 12) after file upload.
src/loomworks/skills/pdf_extraction.py. Same skill-vs-agent distinction as transcription.
pip install pdfplumber --break-system-packages (or uv add pdfplumber). No system-level dependencies.
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.
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.
The contribution flow varies by mode but converges on the same outcome: an assertion in the held state with metadata recording the source.
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"}.
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:
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.
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:
Same held-state-then-commit pattern as voice mode.
| 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 |
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:
/engagement/[id]/memory. Shows assertion count.version !== null. Not clickable (room not built). Shows version number.count > 0. Not clickable. Shows count.count > 0. Not clickable. Shows count.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:
{typography.caption}, count in {typography.mono}.duration-fast with ease-out for hover; duration-slow with ease-in-out for active-room background change.
A shared layout component at components/engagement/EngagementRoomLayout.tsx wraps all room pages:
The Memory room page lives at app/engagement/[id]/memory/page.tsx. Server-rendered by default (standing preference). The page component:
GET /engagements/{eid}/room-summary.GET /engagements/{eid}/assertions?state=committed (initial load shows committed assertions).EngagementRoomLayout with RoomSwitcher and the Memory room content.
The engagement overview page at app/engagement/[id]/page.tsx is restructured:
Before (Phase 15):
After (Phase 16):
env-memory tint, "Memory" label, assertion count, "View" link to /engagement/[id]/memory.Removed:
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
}
The Memory room page at /engagement/[id]/memory:
env-memory (#E4E6E8) on the outer frame.{typography.h2}.
Each assertion is rendered as a card ({components.card}) containing:
{typography.body}. Truncated with "Show more" for assertions longer than 300 characters.{typography.caption} with {colors.ink-faint}:committed_by.display_name or the Add event's actor).metadata.source_mode. No indicator for assertions without metadata (pre-Phase 16 assertions).{typography.mono} if version > 1 (indicating the assertion has been revised).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.
Per S4 and the "only show what is available" principle:
"contributor" designation: sees the contribution surface + their held assertions + committed assertions + retracted assertions."operator" designation: same as contributor (Operators can contribute)."contributor" designation (future case — observe-only): sees committed assertions + retracted assertions. No contribution surface. No held assertions (they can't create any).
The check: does this person's membership include "contributor" or "operator" in designations? If yes, contribution surface appears. If no, it does not.
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).
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.
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:
POST /engagements/{eid}/files.POST /engagements/{eid}/assertions/contribute-voice.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.
A file input that accepts application/pdf. After file selection:
POST /engagements/{eid}/files.POST /engagements/{eid}/assertions/contribute-pdf.For long PDF extractions, the text is shown truncated with "Show full text" expansion.
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.
No frontend test framework exists (Residue from Phase 14). Frontend verification is: lint + tsc + build clean, plus manual Operator verification at checkpoints.
Projected new test count: approximately 60–80 new substrate tests. Frontend: lint + tsc + build clean.
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.
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).
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.
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.
Phase 16 is accepted when:
committed_by showing their name.
On acceptance: tag both repos as phase-16-memory-contribution-ui. Write implementation notes.
data/files/ directory with any uploaded audio/PDF files from testing.pdfplumber, httpx (for Whisper API; may already be present via py_webauthn or FastAPI).OPENAI_API_KEY (optional — voice mode only).pdfplumber — PDF text extraction. Pure Python. pip install pdfplumber --break-system-packages or uv add pdfplumber.httpx — async HTTP client for Whisper API calls. May already be present (FastAPI's test client uses it). If not present, pip install httpx --break-system-packages or uv add httpx.All existing dependencies unchanged.
No new frontend dependencies anticipated. MediaRecorder is a browser-native API.
OPENAI_API_KEY — required for voice-mode transcription. Optional: the app starts without it; voice mode returns 503.
> 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.
pdfplumber extracts text from text-layer PDFs only.GET /engagements authentication. Residue 6 carried forward.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