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}.
1. What exists today
Section titled “1. What exists today”1.1 The ordering, end to end
Section titled “1.1 The ordering, end to end”| Step | Site | What it does |
|---|---|---|
| Mount targets | flow.py:4267-4336 | seven mount_table_target(DB_CTX, …, managed_by=ManagedBy.USER) calls, including em_target at flow.py:4281-4286 |
| Per-item fan-out | flow.py:4426-4436 | coco.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 declare | flow.py:2724-2726 | em_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 settles | flow.py:4447 | await handle.ready() |
| Resolve after declare | flow.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.
1.2 The key collision, stated exactly
Section titled “1.2 The key collision, stated exactly”- 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 atsupabase/migrations/20260617130000_squash_baseline.sql:8203, renamed offcontent_item_idat20260628200000_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”_select_prior_op_key_holders—stage_5.py:172-215. Op-agnostic probe joiningunnest($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 atstage_5.py:125-131cannot see a prior-op or NULL-op row already holding the key a survivor is about toUPDATEinto.- The cross-op widened
DELETEwith curation re-assertion — planned atstage_5.py:444-482, executed atstage_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. - The bl-225 collision-collapse block —
stage_5.py:379-420. Groups by the post-resolution natural key(source_document_id, entity_type, resolved), picks a survivor bymin(members, key=lambda m: (-(conf), id))and appends every other member todeletes. 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 twoUPDATEs to the same key. - The Step-6
UPDATEloop —stage_5.py:525-540, inside theDELETE-then-UPDATEtransaction atstage_5.py:492-540. The DELETE-first ordering is explicitly load-bearing (stage_5.py:484-488): a survivor mayUPDATEinto 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 atstage_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.
1.4 The resolver call site and its inputs
Section titled “1.4 The resolver call site and its inputs”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 typedstandardin one document andcertificationin another can never be resolved together, by construction. See §7 (S551). is_existing_canonicalis currently wired to the prior-run roster, not to the curated pin. DR-140 namesExistingCanonicalPolicy.PINNED+is_existing_canonicalas “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 rowsWHERE (metadata->>'curation_pinned') = 'true', keyed by row id.- The declare plan is built before any
declare_rowfires (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_idall preserved, no re-stamp even underfull_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), withcocoindex.ingest.curation_pin_won_natural_keylogged.
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 freshop_id, which is precisely the condition that exercises the collision machinery hardest today (§6). entity_pair_resolutionsis 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.
2.1 The two examples
Section titled “2.1 The two 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.
2.2 The structure, quoted
Section titled “2.2 The structure, quoted”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.fnasync 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.
2.3 The stated reason, verbatim
Section titled “2.3 The stated reason, verbatim”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
Personnode. 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 Processingis oneEntitynode 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”| # | Finding | Call |
|---|---|---|
| I-1 | The 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-2 | The 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-5 | Resolution 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-6 | The 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-7 | entity_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). |
4. Prior decisions and context
Section titled “4. Prior decisions and context”- 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 aresolved_canonicalcolumn “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-Ncitation 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 = truematched on the effective entity typeCOALESCE(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 insideingest_file, and Option B (a flow-scope post-pass) is the only path that can callresolve_entitiesat 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_relationshipandholder_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; theholder_mdmerge atflow.py:2657-2660resolves to{}and the span keys stand alone;_generate_client_alias_snapshot(flow.py:4982) goes. After id-433,resolve_entitiesis 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_targetis mounted atflow.py:4292-4297and declared per-document alongsideem_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?”- 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.
- 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.
- The DR-135 snippet must be computed in phase 1 and carried, not recomputed in phase 2.
content_textis 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. - Where the resolver runs changes when
entity_pair_resolutionsis 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 toentity_pair_resolutionsat 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. - The failure route changes. Today a Stage-5 escape is re-wrapped as
_EntityResolutionStageError(flow.py:4614-4615) insideapp_main’s try, and landsstatus='failed'for the whole walk. A mounted component’s failure is the engine’s to route, andbound_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. items_processedstill 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.- 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
UserStatekey 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.
6. If this breaks in three months, why?
Section titled “6. If this breaks in three months, why?”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_fileis@coco.fn(memo=True)(flow.py:1890), so a byte-identical document re-declares nothing, its rows keep the previous run’sop_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--fingerprintsand-lfor detail; the underlying reader is_internal/inspect_api.py:88. Diff itsentity_mentionsentries againstSELECT id, canonical_name, entity_type, source_document_id FROM entity_mentions. Today that diff is non-empty by construction after any Stage-5UPDATE; the AC is that it becomes empty. - Unchanged-corpus re-run. Snapshot
--target-statesand the table (includingxmin, since there is noupdated_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
xminadvanced 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_idare byte-identical and thatcocoindex.ingest.curation_pin_won_natural_keystill fires on a natural-key clash. - The bl-225 fixture.
docs/reference/testing/corpus-manifest.json:470— putting both forms ofCYE 14001in 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_typepartitions resolution (stage_5.py:338), so the two typings can never be resolved into one thing however good the resolver is;entity_typeis 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.
8. Open questions
Section titled “8. Open questions”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.