DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path inspection-briefs/loomworks-cr-c-step-0-findings-v0_1.md

Loomworks — change request C Step 0 findings — v0.1

Version. 0.1 Date. 2026-08-02 Executed by. Claude Code on DUNIN7-M4, one session. Brief. inspection-briefs/loomworks-cr-c-step-0-inspection-brief-v0_1.md — confirmed to exist before any check was run against it, and confirmed the highest version present (numeric sort; v0.1 is the only one). Charter. standing-notes/dunin7-standing-authorization-charter-v0_1 — R-5 inspection run. Status. Complete. Four questions answered. Two unread items, named at §7.

Headline. B-28 is a validation fix, not a data-loss fix — the assertion is written and then rolled back, and the located cause is a two-line type mismatch. B-7's download half is a wire, not a build: the engine has served /renders/{id}/download all along and the surface has no caller — the same shape the Manifestation inspection found. B-7's currency half is a real gap on both sides. And rendering is not a forbidden term, so only one of the two rooms faces the wall at all.


1. Environment

| | | |---|---| | Operator Layer | /Users/dunin7/loomworks, main 7e81e30, tag manifestation-room-v0_1. Tree clean. | | Engine | /Users/dunin7/loomworks-engine, main a6a3ed6, read-only. Tree clean. |

Fences honoured. No dev server, no build, no test run, no npm install. No database of any kindplayground_dev was never connected to. No perimeter call. No branch, commit or fix in either repository. Every finding is a static read of source at the two SHAs above.

Nothing inherited. The CR-A findings were read pre-B-5. Every claim carried from them is marked re-verified or unverified below.


2. Question one — B-28: the contribution is lost before persistence commits

The whole path, hop by hop, at a6a3ed6:

| # | Hop | Anchor | |---|---|---| | 1 | supported-types advertises the extraction registry's entries | api/routers/contributions.py:285-313 | | 2 | The registry registers "text/markdown"extract_discovery_to_seed_skill, label "Discovery document (Markdown)", mode="discovery" | skills/registry.py:201-206 | | 3 | The file is uploaded in a prior, separate request and persists a file row | contributions.py:193 reads it back via get_uploaded_file | | 4 | registry.resolve("text/markdown") matches by equality-or-prefix and returns the discovery skill | skills/registry.py:154-158 | | 5 | The skill returns ExtractionResult(source_mode="discovery") | skills/discovery_to_seed.py:156-159 | | 6 | add_assertion(...) PERSISTS the assertion, with metadata.source_mode = "discovery" | contributions.py:268-276 | | 7 | Then the response is constructed | contributions.py:277-281 | | 8 | The assertion schema declares source_mode: Literal["text", "voice", "pdf", "image"] \| None"discovery" is not a member | api/schemas.py:2524 and :2806 | | 9 | The exception propagates out through the get_db_session yield-dependency, whose except Exception: rolls the transaction back | api/deps.py:50-55 |

The answers the brief asked for

Do the two type lists agree? No, and the disagreement is the defect. ExtractionResult.source_mode is an unconstrained str (skills/registry.py:31); the response schema is a closed Literal of four values. A skill may emit anything; the response admits four things.

The four skills emit image, voice, discovery, pdf (image_description.py:268, transcription.py:211, discovery_to_seed.py:159, pdf_extraction.py:86). The schema allows text, voice, pdf, image.

> discovery is emitted and not allowed. text is allowed and emitted by no skill. The overlap is three of four in each direction, which is why every other contribution type works and only Markdown fails.

Where does the refusal happen? Engine-side, after the write, on response validation — not surface-side, and not at the registry.resolve guard at contributions.py:202, which passes because Markdown is registered.

What state is the contribution in at the moment of refusal? The assertion is written and then rolled back. get_db_session wraps the request in one transaction and rolls back on any exception (deps.py:53-55), and response-model validation raises inside the dependency exit stack. This matches walk-audit W-3's observation that the assertion total stayed at five. The record is not corrupted and nothing is orphaned at the assertion level.

Is anything recoverable? Yes, and this is the load-bearing answer. The uploaded file row survives, because the upload is a separate earlier request that committed normally. The contribution can be re-made from the same file_id the moment the type mismatch is fixed — no re-upload, no lost bytes.

Is the person told anything? A response-validation failure yields an opaque server-side error naming a schema field, not a message about Markdown. The specific status code the walk observed (422) was not re-verified here — that needs a perimeter call, which the fences forbid. See §7.

The shape decision, which the brief said this question decides

B-28 is a validation fix. The brief's own test: "A refusal before persistence is a validation fix. A refusal after persistence is a data-loss fix and a different change request." The write happens, and then it is undone — so the durable state is as though nothing was written. The loss is of work, not of data.

The minimal correction is two lines, both declarations of the same Literal (schemas.py:2524 and :2806). A fix that reaches only one leaves the other to fail identically — the two are separate declarations, not a shared alias.

But the smaller question is whether the mapping is right at all. text/markdown is bound to the discovery-to-seed skill, whose registration comment (registry.py:196-200) says the canonical invocation path is api/routers/seed_extraction, and that the registration exists "to keep the skill family consistent." So contributing a Markdown file routes it through seed extraction rather than text extraction — while uploads/skills/text_extraction.py:110-118 registers a detection rule for .mdtext/markdown at a different layer entirely. Which of the two should own a contributed Markdown file is a design question, not a read, and the change request must answer it before choosing between widening the Literal and re-binding the pattern.


3. Question two — the Rendering room, re-read post-B-5

4.1 — RenderingRoom does NOT use ReadState. Re-verified at 7e81e30. It still derives its own states with the if ladder: loading (:106), error (:109), items.length === 0 (:112), else cards.

Which rooms are on the contract:

| Room | ReadState | Note | |---|---|---| | MemoryRoom | yes (3 refs) | adopted for the move-target read only | | ManifestationRoom | yes (3 refs) | built on it | | RenderingRoom | no (0 refs) | untouched by CR-2026-160 except the reset key |

This matches CR-2026-160's own scope statement — it adopted the contract only where it already touched things — but it means the Rendering room still cannot distinguish a failed read from an empty one. Its error branch renders roomLoadError, so it does not currently make a false empty claim; the exposure is that the distinction is incidental rather than structural, exactly as it was for Memory before B-5.

4.2 — refreshNonce landed. Re-verified. RenderingRoom.tsx:75 declares the prop, :84 types it, and :103 folds it into the reset key: ` ${projectId}:${pageSize}:${refreshNonce} `. A conversation-side event can now refresh the room, which it could not before.

4.3 — The download path exists on the engine and has no caller on the surface.

The engine serves three content routes, read at a6a3ed6 in api/routers/renders.py:


GET /engagements/{engagement_id}/renders/{object_id}/download
GET /engagements/{engagement_id}/renders/{object_id}/content
GET /engagements/{engagement_id}/renders/{object_id}/files/{filename:path}

The download route's own description: "Materializes a produced render into a downloadable file and returns it as an attachment. Optionally override the format to grab, say, an HTML or Markdown copy of a render whose declared format is PDF, without producing a second render."

The surface calls exactly one render path/engagements/${projectId}/renders?… (lib/api/renders.ts:117). A sweep of src/lib/api/ and src/app/ for download, /content or /files/ against renders returns nothing.

> B-7's download half is a wire, not a build — the same shape the engine inspection found for the Manifestation staleness marker, and the brief was right to say check before assuming. The engine can already produce a downloadable file and convert format on the way out, which is more than B-7 asks for.

The room's own comment (RenderingRoom.tsx:5-9) says the action appears only for inline HTML "no dead Download button" — an only-show-what-is-available choice made when the surface had no download route to call. It has one.

4.4 — Currency and supersession: a real gap, and partial on both sides.

What the engine expresses: state: Literal["produced","retired","invalidated"] (schemas.py:6137), created_at, last_updated_at, display_number, declared_render_type_ref (schemas.py:~6101-6171).

There is no supersession marker. Renders carry no superseded_by_ref — unlike Manifestations, which do. Currency is inferable from display_number and the timestamps; it is not stated.

What the surface displays: the adapter projects state, createdAt, displayNumber (lib/api/renders.ts:92-97), and the room renders a "Render" badge, the format label, #displayNumber, the title and a produced-by line. It displays neither state nor any timestamp.

And the room never sees a non-current render: fetchRendersPage hardcodes state: "produced" (renders.ts:114), so retired and invalidated outputs are filtered out server-side.

So when an older and a newer render sit side by side, both are produced, and the only distinguishing mark shown is #displayNumber — which ascends, but says nothing about currency and is described in the adapter as "sequential number within its output type." The gap is on both sides: the engine states no current-marker, and the surface displays none of the ordering signals it already receives.


4. Question three — the Shaping room

5.1 — The engine serves a great deal, across three routers, read at a6a3ed6:

| Router | Routes | |---|---| | shapings.py | GET/POST /engagements/{id}/shapings, GET /engagements/{id}/shapings/{shaping_id}, GET /engagements/{id}/available-executors | | shape_events.py | GET/POST /engagements/{id}/shape-events, GET /engagements/{id}/shape-events/{object_id}, POST …/{id}/confirm, POST …/{id}/retire, GET /engagements/{id}/shaping-jobs/{job_id}, POST …/shape-events/self-consumer | | declared_shape_types.py | GET/POST/PATCH on /engagements/{id}/declared-shape-types |

Against the brief's four capabilities: all four exist. Listing (GET /shape-events, with state, engagement_version_from/_to, limit, cursorshape_events.py:401-406); retrieval (GET /shape-events/{object_id}); retrieval at a version (version: int | None = Query(default=None, ge=1) at shape_events.py:475the same query-parameter shape as Manifestations, not a path segment); and creation (POST /shape-events, plus a confirm/retire lifecycle the Manifestation room has no equivalent of).

So Shaping is like Manifestation: the engine is open and the surface has no caller. src/lib/api/ holds no shape adapter at all — 27 files, none of them for shapings or shape-events.

But it is materially larger than Manifestation was. Shaping carries a confirm/retire lifecycle, an executor concept (available-executors), and asynchronous jobs (shaping-jobs/{id}, implying poll-for-completion). The Manifestation room was a single read; the Shaping room is a read plus a state machine plus a job poll, and the change request should not inherit "the engine needed no work" as though the two were the same size.

5.2 — The shape-type → render-type mapping is exposed, and enforcement is unread. declared-shape-types is a full CRUD surface, and renders.py carries a matching declared-render-types surface. Whether the engine enforces the mapping between them was not established — see §7.

5.3 — The two Shaping strings, verbatim at 7e81e30 (lib/strings.ts:1136-1138), recorded so their replacement is deliberate:


shapeEmpty:     "This room isn't built yet."
shapeEmptyHint: "Shaping arranges organized memory for a particular reader.
                 The screen is coming; nothing has been checked."

Both assert the screen, not the record — CR-2026-160's correction, intact. When the room is built these become false in the other direction (the room will exist), so they are replaced, not merely edited.


5. Question four — the wall, and what change request C may name

Re-verified at 7e81e30: the wall is a case-sensitive substring scan over src/, run as a Vitest test, with whole-file WIRE_BOUNDARY_FILES exemptions and a term-scoped exemption for lib/room-labels.ts.

The forbidden list, verbatim (tests/components/vocabulary-wall.test.ts:22-37):


engagement_id · engagement_title · engagement_name · assertion_id
shape_event · render_event · manifestation · shaping
specialist · materializer · normative_force

The finding that changes the shape of the work

rendering is NOT a forbidden term. Confirmed by direct search of the list: zero hits. Only one of the two rooms this CR builds faces the wall at all.

| Room | Lowercase token | Forbidden? | Discriminator (room-labels.ts:25-28) | |---|---|---|---| | Shaping | shaping | yes | shape | | Rendering | rendering | no | rendering |

Consequences, and they are asymmetric:

Substring collisions a new component would plausibly hit:

| Token | Collision | Status | |---|---|---| | shaping | reshaping contains it | any such identifier trips the wall | | shaping | shapingRunninglib/api/activity.ts:39 | passes only because activity.ts is a boundary file | | render_event | render wire fields | lib/api/renders.ts is a boundary file | | specialist | render_specialist_display_name | same file, same exemption | | rendering | renderingEmpty, renderingRunning | free — not forbidden |

No React-vocabulary collision exists, contrary to the brief's expectation: because rendering is not forbidden, ordinary frontend words like render, rendering, rerender are unconstrained. The brief's concern does not materialise.

The §6 trap, answered before the change request specifies a path

CR-2026-160's adapter path could not work because the wall matches import specifiers, so a lowercase room-named adapter would force a whole-file exemption on every consumer. Applying that test here:

The current WIRE_BOUNDARY_FILES set is 15 entries — 14 under lib/api/ (activity, assertions, commit-ceremony, compose, dashboard, door3, me-current-engagement, me-spend, memory, projects, renders, saved-filters, tags, workspaces) plus the term-scoped lib/room-labels.ts. The convention for adding one is a path string with a comment naming the wire tokens it carries and why, matching the fourteen already there.

> A Shaping adapter will need an entry — the shape-event wire shape carries shape_event, and executors carry specialist. A Rendering adapter will not need a new one; renders.ts is already listed.


6. Corrections preserved

1. The CR-A findings' "15 whole-file exemptions" is now 15 including room-labels.ts, of which 14 are lib/api/. The pre-B-5 read counted 15 lib/api/*.ts entries; CR-2026-160 added compose.ts. The set as it stands is enumerated at §5. Recorded because the count moved and the brief asked for the current list.

2. The brief's expectation of a React-vocabulary collision on rendering does not hold, because rendering is not a forbidden term. The concern was reasonable and is answered in the negative.

3. The CR-A findings' claim that both live rooms fetch through usePagedList is re-verified and still trueRenderingRoom.tsx:93 and MemoryRoom.tsx:418/423. What changed is that two of the four rooms now also carry ReadState, and Rendering is not one of them (§3).


7. Unread

Two.

| # | Unread | Why | What would settle it | |---|---|---|---| | 1 | The status code and message the Operator actually sees on a Markdown contribution. The schema mismatch is established from source; the walk audit recorded a 422, which was not re-observed here. | Needs a perimeter call, which the fences forbid. | One request against a dev stack, or the change request's own verification step. The defect's location and mechanism do not depend on it. | | 2 | Whether the engine enforces the declared-shape-type → declared-render-type mapping, or merely exposes both. | Establishing enforcement means reading the shaping/composition validation chain end to end — a larger sweep than this brief's four questions, and §5.2 asks only whether it is enforced, exposed, or neither. Both surfaces are confirmed to exist; the link between them is what is unread. | A targeted read of engagement/shaping*.py validation, or the change request's own Step 0. |

Nothing else was blocked. No question required a server, a build, a test run, a database or a perimeter call.


8. What this session did not do


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — change request C Step 0 findings — v0.1 — 2026-08-02 The document is not lost, only its extraction. The download button was there all along.