DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/phase-35-render-content-kinds/phase-35-cr-render-content-kinds-v0_2.md

Loomworks — Phase 35: Render content kind and external reference support — CR

Version. 0.2 Date. 2026-05-04 CR number. CR-2026-047 Provenance. Claude.ai CR drafting session, revised after CC pre-flight audit (phase-35-cr-audit-v0_1.md). Operator: Marvin Percival. Status. Working draft. Awaiting Operator approval. Strategy document. loomworks-engine-implementation-strategy-v0_2.md §5. Sits alongside. Operator Layer v0.6, methodology v0.20. Engine-Phase mapping. Substrate Phase 35 = engine-Phase-2 per the strategy doc. Lane A (specialist plumbing). Independent of Phase 34's substrate; can ship in parallel against engine capacity. Supersedes. v0.1 (same date) — addresses 9 blockers, 9 recommended changes, and 7 non-blocking findings from CC's pre-flight audit. Major substantive changes: §3.4 and §2 S5 reframed against the actual current materializer registry (the May-2 work shipped most of what v0.1 framed as "Phase 35 signature change"; Phase 35 actually adds content-kind-level dispatch on top of the existing registry, with no signature change); settings.loomworks_data_dir (correct name) replaces settings.data_directory; /content recharacterized as a new endpoint rather than an extension; MultiFileManifestEntry as a Pydantic model with filename uniqueness validator; storage paths gain version disambiguation for Phase 36 readiness; projector defaulting for pre-Phase-35 events; /files/{filename} traversal safety; Pydantic v2 model_validator(mode="after") named explicitly.


1. What this builds

Today RenderEvent.render_content is JSONB (a dict | bytes union per Phase 34's binary-specialist support; the bytes arm is vestigial after Phase 35 — see §3.1). Every render must shove its content into the dict, regardless of whether the content is naturally dict-shaped (a structured spec), binary (an STL file, a generated image), or a reference (a deployment URL). The May 2 materializer registry produces bytes from RenderEvent for download; that registry is good but it's downstream of the actual gap, which is on the render-event side: there's no discriminator for what kind of content storage backs the render.

This CR extends RenderEvent with a content_kind discriminator and four content-storage shapes:

The materializer registry gains a content-kind-level dispatch wrapper around its existing format-keyed lookup. Three new default materializers (binary_blob, external_reference, multi_file) live at the new outer level; existing format-keyed materializers (PDF, HTML, MD) stay unchanged as the inline_dict inner registry. New API endpoints surface non-inline content (/content for content-kind-aware retrieval; /files/{filename} for multi-file constituent lookup); the existing /download endpoint extends to handle binary, external, and multi-file content.

This is the prerequisite for Arc 6 Phases B (application-rendering, where the artifact is a deployed system + URL), C (3D printing, where the artifact is an STL file), and any Render-side work involving generated images or video. It's also the prerequisite for Phase 36 (incremental re-render) — an incremental re-render only makes sense if the engine knows what kind of artifact it's incrementing.


2. Strategy decisions consumed

From engine implementation strategy v0.2 §5, with adjustments per the v0.1 → v0.2 audit:

S1 — Four content kinds, not two. The strategy doc names binary_blob and external_reference as the two new kinds beyond inline_dict. v0.2 (carrying v0.1's choice) adds multi_file as a fourth, because the strategy doc's §5.4 explicitly carries it as a content kind needing first-class support. A website is many files; a 3D-printed package is STL plus assembly instructions. Multi-file is not a future-work item; it ships in Phase 35.

S2 — File storage convention. Following Phase 16's pattern: data/renders/{engagement_id}/{render_event_object_id}-v{version}{ext}. The -v{version} suffix disambiguates re-renders of the same MemoryObject under future amendment scenarios (Phase 36); the cost is one path token. Path stored is relative to the data directory (anchored on settings.loomworks_data_dir) so backup-and-restore works correctly. Binary content survives the database round-trip because the bytes live on disk, not in the JSONB column.

S3 — File cleanup posture. Bytes are kept for audit. A retired or invalidated render's content is still referenceable. Hard-delete pathway for binary blobs is future work, not Phase 35. (See §9 for the explicit interaction with Phase 31's hard-delete-engagement.)

S4 — Backfill posture. Existing renders get content_kind="inline_dict" (the value implicit in their current shape). No content migration; only schema-level backfill of the discriminator column. The projector reads content_kind from payload.get("content_kind", "inline_dict") — defaulting to inline_dict when the key is absent in pre-Phase-35 event payloads (see B14 / §3.4).

S5 — Content-kind dispatch in the registry, not a materializer-signature change. Reframed in v0.2 against the audit. The May-2 render-download work already shipped the materializer signature ((RenderEventLike, str, str, *, str | None) → MaterializedFile). What's actually new in Phase 35 is a content-kind-level dispatch layer that sits above the existing format-keyed registry. Existing materializers (PDF, HTML, MD) stay at the inner level handling inline_dict. Three new default materializers handle binary_blob, external_reference, and multi_file at the outer level. No signature change. (See §3.4 for the dispatch shape.)

S6 — Sync materializers throughout. New in v0.2 (resolves audit B5.) All three new defaults are sync (file read for binary_blob, JSON envelope construction for external_reference, zip assembly for multi_file). None of the work benefits from async. Materializer signature stays sync, matching all current materializers and all three new defaults.


3. Substrate changes

3.1 RenderEvent extension

RenderEvent (verified at src/loomworks/engagement/types.py:1150) gains five new optional fields. Cross-field validation runs at construction via @model_validator(mode="after") (Pydantic v2 hook; RenderEvent is BaseModel, not a dataclass).


class RenderEvent(MemoryObject):
    # ... existing fields unchanged ...

    # New in Phase 35:
    content_kind: Literal["inline_dict", "binary_blob", "external_reference", "multi_file"] = "inline_dict"

    # When content_kind = "inline_dict": render_content carries the dict (existing behavior)
    # When content_kind = "binary_blob": render_content carries metadata; storage_path points at bytes
    # When content_kind = "external_reference": render_content carries metadata; reference_uri / reference_metadata carry the link + context
    # When content_kind = "multi_file": render_content carries the manifest summary; multi_file_manifest lists the files

    storage_path: str | None = None
    """For binary_blob: path under data/renders/{engagement_id}/, relative to settings.loomworks_data_dir.
    Format: "{render_event_object_id}-v{version}{ext}" — version suffix disambiguates Phase 36 amendments."""

    reference_uri: str | None = None
    """For external_reference: the URL or URI of the externally-stored artifact (e.g. deployment URL, cloud storage URI)."""

    reference_metadata: dict | None = None
    """For external_reference: ancillary context (deployment timestamp, version, deployment ID, etc.). Specialist-defined; engine treats as opaque."""

    multi_file_manifest: list["MultiFileManifestEntry"] | None = None
    """For multi_file: list of file descriptors. See MultiFileManifestEntry below."""

    @model_validator(mode="after")
    def _validate_content_kind_combination(self) -> "RenderEvent":
        """Cross-field validation: each content_kind requires its associated
        storage field(s) and forbids the others. Raises RenderContentValidationError
        at construction if the combination is invalid."""
        ...  # see §3.1.1 for the validation table

render_content deprecation note. Phase 34 added bytes to the render_content: dict | bytes union for binary-specialist support (engagement/types.py:1214). After Phase 35, the bytes arm is vestigial — every Phase 35 content kind uses dict for render_content (carrying metadata, not bytes), with bytes living at storage_path. v0.2 narrows the type annotation to dict for new code and adds an inline note marking the bytes arm as deprecated and reserved for any pre-Phase-35 callers that may still construct bytes-shaped content.

3.1.1 Validation rules

Cross-field validation table (enforced by @model_validator(mode="after")):

| content_kind | Required fields | Forbidden fields | |---|---|---| | inline_dict | (none new) | storage_path, reference_uri, reference_metadata, multi_file_manifest | | binary_blob | storage_path | reference_uri, reference_metadata, multi_file_manifest | | external_reference | reference_uri | storage_path, multi_file_manifest. reference_metadata is optional. | | multi_file | multi_file_manifest (non-empty), and entry filenames are unique | storage_path, reference_uri, reference_metadata |

Filename uniqueness for multi_file enforced as: len({e.filename for e in multi_file_manifest}) == len(multi_file_manifest).

Invalid combinations raise RenderContentValidationError(ValueError) defined in src/loomworks/engagement/types.py alongside RenderEvent. The validator runs at construction time (Pydantic semantics) — a specialist that constructs an invalid RenderEvent and tries to append it gets the validation error before the append; the append never sees an invalid object.

3.2 Storage convention for binary blobs

Phase 16 established the file-storage pattern at data/files/{engagement_id}/{file_id}{ext}. Phase 35 mirrors it for renders at:


data/renders/{engagement_id}/{render_event_object_id}-v{version}{ext}

The -v{version} suffix is the audit-driven addition (B16) that disambiguates re-renders of the same MemoryObject in Phase 36 amendment scenarios. For Phase 35 alone, every render is v1 so the suffix reads as -v1; Phase 36 ships when the version increments matter.

The storage_path field carries the path relative to the data directory, not absolute. The data directory root is read from settings.loomworks_data_dir (verified at src/loomworks/config.py:34–35; the loomworks_ prefix carries the project namespace, consistent with Phase 16's files/storage.py:resolve_data_dir).

A small storage helper module (src/loomworks/engagement/render_storage.py) wraps the read/write operations. The helpers reuse Phase 16's resolve_data_dir (lifted from files/storage.py to a shared location, or imported from there directly — pre-flight confirms the cleanest factoring):

3.3 Multi-file render manifest shape

Each manifest entry is a frozen Pydantic model defined alongside RenderEvent:


class MultiFileManifestEntry(BaseModel):
    model_config = ConfigDict(frozen=True)

    filename: str                                                 # manifest key (see §3.5 lookup discipline)
    content_kind: Literal["binary_blob", "external_reference"]   # narrowed: no inline_dict, no recursive multi_file
    storage_path: str | None = None                              # populated for binary_blob entries
    reference_uri: str | None = None                             # populated for external_reference entries
    content_type: str                                            # MIME type
    size_bytes: int | None = None                                # optional; helpful for download progress

    @model_validator(mode="after")
    def _validate_entry(self) -> "MultiFileManifestEntry":
        """Per-entry validation: kind requires its storage field; filename
        is a manifest key, not a path (no '/', '\\', or '..' allowed)."""
        # filename safety: defense in depth for §3.5's traversal-safety contract
        if "/" in self.filename or "\\" in self.filename or ".." in self.filename:
            raise RenderContentValidationError(
                f"Manifest filename {self.filename!r} contains path separators or '..'; "
                f"filenames must be plain names, not paths"
            )
        # storage field requirement
        if self.content_kind == "binary_blob" and self.storage_path is None:
            raise RenderContentValidationError(
                "binary_blob manifest entry requires storage_path"
            )
        if self.content_kind == "external_reference" and self.reference_uri is None:
            raise RenderContentValidationError(
                "external_reference manifest entry requires reference_uri"
            )
        return self

Per-entry validation runs automatically at parent RenderEvent construction (Pydantic validates list items). The audit's B17 nit — "co-locate manifest entry kind constraints with the parent RenderEvent validator" — is addressed by both validators raising the same exception (RenderContentValidationError) so reading either validator surfaces the broader rule shape.

3.4 Materializer registry — content-kind-level dispatch

Reframed from v0.1 against the actual current registry.

The current registry (verified at src/loomworks/rendering/materializers.py:76+) has the following shape:


class MaterializedFile(NamedTuple):
    """Result of a materializer call. Existing definition, unchanged in Phase 35."""
    content: bytes
    content_type: str
    filename: str

MaterializerFn = Callable[..., "MaterializedFile"]
"""Materializer signature: (render_event, engagement_title, render_type_name,
*, source_shape_title=None) -> MaterializedFile.
"""

Existing format-keyed registry (PDF, HTML, MD) maps render_formatMaterializerFn. Each materializer takes RenderEventLike and the title kwargs needed for filename and template construction.

What Phase 35 adds: a content-kind-level dispatch wrapper around the existing registry. The shape is two-level:


# Outer dispatch: by content_kind.
# - inline_dict → fall through to the existing format-keyed inner registry
# - binary_blob → materialize_binary_blob (new default)
# - external_reference → materialize_external_reference (new default)
# - multi_file → materialize_multi_file (new default; assembles zip via the inner registry)

def materialize_render(
    render_event: RenderEventLike,
    engagement_title: str,
    render_type_name: str,
    *,
    source_shape_title: str | None = None,
) -> MaterializedFile:
    """Top-level materializer entry point. Dispatches by content_kind first;
    for inline_dict, falls through to the format-keyed inner registry."""
    if render_event.content_kind == "inline_dict":
        # Existing pathway, unchanged.
        return _format_keyed_registry[render_event.render_format](
            render_event, engagement_title, render_type_name,
            source_shape_title=source_shape_title,
        )
    elif render_event.content_kind == "binary_blob":
        return materialize_binary_blob(
            render_event, engagement_title, render_type_name,
            source_shape_title=source_shape_title,
        )
    elif render_event.content_kind == "external_reference":
        return materialize_external_reference(
            render_event, engagement_title, render_type_name,
            source_shape_title=source_shape_title,
        )
    elif render_event.content_kind == "multi_file":
        return materialize_multi_file(
            render_event, engagement_title, render_type_name,
            source_shape_title=source_shape_title,
        )

No signature change. Every materializer (existing and new) takes the same (RenderEventLike, str, str, *, str | None) → MaterializedFile signature. The content-kind dispatch lives at the outer wrapper, not inside the materializers.

Three new default materializers (each fits the existing signature):

Registry data structure. A small extension to materializers.py: a top-level _CONTENT_KIND_DISPATCH dict keyed by content_kind, holding the four entries above (with inline_dict mapped to the format-keyed-registry-lookup function). The download pathway calls materialize_render(...) instead of _format_keyed_registry[...] directly.

3.5 API endpoint changes

Reframed from v0.1: /content is a new endpoint, not an extension.

GET /engagements/{eid}/renders/{rid}/contentnew endpoint. Returns content-kind-aware retrieval of render content (distinct from /download, which always returns a downloadable artifact).

GET /engagements/{eid}/renders/{rid}/files/{filename}new endpoint. For multi_file renders, retrieves one named constituent from the manifest.

Lookup mechanism (load-bearing for traversal safety per audit B15): the handler iterates render_event.multi_file_manifest, finds the entry whose entry.filename equals the URL path component (string equality, no path arithmetic), and serves the manifest entry's storage_path (for binary_blob) or returns the JSON envelope (for external_reference). Path arithmetic on the URL parameter is forbidden — the URL parameter is a manifest key, not a filesystem path.

Per-entry filename safety is enforced at construction via MultiFileManifestEntry's validator (no /, \, or .. in filenames). Endpoint-side defense in depth: if a URL parameter contains any of these sequences, return 400 immediately rather than scanning the manifest.

Returns 404 if the URL filename doesn't match any manifest entry.

GET /engagements/{eid}/renders/{rid}/downloadextended (existing endpoint). New behavior:

The download pathway's existing call to _format_keyed_registry[...] is replaced with a call to the new materialize_render(...) top-level dispatcher (§3.4). The signature remains unchanged from the materializer side; the change is local to the download handler.

3.6 Multi-file zip assembly helper

The zip-bundling logic lives in materialize_multi_file (§3.4), implemented with Python stdlib zipfile. Pseudocode:


def materialize_multi_file(render_event, engagement_title, render_type_name, *, source_shape_title=None):
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        # Write a top-level manifest.json describing the full manifest
        manifest_json = json.dumps([entry.model_dump(mode="json") for entry in render_event.multi_file_manifest], indent=2)
        zf.writestr("manifest.json", manifest_json)

        # Write binary_blob entries as their named files
        for entry in render_event.multi_file_manifest:
            if entry.content_kind == "binary_blob":
                content = read_render_blob(storage_path=entry.storage_path)
                zf.writestr(entry.filename, content)
            # external_reference entries are listed in manifest.json but not included as zip members

    return MaterializedFile(
        content=buf.getvalue(),
        content_type="application/zip",
        filename=_build_filename(
            engagement_title=engagement_title,
            render_type_name=render_type_name,
            source_shape_title=source_shape_title,
            extension=".zip",
        ),
    )

The manifest.json shape (a JSON list of full MultiFileManifestEntry records) lets a consumer reconstruct the full multi-file render from the zip alone — bytes-on-disk files are present as zip members; external references are in the manifest with their URIs.


4. Migrations

The next available migration number is 0053 (highest applied at v0.25 commit was 0052 from Phase 34). Pre-flight confirms whether intermediate work has landed migrations between v0.25 commit and execution start; renumber accordingly if so.

4.1 Migration 0053 — render_events_view content_kind columns

Adds the new columns to the operational view:


# 0053_phase_35_render_event_content_kind.py

def upgrade():
    # Add content_kind discriminator with default 'inline_dict' for backfill.
    op.add_column(
        "render_events_view",
        sa.Column(
            "content_kind",
            sa.String(32),
            sa.CheckConstraint(
                "content_kind IN ('inline_dict', 'binary_blob', 'external_reference', 'multi_file')",
                name="ck_render_events_view_content_kind",
            ),
            nullable=False,
            server_default=sa.text("'inline_dict'"),
        ),
    )

    # Add content-storage fields. All optional.
    op.add_column(
        "render_events_view",
        sa.Column("storage_path", sa.Text(), nullable=True),
    )
    op.add_column(
        "render_events_view",
        sa.Column("reference_uri", sa.Text(), nullable=True),
    )
    op.add_column(
        "render_events_view",
        sa.Column("reference_metadata", postgresql.JSONB(), nullable=True),
    )
    op.add_column(
        "render_events_view",
        sa.Column("multi_file_manifest", postgresql.JSONB(), nullable=True),
    )

    # No (engagement_id, content_kind) index in v0.2 — no committed query
    # currently filters by content_kind. Add when a query needs it (audit B19).

def downgrade():
    op.drop_column("render_events_view", "multi_file_manifest")
    op.drop_column("render_events_view", "reference_metadata")
    op.drop_column("render_events_view", "reference_uri")
    op.drop_column("render_events_view", "storage_path")
    op.drop_constraint("ck_render_events_view_content_kind", "render_events_view", type_="check")
    op.drop_column("render_events_view", "content_kind")

Lesson 1 verification (column-width). content_kind admissible values: inline_dict (11), binary_blob (11), external_reference (18), multi_file (10). Longest: 18. Column proposed: VARCHAR(32). 18 ≤ 32 ✓ PASS. (Style note: existing render_events_view columns of similar nature use sa.Text() unbounded; the explicit VARCHAR(32) here is a small departure justified by the Literal-bounded value set. Either choice works; VARCHAR(32) is the v0.2 choice.)

Pre-flight items the audit resolved that v0.2 inherits:

4.2 Projector defaulting

New section in v0.2, addressing audit B14.

memory_events.payload JSONB for pre-Phase-35 RenderEvents has no content_kind field. When apply_event_to_render_events_view runs (or runs in rebuild_render_events_view recovery mode), it must read new fields via payload.get(field, default) rather than indexed access:


# In apply_event_to_render_events_view (and rebuild_render_events_view):
content_kind = payload.get("content_kind", "inline_dict")
storage_path = payload.get("storage_path")  # None for legacy
reference_uri = payload.get("reference_uri")  # None for legacy
reference_metadata = payload.get("reference_metadata")  # None for legacy
multi_file_manifest = payload.get("multi_file_manifest")  # None for legacy

Indexed access (payload["content_kind"]) would raise KeyError for pre-Phase-35 events at projection time. v0.2 calls this rule out explicitly so the projector implementation honors it.

The server_default=sa.text("'inline_dict'") in the migration handles the column-level default for rows present at migration time. The payload.get(...) defaulting in the projector handles event-replay cases for the same legacy data.

4.3 No second migration

The new event kinds for tracking content_kind transitions don't exist; renders are produced once with their content kind, no later mutation. No CHECK constraint extension on memory_events.event_kind. No separate migration for projector-extension wiring (that's code work in apply_event_to_render_events_view per the existing pattern).


5. Substrate tests

New test file tests/test_render_content_kinds.py. Fifteen tests across construction validation, persistence, materializer dispatch, API endpoint behavior, and backwards compatibility.

5.1 Construction validation

  1. test_inline_dict_render_event_constructs_with_default — A RenderEvent constructed with only existing fields gets content_kind="inline_dict" by default and all new fields are None.
  2. test_binary_blob_requires_storage_path — Constructing with content_kind="binary_blob" and no storage_path raises RenderContentValidationError.
  3. test_external_reference_requires_uri — Constructing with content_kind="external_reference" and no reference_uri raises RenderContentValidationError.
  4. test_multi_file_requires_non_empty_manifest — Constructing with content_kind="multi_file" and either missing or empty multi_file_manifest raises RenderContentValidationError.
  5. test_multi_file_filename_uniqueness — Constructing with content_kind="multi_file" and two manifest entries sharing a filename raises RenderContentValidationError.
  6. test_invalid_combinations_raise — A RenderEvent with content_kind="binary_blob" and a non-None reference_uri (or any other invalid combination from §3.1.1's table) raises RenderContentValidationError.
  7. test_manifest_entry_filename_safety — A MultiFileManifestEntry with filename containing /, \, or .. raises RenderContentValidationError at construction.

5.2 Persistence

  1. test_binary_blob_render_round_trips — Construct a binary_blob RenderEvent with bytes-on-disk; persist; retrieve; verify storage_path and the bytes-on-disk both survive.
  2. test_external_reference_render_round_trips — Same pattern for external_reference; verify URI and metadata survive.
  3. test_multi_file_render_manifest_round_trips — Same pattern for multi_file; verify the manifest entries (Pydantic models) survive correctly through the JSONB round-trip.

5.3 Materializer dispatch

  1. test_binary_blob_materializer_returns_bytes_from_storage_path — A binary_blob RenderEvent with bytes-on-disk materializes through materialize_render to those bytes; content_type and filename match the materializer's contract.
  2. test_external_reference_materializer_returns_envelope — The default external_reference materializer returns a JSON envelope with the URI and metadata.
  3. test_multi_file_materializer_produces_zip — A multi_file RenderEvent with two binary_blob entries and one external_reference entry materializes to a zip containing: the two binary blob files, plus a top-level manifest.json listing all three entries (the external_reference entry is in the manifest but not as a zip member).

5.4 API endpoints

  1. test_content_endpoint_dispatches_by_kind — The new /renders/{rid}/content endpoint returns appropriate response shapes for each of the four kinds.
  2. test_files_endpoint_traversal_safety — The /renders/{rid}/files/{filename} endpoint rejects URL parameters containing /, \, or .. with HTTP 400. Also: returns 404 for filenames not in the manifest; returns the right content for valid manifest filenames.
  3. test_download_endpoint_returns_zip_for_multi_file — The /renders/{rid}/download endpoint produces a zip for multi_file renders matching test_multi_file_materializer_produces_zip's shape.

5.5 Backwards compatibility

  1. test_existing_inline_dict_renders_unchanged_via_raw_payload — Insert a pre-Phase-35-shape RenderEvent payload directly via raw SQL into memory_events (no content_kind key in the JSONB). Run rebuild_render_events_view. Assert the resulting view row has content_kind = 'inline_dict', all new fields are NULL, and the existing materialization pathway (/download) returns the same bytes as before Phase 35. Tests the actual production pathway via the projector defaulting (§4.2).

(Test count: 17, slightly above v0.1's enumeration. Acceptance gate baseline: 1,296 + 17 = ~1,313 expected; pre-flight confirms.)


6. Order of operations

Auto-mode posture. Step 0 auto. Steps 1–4 auto, Checkpoint A halts for substrate verification. Steps 5–7 auto, Checkpoint B halts for the manual smoke test.

Step 0 — Pre-flight verification and CR archive. The audit (phase-35-cr-audit-v0_1.md) resolved most pre-flight questions definitively. CC's pre-flight at execution time is verification, not first-time inspection:

  1. Archive this CR to docs/phase-crs/phase-35-cr-render-content-kinds-v0_2.md.
  2. Verify RenderEvent is still at src/loomworks/engagement/types.py:1150 (audit-confirmed; line numbers can drift).
  3. Verify materializers.py:76+ still defines MaterializedFile and the format-keyed registry as audit-confirmed.
  4. Verify render_events_view is the right migration target (audit-confirmed via migration 0022).
  5. Verify the projector at src/loomworks/memory/projector.py:323 is the right extension target.
  6. Verify settings.loomworks_data_dir is still at config.py:34–35 and that files/storage.py:resolve_data_dir is the helper to reuse or lift.
  7. Confirm migration 0053 is still next available; if intermediate work has landed migrations, renumber.
  8. Re-baseline test count: uv run pytest --collect-only reported 1,296 at audit; capture current count.

Commit: Phase 35 step 0: CR archive and pre-flight verification.

Step 1 — RenderEvent extension, MultiFileManifestEntry, and storage helpers. Per §3.1, §3.2, §3.3. Add RenderContentValidationError. Add the model_validator for cross-field rules. Add MultiFileManifestEntry as a frozen Pydantic model. Add src/loomworks/engagement/render_storage.py with the four helpers per §3.2. Verify imports succeed; existing tests still pass.

Commit: Phase 35 step 1: RenderEvent extension, manifest entry model, storage helpers.

Step 2 — Migration 0053 and projector extension. Write the migration per §4.1. Extend the projector at src/loomworks/memory/projector.py:323 to read payload.get(field, default) for the new fields per §4.2. Run upgrade then downgrade then upgrade; verify clean round-trip on dev and test DBs.

Commit: Phase 35 step 2: migration 0053 and projector defaulting.

Step 3 — Materializer registry content-kind dispatch. Add materialize_render top-level dispatcher per §3.4. Add materialize_binary_blob, materialize_external_reference, materialize_multi_file default materializers. Add _CONTENT_KIND_DISPATCH dict. Existing format-keyed materializers and MaterializedFile definition unchanged. Update the download pathway to call materialize_render(...) instead of _format_keyed_registry[...] directly.

Commit: Phase 35 step 3: content-kind dispatch and three new default materializers.

Step 4 — API endpoint additions and extensions. Add new /content endpoint per §3.5. Add new /files/{filename} endpoint with the manifest-key lookup discipline and traversal-safety defense in depth. Extend /download per §3.5. The OpenAPI descriptions for new endpoints carry the four content kinds, no Playground-era prose (per Residue 58 lessons; Acceptance gate item 10).

Commit: Phase 35 step 4: /content, /files/{filename}, and extended /download.

Checkpoint A — Substrate complete.

Operator verifies:

Step 5 — Polling-loop tests. Write the 17 tests in tests/test_render_content_kinds.py per §5. Verify all pass.

Commit: Phase 35 step 5: render content kind tests.

Step 6 — Full suite verification. Full substrate suite plus the 17 new tests pass.

Verification: uv run pytest -v green. Test count: ~1,313+ (1,296 baseline + 17 new), 2 skipped (carried forward).

Commit: Phase 35 step 6: full suite green.

Step 7 — Implementation notes. Write to docs/phase-impl-notes/phase-35-implementation-notes-v0_1.md recording:

Commit: Phase 35 step 7: implementation notes.

Checkpoint B — Final.

Operator verifies via a manual smoke test using a stub specialist that produces three renders:

  1. A binary_blob render — verify the file lands at data/renders/{eid}/{rid}-v1.bin (or appropriate extension) and /download returns the bytes.
  2. An external_reference render — verify /content returns the JSON envelope and /download returns the same envelope.
  3. A multi_file render with mixed kinds — verify /files/{filename} retrieves named constituents, /files/some/../bad returns 400, and /download produces a zip with manifest.json plus the binary_blob files.

On acceptance: tag the substrate repo as phase-35-render-content-kinds. Bump the manifest.


7. Acceptance gate

This CR is accepted when:

  1. Substrate: all tests pass (~1,313+, 2 skips). Baseline reconciled at Step 0.
  2. RenderEvent supports content_kind ∈ {inline_dict, binary_blob, external_reference, multi_file} with appropriate storage fields.
  3. Cross-field validation via @model_validator(mode="after") raises RenderContentValidationError for invalid combinations at construction time.
  4. Multi-file manifest entries are MultiFileManifestEntry Pydantic models with per-entry validation (filename safety, kind-specific required fields).
  5. Filename uniqueness within a multi-file manifest is enforced at construction.
  6. Existing renders (default content_kind="inline_dict") continue to work unchanged; pre-Phase-35 events project correctly via payload.get(...) defaulting.
  7. Binary blobs persist to data/renders/{engagement_id}/{render_event_object_id}-v{version}{ext} and survive database round-trip.
  8. External references store URI and metadata; /content and /download both surface them as envelopes.
  9. Multi-file renders carry a manifest; constituent files are individually retrievable via /files/{filename}; the download endpoint produces a zip with manifest.json plus binary_blob files.
  10. The OpenAPI descriptions for the new and modified endpoints reflect the four content kinds and use current product identity (no Playground-era prose, per Residue 58 lessons).
  11. The materializer registry dispatches by content_kind first, then by render_format for inline_dict; existing PDF/HTML/MD materializers are unchanged.
  12. The /files/{filename} handler treats the URL parameter as a manifest key (string equality, no path arithmetic); URL parameters containing /, \, or .. return HTTP 400.
  13. Storage path includes version disambiguation (-v{version}).
  14. Migration 0053 round-trips cleanly (up then down then up).
  15. Materializer signature unchanged from pre-Phase-35 (no Awaitable, no dropped kwargs).

8. Post-CR state


9. What this CR does not build


10. Kickoff prompt for the Claude Code session


Read the Change Request document at the path I supply below. This is
CR-2026-047 v0.2, the Phase 35 Change Request. You are the executing
agent named in the CR.

CR path: ~/Downloads/phase-35-cr-render-content-kinds-v0_2.md

Phase 35 extends RenderEvent with a content_kind discriminator
(inline_dict, binary_blob, external_reference, multi_file) plus
four storage fields. The materializer registry gains a content-kind-
level dispatch wrapper around the existing format-keyed registry;
NO signature change (the signature shipped in May 2's render-download
work). Three new default materializers (binary_blob, external_reference,
multi_file). New /content and /files/{filename} endpoints; extended
/download. Multi-file download produces zip with manifest.json plus
binary_blob constituents.

Note: v0.2 supersedes v0.1 after a CC pre-flight audit identified
9 blockers, 9 recommended changes, and 7 non-blocking findings. The
audit lives at ~/Downloads/phase-35-cr-audit-v0_1.md (read this for
context — it explains why several substrate paths look different
from typical "extend RenderEvent and the registry" patterns).

Key points:
  - Materializer signature UNCHANGED: (RenderEventLike, str, str,
    *, str | None) -> MaterializedFile. Sync, takes engagement_title
    and render_type_name kwargs (used by templating + filename logic).
  - Content-kind dispatch lives at a NEW outer wrapper; existing
    format-keyed registry stays as the inner registry for inline_dict.
  - settings.loomworks_data_dir is the data root (not data_directory).
  - MultiFileManifestEntry is a frozen Pydantic model; per-entry
    validation runs at parent RenderEvent construction.
  - Filename uniqueness within multi_file manifest enforced at construction.
  - /files/{filename} is a manifest-key lookup, NOT a filesystem path
    lookup. URL params containing / \ .. return 400.
  - Storage path: data/renders/{eid}/{rid}-v{version}{ext} —
    version suffix is for Phase 36 readiness.
  - Projector reads new fields via payload.get(field, default) so
    pre-Phase-35 events default cleanly to inline_dict + None.
  - Pydantic v2 @model_validator(mode="after"), not __post_init__.
  - 17 substrate tests covering construction, persistence, materializer
    dispatch, API endpoints, and backwards compatibility.

Substrate baseline at audit: 1,296 tests, 2 skips. Re-baseline at
Step 0 if drifted.

Migration 0053 (or whatever number is next available at execution).
Adds the discriminator (VARCHAR(32) with CHECK constraint), four
content-storage columns, no index in v0.2 (audit B19). Round-trip tested.

Step 0: archive CR + pre-flight verification (audit-resolved facts,
just confirm line numbers haven't drifted).

Steps 1-4 auto, Checkpoint A halts for substrate verification.
Steps 5-7 auto, Checkpoint B halts for manual smoke test against
three stub-specialist outputs (binary, external, multi-file).

Implementation notes at Step 7:
docs/phase-impl-notes/phase-35-implementation-notes-v0_1.md

DUNIN7 — Done In Seven LLC — Miami, Florida Phase 35: Render content kind and external reference support — CR v0.2 — 2026-05-04