Skip to content

RESEARCH — {434.1} Hoist entity resolution before declaration

RESEARCH — ID-434 Hoist entity resolution before declaration

Section titled “RESEARCH — ID-434 Hoist entity resolution before declaration”

Task: id-434 — replace Stage-5’s post-hoc mutation with a two-phase use_mount() flow. Spec-chain artefact: {434.1} RESEARCH (of {434.1} → {434.2} → {434.3}). Date: 11/08/2026. Baseline: written against the post-id-433 strip state — no alias layer, no ISO branch, no canonicalise_for_relationship, no holder_rule.py, one deterministic key function (canonicalise_entity_name = strip → NFKD → lower). id-433 had not landed on main at the time of writing (git log -- scripts/cocoindex_pipeline/canonicalisation.py tops out at 99c5b3aad), so every file:line below is HEAD-as-observed and the strip’s effect is stated where it changes the finding. Posture: findings and evidence. No implementation plan — that is {434.3}.


StepSiteWhat it does
Mount targetsflow.py:4267-4336seven mount_table_target(DB_CTX, …, managed_by=ManagedBy.USER) calls, including em_target at flow.py:4281-4286
Per-item fan-outflow.py:4426-4436coco.mount_each(coco.component_subpath("ingest_file"), bound_ingest_file, source.items(), …, em_target, …)em_target is passed down into the per-file component
Per-doc declareflow.py:2724-2726em_target.declare_row(row=_em_row) inside _ingest_content_branch, i.e. inside the memoised ingest_file (@coco.fn(memo=True), flow.py:1890)
Fan-out settlesflow.py:4447await handle.ready()
Resolve after declareflow.py:4607-4616_run_stage_5_resolution(...) — reads the rows just declared, resolves, then UPDATEs/DELETEs them

The per-file component owns the entity_mentions rows. Its declared row id is uuid5(_KH_PIPELINE_DOC_NS, f"em:{source_document_id}:{per_doc_canonical}:{entity_type}") (flow.py:2614-2617) — the surrogate primary key is itself derived from the pre-resolution canonical.

  • The cocoindex target’s primary key is the surrogate id: ENTITY_MENTIONS_SCHEMA, primary_key=("id",) (flow.py:1522-1537).
  • The database’s natural key is different and contains canonical_name: entity_mentions_canonical_name_entity_type_source_document_id_key UNIQUE (canonical_name, entity_type, source_document_id) — created at supabase/migrations/20260617130000_squash_baseline.sql:8203, renamed off content_item_id at 20260628200000_id131_extract_reparent.sql:43-44.
  • Stage-5 then writes UPDATE public.entity_mentions SET canonical_name = $1 WHERE id = $2 AND op_id = $3 (stage_5.py:530-537) — mutating a component of a unique key on rows the engine’s tracking record still describes under their pre-update values.

entity_mentions has no updated_at column (table DDL, squash baseline) — relevant to §6.

1.3 The four collision-absorption mechanisms, with file:line

Section titled “1.3 The four collision-absorption mechanisms, with file:line”
  1. _select_prior_op_key_holdersstage_5.py:172-215. Op-agnostic probe joining unnest($1::text[], $2::text[], $3::uuid[]) against the exact post-resolution target keys, WHERE em.op_id IS DISTINCT FROM $4. Exists because the op-scoped read at stage_5.py:125-131 cannot see a prior-op or NULL-op row already holding the key a survivor is about to UPDATE into.
  2. The cross-op widened DELETE with curation re-assertion — planned at stage_5.py:444-482, executed at stage_5.py:508-515. DELETE … WHERE id = ANY($1) AND op_id IS DISTINCT FROM $2 AND (metadata->>'curation_pinned') IS DISTINCT FROM 'true'. Its own comment calls it “the ONE deliberate exception to the op_id-scoped write rule (Inv-5)” (stage_5.py:496-504). It deletes a row a previous run wrote.
  3. The bl-225 collision-collapse blockstage_5.py:379-420. Groups by the post-resolution natural key (source_document_id, entity_type, resolved), picks a survivor by min(members, key=lambda m: (-(conf), id)) and appends every other member to deletes. The module header states the cause plainly (stage_5.py:13-20): two distinct per-doc canonicals in the same document resolving to one value produced two UPDATEs to the same key.
  4. The Step-6 UPDATE loopstage_5.py:525-540, inside the DELETE-then-UPDATE transaction at stage_5.py:492-540. The DELETE-first ordering is explicitly load-bearing (stage_5.py:484-488): a survivor may UPDATE into a canonical a loser currently holds.

Two further mechanisms sit adjacent and are not on the task’s list of four, but are entangled with them and should be dispositioned by {434.2}/{434.3} rather than silently inherited:

  • _select_existing_canonical_roster (stage_5.py:134-169) plus its ID-81.9 self-subtraction (stage_5.py:314-334). The task file already names this as the weakest possible authority — an invariant authored by the task that built the mechanism, carrying a fix for a bug the mechanism created.
  • The curation-pin exclusion from the write-back domain (stage_5.py:116-131, stage_5.py:459-466, and the DELETE-site re-assertion at stage_5.py:505-512). This is DR-105 defence-in-depth against the mutation pass; if the mutation pass goes, what it defends against goes with it.

resolve_entities is invoked once per entity_type group (stage_5.py:338-356):

resolved_by_type[entity_type] = await resolve_entities(
sorted(names),
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,
existing_policy=ExistingCanonicalPolicy.PINNED,
)

Two structural facts follow and both matter downstream:

  • Resolution is partitioned by entity_type. A name typed standard in one document and certification in another can never be resolved together, by construction. See §7 (S551).
  • is_existing_canonical is currently wired to the prior-run roster, not to the curated pin. DR-140 names ExistingCanonicalPolicy.PINNED + is_existing_canonical as “cocoindex’s own native human-override hook”; today that hook carries the roster, and the human override is carried separately by the pin carry-forward (§1.5). Under a two-phase shape these two uses of one API surface meet. This is the highest-risk interaction in the task and is raised as OQ-1.

1.5 The pin carry-forward — guardrail 2, verbatim mechanics

Section titled “1.5 The pin carry-forward — guardrail 2, verbatim mechanics”

flow.py:2603-2722, inside _ingest_content_branch (the guardrail names 2604-2712; the block’s true extent is the comment at 2603 through the unconsumed-pin loop ending at 2722):

  • _fetch_curation_pinned_mentions(source_document_id) (flow.py:2995-3039) reads this document’s rows WHERE (metadata->>'curation_pinned') = 'true', keyed by row id.
  • The declare plan is built before any declare_row fires (flow.py:2603-2606), so a pinned row is always declared verbatim and never raced by a fresh candidate.
  • A candidate whose recomputed uuid5 matches a pinned id re-declares the stored row — canonical, entity_name, confidence, snippet, metadata and op_id all preserved, no re-stamp even under full_reprocess (flow.py:2618-2638).
  • Unconsumed pins are re-declared anyway, and on a natural-key clash the pin wins and the fresh candidate is dropped (flow.py:2689-2722), with cocoindex.ingest.curation_pin_won_natural_key logged.

This is the DR-105 pattern and it must survive. Note what it depends on: it runs inside the per-document component, at the point where declare_row is called. A two-phase shape moves the declare site; the pin logic has to move with it or be split. That relocation is the seam — see OQ-1.

1.6 DR-135 anchoring depends on per-document state

Section titled “1.6 DR-135 anchoring depends on per-document state”

_admissible_context_snippet (flow.py:889-…, called at flow.py:2642-2650) needs content_text to decide admissibility, and refuses the mention outright when neither the declared span nor a name search anchors it (flow.py:2651-2656). content_text is a per-document artefact. Any shape that moves declaration out of the per-document component must carry the computed snippet forward rather than recompute it — a constraint on the transfer type, named here so {434.3} does not rediscover it.

1.7 KhPairResolver persistence — what actually gives cross-run stability

Section titled “1.7 KhPairResolver persistence — what actually gives cross-run stability”

pair_resolver.py:145-192. Cache-first: SELECT decision FROM public.entity_pair_resolutions WHERE name_a = $1 AND name_b = $2 AND entity_type = $3 with key_a, key_b = sorted((name_a, name_b)) so (a,b) and (b,a) hit one row; on miss, one Anthropic call at temperature=0, max_tokens=4 (pair_resolver.py:194-237), then INSERT … ON CONFLICT (name_a, name_b, entity_type) DO NOTHING.

The load-bearing facts:

  • The cache is in Postgres, not in cocoindex’s state store. Table + constraint: 20260617130000_squash_baseline.sql:7947-7978, …:8213 (entity_pair_resolutions_pair_unique UNIQUE (name_a, name_b, entity_type)). It therefore survives an LMDB reset, which is the single most useful property it has and is not stated anywhere in the pin’s own documentation.
  • The LMDB warning at requirements.txt:74-77“1.0.9 moved the UserState db key (0xc0 -> 0x34, #2102) — verify engine-managed migration or reset the /cocoindex-state/lmdb volume on first deploy at this pin” — costs the memo cache and the engine’s declared-target-state record, not the pair decisions. After such a reset every document re-extracts and re-declares under a fresh op_id, which is precisely the condition that exercises the collision machinery hardest today (§6).
  • entity_pair_resolutions is also, per DR-140 and id-433, “the only surviving archaeology of the real extractor” — the 17-pair measurement was taken from it. Anything that changes when it is written changes what future measurement is possible.
  • Determinism is only as good as the cache’s key-space. The cache keys on raw name pairs. Post-id-433 the names reaching it change (no alias pre-application at stage_5.py:248-264), so existing cache rows keyed on alias-applied names become unreachable for those pairs. Not a correctness defect — a miss re-asks the LLM — but it is a one-off cost and a silent change to what “replayed from cache” means. Flagged as OQ-5.

1.8 The corpus already bends around the collision

Section titled “1.8 The corpus already bends around the collision”

docs/reference/testing/corpus-manifest.json:470, on the Inv-14 pair fixture:

“The pair MUST stay across two documents: entity_mentions is unique on (canonical_name, entity_type, source_document_id), so both forms in ONE document collide the moment Stage-5 resolves them (nightly run 31283783895: three UniqueViolations on ‘cye 14001’, NM-8 census gate failed).”

A test fixture whose layout is dictated by the defect is strong evidence the defect is structural rather than incidental. It is also a ready-made acceptance probe: under a shape that resolves before declaring, both forms in one document is the normal case, and that fixture constraint should become unnecessary.


2. Best practice — the upstream cocoindex shape

Section titled “2. Best practice — the upstream cocoindex shape”

API-surface correction, load-bearing. The v0 vocabulary in circulation around this task (@cocoindex.flow_def, add_source, collect(), export(), Neo4jDeclaration, NodeFromFields, primary_key_fields) does not exist at the 1.0.18 pin — grep over the installed package at /Users/liamj/Library/Python/3.14/lib/python/site-packages/cocoindex/ returns zero hits for every one of those names. The v1 surface is coco.App + @coco.fn + mount_table_target / mount_relation_target + declare_record / declare_relation, which is what this repo already uses (_coco_api.py:55-78). No v0→v1 translation is needed to compare against the upstream examples.

Neither ships in the wheel (the 1.0.18 RECORD contains no examples/). Both were read from the tag:

  • https://github.com/cocoindex-io/cocoindex/blob/v1.0.18/examples/docs_to_knowledge_graph/main.py — the two-phase base.
  • https://github.com/cocoindex-io/cocoindex/blob/v1.0.18/examples/meeting_notes_graph_neo4j/main.py — the same shape with an entity-resolution phase inserted between the two. This is the direct analogue of id-434.

docs_to_knowledge_graph, phase 1 — the per-file component declares only the node it owns and returns the shared names:

@coco.fn(memo=True)
async def process_file(
file: localfs.File,
document_table: neo4j.TableTarget[Document],
) -> DocTriples:
content = await file.read_text()
filename = file.file_path.path.as_posix()
summary = await extract_summary(content)
document_table.declare_record(
row=Document(filename=filename, title=summary.title, summary=summary.summary)
)
triples = await extract_relationships(content)
return DocTriples(filename=filename, triples=triples)

Phase 2 — one cross-file component declares the deduplicated shared nodes and every cross-document edge:

@coco.fn
async def build_graph(docs, entity_table, relationship_rel, mention_rel) -> None:
...
for value in entities:
entity_table.declare_record(row=Entity(value=value))
for filename, entity in mentions:
mention_rel.declare_relation(from_id=filename, to_id=entity)

Mounting in app_main: per-file work via coco.use_mount(coco.component_subpath("file", path_key), process_file, file, document_table) gathered with asyncio.gather, then the graph pass via await coco.mount(coco.component_subpath("build_graph"), build_graph, docs, …).

meeting_notes_graph_neo4j inserts resolution as its own mounted component between them:

@coco.fn(memo=True)
async def _resolve_persons(raw_persons: set[str]) -> ResolvedEntities:
return await resolve_entities(
entities=raw_persons,
embedder=coco.use_context(EMBEDDER),
resolve_pair=LlmPairResolver(model=coco.use_context(RESOLUTION_LLM_MODEL)),
)
persons = await coco.use_mount(
coco.component_subpath("resolve_persons"), _resolve_persons, raw_persons
)
await coco.mount(
coco.component_subpath("person_relations"),
create_person_relations, all_meetings, persons, person_table, attended_rel, assigned_rel,
)

and phase 3 declares keyed on the resolved canonical:

for canonical_name in persons.canonicals():
person_table.declare_record(row=Person(name=canonical_name))
...
attendees: dict[str, bool] = {persons.canonical_of(m.organizer): True}
for p in m.participants:
attendees.setdefault(persons.canonical_of(p), False)

Note the comment on that dict, which is the upstream answer to the whole bl-225 collapse block: “Resolution happens before aggregation so two raw names that resolve to the same person also collapse.” Two names collapsing is a dict write, not a DELETE.

examples/meeting_notes_graph_neo4j/README.md:

Cross-file nodes, owned in one place. People are shared across notes, so no single note’s component can own a Person node. The two cross-file phases own the canonical set and the person-touching edges, exactly once.

examples/docs_to_knowledge_graph/README.md:

Shared nodes, done right. Concepts are deduplicated and owned by a single graph pass, so Incremental Processing is one Entity node every doc can point at — not a copy per doc.

2.4 The mechanism underneath it, from the v1 docs

Section titled “2.4 The mechanism underneath it, from the v1 docs”

From https://cocoindex.io/docs/programming_guide/processing_component.md:

Mounting is how you declare (instantiate) a processing component within an app at a specific path, so CocoIndex knows that component exists, should run, and owns a set of target states — and can match it against its previous run to sync only what changed.

The component path tree determines ownership. When a component is no longer mounted at a path (e.g., a source file is deleted), CocoIndex automatically cleans up its target states — and recursively for all its sub-paths.

After a processing component finishes, CocoIndex syncs its target states: compares the target states declared in this run against those from the previous run at the same path; applies changes as a unit; recursively cleans up sub-paths where components are no longer mounted.

Which one you reach for comes down to a single question: does the caller need a value back? use_mount() consumes the child’s return value, which couples the two … mount() takes nothing back and returns as soon as the child is scheduled.

Target states owned by different components do not sync together as a unit.

And on container-vs-child, from programming_guide/target_state.md:

CocoIndex treats your declarations as the source of truth: if you stop declaring a target state, CocoIndex will remove it from the target.

When you change a container target state’s declaration (e.g., add a column to a table schema, change a primary key), CocoIndex detects the change and does its best to alter the target in place. If the change is too large to alter (e.g., changing primary keys), the target is dropped and recreated … CocoIndex automatically reprocesses all affected components to backfill the data.

The relevant API signatures at 1.0.18: use_mount at site-packages/cocoindex/_internal/api.py:231 (“Mount a dependent processing component and return its result. The child component cannot refresh independently”); mount at api.py:355; resolve_entities at site-packages/cocoindex/ops/entity_resolution/__init__.py:491, whose signature already carries is_existing_canonical, existing_policy, and an on_resolution callback taking a ResolutionEvent; ResolvedEntities.canonical_of / .canonicals() / .groups() at __init__.py:135/152/156.

2.5 What this repo already has vs what it lacks

Section titled “2.5 What this repo already has vs what it lacks”

mount_each (flow.py:4426) is the per-item half of phase 1 and is correct. What is absent is the second mounted component: there is no coco.mount / coco.use_mount call after the fan-out. _run_stage_5_resolution is a plain await inside app_main’s own frame (flow.py:4609) — it is not a component, owns no target states, and is invisible to the engine’s tracking record. That is the precise gap between the current shape and the upstream one, and it is why the engine’s record and the database can diverge at all.


3. Issues found — resolve-first vs build-on

Section titled “3. Issues found — resolve-first vs build-on”
#FindingCall
I-1The per-file component owns rows it cannot own: em_target is passed into mount_each (flow.py:4432) and declare_row’d per document (flow.py:2724), while the values those rows are keyed on are cross-document facts.Resolve first. This is the task.
I-2The surrogate id is derived from the pre-resolution canonical (flow.py:2614-2617), so the resolved value and the row identity disagree by construction. Keying the uuid5 on the resolved canonical instead changes every existing row’s id.Resolve first, and it is a data-shape decision for {434.2}: DR-093 says no backfill pre-launch, so the practical question is whether the id seed changes or the id stops being name-derived. OQ-3.
I-3_run_stage_5_resolution is not a mounted component, so nothing it writes is in the engine’s tracking record.Resolve first — this is the AC “the engine’s tracking record and the database agree”.
I-4_select_existing_canonical_roster + the ID-81.9 self-subtraction (stage_5.py:314-334) exist to make a mechanism work around itself.Do not build on. Its authority is the weakest kind under DR-139; whether the roster survives in any form belongs to {434.2}. It is not one of the named four, so removing it needs an explicit ruling, not a silent inclusion.
I-5Resolution is partitioned by entity_type (stage_5.py:338), and entity_type is also part of the natural key. Cross-type identity is unrepresentable.Build on for now, flag. This is S551 (§7) and its disposition is owner-facing, not a mechanical consequence of hoisting.
I-6The pair cache and a memoised resolve component would be two caches over the same decision. Upstream memoises the resolve component (@coco.fn(memo=True) on _resolve_persons); this repo caches per pair in Postgres.Build on both, but say why each exists. They are not redundant: the memo keys on the whole name set (any corpus change misses), the pair cache keys on a pair (survives corpus change and LMDB reset). OQ-5.
I-7entity_mentions has no updated_at, so “only that document’s mentions moved” cannot be measured with a timestamp.Build on — measure via xmin and the engine’s --target-states record instead (§6).

  • DR-140 (accepted, S545) is the ruling this task executes — clause 1 verbatim: “Stage-5’s post-hoc mutation is replaced by a two-phase use_mount() flow: phase one declares documents and collects names; phase two resolves, then declares mentions keyed on the resolved canonical.” Its rejected alternative is worth carrying into {434.2}: keeping the ordering and adding a resolved_canonical column “does make collisions impossible — but it leaves the engine’s tracking record disagreeing with the database, which is the condition the collision machinery was built to survive rather than the collisions themselves.”
  • DR-139 removes the standard objection in advance. An Inv-N citation against a change here is not a verdict; naming the invariant’s authoring task and date and finding a current re-affirming source is. Schema change is in scope.
  • DR-105 governs the pin. The predicate is metadata.curation_pinned = true matched on the effective entity type COALESCE(entity_type_override, entity_type), and “no consumer may re-derive its own pin predicate.” Note that the two pipeline-side pin reads currently match on the base column only (stage_5.py:129, stage_5.py:203, flow.py:3021) — the effective-type form is what DR-105 ratified. Whether that is a live gap or a deliberately-narrower walk-side scope is not something this research resolves; it is OQ-2, and it is adjacent to the pin work guardrail 2 protects.
  • DR-135 binds the declare site: a mention whose snippet would be empty is refused, the refusal is logged per mention and is not an item failure, and “a curator-pinned row is never subject to the rule.” All three properties must survive relocation of the declare site.
  • id-53’s option menu is where the ordering came from, and it had two entries. specs/ id-53-stage-5-entity-resolution/RESEARCH.md:105: “the collection-level shape is the ONLY shape cocoindex 1.0.3 exposes … Option A (per-doc deterministic canonicalisation) is the only path that keeps work strictly inside ingest_file, and Option B (a flow-scope post-pass) is the only path that can call resolve_entities at all.” The first half of that is correct and still is. The second half does not follow: “cannot be called inside the per-item component” does not entail “must therefore mutate rows the per-item component already declared”. The third shape — the per-item component declares no shared rows at all, and a second mounted component resolves and declares — was never on the menu. Under DR-139 the right reading is that id-53 chose correctly between the two options it had; the defect is the menu.
  • id-433 (parallel, this session) removes the alias layer, the ISO branch, canonicalise_for_relationship and holder_rule.py. Consequences that land inside id-434’s blast radius: Step 3 of Stage-5 (stage_5.py:240-264) loses its alias application and becomes an identity map; _preload_entity_aliases (stage_5.py:80-93) goes; the holder_md merge at flow.py:2657-2660 resolves to {} and the span keys stand alone; _generate_client_alias_snapshot (flow.py:4982) goes. After id-433, resolve_entities is the only mechanism that decides two names are one thing. That raises the stakes on everything in §5 and §6.
  • id-435 (depends on id-434, held) derives relationship endpoints from resolved mentions and refuses an endpoint matching no mention. The seam id-434 must leave clean, and nothing more: whichever phase-2 component holds the resolved mention key-space is where id-435 will read from. er_target is mounted at flow.py:4292-4297 and declared per-document alongside em_target (flow.py:2728-2759), so it sits in exactly the same ownership position the mentions do. This research does not design that; it names the seam.
  • Memory recall (mempalace, wing_canonical/specs) surfaced the id-53 PRODUCT Inv-1/Inv-5/Inv-7 wording and the S273 deferral chain that produced the ordering; nothing in the recall contradicts the above, and nothing in it re-affirms the pre-130 invariants under DR-139.

5. What are we not thinking about, but should be?

Section titled “5. What are we not thinking about, but should be?”
  1. A single phase-2 component would own every mention row in the corpus. Upstream’s own docs say “target states owned by different components do not sync together as a unit” and recommend one component owning all target states only “for small datasets”. Ownership granularity is a real design axis: one component (simple, atomic, whole-corpus blast radius on failure) versus per-document sub-components under the resolved key (finer sync, but then which component owns a canonical that two documents share — the exact question the upstream comment answers). {434.2} should state the granularity as a decision, not inherit it.
  2. Component re-execution is not the same as row mutation, and the AC turns on that. If one document changes, phase 2’s input changes and phase 2 re-runs in full. Whether that touches rows depends on the target-state comparison, not on the re-run: cocoindex “compares the target states declared in this run against those from the previous run at the same path” and applies only the differences. The AC “a re-run over a corpus where one document changed touches only that document’s mentions” is therefore satisfiable, but only if it is measured at the declared-state / row level and not at the component level. Stated as the wrong kind of assertion, it fails a correct implementation.
  3. The DR-135 snippet must be computed in phase 1 and carried, not recomputed in phase 2. content_text is per-document and does not exist in a cross-file component (§1.6). The transfer type between phases is therefore not just names — it is at minimum (source_document_id, entity_type, entity_name, per_doc_key, snippet, confidence, spans). A design that carries only names cannot produce an admissible row.
  4. Where the resolver runs changes when entity_pair_resolutions is written. If the resolve component is memoised the way upstream memoises _resolve_persons, a memo hit means the resolver never executes and nothing is written to entity_pair_resolutions at all. That is fine for correctness and quietly changes the archaeology DR-140 and id-433 both relied on. It also means the two caches can disagree about their own freshness after an LMDB reset — memo gone, pair rows intact — which is the good direction, but only by accident unless it is stated.
  5. The failure route changes. Today a Stage-5 escape is re-wrapped as _EntityResolutionStageError (flow.py:4614-4615) inside app_main’s try, and lands status='failed' for the whole walk. A mounted component’s failure is the engine’s to route, and bound_ingest_file’s per-item containment (flow.py:4402-4421) has no analogue at flow scope. Which failures red the run, and which are contained, is a PRODUCT decision that the relocation forces open — it should not be discovered at implementation time.
  6. items_processed still cannot see any of this — its counter sits inside the memoised body (task file, guardrail 1). Any acceptance evidence resting on it is not evidence.
  7. Inv-7 is a live bug and is out of scope. Carried so the next investigator does not restart: the untested hypotheses are item-key instability and the cocoindex 1.0.9 LMDB UserState key move. Untested means untested; neither is a cause. The claim that a memo-skipped component declares nothing and has its children reconciled away was reproduced FALSE — a byte-identical re-stage produces a memo hit and the declared children survive; only source-item deletion cascades. Nothing in this document may be read as relying on the false version.

Most likely: the identity of a mention row stops being stable across runs, and nobody notices until a re-run rewrites the corpus.

The concrete mechanism. Today, run-to-run stability of entity_mentions comes from two places, and neither is the resolution being idempotent:

  • the memo. ingest_file is @coco.fn(memo=True) (flow.py:1890), so a byte-identical document re-declares nothing, its rows keep the previous run’s op_id, and Stage-5’s op-scoped read (stage_5.py:125-131, WHERE op_id = $1) therefore does not even see them. The pass is stable because it has no work, not because the work is idempotent.
  • the pair cache in Postgres (pair_resolver.py:160-192), which replays prior LLM decisions.

Break either and the picture changes. The requirements.txt warning at requirements.txt:74-77 describes exactly the event that breaks the first one: an LMDB volume reset on deploy at this pin. Every memo misses, every document re-declares under a fresh op_id, the whole corpus enters Stage-5’s write-back domain at once, and all four collision-absorption mechanisms run at full corpus scale — including the widened cross-op DELETE that removes rows a prior run wrote (stage_5.py:508-515). That is the worst-case path today, and it is a deploy-time operation, not an exotic one.

The two-phase shape removes the mutation, but it substitutes a new dependency: the stability of the resolved canonical becomes the stability of the row’s identity. If phase 2 keys rows on the resolved canonical and the resolver returns a different canonical on some later run — because a new document added a longer name and KhPairResolver.__call__ prefers the longer name (pair_resolver.py:141, max((entity, candidate), key=len)), or because the input name set changed the greedy resolution order inside resolve_entities, or because an LLM model version moved on a cache miss — then the declared target-state key changes, the engine cleans up the old state and creates a new one, and the corpus’s mention rows churn wholesale. The churn would be correct by cocoindex’s contract and still wrong as product behaviour.

What would make it robust, stated as a property rather than a design: the resolved canonical must be a function of the accumulated decision record (entity_pair_resolutions plus, if kept, the existing-canonical seeding), not of the current run’s name set alone — so that adding a document can add members to a group but cannot rename a group that already exists. DR-140’s ExistingCanonicalPolicy.PINNED + is_existing_canonical is the API surface for exactly that property, and it is currently carrying the roster instead (§1.4). Whether the resolved canonical is allowed to change at all, and under what authority, is the question {434.2} has to answer; it is the one that decides whether this lasts three months.

The measurement surface for the ACs (all three instruments already exist):

  • Engine record vs database. cocoindex show <app> --target-states — “List all tracked target states with their owner components” (site-packages/cocoindex/cli.py:639-645), with --fingerprints and -l for detail; the underlying reader is _internal/inspect_api.py:88. Diff its entity_mentions entries against SELECT id, canonical_name, entity_type, source_document_id FROM entity_mentions. Today that diff is non-empty by construction after any Stage-5 UPDATE; the AC is that it becomes empty.
  • Unchanged-corpus re-run. Snapshot --target-states and the table (including xmin, since there is no updated_at) before and after; the second run must move neither. Note the memo-hit caveat above — the test only means something if it distinguishes “no work” from “idempotent work”, so it should be run once with the memo warm and once with it cold.
  • One-changed-document re-run. Same instruments, asserting that the set of rows whose xmin advanced is exactly the changed document’s mentions. Assert at the row level, not the component level (§5.2).
  • Pin probe. Guardrail 2 requires the pin carry-forward be verified by probe rather than by inspection: pin a mention, re-run, assert the stored canonical, metadata and op_id are byte-identical and that cocoindex.ingest.curation_pin_won_natural_key still fires on a natural-key clash.
  • The bl-225 fixture. docs/reference/testing/corpus-manifest.json:470 — putting both forms of CYE 14001 in one document should stop being a collision.

7. S551 — the extractor-vocabulary vs grain-key divergence (parked on this lane)

Section titled “7. S551 — the extractor-vocabulary vs grain-key divergence (parked on this lane)”

Recorded here because the owner deferred it to the scheduled entity work and its disposition belongs to the id-434/id-435 spec chain, not because id-434 fixes it.

The extractor’s entity_type is a closed 12-value Literal (extraction.py:333-345) containing both certification and standard, mirrored by the DB CHECK constraint (20260617130000_squash_baseline.sql, entity_mentions_entity_type_check) and by VALID_ENTITY_TYPES in lib/validation/schemas.ts. An LLM typing ISO 9001:2015 as standard is inside that vocabulary and passes every gate.

The consumer disagrees. The certification concept grain enumerates “distinct entity_mentions.canonical_name where entity_type='certification' (scripts/cocoindex_pipeline/sources/l_records.py:70, SQL at l_records.py:330-331), and the certification bundle assembly groups mentions by canonical_name for that type only (l_records.py:847-851). A mention typed standard therefore produces no certification concept, silently.

Two things make this id-434-adjacent rather than merely nearby:

  • entity_type partitions resolution (stage_5.py:338), so the two typings can never be resolved into one thing however good the resolver is;
  • entity_type is a component of the natural unique key, so the same real-world entity under two typings occupies two rows in the same document without colliding — invisible to every collision mechanism in §1.3.

It is genuinely open whether the fix is a vocabulary change (merge the two types), a grain change (the grain reads a set of types), or a resolution change (resolve across types and keep the type as a label). This is an owner decision. It is not in id-434’s ACs and should not be absorbed into them.


OQ-1 (highest risk — for {434.2}). Where does the pin carry-forward live once the declare site moves? Guardrail 2 says flow.py:2604-2712 must survive untouched and keep working. But its logic is “reconcile candidates against the pin map before any declare_row fires”, and the two-phase shape moves the declare_row for mentions out of the per-document component. Either the pin reconciliation moves with the declare into phase 2 (relocation — arguably “touching” it), or phase 1 keeps declaring pinned rows while phase 2 declares the rest (two components declaring into one table, which is precisely the ownership split the upstream comment warns against). There is a third reading — that the pins become the is_existing_canonical seed for the resolve component, which is what DR-140 says that API is for — but that changes the pin from a declare-time override to a resolution-time input, which is a behaviour change and needs a ruling. The owner has to choose; research cannot.

OQ-2. Is the base-vs-effective entity-type mismatch in the pipeline’s pin reads a live gap? DR-105 ratifies matching on COALESCE(entity_type_override, entity_type) and forbids consumers re-deriving the predicate; stage_5.py:129, stage_5.py:203 and flow.py:3021 all match on metadata->>'curation_pinned' without the effective-type join. DR-105’s own “Alternatives considered” describes base-type matching as “the actual implementation and it was a live bug” for pin_entity_mentions. Whether the walk-side reads inherit that finding is unresolved here. Adjacent to guardrail 2 and therefore owner-facing.

OQ-3. Does the row’s surrogate id stop being name-derived? uuid5(…, f"em:{sd_id}: {per_doc_canonical}:{entity_type}") (flow.py:2614-2617) makes identity a function of the name. Keyed on the resolved canonical instead, identity becomes a function of a cross-document fact — which is coherent, but means a resolution change is an identity change (§6). Keyed on something stable (the document plus the extracted surface form, say) identity survives resolution changes but the natural key and the primary key stop agreeing again. This is the shape decision the whole task turns on.

OQ-4. Which of the two adjacent mechanisms in §1.3 go with the four? _select_existing_canonical_roster + ID-81.9 self-subtraction, and the curation-pin exclusion from the write-back domain. Neither is on the task’s named list. Per the AC, a surviving mechanism must have its requirement and its current source named, or the verdict is UNDECIDABLE and it stays until the owner rules. Both look like candidates for that verdict.

OQ-5. Two caches over one decision — is that the intended end state? A memoised resolve component (upstream’s shape) plus entity_pair_resolutions. They key differently and fail differently (§1.7, §5.4), which is an argument for keeping both — but it should be a decision with a stated reason, not an accident of layering. Sub-question with a cost attached: after id-433 removes alias pre-application, existing cache rows keyed on alias-applied names go unreachable and those pairs re-ask the LLM once. Accept, or repair the key-space?

OQ-6. What is the ownership granularity of phase 2? One component owning every mention row, or a finer split — and if finer, what owns a canonical two documents share (§5.1)?

OQ-7. What reds the run? The Stage-5 failure route (flow.py:4614-4615, whole-walk failed) is a property of being a plain await inside app_main. As a mounted component the routing changes and there is no flow-scope analogue of bound_ingest_file’s per-item containment. PRODUCT decision, forced by the relocation.

OQ-8 (owner, parked). S551 — extractor vocabulary vs grain key, §7.