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

Loomworks — B-8 Step 0 findings — the Companion and the four rooms — v0.1

Version. 0.1 Date. 2026-08-05 Author. Claude Code (executing session). Operator: Marvin Percival. Brief. inspection-briefs/loomworks-b8-step-0-inspection-brief-v0_1.md (only version present; confirmed via directory listing). Charter. standing-notes/dunin7-standing-authorization-charter-v0_1. R-5 inspection run. Status. Read-only. No fixes, no branches, no commits to either target repository, no dependency changes, no dev server, no perimeter call, no model call, no database touched.


0. Environment


1. Question one, answered first — the authority gap

Can a vaguely-worded request reach a state-changing operation? No. Classification collapses any low-confidence or unmatched intent to general_conversation (src/loomworks/orchestration/classifier.py:337-360, threshold 0.7 at classifier.py:43; no-LLM-key path hard-codes the same at src/loomworks/orchestration/routers/converse.py:847-855). route_intent() handles that case as a pure early exit:


# src/loomworks/orchestration/router.py:3694-3695
if intent == "general_conversation":
    return None

operator_converse() then falls through to an unconstrained LLM completion with no operation_data and no engine call attached (src/loomworks/orchestration/responder.py:64-125) — the reply is response.content[0].text verbatim, with no tool-use/function-calling wiring in that call. This is a structural guarantee, not a prompt-level restraint: the write-capable handlers are never entered, because route_intent returns before reaching any of them. A vague sentence cannot produce an operation no matter what the model outputs, because there is no mechanism by which unconstrained text becomes a function call on this path.

Can the Companion reach the Shaping confirm that enqueues render production? No — and this was checked first, per the brief's instruction, before continuing. Three independent checks converge on the same answer:

  1. The IntentLabel taxonomy (classifier.py:48-154) contains no intent representing "confirm this shape" — nothing named confirm_shape, approve_shape, or equivalent.
  2. route_intent() (router.py:3660-4222, every if intent == ... branch) contains no branch that calls open_shape_confirmation_consideration or close_shape_confirmation.
  3. A grep across src/loomworks/orchestration/ for ShapeEvent, shape_event, shape_confirmed, RenderDispatchAgent, ShapingAgent returns exactly one hit, and it is inside a read-only aggregate-count SQL string (router.py:1463, ... object_type = 'shape_event' ...) — not a call site.

The only code path anywhere in the engine that calls open_shape_confirmation_consideration / close_shape_confirmation is respond_to_inbox_item(), src/loomworks/orchestration/routers/inbox.py:229-337 — a structurally separate FastAPI route, POST /operator/inbox/{item_id}/respond, driven by a structured {item_kind, verb, rationale} body from an inbox-card button. It is not called from converse.py, router.py, or the classifier at any point. The confirm operation exists and does enqueue render production (per B-6, hook file shape_confirmed_dispatch.py confirmed present by grep, not re-read line-by-line this session), but the Companion's conversation path has no code route to it. What stands in front of the confirm on the inbox route itself is a membership check (inbox.py:277-282) plus a verb-allowlist and item-state precondition (inbox.py:69-76, 171-178, 256-264) — none of it delegation-gated; any operator/contributor member can approve. This is irrelevant to the chat path specifically because chat cannot reach the route at all.

Conclusion: the seed's category error — automatic state transitions on artifacts the Operator has authority over, triggered by a vague instruction — does not currently exist in code. The gap the build list has carried as a Step 0 question is closed in the negative: there is no live call chain from /operator/converse to shape confirmation, so this brief's output is not a defect report on that specific question. §5 below still asks what building genuine Manifestation/Shaping/Rendering conversational access would require, and that section's answer is where a future authority design would need to hold this same property deliberately, since today it holds only because the wiring was never built.

1.1 Every state-changing operation the Companion can currently invoke

All reachable only through POST /operator/converseoperator_converse() (converse.py:617) → classify_intentroute_intent() (router.py:3660) → a _route_* handler.

| Intent | Handler (router.py) | Write | |---|---|---| | add_knowledge | :316 | Creates a held assertion, as the Companion's own actor. | | commit_assertion | :726 | Commits held assertion(s) to durable state. | | discard_held | :1092 | Discards held draft(s) (append-only). | | change_assertion | :1225 | Held branch: revises in place. Committed branch: revises then commits — supersedes a saved fact. | | remember_about_me | :3254 | Creates/revises a held personal-memory assertion, as the Companion actor. | | forget_about_me | :3406 | Retracts a committed personal assertion, or revokes a delegation grant. | | save_filter | :4060 | Creates a saved filter. | | tune_setting | :4092 | Writes a changed setting value. | | open_engagement / close_engagement | :3965 / :4038 | Writes/clears the person's current-engagement focus. | | create_project / finalize_project / review_project_draft / commit_project_draft | :2139 / :2247 / :2431 / :2464 | finalize_project calls commit_engagement() (durable). | | create_engagement_entry / active / commit | :2664 / :2698 / :2922 | Conversational engagement-creation writes. | | request_draft / request_revision | :1777 / :1930 | Conditional: only executes a real dispatch if a pre_authorized delegation exists and a dispatcher is registered for the target capability. Neither produce_specification nor initiate_render has a registered dispatcher at this SHA (src/loomworks/delegation/action_dispatch.py:90, 170-180) — the call structurally dead-ends with a failure result. Otherwise creates an approval card only; no write to Shaping or Rendering. | | approve_draft | :2069 | Resolves a pending approval card; same dispatcher-registry dead-end for Shaping/Rendering capabilities specifically. |

1.2 What guards each write — code, separated from prompt

(a) Membership/authority re-check, per durable write. commit_assertion, discard_held, the committed branch of change_assertion, and finalize_project each re-derive get_membership_for_person_on_engagement and require OPERATOR/CONTRIBUTOR designation before writing (router.py:802-819, 1141-1157, 1360-1377, 2303-2341). This is code, checked at the moment of write, independent of anything the prompt tells the model.

(b) Data-layer actor-kind gate, inside the write function itself. retract_assertion (src/loomworks/engagement/assertions.py:515-517) raises if actor.kind is non-human — a second, independent enforcement point below the router. commit_assertion and discard_assertion carry the equivalent. Every router call site confirmed to pass the human actor, never the Companion actor, into these three functions.

(c) Companion-actor writes are weak by design, not unguarded. add_knowledge and remember_about_me write as the Companion's own actor with no membership check — but the write only ever lands in held state, non-authoritative until a separate human commit_assertion call, which carries guard (a).

(d) Delegation gate for request_draft/request_revision. verify_companion_authorization() (src/loomworks/delegation/authorization.py:99) is default-deny: no personal engagement, no matching committed delegation, or a constraint mismatch all deny (:130-158). Real code, not a prompt instruction — but currently moot for Shaping/Rendering specifically, since no dispatcher is registered for either capability (§1.1).

(e) open_engagement/close_engagement/save_filter/tune_setting carry no membership/authority gate beyond authentication — they are scoped to the calling person's own settings/focus, not durable project memory.

An instruction inside companion_persona.md telling the model to seek approval, if one exists, was not treated as a guard in the above — none of the guards found are prompt text; all are executable checks in router.py, assertions.py, or authorization.py, quoted or cited above with file:line.

1.3 The vague-request path specifically

Covered fully in the opening of this section. To restate the structural point directly: route_intent returns None for general_conversation (router.py:3694-3695) before any handler runs, and the fallback responder call has no function-calling/tool-use wiring (responder.py:64-125). The fallback path has zero code-level access to any state-changing operation — this holds regardless of what the model is told to say, because the write-capable code is never invoked on this branch.

1.4 Confirmation and its consequences

Answered above (opening of §1) — no call chain exists at this SHA from chat to shape confirmation.

1.5 The approval mechanism, if one exists

Four distinct code mechanisms found, all independent of prompt text:

  1. Membership/authority re-check (§1.2a) — covers durable assertion writes and finalize_project. Does not cover held-state writes (by design), person-scoped settings, or Shaping/Rendering (chat never reaches them).
  2. Two-turn confirm, keyed off conversation history, not persisted state. change_assertion (committed branch) proposes on turn N and only acts if the prior turn's classified intent matches on turn N+1 (router.py:1362-1387). Same idiom in forget_about_me and add_knowledge's correction-recognition. A genuine code gate — the write does not execute on the propose turn — but the gate is keyed on intent classification of the next turn, itself LLM output; a misclassification could in principle skip or repeat a turn. Not verified live; flagged as a limit of the mechanism, not a defect claim.
  3. Delegation/approval-card mechanism (src/loomworks/delegation/, process_approval in the notifications service) — covers request_draft/request_revision/approve_draft. Default-deny. per_action creates a notification row the Operator must separately approve via the notifications surface, not chat; pre_authorized dispatches immediately once a standing delegation exists. At this SHA this is a dead end in practice for Shaping/Rendering specifically — no dispatcher registered for either capability — though the gate machinery is real and would bind the moment a dispatcher is registered.
  4. Inbox approve, membership-gated only (inbox.py:277-337) — covers Shape confirmation, assertion commit/discard, render retire, composition accept/revise. This is the mechanism that actually reaches shape confirmation, and per §1 above it is not chat-reachable; any operator/contributor member can hit it directly on the inbox surface, with no delegation check.

2. Question two — what the Companion currently knows

2.1 The rooms it can act in — per room, per operation

Memory. Reads: ask_about_past_input (router.py:1529-1565), recall_personal (:1568-1643), show_held (:897-954), find_files (:957-1075) — all call the data-layer list_assertions (src/loomworks/engagement/assertions.py:807) directly, bypassing the HTTP router. Writes: add_knowledge, commit_assertion, discard_held, change_assertion, remember_about_me, forget_about_me (§1.1). Full read and write access via chat.

Manifestation. A grep for "manifestation" across src/loomworks/orchestration/router.py returns zero matches. No intent branch reads or writes anything in manifestations.py. No reachable read or write path from chat at all.

Shaping. Read: only an aggregate count, via ask_about_progress → raw SQL SELECT COUNT(*) FROM shaping_jobs ... and a current_memory_objects count filtered to object_type='shape_event' AND payload->>'state'='pending_confirmation' (router.py:1449-1468) — counts only, never shape-event or consideration content. No Companion route calls list_shapings, the shape-events list, or the considerations list for content. Write: none direct; request_draft/request_revision route through the delegation/authorization layer, which currently dead-ends for this capability (§1.1). Aggregate-count read only; no content read; no working write.

Rendering. Read: request_download_list_artifacts_for_engagement (router.py:1646-1733), raw SQL against render_events_view filtered to state='produced' — a lightweight produced-artifact listing, bypassing the HTTP router. ask_about_progress also counts render_jobs and produced render_events_view rows, aggregate only. Write: none direct; same delegation dead-end as Shaping via request_draft/request_revision (capability CAPABILITY_INITIATE_RENDER, router.py:52, 1926). Narrow produced-artifact-list read plus aggregate counts; no working write.

2.2 How a request becomes an operation

Classification: classify_intent (classifier.py) against the fixed IntentLabel taxonomy (:48-154), confidence-thresholded at 0.7 (:43). Dispatch: route_intent() (router.py:3660), one if intent == ... branch per label. A request matching none of them, or matching below threshold, collapses to general_conversation and — per §1.3 — reaches no handler at all; it gets an unconstrained conversational reply with no engine call attached.

2.3 What the model is told about the rooms

The persona does not name any of the four rooms as capabilities. src/loomworks/orchestration/prompts/companion_persona.md:18 instructs the model to actively avoid the engine vocabulary that names three of the four rooms:


Never use engine vocabulary. Never say: engagement, assertion, shape, render, manifestation, specialist, materializer, normative_force, held, committed, retracted. Use Operator vocabulary: project, note, specification, artifact, draft, saved, waiting.

Nowhere in companion_persona.md or in assemble_prompt() (src/loomworks/orchestration/prompt.py:856-1000) is a room-catalog or room-name block assembled for the model. The classifier prompt (a separate asset, seen only by the classification LLM, not the responder) contains exactly one literal room mention, as a single worked example: src/loomworks/orchestration/prompts/intent_classifier.md:306, "Open the rendering room." → its navigation intent. This does not enumerate all four rooms and does not resolve to any capability that reaches Shaping/Rendering writes. A Companion that cannot name a room cannot be asked to work in it, and per the persona instruction quoted above, it is actively told not to name three of the four — this is a knowledge gap, separate from and prior to the action gap in §1.

2.4 The server-composed intents

Not independently re-enumerated this session beyond what §1.1/§1.5 already covers — the intents that bypass the model and compose replies directly were not separately catalogued; flagged as unread for a complete list. The pattern is established by example: _route_recall_personal's empty/non-empty branch (router.py:1617-1626) is server-composed prose around retrieved data, not model-generated.


3. Question three — what teaching it three rooms would actually mean

Enumeration only, per the brief's fence (no design, no recommendation).

Manifestation

3.1 Operations the engine exposes (src/loomworks/api/routers/manifestations.py): read — list, get-by-id, preview (a stated "pure read," manifestations.py:5); write — derive.

3.2 Conversationally plausible vs. surface-only. "What manifestations exist for this project" / "show me the latest manifestation" are plausible sentences mapping to the list/get reads. "Derive a manifestation" is plausible as a sentence too, in principle — it is a single triggering action, not a form with fields to fill conversationally, so it is closer to the surface-action end than a multi-field request like add_knowledge.

3.3 Build vs. wire. The read and write routes already exist in the HTTP router; nothing here needs building to expose room content — a Companion intent for "list/describe manifestations" would call the existing manifestations.py reads the same way ask_about_past_input calls list_assertions directly today. This mirrors the pattern the other rooms' builds found repeatedly (per the brief's framing): the engine already serves what a surface needs.

Shaping

3.1 Operations exposed (shapings.py, shape_events.py, declared_shape_types.py, considerations.py): reads — list/get shapings, list/get shape-events, list/get considerations, list dismissal-patterns, list declared-shape-types; writes — create/patch/delete shaping, create shape-event, confirm/retire/title shape-event, consideration summarize/surface/deeper-dive/close.

3.2 Conversationally plausible vs. surface-only. "What's waiting on my confirmation" (a content-bearing version of what ask_about_progress today only counts) is plausible. Confirm itself is the sharpest example of a surface-only action named directly in the brief: it is a control that, per the brief's own framing, "shows what it will produce before it is pressed" — the inbox card presumably surfaces a preview before the Operator hits approve (not independently re-verified this session at the UI layer). A sentence cannot carry the same "look before you commit" property a button click adjacent to a preview can. Consideration summarize/surface/deeper-dive read more like conversational shapes; close is closer to a decision act.

3.3 Build vs. wire. Reads for shape-event/consideration content (not just counts) would be wiring, using the same direct-data-layer-call pattern the Memory-room routes already use. Confirm specifically is the one write in this room that this brief's own framing (§14 of the brief, restated in its kickoff) says should not be reached by a vague instruction — whether it should be reachable by any chat instruction, even a precise one, is a design question this brief does not answer, per its fence.

Rendering

3.1 Operations exposed (renders.py): reads — list renders, get render-job, get render, download/content/files, candidates/supported-formats, list declared-render-types; writes — create render, ad-hoc render, retire render, declared-render-type creates.

3.2 Conversationally plausible vs. surface-only. "What renders exist / are ready to download" is plausible, and partially already implemented narrowly (request_download's produced-artifact listing). "Retire a render" and declared-render-type management read as administrative/surface actions, not natural sentences. "Create a render" sits on the same delegation-gated path as request_draft today, currently dead-ended by the missing dispatcher (§1.1/§1.5).

3.3 Build vs. wire. Listing renders with content (not just counting) would be wiring, extending the existing raw-SQL artifact-listing pattern already used by request_download. The dispatcher gap (§1.1) is the one place where something is missing rather than merely unwired — initiate_render has no registered dispatcher at all, so a chat-triggered render creation has no code to call even once authorization is granted.


4. Question four — what must not break

4.1 Membership

The entire conversation sits behind exactly one membership check, run once at the top of operator_converse: verify_project_membership(person=person, project_id=engagement_id, db=session) (src/loomworks/orchestration/routers/converse.py:710), calling get_membership_for_person_on_engagement (src/loomworks/persons/memberships.py:293). Every Companion room-read handler reachable from route_intent on a caller-supplied engagement_idask_about_past_input, ask_about_progress, show_held, find_files, request_download — performs no additional membership check of its own; each trusts the single upstream gate. This is the "fenced at the endpoint, once" pattern the standing rule describes, with the conversation entry point playing the role of the endpoint for everything reachable beneath it.

One documented exception: recall_personal, remember_about_me, forget_about_me never call verify_project_membership — they scope instead to person.personal_engagement_id, a value derived from the authenticated caller's own account (router.py:203-213), not attacker-suppliable. This is not a cross-tenant leak, but it is genuinely a path that sits outside the named membership gate — worth naming precisely because the brief asks whether every operation the Companion can reach sits behind the same gate, and this one does not, by a different but still-sound mechanism.

Manifestation, Shaping, and Rendering's own HTTP endpoints each carry their own per-endpoint membership dependency (e.g. manifestations.py:285, shapings.py:168, renders.py:288) — but since the Companion doesn't call those HTTP routes today (only raw SQL inline in router.py for aggregate counts, and one raw-SQL artifact listing), those room-specific gates are not currently in the Companion's call path at all; the converse-level check is what stands in for them today, and any future direct call to those HTTP routes would need to either reuse that pattern or inherit the routes' own dependency.

4.2 The room-state contract

The Companion can report on Memory-room contents (ask_about_past_input, recall_personal). list_assertions (src/loomworks/engagement/assertions.py:807-874) runs its SQL with no try/except, and operator_converse wraps the call to route_intent with no try/except either (converse.py:957-969). A genuine read failure propagates as an unhandled exception, not as a fabricated "empty" result. The empty/non-empty branch in _route_recall_personal (router.py:1617-1626) is only reached after list_assertions has already succeeded and returned an empty list — a failed read never arrives at that branch to be mislabeled. The conversation path inherits the contract; it does not bypass it, by construction — there is no swallow-and-relabel found anywhere on this path.

One narrower, distinct case: _route_find_files treats an HTTPException from an LLM matching call as "no matches" (router.py:1033-1034) — but this is the fuzzy-matching step, not the underlying assertion read, and the code's own comment (:983-985) documents it as an intentional degrade-not-fail policy for that specific step, not a case of a read failure being mislabeled empty.

4.3 Sources

AnswerSource (src/loomworks/orchestration/schemas.py:728-747) and ConverseResponse.sources (:993-1002, docstring: "Null when this turn's intent didn't retrieve material") are constructed at converse.py:1225-1240:


sources: list[AnswerSource] | None = None
if route_result is not None:
    retrieved = route_result.operation_data.get("assertions")
    if retrieved is not None:
        sources = [
            AnswerSource(item_id=a["item_id"], display_number=a["display_number"])
            for a in retrieved
        ]

The coupling is to the literal key "assertions" on operation_data — hard-coupled, not generic. Only _route_ask_about_past_input populates that exact key. _route_recall_personal returns the same kind of retrieval-backed data under a different key, "personal_memories" (router.py:1638-1641), and produces no sources. _route_show_held ("held_items"), _route_find_files ("matched_files"), _route_request_download ("artifacts"/"all_artifacts") likewise produce no sources despite each being a real, retrieval-backed answer. An answer about Manifestation, Shaping, or Rendering content — were one built — would carry no sources unless it were separately wired into this same one-key check; the channel does not generalize on its own.


5. Corrections preserved

None. No misstep occurred in this session; both repositories were confirmed unmoved and clean throughout, no database was touched, and no model call was made.

6. Unread / unrun


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — B-8 Step 0 findings — v0.1 — 2026-08-05 The wiring for the sharpest case was never built — which is a different finding than the wiring being safe.