Version. 0.1
Date. 2026-07-31
Author. Claude Code, inspection session on DUNIN7-M4.
Brief. inspection-briefs/loomworks-operator-layer-cr-a-step-0-brief-v0_3.md — confirmed the highest version present (the record holds v0.1 and v0.3; the missing v0.2 is the deliberate gap the brief itself records).
Charter. standing-notes/dunin7-standing-authorization-charter-v0_1 — R-5 inspection run.
Status. Complete. Both questions answered. No unread items.
| | |
|---|---|
| Repo | /Users/dunin7/loomworks |
| HEAD | 99f7f64 — Merge Item 7a into main: /claim standing→credit fall-through + admin claim_url |
| Branch | main |
| Working tree | Clean. git status --porcelain returned nothing. Nothing unstaged, nothing untracked. |
Explicitly not done, per §1's fences: no dev server, no build, no test run, no npm install, no branch, no commit, no stash, no fix. No engine call, no database connection, no perimeter call. Every finding below is from source reads and static greps only.
Every §4 read the drafting session performed was re-verified directly rather than inherited, per §2 item 3. Both verify — see §3.
MemoryRoom.tsx — data-fetching path
What it fetches and from where. Two independent paged lists over one endpoint, filtered by state (MemoryRoom.tsx:407-427):
fetchAssertionsPage(projectId, "held", …)fetchAssertionsPage(projectId, "committed", …)
Both resolve to GET /engagements/{projectId}/assertions?… (lib/api/memory.ts:186). A third, separate fetch — fetchProjectSummaries() at MemoryRoom.tsx:388 — loads the Operator's other engagements for the move-target picker.
The distinct states it models today. Three, and they are genuinely distinguishable — this is better than the brief's framing anticipates:
| State | Where | Rendering |
|---|---|---|
| loading | :555 if (held.loading \|\| settled.loading) | SURFACE.loading |
| failed | :558 if (held.error \|\| settled.error) | SURFACE.roomLoadError |
| loaded-and-empty | :566 / :613 items.length === 0 | SURFACE.heldEmpty / SURFACE.settledEmpty |
| loaded-and-populated | :571 / :618 | the card lists |
not-yet-loaded collapses into loading, and collapses into the safe one. usePagedList initialises loading to true (hooks/usePagedList.ts:62), not false. So there is no first render in which an unloaded list renders as empty — the empty branch at :566 is reachable only after the fetch has settled with error === false. The empty state is therefore already backed by a completed read in this room.
One narrow exception, worth naming for the contract. On a resetKey change the reset work (setLoading(true), setItems([])) happens inside the effect (usePagedList.ts:86-93), which runs after the committed render. The render between a resetKey change and the effect firing shows the previous engagement's items, not an empty state. It is stale-data-briefly, not false-empty — but a typed state value carrying "the read that produced them" would make this expressible rather than incidental.
How refreshNonce propagates. It is folded into the reset key as a string component (:421, :426):
resetKey: `${projectId}:${pageSize}:${refreshNonce}`
usePagedList's first-page effect keys on [resetKey, reloadNonce] (usePagedList.ts:113), so a bump refetches page one of both lists. The button-driven path is separate and more direct: held.reload() / settled.reload() (:445-446, :501, :535) bump the hook's internal reloadNonce.
State derived from client-side state rather than a completed read. One, and it is a real instance of the pattern change request A cares about — canMove (:403):
const canMove = otherEngagements.length > 0;
otherEngagements is [] both when the Operator genuinely has no other engagement and when fetchProjectSummaries() rejected — the .catch at :394-398 sets [] deliberately ("best-effort"). A failed read and a true-empty read are indistinguishable downstream, and the consequence is a silently hidden Move action (:190). This is exactly the shape the typed contract exists to prevent, in a corner the brief did not name.
RenderingRoom.tsx — the same questions
Same hook, same three states, one page size, one list (RenderingRoom.tsx:85-111): loading → SURFACE.loading; error → SURFACE.roomLoadError; items.length === 0 → SURFACE.renderingEmpty; otherwise cards. Fetches fetchRendersPage(projectId, …) → GET /engagements/{projectId}/renders (lib/api/renders.ts:119).
One material difference from MemoryRoom: it has no refreshNonce. Its reset key is ` ${projectId}:${pageSize} (:96) — the nonce is neither a prop nor threaded from RoomView, which passes only projectId and compact (RoomView.tsx:55`). The Rendering room cannot be refreshed by a conversation-side event. It refetches only on engagement switch or page-size change. Nothing in the room mutates its own data, so there is no in-room reload path either.
The two rooms are therefore not symmetrical today, and the note's treatment of them "together as the pattern" holds for state modelling but not for refresh.
There is one, and it is src/hooks/usePagedList.ts. Both rooms fetch through it; neither fetches independently. It is a general keyset-pagination hook — first page on mount, accumulate on demand, tracking items / hasMore / totalCount / loading / loadingMore / error / loadMore / reload (usePagedList.ts:38-50).
This is where the typed contract most plausibly lands, and it makes the contract an addition rather than a replacement. The hook already holds every input the five-value state needs; what it does not do is combine them into one value. Today each consumer re-derives the combination with its own if ladder, which is why MemoryRoom and RenderingRoom express the same three states in two slightly different shapes, and why canMove (§2.1) gets to invent a fourth. A derived state value computed inside the hook would be additive — every existing field can stay.
Callers pass fetchPage and a resetKey; the hook reads fetchPage through a ref (:71-72) so a new closure per render does not retrigger. Any contract change must preserve that, or every room refetches on every render.
There are no Operator Layer route handlers at all. find src -name route.ts returns nothing — the Operator Layer defines zero Next.js API routes.
Proxying is done by rewrite, not by handler (next.config.ts):
async rewrites() {
const engine = process.env.ENGINE_ORIGIN || "http://127.0.0.1:8000";
return [{ source: "/api/:path*", destination: `${engine}/:path*` }];
}
API_BASE is process.env.NEXT_PUBLIC_API_URL || "/api" (lib/api.ts:33) and every call goes through fetch(\${API_BASE}${path}\) (lib/api.ts:84). The /api prefix is stripped in transit. So the answer to "which routes proxy to the engine" is: all of them, generically, by one wildcard rewrite — the surface reaches any engine route by path, with no per-route surface code.
**Is any Manifestation route proxied today? No — and more precisely, none is reached.** Because the rewrite is a wildcard, nothing needs adding to make a Manifestation route reachable; what is missing is a caller. A full grep of src/ for manifestation, memory-status and memory_status finds:
lib/room-labels.ts:26 — the room key, a label definitionlib/strings.ts:372, :585, :588 — display stringslib/api/me-spend.ts:14,19,28 — the spend adapter, reading a pipeline-stage valueapp/settings/components/SpendPausePreferenceSection.tsx:12-13 — a comment
No call site. GET /engagements/{eid}/memory-status is never called, and none of the four Manifestation routes the engine exposes is reached. The engine side being established means the surface work is a new caller against an already-open door, not a plumbing change.
EmptyRoom and its strings — verbatim
RoomView.tsx:14-21 renders EmptyRoom with a message and a hint. The values, read from src/lib/strings.ts:1076-1080, under the section comment **/ Rooms with no surface path yet — honest empty states /**:
composeEmpty: "Nothing organized into a picture yet."
composeEmptyHint: "Committed memory is the raw material here."
shapeEmpty: "No shapes waiting on you yet."
shapeEmptyHint: "Shapes appear here once there's settled memory to shape."
Which do they assert — the surface, or the data? The data. All four.
Not one of the four mentions the surface, this screen, or availability. "Nothing organized into a picture yet" is a claim about the record — that no manifestation exists. The walk audit recorded it on an engagement that had derived one that morning, and the string says exactly what the walk audit reports it saying. Face 2 is confirmed verbatim, and the gap is total rather than partial.
The sharpest evidence is the contrast inside this one file. The room that genuinely reads says:
renderingEmpty: "No finished outputs yet." (strings.ts:1074)
That is also a claim about the data — and it is honest, because a completed read stands behind it (§2.2). composeEmpty is the same grammatical form making the same kind of claim with no read behind it at all. The two are indistinguishable to the Operator, which is precisely why the false one is false: the surface has taught them that this sentence form means "the engine was asked and said none," and then used it where the engine was never asked.
The comments and the strings disagree. RoomView.tsx:6-7 says these rooms "show an honest empty state rather than fake cards"; strings.ts:1076 says "Rooms with no surface path yet — honest empty states." Both authors were honest about the surface in the comment and then wrote a sentence about the data in the string. The intent was right and did not survive into the user-visible text. Worth recording, because it means B-5 is not correcting a careless string — it is correcting a slip that the surrounding comments actively conceal from a reader skimming for the problem.
For completeness, the Memory room's own two empties, which are read-backed (strings.ts:918-920):
heldEmpty: (companionName) => `Nothing held right now — ${companionName} isn't waiting on you.`
settledEmpty: "Nothing settled into the record yet."
src/lib/room-labels.ts header — verifies. Text as quoted, at lines 2-6: "operator-surface ROOM-NAME LABELS (the core concept the Operator learns), NOT engine wire shorthand" and "They live in this single file so the vocabulary-wall can allow these two label words here while still forbidding the engine wire code-tokens everywhere — including here." ROOMS carries label: "Manifestation" (:26) and label: "Shaping" (:27) explicitly.
RoomView.tsx header — verifies. At lines 3-5: "Switches on the wall-safe content discriminator from room-labels.ts (never the forbidden room-key literals)" — the brief's ellipsis elides only "from room-labels.ts".
The brief's reading — that the wall and the seed agree, and no Operator ruling is needed — holds. Nothing below disturbs it. What follows is the part a comment cannot settle.
tests/components/vocabulary-wall.test.ts — a Vitest static scan, not a lint rule, not a CI-only check. It runs under npm test (vitest run).
What it does. Walks src/ recursively for .tsx? / .jsx? / .css files (:100), and for each line checks line.includes(term) (:128) against a fixed list (:22-37):
engagement_id · engagement_title · engagement_name · assertion_id
shape_event · render_event · manifestation · shaping
specialist · materializer · normative_force
seed is deliberately excluded for false positives (:34-36).
What it allows. Two exemption mechanisms:
WIRE_BOUNDARY_FILES (:43-80) — 15 whole-file exemptions by path, each an lib/api/*.ts adapter, each with a comment naming why. memory.ts and renders.ts — the two room adapters — are both on it.ROOM_LABEL_FILE / ROOM_LABEL_TERMS (:89-90, applied at :127) — lib/room-labels.ts is exempted for the two terms manifestation and shaping only. Every other forbidden term still applies there.
So room-labels.ts is exempted by path, and narrowly — term-scoped, not blanket. The test's own comment (:82-88) describes this as "a tight distinction, not a blanket exception," and mechanically that is accurate.
The match is case-sensitive. line.includes("manifestation") does not match Manifestation. The consequence is decisive and is not what the comments describe:
The capitalised label words are permitted everywhere in src/, not only in room-labels.ts. They are live in at least five other files today, all passing the wall:
| Site | Content |
|---|---|
| lib/strings.ts:372 | roomLabelManifestation: "Manifestation" |
| lib/strings.ts:373 | roomLabelShaping: "Shaping" |
| lib/strings.ts:588 | "…The first drafting step (Manifestation) always shows its spend…" |
| app/settings/components/SpendPausePreferenceSection.tsx:12-13 | Manifestation in comments |
| components/spend/SpendVisibilitySection.tsx:187 | Shaping/Rendering in user-visible JSX |
Therefore the ROOM_LABEL_TERMS exception is not protecting the labels at all. The labels never needed protecting — they are capitalised, and the wall cannot see them. What the exception actually protects is the two lowercase room keys at room-labels.ts:26-27 (key: "manifestation", key: "shaping"). The comment calls it an exception for "these two label words"; mechanically it is an exception for two lowercase key literals. The distinction is invisible until you write a new file.
The practical output for B-5 — what a new Manifestation room component may and may not carry:
May, freely and anywhere: Manifestation capitalised — component name ManifestationRoom, filename ManifestationRoom.tsx, the import specifier ./ManifestationRoom, type names, and every user-visible string. The brief's concern that the noun could not appear in the component does not materialise, provided the capital is kept.
May not, anywhere outside lib/room-labels.ts: the lowercase tokens. Concretely this rules out —
data-testid="manifestation-room" — testids are lowercase by the repo's existing convention (memory-held-card, rendering-card, room-empty)manifestation / shapingmanifestationData, shapingState
And because the match is a plain substring, any longer word containing them also trips: reshaping, manifestationId, shapingRunning. (lib/api/activity.ts:39 uses shapingRunning and passes only because that file is a WIRE_BOUNDARY_FILES exemption.)
The practical shape: capitalised in every human-facing place, content: "compose" in every machine-facing place — exactly what RoomView already does. B-5 changes what the "compose" branch renders; it need not touch the discriminator or the wall.
projectId / engagement displacement — sized, not fixed
RoomView.tsx takes projectId (:25) and passes it to both live rooms (:47, :55). Sizing across src/:
| Category | Count |
|---|---|
| Total project occurrences (case-insensitive) | 457, across 50 files |
| projectId — component props and internal identifiers | 162 |
| project_id / project_title — snake_case wire-shaped fields | 77 |
| User-visible strings in lib/strings.ts | 12 |
| User-visible inline JSX outside strings.ts | ~13 |
| Route params / dynamic segments carrying project | 0 |
Route params are already clean. The only dynamic segment in the app is src/app/operator/engagement/[engagement_address] — the seed's noun, already. There is one URL-surface exception: the query parameter ?project= at app/chat/page.tsx:42.
Are the user-visible ones separable from the internal ones? Yes, and cleanly. The ~25 user-visible sites are dominated by the 12 in lib/strings.ts (a single file, per the repo's brand-strings discipline), leaving roughly a dozen inline JSX sites and the one query param. That is separable from the 162 internal projectId props, and a user-visible-only pass is a genuinely smaller change than the raw 457 suggests.
One user-visible site is a wire field rendered unprojected: app/operator/engagement-navigation/HomeCards.tsx:160 and :200 render {item.project_title ?? "—"} directly.
**And the finding that matters most for change request D — the wall is causing the displacement.** At lib/api/dashboard.ts:79 and :143:
project_title: row.engagement_title,
The engine sends engagement_title — the seed's own noun. engagement_title is on the wall's FORBIDDEN_TERMS list (vocabulary-wall.test.ts:24). The sanctioned escape is to project the wire shape into "Operator vocabulary" inside a boundary adapter — and the Operator vocabulary this codebase chose for it was project. dashboard.ts's own header comment (:17) states it plainly: "adapters project to Operator-vocab project_id + project_title."
So the displacement W-37 recorded is not accumulated carelessness. It is the systematic downstream consequence of a wall that forbids the seed's noun in its wire form and left the replacement unspecified. Change request D cannot fix it by renaming call sites alone: as long as engagement_id / engagement_title / engagement_name are forbidden terms, every new adapter faces the same fork and will keep reaching for project. The wall's forbidden list is part of D's surface, and that is a finding about the mechanism, not about the 457 sites.
C-1. The room-labels.ts header says "these two label words"; the file defines four labels. ROOMS carries Memory, Manifestation, Shaping, Rendering. Only two are forbidden terms, so the sentence is right about which two need the exception and loose about what the file contains. No consequence; recorded because §2's clause asks for it.
C-2. The exception is term-scoped to the lowercase keys, not to the labels. See §3.3. The brief's §4 reading ("the methodology nouns are deliberately preserved as operator-facing labels in a single sanctioned module") is right about intent and about the seed question, and does not hold mechanically: the capitalised labels are preserved everywhere, and the single-module confinement applies to the lowercase keys. The conclusion the brief draws — wall and seed agree, no Operator ruling needed — is unaffected.
C-3. The brief's §3.5 attributes SURFACE.composeEmpty / composeEmptyHint to Manifestation and shapeEmpty / shapeEmptyHint to Shaping. Correct, via the content discriminator — manifestation → "compose" and shaping → "shape" (room-labels.ts:26-27), dispatched at RoomView.tsx:56-66. Verified rather than assumed, because the string names and the room names do not share a word.
None. Both questions were fully decidable inside the fences, as §1 predicted. No question required a dev server, a build, a test run, an engine call or a database.
hooks/usePagedList.ts is where it lands — it already holds every input the five-value state needs and merely does not combine them (§2.3).unloaded-unsafe. loading initialises true, so no empty state renders before a read completes (§2.1). The contract's job here is to make an existing correctness property expressible and enforced rather than incidental.canMove, where a failed read and a true-empty read are indistinguishable (§2.1); and RenderingRoom's missing refresh path (§2.2).project displacement, not the 457 call sites (§3.4).DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Operator Layer — change request A Step 0 findings — v0.1 — 2026-07-31 Read-only. Two questions answered, none unread. The rooms model their states honestly; the strings do not.