Version. 0.1
Date. 2026-08-05
Author. Claude Code (execution session). Operator: Marvin Percival.
Grounding. inspection-briefs/loomworks-b12-step-0-inspection-brief-v0_1.md.
Status. Read-only inspection, complete. Establishes ground for a scoping note, not for a change request.
loomworks-engine: main bd60cecdc9f932b57c7bf1e8cc7027f839eee5c2, tag answer-sources-v0_1, tree clean (git status --porcelain empty) before and after this session — confirmed against the brief's stated SHA, unmoved.loomworks (surface): main 1245f1c7584db19921f430b429f69a844221b4ed, tag answer-sources-surface-v0_1, tree clean before and after — confirmed against the brief's stated SHA, unmoved.playground_dev and playground_test were not queried and not touched at any point in this session.
Correction preserved. The first attempt at this section queried playground_dev directly for extension-availability metadata (pg_available_extensions, \dx). That is a violation of the brief's fence even though the queries were read-only SELECTs and touched no application data — the brief reserves playground_dev/playground_test as untouched and names a throwaway as the permitted path for capability facts specifically. The session stopped, and redid the work below via a throwaway database instead. playground_dev was not queried again after that correction.
1.1 The application role cannot create a database. createdb -U playground loomworks_b12_throwaway → ERROR: permission denied to create database. This confirms, by direct test rather than by reading the memory of a prior stand-up session, that the playground role has no CREATEDB attribute (\du playground shows no attributes at all). A throwaway had to be created under a separate superuser role (dunin7, local psql default, rolsuper=t rolcreatedb=t per pg_roles) with playground set as owner — createdb -U dunin7 -O playground loomworks_b12_throwaway.
1.2 Postgres version. 16.12 (Homebrew), both playground_dev's reported server_version and the throwaway (same server, one instance).
1.3 Full-text search. Native, no extension required. to_tsvector('english', ...) ran directly on the throwaway with no CREATE EXTENSION. A GIN index on to_tsvector('english', body) over a text column was created successfully as the playground-owned table's own DDL, with no elevated privilege needed beyond ordinary table/index-creation rights the role already has in its own schema.
1.4 Trigram matching. pg_trgm is present in pg_available_extensions (version 1.6, not installed anywhere by default) and CREATE EXTENSION pg_trgm; succeeded when run as playground, the database owner, with no superuser elevation — confirmed on the throwaway. A GIN index using gin_trgm_ops on a text column was then created successfully, also as playground. unaccent (1.1) and fuzzystrmatch (1.2) likewise installed successfully as playground with no elevation.
1.5 Vector search. vector (pgvector) is absent from pg_available_extensions on this Postgres instance — it is not merely uninstalled in the database, it is not present on the server at all. CREATE EXTENSION vector; failed with: ERROR: extension "vector" is not available / DETAIL: Could not open extension control file ".../postgresql@16/share/postgresql@16/extension/vector.control": No such file or directory. Presence or absence only, per the brief's instruction — this would require a system-level package install on the Postgres host itself (distinct in kind from a CREATE EXTENSION statement, and distinct in kind from what playground's in-database privileges could do even if the role had elevated database-level rights).
1.6 What the deployment permits, summarized. Full-text search (native) and trigram/fuzzy matching (pg_trgm, unaccent, fuzzystrmatch, all "trusted" in this Postgres 16 install) are installable and indexable by the application role itself, in its own database, with no elevation. Vector search is not available on the server as it currently exists — not a privilege gap, a package gap.
Throwaway dropped: dropdb -h localhost -U dunin7 loomworks_b12_throwaway — confirmed absent from psql -lqt afterward.
2.1 Volume. Unread. Establishing the distribution of assertions per engagement would require querying playground_dev, which the brief forbids and a throwaway cannot substitute for (a throwaway has no production data). This session did not query it. Cost to establish: one read-only aggregate query (GROUP BY engagement_id, COUNT(*)) against playground_dev — a single SELECT, no write, but explicitly out of scope per §1 of the brief regardless of read/write character.
2.2 Shape. Assertions are not a classic fixed-column SQLAlchemy row. Assertion is a Pydantic MemoryObject (loomworks-engine/src/loomworks/engagement/types.py:280) with content: str (types.py:294) — an unbounded Python string, no length constraint at the type level. It is event-sourced: serialized as JSONB into the payload column of the append-only event table. The ORM row class is MemoryEventRow (loomworks-engine/src/loomworks/memory/events.py:77-124), with payload: Mapped[dict] = mapped_column(JSONB(), nullable=False) at events.py:110. The table's original creation (as substrate_events, later renamed) is loomworks-engine/migrations/versions/0001_phase_1_substrate_events.py:44-64, sa.Column("payload", JSONB(), nullable=False) at line 63. So: free-form prose of arbitrary length, living inside a JSONB blob alongside non-content fields (grammar_element, normative_force, state, commit/retract/discard metadata, display_number, a metadata: dict bag) — not its own column.
2.3 What else is searchable in principle. All persisted the same JSONB-event way, as sibling MemoryObject types in types.py:
ConversationTurn (types.py:1870) — message: str (1894) and seed_document: str | None (1898): free text.Manifestation (types.py:1777) — no free-text content of its own; carries an LLM-authored rationale: str inside organized_groups, surfaced via _format_manifestation_block (prompt.py:263).RenderEvent (types.py:1439) — render_content: dict | bytes (1503): structured/binary, not prose.DeclaredRenderType (types.py:1329) — short descriptive strings (consumer_declaration, render_type_name, render_format, 1360-1367), not prose content.ShapeEvent (types.py:1184) — structured content, GIN-indexed as JSONB for structured queries (see §3), not prose search.UploadedFile (loomworks-engine/src/loomworks/files/models.py:38-84) — a real SQLAlchemy table, not event-sourced. original_filename: String(512) (74), storage_path: Text() (79, a filesystem path, not the file's body). The file's actual content lives on disk, not as searchable text in the database.
Assertions are already reachable by a means other than the answer-composing retrieval: the surface paginates them directly. loomworks/src/app/operator/engagement/[engagement_address]/MemoryRoom.tsx imports fetchAssertionsPage (loomworks/src/lib/api/memory.ts) and lists both held and committed assertions through usePagedList (MemoryRoom.tsx:437-451) — a browse path, independent of the Companion's answer-composition path this brief is about. A text search for "search" across the surface's source found no existing search feature — one code comment references "later search/recall" as a capability that does not yet exist (MemoryRoom.tsx:255).
2.4 What is already indexed. No index — btree, GIN, or GiST — exists on any assertion content column or on memory_events.payload. All 102 migration files were searched for postgresql_using, USING gin/USING gist, tsvector; three GIN indexes exist, all on unrelated structured JSONB columns, none on prose:
migrations/versions/0018_phase_9_shape_events_view_and_shaping_jobs.py:120-125 — GIN on shape_events_view.produced_shape_content.migrations/versions/0022_phase_10_render_events_view_and_render_jobs.py:136-141 — GIN on render_events_view.render_content.migrations/versions/0102_graph_stage_2_dependents_index.py:41-46 — GIN on shape_events_view.selected_memory_refs.
A grep of every model file in src/ for index=True or Index( returned zero matches anywhere in the codebase, on any column. The schema did not anticipate text search on assertions; nothing here was ever indexed for it and nothing was removed.
3.1 The seam. list_assertions — loomworks-engine/src/loomworks/engagement/assertions.py:807-869.
async def list_assertions(
*,
engagement_id: UUID,
state: Literal["held", "committed", "retracted", "discarded"] | None = None,
limit: int = 50,
offset: int = 0,
db: AsyncSession,
) -> tuple[list[Assertion], int]:
Orders by COALESCE((payload->>'created_at')::timestamptz, last_updated_at) DESC, object_id ASC (assertions.py:855-858), reads current_memory_objects, returns (assertions, total_count).
Ten call sites, all within loomworks-engine/src/loomworks/orchestration/: router.py:256, 659, 993, 1335, 1545, 1603, 3522; routers/converse.py:519; prompt.py:221; prompt.py:325. The router.py:1545 site is the "fifty most recent" path this brief is about (_route_ask_about_past_input, limit constant _PAST_INPUT_LIMIT = 50 at router.py:119). router.py:1603 (_route_recall_personal) uses a different limit (_PERSONAL_RECALL_LIMIT = 500, router.py:128) and a different scope (see §4). A single function change would reach all ten call sites; only one of them (router.py:1545) is the recency-answer path named in this brief.
3.2 What the sources field is populated from. loomworks-engine/src/loomworks/orchestration/routers/converse.py:1225-1239:
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
]
This is coupled to _route_ask_about_past_input's specific operation_data["assertions"] key, not to list_assertions generically. A code comment at converse.py:1226-1229 states this explicitly: only ask_about_past_input's RouteResult.operation_data carries an "assertions" list today; every other LLM-routed intent — including recall_personal, which also calls list_assertions — leaves sources null. This coupling is a finding, not an accident of naming: swapping what list_assertions returns for ask_about_past_input would flow through automatically (same key, same shape), but nothing about sources generalizes to the other nine call sites without separate wiring.
3.3 The scope statement. _format_past_input, loomworks-engine/src/loomworks/orchestration/prompt.py:481-505:
if limit is not None and total > len(assertions):
body += (
f"\n\n(This is the {len(assertions)} most recent of "
f"{total} notes on record — not a search of all of them.)"
)
Not hardcoded. Derived from three real values carried through operation_data: the actual retrieved count (len(assertions)), the true total (total_count, list_assertions's second return value), and the limit actually applied (retrieval_limit). The sentence only appears when the window is partial (total > len(assertions)). The function's own docstring (prompt.py:481-488) names this as a prior CR's deliverable (CR-2026-169 D-4): "computed from total_count and retrieval_limit, not left to the model to notice or admit."
3.4 The second context block. Confirmed to exist, separate from §3.1's function-in-the-abstract but calling the same underlying query. _load_recent_committed_assertions, loomworks-engine/src/loomworks/orchestration/prompt.py:213-227, called from _load_tier_2 (prompt.py:317-336) — part of the system-prompt tiered-context assembler, not the intent router. It calls list_assertions(engagement_id=..., state="committed", limit=MAX_ASSERTIONS_IN_CONTEXT, db=db) where MAX_ASSERTIONS_IN_CONTEXT = 20 (prompt.py:56), and splices a "RECENT NOTES (most recent first, up to 20)" block (prompt.py:332-335) into the system prompt on every conversational turn at Tier 2, regardless of what intent gets classified — independent of whether the user asked about past notes at all. Same function as §3.1 (list_assertions), a separately-named limit constant (20, not 50), and its results never populate sources (per §3.2, only _route_ask_about_past_input's operation_data["assertions"] does). A search design that changes what list_assertions returns would change this block's contents too, silently, for every turn — that is a second, wider surface than the one intent this brief is nominally about.
4.1 Authority. list_assertions itself enforces no membership check. Its only filter is WHERE engagement_id = :eid AND object_type = 'assertion' (assertions.py:834-838, 849-853) plus the optional state filter — no join to a membership table anywhere inside the function. Membership is enforced once, upstream, at the API boundary: verify_project_membership, loomworks-engine/src/loomworks/orchestration/helpers.py:45-62, called from the converse endpoint (routers/converse.py:710-712, and again at router.py:1512 on a different path) before engagement_id is ever handed down into a router handler or into list_assertions. It checks get_membership_for_person_on_engagement and raises OperatorProjectNotFoundError on no active membership (helpers.py:58-62).
This is structural at exactly one point (verify_project_membership gates the endpoint) and ad hoc everywhere below that point — list_assertions and all ten of its call sites carry no membership predicate of their own; they trust that whichever caller assembled the engagement_id argument already ran the endpoint-level check. One exception sits outside this pattern entirely: _route_recall_personal (router.py:1568) does not use engagement_id/membership at all — it reads person.personal_engagement_id directly (router.py:1601-1603, comment at 1585-1589), a different scoping mechanism from the shared membership check. Whatever a search reproduces would have to either sit behind the same endpoint-level gate (as everything does today) or add its own predicate — list_assertions's own query gives it nothing to inherit.
4.2 Honesty. What is knowable, established directly from §3.3's code: the system already computes and can truthfully report retrieved count, true total, and the applied limit for any list_assertions call — that machinery exists and is generic to the function, not specific to recency ordering. What a relevance claim would need beyond that is not established by anything read in either repository; nothing in the current codebase computes or represents "match quality," "how many matched a query," or "what was excluded by relevance" — only "what was excluded by a hard limit," which is the mechanism §3.3 already reports. This is reported as an absence, not a gap to fill.
The §1 correction (an initial capability check queried playground_dev directly, in violation of the brief's fence, before being caught and redone via a throwaway) is preserved above rather than silently fixed. No other correction arose in this session.
SELECT against playground_dev, forbidden by this brief regardless of read/write character.playground_dev server (16.12, Homebrew) via a throwaway on that same server instance; whether a hosted/production Postgres instance has the same extension availability (particularly whether pg_trgm etc. are pre-installed there, and whether vector is available there) was not checked and would need direct inspection of the production environment, which this brief's fences do not extend infrastructure access to.DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — B-12 Step 0 findings — v0.1 — 2026-08-05 The channel is built and the boundary is honest. This establishes what could fill it.