DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path inspection-briefs/loomworks-topic-filtered-recall-step-0-report-v0_1.md

Loomworks — topic-filtered recall (Tier 1) — Step 0 inspection report — v0.1

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/).


SHA and drift

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).


V1 — The retrieval seam: list_assertions or a sibling

VERDICT: HOLDS-sibling — recommend a dedicated search_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.

Finding V1-A (load-bearing) — assertions live in JSONB, not a relational table

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.

Finding V1-B — 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.

Finding V1-C — call-site census

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).

Recommendation

Add search_assertions beside list_assertions, do not extend it. Three reasons, in order of weight:

  1. The contract differs, not just the filter. 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.
  2. Blast radius. 10 production + 30 test call sites do not need to know a search path exists. A new function is a zero-diff change to all 40.
  3. Precedent. 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.


V2 — Migration sequencing

VERDICT: HOLDS — clean single head, both places, in agreement.

Repo head. uv run alembic heads0102 (head). Single head; no branch to merge.

Database head. SELECT version_num FROM alembic_version on playground_dev0102.

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:

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.


V3 — How the utterance reaches the route

VERDICT: HOLDS. The utterance is already at the dispatch, already threaded to sibling routes. Adding it to _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.


V4 — The readout path and the truthfulness pattern

VERDICT: HOLDS, with one correction to a load-bearing comment.

V4-A — The engagement path is responder-composed today

_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.

V4-B — CR-2026-129's delegated mechanism, for comparison

_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.

V4-C — Recommendation: server-composed, following CR-2026-129

Take the delegated_response route. Three reasons:

  1. The honest-selection claim must be gated on the real query result, not the LLM's wording. "I found 3 of your 78 notes that mention vesting" is a factual claim about a retrieval. That is precisely the class of claim CR-2026-129 moved server-side, and the same class as _route_remember_about_me's "held" claim (router.py:3325+, gated on the completed add_assertion).
  2. Decision (3) is a truthfulness requirement, not a formatting preference. A zero-match answer that must not silently fall back to recency is exactly what a responder-composed path cannot guarantee — the responder still has the Tier-2 recency block in its prompt (see V4-D) and could compose from that.
  3. Symmetry. 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).

V4-D — The Tier-2 alignment comment is coupled only in the comment, and the comment is stale

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:


V5 — The pinning test

VERDICT: HOLDS — one test asserts the carve-out directly and must be revised by the CR. Four more pin the responder-composed shape indirectly.

V5-A — The direct pin, verbatim

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:

  1. assert "delegated_response" not in result.operation_datainverts if V4-C is adopted.
  2. _route_ask_about_past_input(person=..., engagement_id=..., db=...) — the call gains message= per V3.
  3. The docstring names the carve-out. It should be rewritten to record that the semantic-recall foundation arrived (naming this CR), not deleted — the historical intent is worth keeping in the file that established it.

V5-B — Indirect pins on the responder-composed / recency shape

| 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.


V6 — FTS precedent re-confirmation

VERDICT: CONFIRMED — zero hits. The venture-diligence V5 holds at 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.


V7 — Term-derivation input

VERDICT: HOLDS — 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 (vestingvest, scheduleschedul). 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:

  1. & 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.

  1. Empty-query is a real case. 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.

Summary of verdicts

| # | 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 | HOLDSmessage: 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.


Items for the CR to settle that Step 0 surfaced but cannot decide

  1. Query semantics — strict AND (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)
  2. Empty-tsquery handling — a distinct honest answer from zero-match, not a conflation. (V7)
  3. Stale comment at router.py:116–118 — delete or correct; it asserts an alignment that does not exist in code and is factually wrong on the numbers.
  4. The unrendered 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.
  5. Production index postureCONCURRENTLY 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