Skip to content

Stage-5 cross-run canonical stability — TECH (ID-81.3)

Stage-5 cross-run canonical stability — TECH (ID-81.3)

Section titled “Stage-5 cross-run canonical stability — TECH (ID-81.3)”

Spec slug: ID-81-canonical-stability Subtask: ID-81.3 (TECH, follows ratified {81.2} PRODUCT; precedes {81.4} PLAN per Q-PLANNER-2 fresh-per-Subtask discipline) Parent Task: ID-81 — Stage-5 cross-run canonical stability (is_existing_canonical seeding) Author: task-planner (opus-4-8 [1m], thinking: max, isolation=worktree) on cmux-worker-subo-id81-27a24fe2.

This TECH translates the 14 ratified PRODUCT invariants into one Proposed change each. §3 is the 1:1 invariant→change deliverable. UK English throughout.


Stage-5’s _run_stage_5_resolution (scripts/cocoindex_pipeline/stage_5.py:108-307) resolves each run’s entity_mentions in op_id-scoped isolation (ID-53 Inv-5) — it reads only WHERE op_id = $current (_select_run_entity_mentions, :82-105), so a new run cannot see canonicals prior runs (or app-side NULL-op_id writes) already materialised. We add existing-canonical seeding: an op-agnostic READ supplies the already-existing canonical names of the relevant entity_type to resolve_entities via its is_existing_canonical callback + existing_policy=PINNED, so a new mention matching an existing canonical chains UNDER it. The write-back path (the bl-225 collapse + op_id-scoped DELETE/UPDATE, :197-307) is UNCHANGED — that invariance is the load-bearing Inv-5 / Inv-11 / bl-225 safety argument (§3 Inv-7/Inv-10/Inv-11; §6).

PRODUCT.md defines the observable behaviour (Inv-1..Inv-14); this TECH does not restate it.

1.2 Code-intelligence orientation (this TECH author, fresh confirmation)

Section titled “1.2 Code-intelligence orientation (this TECH author, fresh confirmation)”

Per .gitnexus/CLAUDE.md “Always Do” + the write-tech-spec orientation step. This TECH re-ran gitnexus_context (not paraphrased — verbatim below) against the indexed knowledge-hub graph:

  • gitnexus_context({name: "_run_stage_5_resolution", repo: "knowledge-hub"})
    • uid: Function:scripts/cocoindex_pipeline/stage_5.py:_run_stage_5_resolution (startLine 107, endLine 306).
    • incoming.calls: flow.py:app_main (the single production caller) + the three regression tests (test_collapse_no_update_needed, test_collapse_with_survivor_update, test_zero_confidence_survivor_not_treated_as_missing in test_cocoindex_stage_5_resolution.py).
    • outgoing.calls: _preload_entity_aliases, _select_run_entity_mentions, _FlowStageCounter.increment.
    • processes: [] (Stage-5 is an out-of-band app_main continuation, not part of an indexed reactive flow).

This corroborates RESEARCH §1 verbatim: single production caller (app_main), LOW fanout. The blast radius of the seeding change is bounded to _run_stage_5_resolution + the one NEW private reader this TECH adds (_select_existing_canonical_roster, §3 Inv-6). gitnexus carries no per-symbol risk verdict in context; the single-production-caller fact IS the orientation evidence. Executor tool-discipline is embedded in §5 (impact-before-edit + detect-changes-before-commit, Inv-2/Inv-3/Inv-7 propagation per .ast-dataflow/CLAUDE.md).

1.3 Relevant code (read in full for this TECH)

Section titled “1.3 Relevant code (read in full for this TECH)”
  • scripts/cocoindex_pipeline/stage_5.py:108-307_run_stage_5_resolution. The seam:
    • :131 alias preload (Inv-10 — UNCHANGED).
    • :134-136 op_id-scoped row read (UNCHANGED).
    • :145-154 name_pairs build with alias-applied canonicals (UNCHANGED — name_pairs is the op_id-scoped write-back domain).
    • :180-182 names_by_type fan-out — the seed-merge insertion point is AFTER this loop.
    • :186-195 the resolve_entities(...) call — the wiring site (today NO is_existing_canonical / existing_policy args; sorted(names) is passed positionally).
    • :197-307 bl-225 collapse + Step-6 op_id-scoped DELETE (AND op_id = $2, :270) / UPDATE (WHERE id = $2 AND op_id = $3, :282) — UNCHANGED.
  • scripts/cocoindex_pipeline/_coco_api.py:55-132 — the façade. _SYMBOL_SOURCES (:55-74) re-exports resolve_entities, ResolvedEntities, PairDecision but NOT ExistingCanonicalPolicy — confirmed ABSENT first-hand. __all__ (:76-88) and the TYPE_CHECKING block (:116-132) both omit it. PEP-562 lazy __getattr__ (:91-108).
  • scripts/cocoindex_pipeline/entity_embedder.py:67-107KhEntityEmbedder (no cache; thin LiteLLM pass-through). Confirms the embedding-cost seam: every name in sorted(set(entities)) is embedded (cost bound = candidate-prefilter, not cache — §3 Inv via §4 PERF).
  • scripts/cocoindex_pipeline/pair_resolver.py:83-237KhPairResolver, entity_type-keyed determinism cache (ID-53 Inv-14). UNCHANGED by this feature. Under PINNED, existings skip the resolver entirely (pass_1), so the resolver-call volume drops, not rises.
  • scripts/tests/test_cocoindex_stage_5_resolution.py — the real-body test fixture. Critical: the _stub_resolver_chain helper (:266-293) defines _fake_resolve_entities(names, *, embedder, resolve_pair) — its signature does NOT accept is_existing_canonical / existing_policy. The wiring change WILL break this stub unless the stub is updated to absorb the two new kwargs (§3 Inv-1 / §5 testing).
  • cocoindex 1.0.3 source cocoindex/ops/entity_resolution/__init__.py (re-read first-hand):
    • resolve_entities(entities, *, embedder, resolve_pair, is_existing_canonical=None, existing_policy=PINNED, on_resolution=None, max_distance=0.3, top_n=5) (:184-194) — default existing_policy is PINNED.
    • entity_list = sorted(set(entities)) (:201) — pass_1 order derives from the sorted-set of the SAME iterable; this is the determinism mechanism for Inv-14.
    • is_existing computed per name via is_existing_canonical(name) (:216-220) — a per-name boolean predicate, NOT an identity test.
    • PINNED split (:262-264): pass_1 = [is_existing], pass_2 = [not is_existing].
    • _new_wins under PINNED (:350-353): if matched_info.is_existing: return False — new NEVER wins against an existing match (the Inv-3 hard guarantee).

1.4 Migration-history finding (decisive — re-verified first-hand)

Section titled “1.4 Migration-history finding (decisive — re-verified first-hand)”

pg_trgm is currently NOT installed on prod or staging. Migration 20260428122115_relocate_extensions_to_extensions_schema.sql:41-50 DROP EXTENSION pg_trgm (it had zero in-use indexes; prod never had it). The squash baseline had installed it WITH SCHEMA "public" (20260416102457:48) — the relocate migration’s deliberate intent is that extensions live in the extensions schema, never public. Consequence for the migration (§4): the gin_trgm_ops index migration MUST re-create the extension WITH SCHEMA extensions (not public), and reach the % operator / gin_trgm_ops opclass via SET search_path = public, extensions (the pattern at 20260528122543:13). There are ZERO existing gin_trgm_ops indexes in the corpus — this is a greenfield trigram surface.


Four mechanical slices, each mapping to a coherent ownership boundary (decomposed by {81.4} PLAN.md):

  1. MigrationCREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions + a gin (canonical_name extensions.gin_trgm_ops) index on entity_mentions. CLI only (supabase migration new + db push).
  2. Façade — add ExistingCanonicalPolicy to _coco_api.py (_SYMBOL_SOURCES + __all__ + TYPE_CHECKING).
  3. Seed-roster reader — a new private async function _select_existing_canonical_roster(db_pool, entity_type, run_names) in stage_5.py returning the candidate-prefiltered op-agnostic roster, plus the is_existing_canonical set-membership predicate.
  4. Wiring — merge the per-type roster into names_by_type[entity_type] (after :182, before :186) and add is_existing_canonical= + existing_policy=ExistingCanonicalPolicy.PINNED to the resolve_entities(...) call.

The seed merges into the resolver’s input name set only — never into name_pairs (the op_id-scoped write-back domain). This keeps Inv-5 by-construction (§6).

flowchart TD
A["_select_run_entity_mentions(op_id)<br/>op_id-scoped READ (UNCHANGED)"] --> B["name_pairs<br/>(op_id-scoped write-back domain — NEVER extended)"]
B --> C["names_by_type[entity_type] fan-out (:180-182)"]
D["_select_existing_canonical_roster(entity_type, run_names)<br/>op-AGNOSTIC READ + pg_trgm prefilter (NEW)"] --> E["roster_by_type[entity_type]"]
C --> F["names_by_type[et] |= roster_by_type[et]<br/>(SEED MERGE — input set only, after :182)"]
E --> F
F --> G["resolve_entities(sorted(names_by_type[et]),<br/>is_existing_canonical=in roster_by_type[et],<br/>existing_policy=PINNED)"]
G --> H["bl-225 collapse + Step-6 DELETE/UPDATE<br/>iterates name_pairs ONLY, AND op_id=$current<br/>(UNCHANGED — :197-307)"]
B --> H

§3. Proposed changes — one per PRODUCT invariant (1:1)

Section titled “§3. Proposed changes — one per PRODUCT invariant (1:1)”

Each row is the implementation realising the correspondingly-numbered PRODUCT invariant. Several invariants are realised by the SAME code construct (e.g. the PINNED wiring realises Inv-1/2/3/4; the by-construction write-scope realises Inv-7/Inv-11). Where so, the change is described once and cross-referenced. Acceptance criteria live in PRODUCT.md; the test that proves each is in §5.

Inv-1 — New mention chains under existing canonical (PINNED seeding)

Section titled “Inv-1 — New mention chains under existing canonical (PINNED seeding)”

Change PC-1. At the resolve_entities call (stage_5.py:186-195), add two keyword arguments:

resolved_by_type[entity_type] = await resolve_entities(
sorted(names_by_type[entity_type]), # names already merged with the seed roster (PC-6)
embedder=KhEntityEmbedder(),
resolve_pair=KhPairResolver(db_pool=db_pool, op_id=meta.op_id, entity_type=entity_type),
is_existing_canonical=lambda name, _roster=roster_by_type[entity_type]: name in _roster, # PC-6 predicate
existing_policy=ExistingCanonicalPolicy.PINNED,
)

is_existing_canonical is the per-name set-membership predicate (PC-6). Under PINNED, a new mention near a seeded existing chains under it via _new_winsreturn False (cocoindex :350-353). The lambda name, _roster=... default-arg binding captures the per-type roster (avoids the late-binding closure trap in the per-type loop). Test-stub coupling: the _stub_resolver_chain fake at test_...:278 MUST be updated to _fake_resolve_entities(names, *, embedder, resolve_pair, is_existing_canonical=None, existing_policy=None) or the existing three tests break at call time (§5).

Inv-2 — Same entity across runs → one canonical

Section titled “Inv-2 — Same entity across runs → one canonical”

Change PC-2. No NEW code beyond PC-1+PC-6 — Inv-2 is the cross-run consequence of PINNED seeding + the roster reading prior-run canonicals. The implementation guarantee is: run 2’s roster includes run 1’s pinned canonical (PC-6 op-agnostic read), and PINNED chains run 2’s near-match under it (PC-1). Verified by integration test (§5, MUST NOT run against shared staging this session).

Inv-3 — Prior-run canonical never demoted/renamed/repointed

Section titled “Inv-3 — Prior-run canonical never demoted/renamed/repointed”

Change PC-3. Realised entirely by existing_policy=PINNED (PC-1). cocoindex :262-264 puts every existing in pass_1 (seeded as its own canonical, no resolver call); _new_wins :350-353 returns False for any matched existing. No KH-side code enforces this — it is the cocoindex PINNED contract, empirically verified PRESENT (RESEARCH §Verification). The KhPairResolver “longer-name-wins” preference (pair_resolver.py:139-141) is correctly OVERRIDDEN by PINNED for matched existings; no change to pair_resolver.py.

Inv-4 — Two existing canonicals never merged

Section titled “Inv-4 — Two existing canonicals never merged”

Change PC-4. Realised by existing_policy=PINNED (PC-1): both existings are in pass_1, neither runs the resolver against the other (cocoindex :269-279). No KH-side code. Unit-asserted via the seed roster supplying two distinct existings with no matching new mention (§5).

Inv-5 — Existing with no matching new mention → no resolution event

Section titled “Inv-5 — Existing with no matching new mention → no resolution event”

Change PC-5. Realised by PINNED pass_1 semantics (PC-1) + the fact that a seeded existing with no near in-flight mention is never a name_pairs member, so the write-back never touches it. Concretely: a seeded existing that no in-flight name_pairs row resolves to forms NO collision_group (the group key is (content_item_id, entity_type, resolved) built ONLY from name_pairs, stage_5.py:221-235), so it appears in neither updates nor deletes. Unit-asserted: updates == [] AND deletes == [] when the run’s only mentions are unrelated (§5).

Inv-6 — Roster is op-AGNOSTIC (prior-run + NULL-op_id eligible) + candidate-prefiltered

Section titled “Inv-6 — Roster is op-AGNOSTIC (prior-run + NULL-op_id eligible) + candidate-prefiltered”

Change PC-6 (the new reader — the core of this feature). Add a private async function to stage_5.py, sited immediately after _select_run_entity_mentions (:105):

async def _select_existing_canonical_roster(
db_pool: asyncpg.Pool, entity_type: str, run_names: set[str]
) -> set[str]:
"""Op-agnostic existing-canonical roster for one entity_type, candidate-prefiltered.
Reads DISTINCT canonical_name across ALL op_ids (prior-run rows + NULL-op_id
app-side rows) for `entity_type` (ID-81 Inv-6), bounded to canonicals
lexically plausible against this run's names (pg_trgm similarity OR
exact case-fold), per ID-81 Inv-1/§4 PERF. Returns a SET for O(1)
set-membership (the `is_existing_canonical` predicate, Inv-7 self-membership
caveat: a name that is both 'existing' and 'in this run' is simply
is_existing=True — pinned, never demoted). NOT a UNION with entity_aliases
(Inv-12). Workspace-agnostic at v1 (Inv-9 — no workspace_id at Stage-5).
`run_names` is the per-type set from `names_by_type[entity_type]`
(`stage_5.py:180`, typed `dict[str, set[str]]`) — a set, NOT a list.
"""
if not run_names:
return set()
probe = list(run_names) # asyncpg ANY() binds a list; order irrelevant (set membership)
rows = await db_pool.fetch(
"SELECT DISTINCT canonical_name "
"FROM public.entity_mentions "
"WHERE entity_type = $1 "
# SCHEMA-QUALIFIED operator: the asyncpg pool session search_path is the
# default ("$user", public) — it does NOT include `extensions` (where
# pg_trgm lives, §4), so a bare `%` is UNRESOLVABLE at runtime. Qualify
# via OPERATOR(extensions.%) so the trigram operator resolves regardless
# of search_path. (§6 HIGH-risk row — this is the safe form to copy.)
" AND ( canonical_name OPERATOR(extensions.%) ANY($2::text[]) " # pg_trgm near-match
" OR lower(canonical_name) = ANY($3::text[]) )", # exact case-fold
entity_type,
probe,
[n.lower() for n in probe],
)
return {row["canonical_name"] for row in rows}

The OPERATOR(extensions.%) is the schema-qualified pg_trgm similarity operator (default pg_trgm.similarity_threshold = 0.3). It MUST be schema-qualified: the asyncpg pool (flow.py:2547) runs with the default session search_path ("$user", public), which does NOT include extensions, so a bare canonical_name % ANY(...) is unresolvable and errors at runtime (§6 HIGH risk). The qualified OPERATOR(extensions.%) resolves the trigram operator independent of search_path. (Alternative — NOT chosen here, but acceptable: add server_settings={"search_path": "public, extensions"} to the pool at flow.py:2547 and use a bare %; that widens the pool’s search_path for ALL queries, so the schema-qualified operator is the narrower, lower-blast-radius fix.) The exact case-fold arm (lower(canonical_name) = ANY(...)) is backed by the EXISTING idx_entity_mentions_canonical_lower btree (20260416102457:4803) — it catches the high-value “same string, different casing, ingested in a prior run” case for free even if a trigram miss occurs. The OPERATOR(extensions.%) arm is backed by the NEW gin_trgm_ops index (§4). is_existing_canonical predicate: a name in roster set-membership test (PC-1 lambda), NOT an identity test — RESEARCH §2 self-membership caveat honoured.

Inv-7 — Op-agnostic READ, op_id-scoped WRITE (Inv-5 preserved by-construction)

Section titled “Inv-7 — Op-agnostic READ, op_id-scoped WRITE (Inv-5 preserved by-construction)”

Change PC-7 (the by-construction guarantee — the most load-bearing, and it is a NON-change). The seed roster (PC-6) returns STRINGS, merged into names_by_type[entity_type] (the resolver INPUT set), NEVER into name_pairs. Code-shape proof:

  • name_pairs (stage_5.py:145-154) is built ONLY from _select_run_entity_mentions(op_id) rows — op_id-scoped. PC-6 does NOT touch this list. It is never extended with seed members.
  • The seed merge (PC-6→PC-1) targets names_by_type[entity_type] AFTER the :180-182 fan-out and BEFORE :186. Only the per-type NAME SET fed to resolve_entities grows.
  • The bl-225 collapse (:218-253) iterates name_pairs exclusively; collision_groups keys are built from name_pairs tuples (:221-235). A seeded foreign-op canonical, having no name_pairs row, contributes no row_id to updates or deletes.
  • Step-6 DELETE retains AND op_id = $2 (:270); UPDATE retains WHERE id = $2 AND op_id = $3 (:282). Both guards UNCHANGED.

Therefore a prior-run / NULL-op_id canonical is READ (a string) but its ROW is physically unreachable by any write. Inv-5 holds by-construction; seeding does not weaken it. The Executor MUST NOT add seed members to name_pairs under any refactor — that is the single line of defence and §5 asserts it.

Change PC-8. Realised by PC-6’s WHERE entity_type = $1 + the EXISTING per-entity_type batch loop (stage_5.py:186). The reader is called once per entity_type WITHIN that loop, so an organisation roster is never offered to a technology batch. No new scoping code beyond the $1 bind. Unit-asserted: a technology “Cisco” mention does not chain under an organisation “Cisco” seed (§5).

Inv-9 — Workspace-agnostic at v1 (DESIGN-NOTE, no test obligation)

Section titled “Inv-9 — Workspace-agnostic at v1 (DESIGN-NOTE, no test obligation)”

Change PC-9 (deliberate absence-of-feature). PC-6 partitions by entity_type ONLY — there is NO workspace_id filter, because no queryable workspace edge exists at Stage-5 (RESEARCH §2: entity_mentions has no workspace_id; the content_item_workspaces junction is ingest-empty per ID-69 BI-1). This is verified by CODE REVIEW at implementation time (the reader carries no workspace predicate), not a unit test — per PRODUCT Inv-9 acceptance (DESIGN-NOTE). OQ-81-WORKSPACE-SCOPE pending; a parent override is a spec amendment.

Inv-10 — bl-225 collapse holds under seeding-induced same-document collision

Section titled “Inv-10 — bl-225 collapse holds under seeding-induced same-document collision”

Change PC-10 (a NON-change to the collapse, + a NEW regression test). The bl-225 collapse logic (stage_5.py:197-307) is UNTOUCHED. Seeding changes only WHICH value rows resolve to — resolved can now be a seeded existing canonical — but the grouping by (content_item_id, entity_type, resolved), the highest-confidence-then-smallest-id survivor pick (:246-248), the DELETE-first ordering (:265-273), and the structured cocoindex.stage_5.collapsed log (:293-302) all absorb a seeding-induced same-doc collision identically. The implementation work is the NEW regression test (§5, Inv-10) — mirrors test_collapse_with_survivor_update with a seeded resolution target.

Inv-11 — Foreign-op rows READ as input, NEVER UPDATEd/DELETEd (ID-53 Inv-5 non-regression)

Section titled “Inv-11 — Foreign-op rows READ as input, NEVER UPDATEd/DELETEd (ID-53 Inv-5 non-regression)”

Change PC-11. Same construct as PC-7 (restated as the explicit ID-53 Inv-5 criterion). No additional code; §5 adds the deliberate assertion that every deletes/updates row id was selected by _select_run_entity_mentions(op_id=current) and that the (foreign-op) seed roster contributes no row id to either list.

Inv-12 — Alias rewrite still applied BEFORE resolution; NOT folded into the seed roster

Section titled “Inv-12 — Alias rewrite still applied BEFORE resolution; NOT folded into the seed roster”

Change PC-12. _preload_entity_aliases (:66-79) + the alias-application in the name_pairs build (:145-154) are UNCHANGED — the alias rewrite stays pre-resolve_entities. PC-6’s reader source is entity_mentions.canonical_name ONLY (no UNION with entity_aliases). Because the roster reads the SAME column the alias-applied name_pairs resolve against, the roster and the run operate in one alias-applied namespace. The run_names passed to PC-6 (the prefilter probe) are the alias-applied per-doc canonicals (built from name_pairs, §3 PC-6 wiring), keeping the prefilter consistent with the alias namespace. No KhPairResolver / alias change.

Inv-13 — Unresolved mention retains per-document canonical

Section titled “Inv-13 — Unresolved mention retains per-document canonical”

Change PC-13. Realised by the EXISTING canonical_of self-return + the is None→self guard (stage_5.py:228-235). Seeding ADDS chaining targets to the resolver’s candidate set (via the merged names_by_type and the faiss index); it never forces a match — a name with no candidate within max_distance=0.3 (after the prefilter and inside cocoindex’s faiss search, :244-260) still resolves to itself. No change. §5 adds coverage: a unique mention with a NON-empty seed roster still resolves to itself and is absent from updates.

Inv-14 — Re-run produces same mapping; seeds determinise via the entities iterable

Section titled “Inv-14 — Re-run produces same mapping; seeds determinise via the entities iterable”

Change PC-14. The seed roster is merged INTO names_by_type[entity_type] and passed to resolve_entities as sorted(names_by_type[entity_type]) (PC-1) — i.e. seeds are MEMBERS of the entities iterable, so cocoindex’s internal sorted(set(entities)) (:201) normalises pass_1 order across runs. The Executor MUST NOT pass seeds out-of-band (e.g. a separate pre-seeding call or appending after the resolver returns) — that would un-determinise pass_1 add-order. The KhPairResolver determinism cache (ID-53 Inv-14) is unchanged and replays prior pair decisions. PINNED additionally guarantees no cross-run canonical flip. This is PRODUCT Inv-14’s “TECH wiring constraint” — honoured here. Integration-tested via full_reprocess (§5, MUST NOT run against shared staging this session).

Façade prerequisite (wiring dependency for PC-1)

Section titled “Façade prerequisite (wiring dependency for PC-1)”

Change PC-FACADE. Add ExistingCanonicalPolicy to _coco_api.py (confirmed ABSENT first-hand):

  • _SYMBOL_SOURCES (:55-74): add "ExistingCanonicalPolicy": ("cocoindex.ops.entity_resolution", "ExistingCanonicalPolicy"),
  • __all__ (:76-88): add "ExistingCanonicalPolicy", (keep alphabetical).
  • TYPE_CHECKING block (:124-128): add ExistingCanonicalPolicy to the from cocoindex.ops.entity_resolution import (...) tuple.

is_existing_canonical is a plain Callable argument — no symbol re-export needed; only the policy enum. stage_5.py then imports it function-locally alongside resolve_entities (:168-170): from scripts.cocoindex_pipeline._coco_api import resolve_entities, ExistingCanonicalPolicy. Façade-guard count bump (REQUIRED): test_coco_api_facade.py asserts a HARDCODED symbol count (len(...) == 11) over _SYMBOL_SOURCES / __all__; adding ExistingCanonicalPolicy takes that to 12, so the count assertion (and any explicit symbol-list assertion) MUST be bumped in the SAME slice as PC-FACADE or the existing façade test fails (§5).


ConcernPlan
Primary embedding-cost boundCandidate-prefilter (PC-6): seed only existing canonicals lexically plausible against this run’s names, per entity_type. Collapses the unbounded O(corpus) historical roster to “existings near this run”. The exact case-fold arm reuses the run’s own embeddings for free (those names are in names_by_type regardless). RESEARCH §4 DECISION (c)+(e).
gin_trgm_ops migrationNEW migration via CLI only (supabase migration new id81_entity_mentions_trgm_index + db push — NEVER MCP execute_sql / apply_migration per CLAUDE.md). Body: (1) CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions; (2) CREATE INDEX IF NOT EXISTS idx_entity_mentions_canonical_trgm ON public.entity_mentions USING gin (canonical_name extensions.gin_trgm_ops);. Schema-qualify the opclass as extensions.gin_trgm_ops (the extension is NOT in public — §1.4). No new function → no SET search_path / REVOKE anon obligation (DDL-only migration).
pg_trgm extension prerequisite (decisive — re-verified)pg_trgm is currently NOT installed (dropped by 20260428122115, §1.4). The migration’s CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions is therefore a real install, not a no-op. Executor MUST verify post-push: SELECT extname, n.nspname FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE extname='pg_trgm'; returns extensions (NOT public). The % operator resolves via search_path = public, extensions.
faiss IndexFlatIP buildSecond-order (RESEARCH §4): exact IP search over a few-thousand 1024-d vectors is sub-second on faiss-cpu==1.14.2. Embedding round-trips dominate, which the prefilter bounds. No faiss tuning needed.
Per-entity_type batchingALREADY present (stage_5.py:186). PC-6 is called inside that loop, so each batch’s roster + faiss index stay one-type-scoped. No new batching.
Resolver-call volumeUnder PINNED, existings skip the resolver (pass_1). Seeding therefore REDUCES KhPairResolver LLM/cache calls relative to a hypothetical PREFERRED, never raises them.
Persistent embedding cacheDEFERRED (v1 non-goal, PRODUCT §6) — embedding reuse via the cocoindex public API is impossible (no precomputed-embedding param; entity_mentions has no embedding column). Build only if telemetry shows the prefiltered roster still dominates.

Recall envelope (deliberate, not a defect): the lexical prefilter MAY miss an embedding-space near-match that is not lexically close — rare for short domain-specific entity names where the embedder mostly catches casing/punctuation/abbreviation variants that ARE lexically close. PRODUCT §6 documents this as an accepted v1 recall loss. The exact case-fold arm guarantees the dominant “same string, prior run” case is never missed.


All Python tests run via python3 -m pytest scripts/tests/ (CLAUDE.md). Real-body unit tests extend scripts/tests/test_cocoindex_stage_5_resolution.py using the existing _stub_resolver_chain pattern. Tests verify real behaviour, not implementation (docs/reference/test-philosophy.md).

Prerequisite test-harness change (BLOCKS all unit tests): update _stub_resolver_chain’s _fake_resolve_entities (test_...:278) to absorb the new kwargs: async def _fake_resolve_entities(names, *, embedder, resolve_pair, is_existing_canonical=None, existing_policy=None). Extend _FakePool.fetch to answer the new _select_existing_canonical_roster query (the SELECT DISTINCT canonical_name ... WHERE entity_type = $1 AND (... % ... OR lower(...) = ...) branch) by returning a test-supplied roster per entity_type. WITHOUT this, the three EXISTING tests break at call time (the new kwargs are passed unconditionally). Bump the test_coco_api_facade.py hardcoded symbol-count assertion (len(...) == 1112) when ExistingCanonicalPolicy is added (PC-FACADE) — same slice, or the façade test fails.

PRODUCT InvTest (new unless noted)ShapeAsserts
Inv-1test_new_mention_chains_under_seeded_existingreal-body unit, _stub_resolver_chain + roster suppliedseed roster supplies “ISO 27001”; in-flight mention “ISO27001” resolves to “ISO 27001”; the in-flight row’s canonical = seeded value
Inv-2cross-run integrationintegration (Python, scripts/tests or __tests__/integration shape) — MUST NOT run against shared staging this sessionsame entity + type in run 1 & run 2 (distinct op_ids) → SELECT DISTINCT canonical_name returns ONE value (run 1’s)
Inv-3cross-run integration (PINNED-override-of-longer-name)integration — MUST NOT run against shared stagingrun 1 pins a short canonical; run 2’s longer variant chains under it; prior-run row unchanged. NB bl-223: the “eir 2004”→“environmental information regulations 2004” illustration is dist ~0.51 > the 0.3 gate = unrealistic (would never near-match); use a within-0.3 case/punct longer-variant per test_cocoindex_stage_5_crossrun_integration.py
Inv-4test_two_existing_seeds_never_mergedreal-body unittwo distinct existings of one type, no matching new mention → len(updates)==0, no seed in deletes
Inv-5test_seeded_existing_no_match_no_eventreal-body unitseed an existing of type T; run’s only mentions unrelated → updates==[] AND deletes==[]
Inv-6test_roster_includes_prior_run_and_null_op_canonical + integration NULL-op casereal-body unit (roster contents) + integration (NULL-op_id chaining) — MUST NOT run against shared stagingroster passed to resolver includes a supplied prior-run canonical of matching type; integration: a near-match chains under a NULL-op_id-only canonical
Inv-7test_foreign_op_row_byte_for_byte_unchangedreal-body unitsnapshot a foreign-op row (all columns) before; supply its canonical as a chaining target; assert byte-identical after (no DELETE/UPDATE touched it); DELETE/UPDATE retain AND op_id guards
Inv-8test_roster_entity_type_scopedreal-body unit”Cisco” seeded as organisation; technology “Cisco” mention does NOT chain under it (org seed absent from technology batch’s roster)
Inv-9CODE REVIEW (no test)review at implementation time_select_existing_canonical_roster carries NO workspace predicate (DESIGN-NOTE per PRODUCT Inv-9)
Inv-10test_seeded_target_bl225_collapse (NEW regression — REQUIRED)real-body unit, mirrors test_collapse_with_survivor_updateseed an existing as resolution target; TWO same-doc per-doc canonicals both resolve to it → collapse to one survivor (highest conf, smallest id), loser in deletes, survivor UPDATE lands seeded canonical, NO UniqueViolation, cocoindex.stage_5.collapsed log fires
Inv-11test_only_in_flight_op_rows_writtenreal-body unitevery id in deletes/updates was selected by _select_run_entity_mentions(op_id=current); seed roster (foreign-op strings) contributes no row id to either list
Inv-12test_alias_applied_pre_resolution_and_roster_not_aliasedreal-body unit (NEW alias coverage — the 3 existing tests return [] for the alias fetch, so this is NOT a mere extension)MUST supply a NON-empty entity_aliases row via _FakePool.fetch (FROM public.entity_aliases branch returns [{"alias": ..., "canonical": ...}]) so the alias preload path is actually exercised; assert the alias is applied to the per-doc canonical BEFORE resolution; roster source is entity_mentions.canonical_name only (NOT a UNION with entity_aliases)
Inv-13test_unresolved_mention_retains_canonical_with_seed_rosterreal-body unitobscure mention with no near-match among run names OR seed roster → row’s canonical = per-doc value, absent from updates
Inv-14cross-run full_reprocess integrationintegration — MUST NOT run against shared stagingrun 1 seeding-based chaining, then full_reprocess run 2 → (per-doc-name → resolved-canonical) pair set matches run 1 byte-for-byte

Integration-test guard (explicit): the cross-run integration tests (Inv-2/3/6-NULLop/14) require two real pipeline runs against a DB. They MUST NOT be authored to run against the parent’s shared staging branch (turayklvaunphgbgscat) during THIS spec/implementation session — they are tagged for a controlled run (local Supabase stack or a dedicated ephemeral branch) the parent orchestrates. The unit tests (real-body, fully stubbed resolver chain + in-memory _FakePool) carry the per-invariant proof obligation that does NOT need a live DB; the integration tests prove the end-to-end cross-run consequence.


RiskSeverityMitigation
Inv-5 regression — a refactor adds seed members to name_pairsCRITICAL (highest-risk invariant)The by-construction guarantee (PC-7) is the sole defence: seeds merge into names_by_type ONLY, never name_pairs; DELETE/UPDATE keep AND op_id. §5 Inv-7/Inv-11 tests assert it. The Executor brief (§7) names this as the non-negotiable invariant.
pg_trgm absent → migration is a real install, not a no-opHIGH§1.4 finding: extension was dropped. Migration CREATE EXTENSION ... WITH SCHEMA extensions; Executor verifies post-push the extension landed in extensions (not public) and the % operator resolves via search_path.
% operator unresolved (wrong search_path / wrong schema)HIGHOpclass schema-qualified extensions.gin_trgm_ops; the seed query relies on session search_path including extensions for the % operator. If asyncpg’s session search_path excludes extensions, the % query errors loudly (no silent failure — surfaces to the flow’s outer except per §P-10). Executor MUST confirm the asyncpg connection’s effective search_path includes extensions, or schema-qualify via OPERATOR(extensions.%) in the SQL.
Test-stub signature drift breaks the 3 existing testsMEDIUM§5 prerequisite: update _fake_resolve_entities + _FakePool.fetch BEFORE wiring PC-1. Caught immediately by pytest.
Schema parity prod ↔ stagingMEDIUMThe migration runs on staging first (turayklvaunphgbgscat); schema-parity.yml guards prod↔staging diff. pg_trgm must land identically on both — verify before any prod push.
Prefilter recall loss (lexical miss of embedding near-match)LOW (accepted)Documented v1 envelope (PRODUCT §6, §4). Exact case-fold arm guarantees the dominant case. Operator-recoverable via admin entity_aliases rewrite.
full_reprocess self-membership mis-flagLOWThe roster is a SET; a name both “existing” and “in this run” is just is_existing=True (pinned, never demoted). PC-6 predicate is set-membership, not identity (RESEARCH §2 caveat). §5 Inv-14 integration covers it.

§7. Executor tool-discipline (carry into implementation per Inv-2/Inv-3 propagation)

Section titled “§7. Executor tool-discipline (carry into implementation per Inv-2/Inv-3 propagation)”

Per .ast-dataflow/CLAUDE.md Inv-2 (Planner duty) / Inv-3 (Executor duty), the {81.4}+ implementation Subtask briefs MUST instruct the Executor to:

  1. Before editing _run_stage_5_resolution or adding _select_existing_canonical_roster: run gitnexus_impact({target: "_run_stage_5_resolution", direction: "upstream", repo: "knowledge-hub"}) and record verdict / caller-count / top-3 flows in the implementation journal. (TECH orientation found: single production caller flow.py:app_main, LOW fanout, processes: [] — the Executor confirms this still holds at edit time.)
  2. Before commit: run gitnexus_detect_changes() and verify only the expected symbols (_run_stage_5_resolution, the new reader, the _coco_api façade symbol) changed.
  3. Python string-literal sweep: ast-dataflow does NOT cover Python — for any SQL-string change (the new %/gin_trgm_ops query), grep scripts/ + supabase/migrations/ rather than relying on ts-morph (.ast-dataflow/CLAUDE.md Python/SQL caveat).
  4. Non-negotiable invariant: seeds merge into names_by_type ONLY, NEVER name_pairs; DELETE/UPDATE keep AND op_id = $current (the by-construction Inv-5 / Inv-11 guarantee, §3 PC-7).

  • docs/specs/ID-81-canonical-stability/PRODUCT.md — the 14 invariants this TECH maps 1:1 (ratified, Checker PASS_WITH_NOTES).
  • docs/specs/ID-81-canonical-stability/RESEARCH.md — DECISIONs (a) SOURCE, (b) PINNED, (c)+(e) prefilter-not-cache, (d) Inv-5/bl-225 by-construction; §Verification (cocoindex==1.0.3 PRESENT); OQ-81-POLICY / OQ-81-WORKSPACE-SCOPE.
  • docs/specs/id-53-stage-5-entity-resolution/PRODUCT.md + TECH.md — Inv-5 (op_id write-scope), Inv-10 (alias preload), Inv-14 (PairResolver cache), Inv-20 (unresolved persistence), Inv-21 (cross-tenant OUT) — the non-regression contract.
  • scripts/cocoindex_pipeline/stage_5.py / _coco_api.py / entity_embedder.py / pair_resolver.py — the seam + collaborators (read in full).
  • scripts/tests/test_cocoindex_stage_5_resolution.py — the real-body test fixture extended in §5.
  • supabase/migrations/20260428122115_relocate_extensions_to_extensions_schema.sql + 20260416102457_pre_squash_reconciliation.sql + 20260528122543_id53_entity_mentions_op_id_and_pair_cache.sql — the migration history establishing the extensions-schema placement constraint (§1.4) and the existing idx_entity_mentions_canonical / _lower indexes.
  • cocoindex/ops/entity_resolution/__init__.py (pinned cocoindex[postgres]==1.0.3) — resolve_entities signature + PINNED semantics, re-verified first-hand.
  • CLAUDE.md — DDL via CLI only; UK English; no silent failures; python3 -m pytest scripts/tests/; verify-and-relink project-ref before db push.

§9. Verification (OQ-3 / Q-EX2 — external-library citations)

Section titled “§9. Verification (OQ-3 / Q-EX2 — external-library citations)”

TECH introduces NO new external-library citation beyond RESEARCH §Verification. The cited symbols (cocoindex.ops.entity_resolution.{resolve_entities, ExistingCanonicalPolicy}) were re-verified first-hand by reading cocoindex/ops/entity_resolution/__init__.py at the pinned cocoindex[postgres]==1.0.3 (requirements.txt):

  • resolve_entitiesPRESENT. Signature (entities, *, embedder, resolve_pair, is_existing_canonical: Callable[[str], bool] | None = None, existing_policy: ExistingCanonicalPolicy = ExistingCanonicalPolicy.PINNED, on_resolution=None, max_distance=0.3, top_n=5) -> ResolvedEntities (:184-194). Matches PC-1 exactly; default existing_policy=PINNED.
  • ExistingCanonicalPolicyPRESENT. StrEnum members PINNED / PREFERRED (:57-66). PINNED _new_wins if matched_info.is_existing: return False (:350-353).
  • is_existing_canonicalPRESENT as a per-name Callable[[str], bool] predicate (:216-220), NOT an identity test — confirms PC-6’s set-membership design.
  • Façade gap re-confirmed: _coco_api.py:55-88 _SYMBOL_SOURCES / __all__ omit ExistingCanonicalPolicy (PC-FACADE prerequisite). The symbol exists upstream; it is merely not re-exported — a wiring add, NOT a drift defect.
  • Verdict: PRESENT — no ABSENT / SIGNATURE_DRIFT / BEHAVIOUR_DRIFT. Source-read + RESEARCH §Verification runtime introspection agree. Spec is safe to return for ratification.

End of TECH.md. ID-81.3: 14 PRODUCT invariants → 14 Proposed changes (PC-1..PC-14) + 1 façade prerequisite (PC-FACADE). Two NON-changes carry the load (PC-7/PC-11 by-construction Inv-5; PC-10 bl-225 untouched). One CLI migration (pg_trgm re-install WITH SCHEMA extensions + gin_trgm_ops index — extension confirmed currently ABSENT). One new private reader (_select_existing_canonical_roster). Test-stub signature update is a hard prerequisite. PINNED + workspace-agnostic per authored-on defaults (OQ-81-POLICY / OQ-81-WORKSPACE-SCOPE still pending parent — override = spec amendment).