DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/render-download-cr-v0_1.md

Loomworks — Render artifact download — CR

Version. 0.1 Date. 2026-05-02 Provenance. Claude.ai CR drafting session. Operator: Marvin Percival. Status. Working draft. Untagged focused addition on main.


1. What this builds

The Rendering room declares output formats but does not produce files. SHOW CONTENT displays the specialist's output in a panel. The Operator cannot hand anything to anyone — there is no downloadable artifact.

This CR adds render artifact download: a server-side materialization layer that converts render_content to the declared format and serves it as a file. Three materializers ship: PDF (via weasyprint), HTML, and Markdown. A DOWNLOAD button appears on each produced render card whose format has a registered materializer.

This completes the Rendering room's purpose. The room exists to deliver the finished artifact. Without a downloadable file in the declared format, the room is a viewer, not a delivery surface.


2. Architecture

2.1 Content versus materialization

The specialist produces content — what the artifact says. Stored as render_content (JSONB). Materialization produces the file — what the artifact looks like as a deliverable in a specific format. This separation is consistent with executor opacity: the specialist's job is content; format conversion is infrastructure.

2.2 Materializer registry

A mapping from render_format string to a materializer function. The download endpoint looks up the format, calls the materializer, returns the result as a file download. New formats are added by registering a new function — no endpoint changes.


# src/loomworks/rendering/materializers.py

from typing import Callable, NamedTuple

class MaterializedFile(NamedTuple):
    content: bytes
    content_type: str
    filename: str

MaterializerFn = Callable  # async (render_event, engagement_title) -> MaterializedFile

MATERIALIZERS: dict[str, MaterializerFn] = {}

def register_materializer(format_key: str, fn: MaterializerFn) -> None:
    MATERIALIZERS[format_key] = fn

def get_materializer(format_key: str) -> MaterializerFn | None:
    # Case-insensitive lookup
    return MATERIALIZERS.get(format_key.upper())

2.3 Three materializers

PDF materializer. Wraps render_content in a print-optimized HTML template, converts via weasyprint. US Letter, 1-inch margins, Plex Serif headings, Inter body, engagement title in header, production date and provenance in footer, DUNIN7 line at base.

HTML materializer. Wraps render_content in a standalone HTML document with inline CSS. Same styling as the PDF template but served as .html. Useful for browser viewing without PDF conversion overhead.

Markdown materializer. Extracts text content from render_content and serves as .md. If render_content is a dict with a text/content field, extracts it. Minimal transformation — the content is already structured text from the specialist.

All three are registered at application startup.

2.4 On-demand generation

Materialized files are generated on each download request. No caching, no stored binaries. The materialization is deterministic given the same render_content + template. Weasyprint on a 28K-char document is sub-second. If download volume grows, caching is a straightforward future addition.


3. Substrate changes

3.1 Dependency: weasyprint

Add weasyprint to the project's Python dependencies.


uv add weasyprint

Weasyprint requires system-level packages: cairo, pango, gdk-pixbuf. On macOS (DUNIN7-M4):


brew install cairo pango gdk-pixbuf libffi

Pre-flight verification: after install, confirm python -c "import weasyprint; print(weasyprint.__version__)" succeeds.

3.2 Materializer module

Create src/loomworks/rendering/materializers.py containing:

Content extraction logic. render_content is stored as JSONB (dict). The specialist's content may be under a key like "content", "text", "html", or the dict itself may be the content structure. The extraction helper should:

  1. If render_content is a dict with a single string value, use that value.
  2. If render_content is a dict with a "content" key, use that.
  3. If render_content is a dict with an "html" key, use that.
  4. Otherwise, serialize the dict to formatted JSON as a fallback.

CC should inspect the actual render_content structure on the Goosey engagement's produced renders to determine the real key layout, and adjust the extraction accordingly.

3.3 PDF template

The HTML template wrapped around content before weasyprint conversion. Inline CSS (no external stylesheets — weasyprint processes the entire document in one pass).


<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Serif:wght@400;600&family=Inter:wght@400;500;600&display=swap');

  @page {
    size: letter;
    margin: 1in;
    @top-center {
      content: "ENGAGEMENT_TITLE — RENDER_TYPE_NAME";
      font-family: 'Inter', sans-serif;
      font-size: 8pt;
      color: #888;
    }
    @bottom-left {
      content: "Produced PRODUCTION_DATE";
      font-family: 'Inter', sans-serif;
      font-size: 7pt;
      color: #999;
    }
    @bottom-right {
      content: "Page " counter(page) " of " counter(pages);
      font-family: 'Inter', sans-serif;
      font-size: 7pt;
      color: #999;
    }
  }

  body {
    font-family: 'Inter', sans-serif;
    font-size: 11pt;
    line-height: 1.5;
    color: #2A2520;
  }

  h1, h2, h3 {
    font-family: 'IBM Plex Serif', serif;
    font-weight: 600;
    color: #2A2520;
  }

  h1 { font-size: 20pt; margin-top: 0; }
  h2 { font-size: 16pt; }
  h3 { font-size: 13pt; }

  ul, ol { padding-left: 1.5em; }
  li { margin-bottom: 0.3em; }

  .lw-footer {
    margin-top: 2em;
    padding-top: 0.5em;
    border-top: 1px solid #ccc;
    font-size: 8pt;
    color: #999;
    text-align: center;
  }
</style>
</head>
<body>
  CONTENT_PLACEHOLDER
  <div class="lw-footer">DUNIN7 — Done In Seven LLC — Miami, Florida</div>
</body>
</html>

The template is a Python string with placeholder substitution — not Jinja. The placeholders (ENGAGEMENT_TITLE, RENDER_TYPE_NAME, PRODUCTION_DATE, CONTENT_PLACEHOLDER) are replaced by the materializer before passing to weasyprint.

Font loading note. Weasyprint loads fonts via fontconfig. If Google Fonts are not available on the system, weasyprint falls back to system serif/sans-serif. This is acceptable — the template declares the preferred fonts; the output degrades gracefully. CC should verify on DUNIN7-M4 that the fonts render. If not, install them locally:


# If Google Fonts don't load via URL in weasyprint
brew install --cask font-ibm-plex-serif font-inter

3.4 Download endpoint

Add to the renders router (src/loomworks/api/routers/renders.py):


GET /engagements/{eid}/renders/{render_id}/download
    Query params: format (optional) — override the declared format
    Returns: binary file with Content-Disposition: attachment
    Authorization: engagement member (any designation)
    Errors:
      - 404 if render not found or wrong engagement
      - 422 if format has no registered materializer
      - 422 if render is in retired or invalidated state

Implementation:

  1. Look up the render from render_events_view by object_id and engagement_id.
  2. Reject if state is retired or invalidated (422 with message).
  3. Look up the engagement title.
  4. Look up the DeclaredRenderType name (for the filename and header).
  5. Determine format: query param override, or render_format from the render.
  6. Look up materializer via get_materializer(format).
  7. If no materializer, return 422 with supported formats list.
  8. Call the materializer with the render event and engagement title.
  9. Return Response(content=result.content, media_type=result.content_type, headers={"Content-Disposition": f'attachment; filename="{result.filename}"'}).

3.5 Available formats endpoint

Add a lightweight endpoint:


GET /engagements/{eid}/renders/supported-formats
    Returns: { "formats": ["PDF", "HTML", "MD"] }
    Authorization: engagement member

The frontend uses this to gate the DOWNLOAD button. This keeps the frontend decoupled from which materializers are registered — new formats appear automatically.

3.6 Tests

Write tests/test_render_download.py:

  1. test_download_pdf_produced_render — create a render with known content, call download endpoint with format=PDF, verify 200 with application/pdf content type and non-empty body.
  2. test_download_html_produced_render — same, format=HTML, verify text/html content type and content contains the template wrapper.
  3. test_download_md_produced_render — same, format=MD, verify text/markdown content type.
  4. test_download_retired_render_rejected — retire a render, call download, verify 422.
  5. test_download_invalidated_render_rejected — invalidate a render, call download, verify 422.
  6. test_download_unsupported_format — call with format=DOCX (not registered), verify 422 with supported formats in response.
  7. test_download_render_not_found — wrong render_id, verify 404.
  8. test_download_wrong_engagement — render belongs to engagement A, request via engagement B, verify 404.
  9. test_filename_slugification — verify the filename is correctly slugified from engagement title and render type name.
  10. test_supported_formats_endpoint — verify the supported-formats endpoint returns the three registered formats.

10 tests.


4. Frontend changes

4.1 Download button on render cards

In the Render row component (inside expanded DeclaredRenderType cards), add a DOWNLOAD button next to SHOW CONTENT and RETIRE.

Visibility rule: DOWNLOAD appears only when the render's format has a registered materializer. On component mount (or on Rendering room load), fetch GET /engagements/{eid}/renders/supported-formats and store the list. The button renders only if supportedFormats.includes(render.render_format.toUpperCase()).

Button style: secondary button (2px border, type-metal border on cartridge ground per Phase 23/27 contextual color rules). Label: DOWNLOAD. Positioned before RETIRE in the button row.

Behavior: on click, construct the download URL GET /engagements/{eid}/renders/${renderId}/download and trigger a browser download. Use an anchor element with download attribute, or window.open() to the URL. The browser handles the file save dialog.

Loading state: while the download is in flight (PDF generation may take a moment), the button shows "Downloading..." and is disabled. On completion or error, it resets. Errors display inline on the render row.

4.2 Component tests

Add to / update the Rendering room tests:

  1. test_download_button_appears_for_supported_format — mock supported-formats response with ["PDF"], render a produced render card with format "PDF", verify DOWNLOAD button is present.
  2. test_download_button_hidden_for_unsupported_format — mock supported-formats response with ["PDF"], render a card with format "DOCX", verify no DOWNLOAD button.
  3. test_download_button_hidden_for_retired_render — render a retired render, verify no DOWNLOAD button (even if format is supported).

3 tests.


5. Order of operations

Auto-mode posture: Steps 0–4 auto, Checkpoint A at the end.

Step 0 — System dependency verification.

Verify weasyprint system dependencies on DUNIN7-M4:


brew install cairo pango gdk-pixbuf libffi
uv add weasyprint
python -c "import weasyprint; print(weasyprint.__version__)"

If the import fails, resolve before proceeding.

Inspect the Goosey engagement's produced render(s) to determine the actual render_content key structure:


uv run python -c "
import asyncio
from loomworks.db import get_session
from sqlalchemy import text

async def check():
    async for db in get_session():
        result = await db.execute(text(
            'SELECT object_id, render_format, render_content '
            'FROM render_events_view LIMIT 3'
        ))
        for row in result:
            print(f'ID: {row[0]}')
            print(f'Format: {row[1]}')
            content = row[2]
            if isinstance(content, dict):
                print(f'Keys: {list(content.keys())}')
                for k, v in content.items():
                    print(f'  {k}: {type(v).__name__} len={len(str(v))}')
            print()

asyncio.run(check())
"

Record the key structure. Use it to implement the content extraction helper in Step 1.

Commit: Render download step 0: weasyprint dependency and content inspection.

Step 1 — Materializer module and PDF template.

Create src/loomworks/rendering/materializers.py per §3.2. Create the PDF template per §3.3. Implement all three materializers (PDF, HTML, MD). Implement register_all_materializers(). Call it from application startup (in main.py or the app factory).

Verify PDF generation works standalone:


uv run python -c "
import weasyprint
html = '<html><body><h1>Test</h1><p>Hello world</p></body></html>'
pdf = weasyprint.HTML(string=html).write_pdf()
with open('/tmp/test-render.pdf', 'wb') as f:
    f.write(pdf)
print(f'PDF generated: {len(pdf)} bytes')
"

Open /tmp/test-render.pdf and verify it renders correctly.

Commit: Render download step 1: materializer registry and three materializers.

Step 2 — Download endpoint and supported-formats endpoint.

Add the two endpoints to src/loomworks/api/routers/renders.py per §3.4 and §3.5. Register the materializers at app startup if not already done in Step 1.

Commit: Render download step 2: download and supported-formats endpoints.

Step 3 — Substrate tests.

Write tests/test_render_download.py per §3.6. Run full test suite.

Commit: Render download step 3: download endpoint tests.

Step 4 — Frontend: DOWNLOAD button.

Implement per §4.1. Add component tests per §4.2. Run lint + tsc + build + test clean.

Commit (frontend repo): Render download step 4: download button on render cards.

Checkpoint A — Final. Both repos green. Operator tests PDF download on Goosey. Opens the PDF and confirms it looks right.


6. Acceptance gate

This CR is accepted when:

  1. Substrate: all tests pass (~1260+, 2 skips).
  2. Frontend: lint + tsc + build + test clean.
  3. A produced render in the Goosey engagement shows a DOWNLOAD button.
  4. Clicking DOWNLOAD produces a PDF file that opens correctly.
  5. The PDF shows: engagement title in header, content with proper typography, page numbers, DUNIN7 footer.
  6. HTML download works (change format or use query param override).
  7. MD download works.
  8. Retired/invalidated renders do not show a DOWNLOAD button.
  9. The download endpoint returns 422 for unsupported formats with a list of supported ones.

7. What this CR does not build


8. Kickoff prompt for the Claude Code session


Read the Change Request document at the path I supply below. This CR
adds render artifact download to the Rendering room — the capability
that completes the room's purpose.

CR path: ~/Downloads/render-download-cr-v0_1.md

Three materializers: PDF (weasyprint), HTML, and Markdown. Server-side
generation, on-demand. A DOWNLOAD button on each produced render card.

Step 0: install weasyprint system deps, add the Python package, inspect
the actual render_content structure on Goosey's renders.

Steps 0–4 auto, Checkpoint A halts for Operator to test the PDF.

Key points:
  - Materializer registry pattern — new formats added by registering
    a function, no endpoint changes needed.
  - PDF template: US Letter, 1-inch margins, Plex Serif headings,
    Inter body, engagement title header, DUNIN7 footer.
  - DOWNLOAD button gated on supported-formats endpoint response.
  - Retired/invalidated renders: no download.
  - Content extraction: inspect actual render_content keys at Step 0
    before implementing the extraction helper.

Substrate baseline: ~1250 tests, 2 skips.
Frontend baseline: 217+ vitest, lint + tsc + build + test clean.

DUNIN7 — Done In Seven LLC — Miami, Florida Render artifact download — CR v0.1 — 2026-05-02