Document. loomworks-topic-filtered-recall-step-0-report-v0_1
Version. v0.1 — 2026-07-29
Status. Step 0 inspection report for CC, answering loomworks-topic-filtered-recall-step-0-inspection-brief-v0_1 (inspection-briefs/). Read-only; nothing edited, nothing committed in code repos.
Governing scoping note. loomworks-topic-filtered-recall-scoping-note-v0_1 (scoping-notes/).
Engine repo. /Users/dunin7/loomworks-engine, branch main.
SHA. 1aac8154ca440b831f5ba2bd0fc02d6af890614d (1aac815) — Archive CR-2026-155 v0.2 alongside executed v0.1.
Drift from the brief's grounding SHA (1aac815): NONE. The repo is at exactly the SHA the prior grounding was taken against. Every line reference in the brief re-verified at this SHA:
| Brief's claim | Verified |
|---|---|
| _PAST_INPUT_LIMIT = 50 at router.py:119 | ✅ exact |
| sole use at :1548 | ✅ exact (inside _route_ask_about_past_input) |
| _route_ask_about_past_input at :1529–1563 | ✅ exact |
| _route_recall_personal at :1566+ | ✅ exact |
| _format_past_input at prompt.py:481–492 | ✅ exact |
| intent labels at classifier.py:94–103 | ✅ exact |
| dispatch at router.py:~3832 | ✅ exact (:3832–3834) |
| zero FTS artifacts repo-wide | ✅ re-confirmed — see V6 |
Working tree carries the same single pre-existing modification noted in the file list: M uv.lock. No other changes.
Database. playground_dev, PostgreSQL 16.12 (Homebrew, aarch64-apple-darwin24.6.0).
list_assertions or a siblingsearch_assertions.But with a finding that outranks the extend/sibling question and reshapes the CR's data model: there is no assertions table, and no content column.
list_assertions reads current_memory_objects — the materialized projection of the event log — where the assertion body is payload->>'content' inside a jsonb column. Live DDL from playground_dev:
Table "public.current_memory_objects"
Column | Type | Collation | Nullable | Default
-----------------+--------------------------+-----------+----------+---------
engagement_id | uuid | | not null |
object_id | uuid | | not null |
object_type | character varying(64) | | not null |
current_version | integer | | not null |
payload | jsonb | | not null |
last_updated_at | timestamp with time zone | | not null |
Indexes:
"pk_current_memory_objects" PRIMARY KEY, btree (engagement_id, object_id)
"ix_cmo_assertion_sort_key" btree (lw_assertion_sort_key(payload, last_updated_at) DESC, object_id) WHERE object_type::text = 'assertion'::text
"ix_cmo_shaping_created_at" btree ((payload ->> 'created_at'::text) DESC, object_id) WHERE object_type::text = 'shaping'::text
Assertion payload keys observed live: id, engagement_id, object_type, version, created_at, provenance, content, grammar_element, normative_force, state, display_number, metadata, committed_at, committed_by, retracted_at, retracted_by, retracted_rationale, discarded_at, discarded_by, was_revision_of.
Consequence for the CR. The GIN index is not CREATE INDEX ... USING gin (to_tsvector('english', content)) on an assertions table. It is an expression index over a JSONB extraction, partial on object_type, in the shape the two existing assertion indexes already establish:
CREATE INDEX ix_cmo_assertion_fts
ON current_memory_objects
USING gin (to_tsvector('english', payload ->> 'content'))
WHERE object_type = 'assertion';
payload ->> 'content' yields text, and to_tsvector('english', text) is IMMUTABLE, so an expression index is viable with no generated column and no schema change to the table. The partial predicate matches the precedent set by ix_cmo_assertion_sort_key and ix_cmo_shaping_created_at — both are partial on object_type, both are expression indexes. The CR should follow that precedent exactly.
A generated column is the other viable route but is strictly worse here: it would add a stored column to a table shared by every memory object type (assertion, shaping, render, manifestation, …), of which only assertions have a content to index. The expression index costs nothing to the other types.
list_assertions signature, verbatim
src/loomworks/engagement/assertions.py:795:
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]:
Its filter composition is raw SQL string interpolation, not SQLAlchemy expression composition:
state_filter = ""
params: dict[str, Any] = {"eid": str(engagement_id)}
if state is not None:
state_filter = "AND payload->>'state' = :state"
params["state"] = state
…interpolated into two text() blocks (a COUNT and a paged SELECT) via f-string. Ordering is COALESCE((payload->>'created_at')::timestamptz, last_updated_at) DESC, object_id ASC.
The pattern does compose cleanly — a second optional fragment would slot in identically. But note the ordering is hard-wired to recency in both the COUNT and the SELECT; a ts_rank order is not an added filter, it is a different ORDER BY and a different meaning for the returned total_count. That is the real argument against extending.
There is a sibling already: list_assertions_page (:865) — same filter, keyset pagination, sorted on the IMMUTABLE wrapper lw_assertion_sort_key. The codebase's own answer to "different retrieval contract → new function beside it" is already on the page.
Production call sites of list_assertions: 10, across 3 modules.
| Path:line | Caller |
|---|---|
| orchestration/router.py:256 | (route) |
| orchestration/router.py:659 | held list |
| orchestration/router.py:993 | _route_find_files |
| orchestration/router.py:1335 | committed list |
| orchestration/router.py:1545 | _route_ask_about_past_input — the Tier-1 target |
| orchestration/router.py:1601 | _route_recall_personal |
| orchestration/router.py:3520 | (route) |
| orchestration/prompt.py:221 | _load_recent_committed_assertions (Tier-2 block) |
| orchestration/prompt.py:325 | total-count probe (limit=1) |
| orchestration/routers/converse.py:518 | held probe |
Test call sites: 30, across 8 modules (test_cr_2026_121, _127, _128, _130, _133, _154, test_phase_43_router, plus imports).
list_assertions_page has exactly 1 production caller (api/routers/assertions.py:642).
Add search_assertions beside list_assertions, do not extend it. Three reasons, in order of weight:
list_assertions returns newest-first with total_count = "how many match the state filter". Tier 1 needs rank-first with two counts — matched_count and total_count (the honest-selection readout the scoping note's decision (3) requires). Bolting a search_query param on means the function's ordering and its total_count semantics silently change meaning depending on whether one optional argument is present. That is the kind of implicit mode-switch the codebase's own list_assertions_page split avoided.list_assertions_page is the standing example of "same table, different retrieval contract, own function, own index." Tier 1 is the third member of that family.
The sibling should take engagement_id, search_query: str, state, limit, db, and return (assertions, matched_count, total_count) — or (list[tuple[Assertion, float]], matched_count, total_count) if the CR wants the rank surfaced.
Repo head. uv run alembic heads → 0102 (head). Single head; no branch to merge.
Database head. SELECT version_num FROM alembic_version on playground_dev → 0102.
They agree. The GIN-index migration lands as 0103, linear, no merge revision needed.
Content column type. Per V1-A: not plain TEXT. The content is payload ->> 'content' out of a jsonb column. The ->> operator returns text, so:
object_type values has content to index.
Row count on playground_dev.
| Scope | Rows |
|---|---|
| current_memory_objects where object_type='assertion' | 235 |
| — of which state='committed' | 75 |
| — discarded | 59 |
| — held | 54 |
| — retracted | 47 |
At 235 rows the index build is instantaneous; CONCURRENTLY is not required on dev. The CR should still state the production posture explicitly rather than let dev's size decide it.
_route_ask_about_past_input is a two-line change — one dispatch line, one signature line. No shared route contract is touched.
route_intent (router.py:3658) takes the Operator's text as a required positional-by-keyword parameter message: str:
async def route_intent(
classified: ClassifiedIntent,
*,
person: Principal,
host_account: "HostAccount | None" = None,
engagement_id: Optional[UUID],
message: str,
db: AsyncSession,
recent_turns: Optional[list[ConverseTurn]] = None,
request: Optional[Any] = None,
llm_client: "Optional[LLMClient]" = None,
surface_context: Optional[str] = None,
) -> Optional[RouteResult]:
The ask_about_past_input dispatch, verbatim (router.py:3832–3834):
if intent == "ask_about_past_input":
return await _route_ask_about_past_input(
person=person, engagement_id=engagement_id, db=db,
)
An utterance-consuming route's dispatch, verbatim — find_files (router.py:3784–3789):
if intent == "find_files":
return await _route_find_files(
engagement_id=engagement_id,
message=message,
db=db,
)
And remember_about_me (router.py:3808–3816):
if intent == "remember_about_me":
return await _route_remember_about_me(
classified=classified,
person=person,
host_account=host_account,
recent_turns=history,
message=message,
db=db,
)
Parameter name carrying the operator message: message — same name at the dispatch and inside both consuming routes. _route_find_files declares it plainly (router.py:957–962):
async def _route_find_files(
*,
engagement_id: Optional[UUID],
message: str,
db: AsyncSession,
) -> RouteResult:
There is no shared route contract to reshape. Routes are plain keyword-only coroutines dispatched by an if intent == ... chain; each declares exactly the parameters it uses. Adding message: str to _route_ask_about_past_input and message=message, to its dispatch is local and mechanical.
Note for the CR — an existing search precedent at the same seam. _route_find_files (router.py:957) already does query-driven selection over assertions using the Operator's message: it pulls state=None, limit=500, filters in Python to AS_CURRENT_STATES + a source_file_id, then hands candidate descriptions plus query=message to find_matching_candidates (an LLM relevance pass). It degrades to "no matches" when no LLM client resolves — "the truthful-failure discipline every other LLM-backed capability in this codebase follows." Tier 1's Postgres-FTS path is a different mechanism, but find_files is the standing precedent for (a) the utterance reaching a retrieval route and (b) honest-empty behavior on zero matches, which the scoping note's decision (3) requires. The CR should cite it.
_route_ask_about_past_input returns operation_data with no delegated_response (router.py:1544–1562):
assertions, total = await list_assertions(
engagement_id=engagement_id,
state="committed",
limit=_PAST_INPUT_LIMIT,
db=db,
)
return RouteResult(
project_id=engagement_id,
operation_data={
"assertions": [
{
"display_number": a.display_number,
"content": a.content,
}
for a in assertions
],
"total_count": total,
},
)
The responder renders it via _format_past_input (prompt.py:481–492), dispatched at prompt.py:648–649:
def _format_past_input(operation_data: dict) -> str:
"""Render ask_about_past_input assertions as a numbered list."""
assertions = operation_data.get("assertions", [])
if not assertions:
return "(No saved notes yet.)"
lines: list[str] = []
for a in assertions:
n = a.get("display_number")
marker = f"#{n}" if n is not None else "#?"
lines.append(f"{marker}: {a.get('content', '')}")
return "\n".join(lines)
if intent == "ask_about_past_input":
return _format_past_input(operation_data)
Note _format_past_input reads only assertions — it never reads total_count. The count is placed in operation_data but is not rendered into the prompt at all. The 50-cap is therefore invisible to the responder today: if 200 assertions exist and 50 come back, nothing in the rendered block says so. That is a live truthfulness gap independent of Tier 1, and it is the same class of gap CR-2026-129 closed on the personal path.
_route_recall_personal (router.py:1615–1641) server-composes and bypasses the responder:
count = len(memories)
if count == 0:
message = "I don't have anything recorded about you yet."
else:
listing = "\n".join(f"— {c}" for c in memories)
if count == 1:
header = "Here's the one thing I have recorded about you:"
else:
header = f"Here are the {count} things I have recorded about you:"
message = f"{header}\n{listing}"
# Faithful count at the retrieval boundary: if the store held more than
# the limit returned, say so rather than imply the list is complete.
if total > count:
message += f"\n(Showing {count} of {total} recorded.)"
delegated = _companion_error(message)
return RouteResult(
operation_data={
"delegated_response": delegated,
"personal_memories": [
{"content": a.content} for a in assertions
],
"total_count": total,
},
)
The if total > count clause is the exact shape Tier 1's honest-selection readout needs, one generalization removed: total > count becomes matched_count vs total_count, and the zero case becomes the scoping note's decision (3) — answer honestly, offer the record, no silent recency fallback.
Take the delegated_response route. Three reasons:
_route_remember_about_me's "held" claim (router.py:3325+, gated on the completed add_assertion).recall_personal and ask_about_past_input are described in the source as siblings (router.py:1574, 1584, 1593). Leaving one server-composed and the other responder-composed after the engagement path gains a selection contract is the asymmetry the CR should close.
Counter-consideration the CR must weigh. The V5 test pins the responder-composed shape deliberately, as proof CR-2026-129 did not touch Path 3. Adopting delegated_response here is not a regression — it is the carve-out being taken up as intended. The CR must say so and revise that test in the same change (see V5).
router.py:116–119:
# Maximum committed assertions returned to the responder for
# ask_about_past_input. Aligned with the prompt assembler's
# Tier-2 limit so the responder sees a comparable view.
_PAST_INPUT_LIMIT = 50
There is no code coupling. _PAST_INPUT_LIMIT appears in exactly three places, all in router.py: the definition (:119), a comment reference (:125), and the sole use (:1548). No other module imports or references it.
And the comment's factual claim is wrong at this SHA. The prompt assembler's Tier-2 limit is:
MAX_ASSERTIONS_IN_CONTEXT: Final[int] = 20 # prompt.py:56
used at prompt.py:320 in _load_recent_committed_assertions. 20 ≠ 50. The two limits are not aligned and are not coupled; they have drifted, and only a comment asserts otherwise.
Consequence. Nothing breaks if _route_ask_about_past_input returns a ranked subset with matched_count + total_count instead of the 50-recency window. Nothing in the engine reads _PAST_INPUT_LIMIT but that one route; nothing reads operation_data["total_count"] for ask_about_past_input but the (unused) formatter path. The CR should:
MAX_ASSERTIONS_IN_CONTEXT = 20, recency) remains recency-ordered and untouched — Tier 1 changes the route, not the ambient context block. That block staying recency-based is also the thing that makes decision (3) load-bearing: the responder will still have 20 recent assertions in front of it on a zero-match turn, which is exactly why the readout must be server-composed.
tests/test_cr_2026_129_recall_truthfulness.py:182–212:
async def test_ask_about_past_input_untouched_no_delegated_response(
db: AsyncSession,
):
"""ask_about_past_input is carved out to the semantic-recall foundation-
effort — it must remain responder-composed (NO delegated_response) after
this CR, proving Path 3 was not touched."""
eid = uuid.uuid4()
async with db.begin():
await db.execute(
text("INSERT INTO engagements (id) VALUES (:id)"), {"id": str(eid)}
)
person, _host, _peid = await create_person_with_personal_engagement(db)
held = await add_assertion(
engagement_id=eid, content="We chose drip irrigation.",
grammar_element="definition", actor=_person_actor(person.id), db=db,
)
await commit_assertion(
engagement_id=eid, assertion_id=held.id,
actor=_person_actor(person.id), db=db,
)
result = await _route_ask_about_past_input(
person=person, engagement_id=eid, db=db
)
# Still responder-composed: the real data is present, but NO server-composed
# reply (the carve-out is intact).
assert "delegated_response" not in result.operation_data
assert result.operation_data["total_count"] == 1
assert result.operation_data["assertions"][0]["content"] == (
"We chose drip irrigation."
)
Three things the CR must handle in this one test:
assert "delegated_response" not in result.operation_data — inverts if V4-C is adopted._route_ask_about_past_input(person=..., engagement_id=..., db=...) — the call gains message= per V3.
| File:line | What it pins | CR impact |
|---|---|---|
| tests/test_router.py:211 test_route_ask_about_past_input | Calls route_intent(..., message="what have I told you?"), asserts total_count == 2 and both contents present | Revises. The message is already passed; with Tier 1 the utterance becomes semantically load-bearing and the assertion set becomes match-dependent. |
| tests/test_router.py:245 test_route_ask_about_past_input_empty | message="show me my notes", asserts assertions == [], total_count == 0 | Revises — becomes the zero-match / empty-store distinction decision (3) requires. |
| tests/test_prompt_assembly.py:123 ..._fills_operation_result_for_past_input | Asserts "#1: Soil note." and "#2: Irrigation note." in the prompt | Revises if V4-C adopted (delegated_response bypasses the responder). |
| tests/test_prompt_assembly.py:222 test_format_operation_result_past_input_empty | assert out == "(No saved notes yet.)" | Revises — the empty-string is the current honest-empty; Tier 1 replaces it. |
| tests/test_recall_personal.py:155 | Comment referencing "the 50-cap of ask_about_past_input" and "nor any topic filter" | Comment update — the sentence becomes false. |
| tests/test_converse_integration.py:370 roundtrip | End-to-end; asserts classified_intent == "ask_about_past_input" | Likely survives; re-run to confirm. |
Not affected (intent-classification and prompt-asset pins only, no retrieval-shape assertions): test_classifier.py:226,237; test_phase_43_classifier.py:289; test_prompt_assets.py:86,138,147–153; test_phase_54_seed_commit_from_brief.py:861; test_cr_2026_124_open_close_intents.py:142; test_cr_2026_137_find_files.py:178; test_cr_2026_154_supersession_aware_answering.py:137.
That is the complete sweep of the 12 modules in recall-path-file-list-v0_1.txt.
1aac815.
$ grep -rniE "to_tsvector|tsquery|ts_rank|pg_trgm|tsvector" src/ migrations/
(no output)
The grep was widened beyond the brief's list to include bare tsvector and was run case-insensitively across both src/ and migrations/. Zero matches.
Corroborating: SELECT typname FROM pg_type WHERE typtype='e' on playground_dev returns zero enum types, and no FTS index appears in any table definition inspected.
There is no precedent to align with. Tier 1 introduces Postgres FTS to this codebase. The CR should therefore establish, not inherit, the conventions: the text-search configuration ('english'), the index shape, and whether to_tsvector is called inline or wrapped in an IMMUTABLE SQL function. Recommendation: wrap it, following lw_assertion_sort_key — the codebase's own precedent for "index expression used identically in the index and in the query so the planner uses it":
CREATE OR REPLACE FUNCTION public.lw_assertion_sort_key(payload jsonb, last_updated_at timestamptz)
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE SET "TimeZone" TO 'UTC'
AS $function$
SELECT COALESCE(
(payload ->> 'created_at')::timestamptz,
last_updated_at
)
$function$
An lw_assertion_fts(payload jsonb) RETURNS tsvector, IMMUTABLE PARALLEL SAFE, would be the exact sibling — and would keep the 'english' configuration in one place rather than repeated across migration and query, where a drift between them silently disables the index.
websearch_to_tsquery is available and behaves as hoped. Term extraction at the route is a no-op.
Postgres server version on playground_dev:
PostgreSQL 16.12 (Homebrew) on aarch64-apple-darwin24.6.0, compiled by Apple clang version 17.0.0 (clang-1700.6.3.2), 64-bit
websearch_to_tsquery has been available since PostgreSQL 11. 16.12 is comfortably past it. plainto_tsquery is not needed as a fallback.
Live result on playground_dev, verbatim:
$ psql -d playground_dev -tAc "SELECT websearch_to_tsquery('english', 'what did I say about the vesting schedule?');"
'say' & 'vest' & 'schedul'
Read what it did: it dropped the stop-words (what, did, I, about, the), dropped the question mark, and stemmed the content words (vesting → vest, schedule → schedul). The Operator's raw question goes in; a clean conjunctive tsquery comes out. No term-extraction code at the route.
Two consequences the CR should carry:
& is a conjunction, and that is the behavior to design around. 'say' & 'vest' & 'schedul' requires all three stems in a matched assertion. A committed note reading "Vesting cliff is 12 months" would not match, because it contains neither say nor schedul. This is the single largest design question Tier 1 has, and it is a product question, not a technical one: strict-AND gives high precision and many zero-match turns (which decision (3) now handles honestly), while an OR-shaped query plus a ts_rank cutoff gives recall at the cost of a ranked-relevance threshold the CR would have to justify. The report flags this for the CR to settle explicitly; it is not settled by the scoping note.
Note the residual verb 'say' — an artifact of the Operator's phrasing ("what did I say about…"), not a topic term. Under strict AND it is an active precision hazard on exactly the phrasings the feature is named for.
websearch_to_tsquery('english', 'what did I say?') reduces to an empty tsquery, which matches nothing. The route must distinguish "your question had no searchable terms" from "nothing in the record matches" — two different honest answers under decision (3), and the readout must not conflate them.
| # | Verification | Verdict |
|---|---|---|
| V1 | Retrieval seam | HOLDS-sibling — recommend search_assertions. Plus finding V1-A: no assertions table; content is payload->>'content' in jsonb; index is an expression index, partial on object_type. |
| V2 | Migration sequencing | HOLDS — single head 0102, repo and playground_dev agree. Not plain TEXT; expression index viable and recommended. 235 assertion rows (75 committed). |
| V3 | Utterance at the route | HOLDS — message: str already at the dispatch and threaded to find_files / remember_about_me. Two-line change. |
| V4 | Readout path | HOLDS — recommend server-composed delegated_response per CR-2026-129. Tier-2 limit coupled only in a comment, and that comment is stale (20 vs 50). |
| V5 | Pinning test | HOLDS — one direct pin to invert, four indirect pins to revise, one comment to correct. |
| V6 | FTS precedent | CONFIRMED ZERO — no precedent; the CR establishes the conventions. |
| V7 | Term derivation | HOLDS — PG 16.12; websearch_to_tsquery works as hoped. Two open design questions flagged (AND-conjunction semantics; empty-query case). |
No verification BREAKS. The build shape in the brief survives Step 0 intact, with one data-model correction (V1-A: JSONB expression index, not a table column) and two design questions (V7) the CR must settle before drafting.
websearch_to_tsquery as-is) vs. relaxed OR + ts_rank threshold. Precision/recall trade with visible consequences for how often decision (3)'s zero-match path fires. (V7)router.py:116–118 — delete or correct; it asserts an alignment that does not exist in code and is factually wrong on the numbers.total_count — _format_past_input never reads it, so the current 50-cap is invisible to the responder. Independent of Tier 1, closed by adopting V4-C.CONCURRENTLY or not. Dev's 235 rows do not decide it.DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — topic-filtered recall (Tier 1) — Step 0 inspection report — v0.1 — 2026-07-29