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.
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:
inline_dict — existing behavior; render_content carries the dict.binary_blob — render_content carries metadata; storage_path points at bytes on disk.external_reference — render_content carries metadata; reference_uri points at the external resource; reference_metadata carries the deployment context.multi_file — render_content carries the manifest summary; multi_file_manifest lists the constituent files (each binary_blob or external_reference).
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.
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.
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.
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.
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):
write_render_blob(*, engagement_id: UUID, render_event_object_id: UUID, version: int, content: bytes, extension: str) -> str — writes bytes to the conventional path, returns the relative path.read_render_blob(*, storage_path: str) -> bytes — reads bytes from the conventional path.render_blob_exists(*, storage_path: str) -> bool — existence check, used for materialization and recovery.delete_render_blob(*, storage_path: str) -> None — present but called by no Phase 35 code path (the "bytes kept for audit" decision per S3).
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.
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_format → MaterializerFn. 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):
materialize_binary_blob — reads bytes from render_event.storage_path (resolved against settings.loomworks_data_dir); returns a MaterializedFile with the bytes, the content-type derived from render_event.render_format or stored separately on the event, and a filename built from render_type_name + source_shape_title matching the existing _build_filename pattern. Does NOT call _extract_content_text (which is for inline-dict→template flows; binary blobs are bytes, not template input).materialize_external_reference — returns a JSON envelope: {"content_kind": "external_reference", "reference_uri": ..., "reference_metadata": ...} encoded as application/json. The filename matches the existing pattern with .json extension.materialize_multi_file — assembles a zip archive. Iterates render_event.multi_file_manifest. For each binary_blob entry: read from its storage_path, add to zip with entry.filename as the zip member name. For each external_reference entry: do not include as a zip member; instead include a top-level manifest.json listing all entries (binary_blob and external_reference) so the consumer sees both the bytes-on-disk files and the URIs. The zip itself uses Python stdlib zipfile (no new dependency).
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.
Reframed from v0.1: /content is a new endpoint, not an extension.
GET /engagements/{eid}/renders/{rid}/content — new endpoint. Returns content-kind-aware retrieval of render content (distinct from /download, which always returns a downloadable artifact).
inline_dict → returns render_content as JSON.binary_blob → returns the file bytes (resolved via materialize_binary_blob) with the correct content-type header.external_reference → returns a JSON envelope: {"content_kind": "external_reference", "reference_uri": ..., "reference_metadata": ...}. Does not fetch the external resource itself; just surfaces the reference.multi_file → returns the manifest as JSON with Content-Type: application/json and a hint to use /files/{filename} for individual constituents or /download for the zip.
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}/download — extended (existing endpoint). New behavior:
inline_dict → existing behavior unchanged (dispatches through the format-keyed inner registry).binary_blob → returns the file directly with Content-Disposition: attachment; filename="..." headers (filename from the materializer).external_reference → returns a small JSON envelope (since there's nothing to download in the conventional sense — the consumer follows the URI).multi_file → produces a zip archive of all constituent binary_blob files plus a manifest.json describing the full manifest.
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.
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.
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.
render_events_view content_kind columnsAdds 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:
render_events_view (operational view), per audit confirmation.apply_event_to_render_events_view lives at src/loomworks/memory/projector.py:323 (audit confirmation).ck_{table}_{column} is correct (Phase 10's ck_render_jobs_status matches).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.
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).
New test file tests/test_render_content_kinds.py. Fifteen tests across construction validation, persistence, materializer dispatch, API endpoint behavior, and backwards compatibility.
RenderEvent constructed with only existing fields gets content_kind="inline_dict" by default and all new fields are None.content_kind="binary_blob" and no storage_path raises RenderContentValidationError.content_kind="external_reference" and no reference_uri raises RenderContentValidationError.content_kind="multi_file" and either missing or empty multi_file_manifest raises RenderContentValidationError.content_kind="multi_file" and two manifest entries sharing a filename raises RenderContentValidationError.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.MultiFileManifestEntry with filename containing /, \, or .. raises RenderContentValidationError at construction.binary_blob RenderEvent with bytes-on-disk; persist; retrieve; verify storage_path and the bytes-on-disk both survive.materialize_render to those bytes; content_type and filename match the materializer's contract.manifest.json listing all three entries (the external_reference entry is in the manifest but not as a zip member)./renders/{rid}/content endpoint returns appropriate response shapes for each of the four kinds./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./renders/{rid}/download endpoint produces a zip for multi_file renders matching test_multi_file_materializer_produces_zip's shape.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.)
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:
docs/phase-crs/phase-35-cr-render-content-kinds-v0_2.md.RenderEvent is still at src/loomworks/engagement/types.py:1150 (audit-confirmed; line numbers can drift).materializers.py:76+ still defines MaterializedFile and the format-keyed registry as audit-confirmed.render_events_view is the right migration target (audit-confirmed via migration 0022).src/loomworks/memory/projector.py:323 is the right extension target.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.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:
/download as before.
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:
RenderEvent, the materializer registry, the projector, the download pathway as found at Step 0 verification.
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:
binary_blob render — verify the file lands at data/renders/{eid}/{rid}-v1.bin (or appropriate extension) and /download returns the bytes.external_reference render — verify /content returns the JSON envelope and /download returns the same envelope.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.
This CR is accepted when:
RenderEvent supports content_kind ∈ {inline_dict, binary_blob, external_reference, multi_file} with appropriate storage fields.@model_validator(mode="after") raises RenderContentValidationError for invalid combinations at construction time.MultiFileManifestEntry Pydantic models with per-entry validation (filename safety, kind-specific required fields).content_kind="inline_dict") continue to work unchanged; pre-Phase-35 events project correctly via payload.get(...) defaulting.data/renders/{engagement_id}/{render_event_object_id}-v{version}{ext} and survive database round-trip./content and /download both surface them as envelopes./files/{filename}; the download endpoint produces a zip with manifest.json plus binary_blob files.content_kind first, then by render_format for inline_dict; existing PDF/HTML/MD materializers are unchanged./files/{filename} handler treats the URL parameter as a manifest key (string equality, no path arithmetic); URL parameters containing /, \, or .. return HTTP 400.-v{version}).Awaitable, no dropped kwargs).content_kind discriminator on RenderEvent; four new fields (storage_path, reference_uri, reference_metadata, multi_file_manifest); RenderContentValidationError exception; MultiFileManifestEntry Pydantic model; data/renders/{engagement_id}/{render_event_object_id}-v{version}{ext} storage convention; render_storage.py helpers; three new default materializers (materialize_binary_blob, materialize_external_reference, materialize_multi_file); top-level materialize_render dispatcher; one new operational view column-set; two new endpoints (/content, /files/{filename}); one extended endpoint (/download)./download continues to serve existing render shapes correctly (default content_kind="inline_dict").current-status-manifest-v0_26.md (or whatever version is current) absorbs Phase 35 and any incidental residues.DELETE /api/engagements/{eid} (Phase 31) FK-cascade-deletes engagement DB rows. After Phase 35, hard-deleting an engagement leaves orphan binary blobs at data/renders/{eid}/... because the blobs are filesystem state, not DB rows. v0.2 explicitly notes this gap: hard-delete-engagement does not rmtree(data/renders/{eid}) in Phase 35; orphan-blob cleanup is queued future work.Literal["binary_blob", "external_reference"]; nested multi-file is future work.(engagement_id, content_kind) index in v0.2; no committed query needs it. Add when a query needs it.
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