Skip to content

ID-101 TECH — cocoindex entity-relationship + holder-rule extraction parity

ID-101 — cocoindex entity-relationship + holder-rule extraction parity (TECH)

Section titled “ID-101 — cocoindex entity-relationship + holder-rule extraction parity (TECH)”

Spec chain: {101.1} RESEARCH (folded into Task description) → {101.2} PRODUCT (ratified-pending) → {101.3} TECH (this document){101.4} PLAN (recommended — see §Decomposition recommendation). Artefact kind: {N.3} TECH. Behaviour lives in the sibling PRODUCT.md; this document owns mechanism + validation. Invariant numbers below (Inv-1 … Inv-16) refer to PRODUCT.md §Behavior. Fresh-Planner note (Q-PLANNER-2 / B4): authored by a fresh Planner instance, not the PRODUCT author. The central correction this review pass surfaces is in §Risks R1: the two canonicalisers do NOT agree today, so Inv-5/Inv-13 “byte-identical” is not satisfiable without a canonicaliser port — the carry-over note framed this as “verify agreement”; the empirical check shows it is “build agreement”.

PRODUCT.md defines a parity contract: the cocoindex ingest path must capture the same directed/typed relationship triples (10-type vocabulary) and the same self/supplier holder attribution that the legacy TS classifyContent path produces, before the ID-45 full-corpus re-ingest. Today the cocoindex path writes entity mentions only and zero entity_relationships rows, and never stamps holder metadata. This Task closes that gap inside the Python pipeline. It is extraction-only: no DDL, no consumption surface (ID-60/ID-71 out of scope per PRODUCT §Non-goals).

Per the “Always Do” section of .gitnexus/CLAUDE.md. ast-dataflow (ts-morph) does not cover the Python pipeline (per .ast-dataflow/CLAUDE.md — “does not cover Python or SQL”); the Python surface below was mapped with grep over scripts/cocoindex_pipeline/ and scripts/tests/, and that is stated explicitly here.

  • gitnexus_query({query: 'cocoindex entity relationship extraction flow write', repo: 'knowledge-hub'})processes: [], process_symbols: []. No named execution flow indexes the cocoindex relationship/holder surface (it does not exist yet). The definitions block returned the write-path test surface (scripts/tests/test_cocoindex_flow_write_path.py: TestIngestFileStageCounters L364–493, _stub_path_a L1353–1376) and flow helpers (scripts/cocoindex_pipeline/flow.py: _classify_stage_exception L264–381, _stamp_if_model L1511–1534; scripts/cocoindex_pipeline/extraction.py: stamp_extraction_base L634–712). Verdict: greenfield write tail on an existing flow — the relationship/holder write path is new, but it slots into the heavily-indexed ingest_file component.
  • gitnexus_context({name: 'deriveHolderMetadata', repo: 'knowledge-hub'}) → verdict-level context: 1 direct caller (Function:lib/ai/classify.ts:classifyContent); outgoing calls to Function:lib/entities/entity-aliases.ts:resolveAlias and Function:lib/entities/entity-dedup.ts:canonicalise; processes: []. This is the exact TS function whose behaviour the Python holder-stamp must reproduce (lines 524–595).
  • gitnexus_context({name: 'canonicalise', repo: 'knowledge-hub'}) → verdict-level context: 6 direct callers including deriveHolderMetadata, classifyContent, formatEntityDisplayName, runBatchReclassifyJob, scripts/batch-reclassify.ts:main, scripts/normalise-entities.ts:main; outgoing calls to slugToProperCase and titleCase (lib/entities/entity-dedup.ts). HIGH blast-radius on the TS side — but this Task does NOT modify canonicalise; it ports its behaviour into Python. The TS function is the oracle, not a modification target (PRODUCT §Non-goals).

Legacy TS oracle:

  • lib/ai/classify.ts:1784-1819 — the legacy relationship writer. Maps each result.relationships[] to a row { source_entity, relationship_type, target_entity, source_item_id, confidence: 1.0 }, canonicalising both endpoints via resolveAlias(canonicalise(x)).toLowerCase(), then upsert(..., { onConflict: 'source_entity,relationship_type,target_entity,source_item_id', ignoreDuplicates: true }) inside a try/catch (best-effort).
  • lib/ai/classify.ts:524-595deriveHolderMetadata(rows, relationships). Pass-1 canonical holds; Pass-2 S196 synonym fallback (complies_with/evidences, cert target, client-org-or-extracted-org source, holds wins on tie); final loop stamps { holder: 'self' } or { holder: 'supplier', supplier_name } onto certification rows only, leaving non-matching rows untouched. HOLDS_SYNONYMS set at :522.
  • lib/ai/classify.ts:653-667ExtractedRelationship (the 10-member union; the parity contract for relationship_type).
  • lib/entities/entity-dedup.ts:114-196canonicalise(name, entityType?): 12-step normaliser (slug→Proper Case, ISO/IEC/version, Cyber Essentials, WCAG, company-suffix Ltd→Limited, ABBREVIATIONS map, multi-word title-case, type-aware plural strip, trailing-period strip).
  • lib/entities/entity-aliases.ts:16-95BASELINE_ALIASES (code-level baseline) + resolveAlias() (reads the DB-backed entity_aliases cache, falls back to baseline).
  • lib/ai/skills/classification.md:698-778 — §Relationship Extraction (the 10-type table)
    • §Holder Disambiguation (trigger phrases, disclaimer paragraphs, supplier-attribution examples). This is the prompt text to port verbatim.
  • scripts/eval-holder-rule-ts.ts — the existing holder-rule oracle (parity-oracle base): fetchHoldsRelationships (:386), fetchEntityMentions (:407), runEvaluation (:695) with the four thresholds (holder coverage / positive-control recall / precision / residual correction) and the env-driven CLIENT_ORG_LOWER / EVAL_POSITIVE_CONTROL_ENTITY.

Cocoindex Python pipeline (the surface this Task changes):

  • scripts/cocoindex_pipeline/extraction.py — the three shipped @coco.fn(memo=True) extractors (extract_classification :988, extract_qa_form :1016, extract_entity_mentions :1042) + their core Pydantic models (EntityMentionExtraction :363) + shared helpers (_anthropic_retry, _anthropic_message, _cached_system_block, _guard_not_truncated, _strip_code_fence, module-level TypeAdapters :773-779, ANTHROPIC_MODEL, _MAX_TOKENS_* :796-798). This is the ID-94 precedent (PRODUCT Inv-1).
  • scripts/cocoindex_pipeline/prompts.pyCLASSIFICATION_PROMPT, Q_A_FORM_PROMPT, ENTITY_MENTION_PROMPT (cached system blocks). New prompt constant lands here.
  • scripts/cocoindex_pipeline/canonicalisation.py:22-49canonicalise_entity_name(name, entity_type): NFKD-fold + lowercase + ISO-only rules. This does NOT match the TS canonicalise + resolveAlias chain (see §Risks R1) — the divergence is the headline parity seam.
  • scripts/cocoindex_pipeline/flow.pyENTITY_MENTIONS_SCHEMA :1267-1280; the ingest_file component signature :1581-1597; Stage-3 extractor invocations :1933-1943; the entity_mentions dedup + declare-row loop :2142-2200; the mount_table_target block in app_main :3006-3064; the mount_each arg wiring :3154-3165. The relationship writer + holder stamp slot into this component.
  • scripts/tests/test_cocoindex_flow_write_path.py — the write-path test surface (TestIngestFileWritePath :150, TestIngestFileStageCounters :365, TestMountEachArityContract :537, test_ingest_file_signature_matches_mount_each_extra_args :637). Adding an er_target arg changes the ingest_file arity → these arity tests must be updated (Inv-3 write site).

Each change cites the PRODUCT invariant(s) it satisfies. The write-path mirrors the legacy TS writer 1:1 except where Python/cocoindex idioms differ (noted inline).

PC-1 — Relationship-extraction @coco.fn + Pydantic model (Inv-1, Inv-2, Inv-4, Inv-8)

Section titled “PC-1 — Relationship-extraction @coco.fn + Pydantic model (Inv-1, Inv-2, Inv-4, Inv-8)”

In scripts/cocoindex_pipeline/extraction.py:

  1. Pydantic core model RelationshipExtraction(_ExtractionCore) with extraction_kind: Literal["relationship"] = "relationship", source: str = Field(min_length=1), target: str = Field(min_length=1), and relationship: Literal[...] enumerating exactly the 10 vocabulary members (holds, complies_with, delivers_to, uses, demonstrated_by, requires, part_of, supersedes, references, evidences). The Literal is the Inv-4 enforcement: any predicate outside the 10 fails TypeAdapter validation. Mirror the bl-220 stamp-free convention — the core model carries NO op_id/content_items_id (those are flow-stamp fields the LLM must not emit). The LLM returns a JSON array (like extract_entity_mentions), so the adapter is _relationships_adapter: TypeAdapter[list[RelationshipExtraction]] (module-level, built once on import alongside the existing three at :773-779).
  2. @coco.fn(memo=True) async def extract_relationships(content_text: str) -> list[RelationshipExtraction] — body is a verbatim structural copy of extract_entity_mentions (:1042-1068): anthropic.AsyncAnthropic(), _anthropic_retry( lambda: _anthropic_message(client, model=ANTHROPIC_MODEL, max_tokens=_MAX_TOKENS_RELATIONSHIPS, system=_cached_system_block(RELATIONSHIP_PROMPT), messages=[{"role": "user", "content": content_text}])), _guard_not_truncated(...), _strip_code_fence(...), return _relationships_adapter.validate_json(response_text). Add _MAX_TOKENS_RELATIONSHIPS = 16384 (relationship arrays are moderate, like entity mentions). Inv-1 compliance: this is the @coco.fn + direct-Anthropic-SDK + Pydantic pattern. It MUST NOT reference cocoindex.ExtractByLlm / cocoindex.LlmSpec (ABSENT in cocoindex[postgres]==1.0.7 — see §Verification). Inv-8 compliance: the prompt instructs “if none are found, return []”; the empty array writes zero rows, matching the legacy if (result.relationships?.length) guard.

Why a separate extractor (not bundled into extract_entity_mentions)? ID-36 §6 row 4 ratified one-extractor-one-call (no bundled “extract everything” prompt); memoisation keys on content_text per extractor, so a separate call keeps the relationship cache independent of mention-prompt edits. This matches the three-extractor precedent.

PC-2 — Ported relationship prompt (Inv-2, Inv-12)

Section titled “PC-2 — Ported relationship prompt (Inv-2, Inv-12)”

In scripts/cocoindex_pipeline/prompts.py, add RELATIONSHIP_PROMPT — a JSON-only instruction prompt that ports verbatim the content of lib/ai/skills/classification.md:698-778:

  • §Relationship Extraction (the 10-type table with meanings + examples; “Only include relationships that are clearly stated or strongly implied … If none are found, [return an empty array]”).
  • §Holder Disambiguation in full — the sentence-level trigger-phrase list (“held by”, “managed by”, “maintained by”, “via supplier”, “delivered through”, “outsourced to”, “provided by”, “operated by”), the content-level disclaimer-paragraph rule, the supplier-attribution worked example, and the “when no supplier signal … author org as source” rule (Inv-12 — the LLM must attribute third-party holds at source; the derivation step cannot recover a misattribution).

Follow the prompts.py house style: force JSON-only (no fences), enumerate the 10 valid relationship values verbatim, instruct a JSON array of { source, relationship, target } objects, omit the flow-stamp fields, UK English. The placeholder {CLIENT_ORGANISATION_NAME} in the source skill is resolved by the LLM from document context exactly as the legacy prompt intends (the prompt does not interpolate the client name — it carries the rule). Byte stability matters — the prompt is the prompt-cache key (_cached_system_block).

PC-3 — Cross-language canonicalisation port (Inv-5, Inv-13) — HIGHEST RISK

Section titled “PC-3 — Cross-language canonicalisation port (Inv-5, Inv-13) — HIGHEST RISK”

This is the central decision. PRODUCT Inv-5 and Inv-13 require byte-identical canonical strings between the legacy resolveAlias(canonicalise(x)).toLowerCase() chain and the cocoindex path, so triples and holder comparisons collide/agree across paths. The existing Python canonicalise_entity_name does not reproduce the TS chain (§Risks R1, empirically confirmed). Decision:

Add a new function canonicalise_for_relationship(name: str) -> str in scripts/cocoindex_pipeline/canonicalisation.py that reproduces the TS resolveAlias(canonicalise(name)).toLowerCase() chain step-for-step:

  1. The 12-step canonicalise body (entity-dedup.ts:114-196): slug→Proper Case, ISO basic / extended / version-strip, Cyber Essentials, WCAG, company-suffix (Ltd→Limited, PLC, Inc), the ABBREVIATIONS map, multi-word title-case, type-aware plural strip, trailing period. The relationship writer calls canonicalise(x) with no entityType arg, so the type-aware plural-strip branch (step 11) is inert for relationship endpoints — the Python port for relationship endpoints SHOULD likewise pass no entity type, keeping the plural branch off (this matters: matching the legacy call shape, not just the function).
  2. resolveAlias: apply the alias map. The legacy resolveAlias reads the DB-backed entity_aliases cache and falls back to BASELINE_ALIASES. Tradeoff/decision: the pipeline is Python and has the asyncpg pool (DB_CTX) available, but reading entity_aliases per-call would add a query to a best-effort write tail. Chosen approach: port BASELINE_ALIASES (the 13-entry code-level baseline, entity-aliases.ts:16-31) as a Python dict and apply it; OPTIONALLY load DB aliases once per flow run into a module cache (mirroring the TS 5-minute TTL cache) if the canonical-string parity oracle (PC-6) shows DB-only aliases causing cross-path divergence. v1 ships the baseline port; the DB alias load is a documented follow-up gated on oracle evidence (avoids over-building before the oracle proves it necessary).
  3. .toLowerCase() (Python .lower()).

Both the relationship write (PC-4) and the holder-stamp comparison (PC-5) call canonicalise_for_relationship. Do NOT reuse canonicalise_entity_name for relationship endpoints — it is the per-document entity-mention canonicaliser with different (ISO-only, NFKD-folding) semantics and is correct for its own Stage-5 purpose; conflating the two would reintroduce the divergence. A scripts/tests/-level cross-language golden test (PC-6 oracle, the canonical-string lane) pins this: a shared fixture of raw→expected pairs asserted identical by both the TS canonicalise/resolveAlias and the Python port.

PC-4 — entity_relationships write target + declare-row loop (Inv-3, Inv-5, Inv-6, Inv-7)

Section titled “PC-4 — entity_relationships write target + declare-row loop (Inv-3, Inv-5, Inv-6, Inv-7)”

In scripts/cocoindex_pipeline/flow.py:

  1. ENTITY_RELATIONSHIPS_SCHEMA = TableSchema(...) beside ENTITY_MENTIONS_SCHEMA (:1267), declaring only the columns the pipeline writes (the content_text_hash-style OMIT convention for PG-defaulted/GENERATED columns): id uuid NOT NULL, source_entity text NOT NULL, relationship_type text NOT NULL, target_entity text NOT NULL, source_item_id uuid (the content-item id, mirroring the legacy source_item_id = itemId), confidence numeric, op_id uuid (per-flow stamp, matching the em/qa pattern). primary_key = ("id",). Inv-3: same column set the legacy writer writes (legacy confidence = 1.0).
  2. er_target = await mount_table_target(DB_CTX, "entity_relationships", ENTITY_RELATIONSHIPS_SCHEMA, managed_by=ManagedBy.USER) in app_main (:3026 block, after em_target). managed_by=USER = rows only, never DDL — the table + 10-type CHECK
    • entity_relationships_unique_tuple NULLS NOT DISTINCT index already exist (PRODUCT §Verification; migration 20260421171520_…). No DDL in this Task.
  3. Thread er_target into the component: add it to the ingest_file signature (:1581 — after em_target, before the keyword-only * block), to the bound_ingest_file / _ingest_file_impl call chain (:1744, :1892), and to the coco.mount_each(...) extra-args tuple (:3154-3165). This is an arity change — update test_ingest_file_signature_matches_mount_each_extra_args and TestMountEachArityContract (PC-7).
  4. Declare-row loop after the entity_mentions loop (:2200), guarded if relationships: (Inv-8 — zero rows when empty). For each extracted relationship:
    • source_c = canonicalise_for_relationship(rel.source), target_c = canonicalise_for_relationship(rel.target) (PC-3 — Inv-5).
    • Drop predicates outside the 10-type set defensively (Inv-4): the Pydantic Literal already rejects them at extraction, but if a future prompt edit leaks one, the relationship_type is validated against the vocabulary before declare; out-of-set → skip + log (do not crash — the legacy path relies on the DB CHECK, but cocoindex’s declare-row should not hand the DB a row the CHECK will reject mid-batch).
    • Deterministic PK seeded on the natural tuple (mirroring the em natural-key PK fix {66.16}/BUG-F at :2156): id = uuid.uuid5(_KH_PIPELINE_DOC_NS, f"er:{rel_path}:{source_c}:{relationship_type}:{target_c}"). This makes re-ingest idempotent (Inv-6): the same tuple on a later run declares the same PK → cocoindex UPSERTs the existing row (the NULLS NOT DISTINCT unique tuple in the DB is the belt-and-braces; the deterministic PK is the cocoindex-side mechanism, exactly as entity_mentions does it). De-dup repeated identical tuples within a single document into a dict keyed on (source_c, relationship_type, target_c) before declaring (same shape as _em_dedup at :2161) so two identical declares in one run don’t race the PK.
    • er_target.declare_row(row={"id": ..., "source_entity": source_c, "relationship_type": relationship_type, "target_entity": target_c, "source_item_id": content_item_id, "confidence": 1.0, "op_id": op_id}). _bump("postgres_upsert") per row (id-28 Inv-17 counter parity with em — id-28’s register, outside this document’s declared Inv-1 … Inv-16 range per the header note; qualified by the S515 id-402 sweep).

Idempotency note (Inv-6). The legacy TS path relies on ignoreDuplicates: true + the NULLS NOT DISTINCT unique index (a 23505 collision treated as success). cocoindex’s mount_table_target UPSERTs on the declared PK (id), not on the unique tuple — so the deterministic uuid5 PK is what delivers idempotency on the cocoindex side. The DB unique tuple still guards against a different PK colliding on the same tuple (e.g. a future seed change); in that edge case the INSERT raises 23505, which the flow-scope try/except (PC-… best-effort, Inv-7) catches and logs. Net behaviour matches PRODUCT Inv-6: repeated tuples never accumulate duplicate rows.

PC-5 — Holder-metadata stamp (ported deriveHolderMetadata) (Inv-9..Inv-14)

Section titled “PC-5 — Holder-metadata stamp (ported deriveHolderMetadata) (Inv-9..Inv-14)”

Port deriveHolderMetadata (classify.ts:524-595) into Python and invoke it on the deduped entity-mention set before the em declare-row loop writes metadata. Add derive_holder_metadata(mentions, relationships, client_org_lower) -> int to a pipeline module (recommend a new scripts/cocoindex_pipeline/holder_rule.py for testability, imported by flow.py):

  1. Inputs: the deduped mention list (the _em_dedup.values() set, post per-doc canonicalisation) and the extracted relationships list. client_org_lower resolves from the same branding source the TS path uses — decision: the pipeline has no BRANDING TS module; source the client-org name from the pipeline’s existing client-config env (the same value eval-holder-rule-ts.ts reads via BRANDING.organisationName). Surface it as a required env knob (fail-fast if unset, mirroring the NEXT_PUBLIC_CLIENT_ID guard at eval-holder-rule-ts.ts:756) so the pipeline never mis-derives every cert as supplier against a fallback org name (Inv-9/Inv-13 correctness).
  2. Pass 1 (canonical holds): build holds_by_target: dict[str,str] mapping canonicalise_for_relationship(rel.target)canonicalise_for_relationship(rel.source) for every rel.relationship == "holds" (last-wins on collision, matching the TS comment at :532-535). Inv-13: both endpoints normalised via PC-3, identical to the TS comparison.
  3. Pass 2 (S196 synonym fallback — Inv-11): build cert_targets / org_sources sets from the mentions (by entity_type and per-doc canonical_namecareful: the TS uses row.canonical_name, which is already the canonical lowercase form; the Python mentions carry canonicalise_entity_name-form canonical_name, NOT the relationship canonicaliser form — see §Risks R2 for the reconciliation). For each complies_with/evidences rel: accept as a holder signal only when (a) target is a cert, (b) source is the client org OR an extracted org, (c) no canonical holds already exists for that target. holds wins on tie.
  4. Stamp loop (Inv-9, Inv-10, Inv-14): for each certification mention only, look up holds_by_target[mention canonical]; if found, set metadata = { holder: 'self' } when the source equals client_org_lower, else metadata = { holder: 'supplier', supplier_name: <source> }. Inv-10 (load-bearing): mentions with no holds/synonym signal are left untouched — never defaulted to 'self'. Inv-14: non-certification mentions are never stamped.
  5. Wire into the em declare-row loop (:2173-2199): the existing loop writes metadata = { source_span_start, source_span_end }. The holder stamp must merge holder keys into that dict (NOT overwrite — the span keys are load-bearing for Stage-5 provenance). So: run derive_holder_metadata to compute a holder_by_mention_id map, then in the declare loop set metadata = { "source_span_start": ..., "source_span_end": ..., **holder_md } where holder_md is {} for untouched mentions (Inv-10), { "holder": "self" }, or { "holder": "supplier", "supplier_name": ... }. This preserves the existing metadata shape and only adds holder keys when a signal exists.

Why before the declare loop, not after. cocoindex’s mount_table_target UPSERTs the whole row; there is no post-write UPDATE metadata hook in the v1 pipeline (the mount_table_target “no per-row callback” constraint, flow.py:31). So the holder keys MUST be present in the metadata dict at declare time. This is the structural reason the stamp runs inline, unlike the legacy path which mutates rows in place then upserts.

PC-6 — Cross-path parity oracle (Inv-2, Inv-9, Inv-16)

Section titled “PC-6 — Cross-path parity oracle (Inv-2, Inv-9, Inv-16)”

The parity oracle is the central validation decision (PRODUCT §Notes). Three lanes, escalating in fidelity:

  1. Canonical-string golden test (deterministic, CI-runnable — the critical lane). A shared fixture scripts/tests/fixtures/canonicalisation_parity.json of [{raw, expected}] pairs covering every canonicalise branch (slug, ISO variants, Cyber Essentials, WCAG, Ltd→Limited, abbreviation-map entries, multi-word title-case, alias-map entries). Two assertions read the same fixture: a TS test (Vitest, asserting resolveAlias(canonicalise(raw)).toLowerCase() === expected) and a Python test (pytest, asserting canonicalise_for_relationship(raw) == expected). This pins Inv-5/Inv-13 byte-identity deterministically without an LLM. This is the gate that must pass before the ID-45 re-ingest — it is the only lane that proves the highest-risk seam.
  2. Holder-rule unit parity (deterministic). Port the deriveHolderMetadata unit fixtures into a Python pytest over derive_holder_metadata: feed identical (mentions, relationships) inputs and assert identical holder-state output (self / supplier+name / untouched) for the self case, supplier-disclaimer case, S196 synonym case, the “untouched-not-self” Inv-10 case, and the non-cert Inv-14 case. No LLM — pins Inv-9/Inv-10/Inv-11/Inv-14 logic parity.
  3. End-to-end cross-path eval (non-deterministic — extend eval-holder-rule-ts.ts). Reuse the existing oracle’s data-read + threshold machinery against a small pinned fixture corpus (the residual + positive-control items the script already knows). Run the document through BOTH paths (legacy classifyContent and the cocoindex extractor) and compare on set-equality of {source_entity, relationship_type, target_entity} triples after canonicalisation (Inv-2), tolerant of ordering, and exact holder-state match per cert (Inv-9). Non-determinism handling: the LLM may vary triple wording run-to-run, so (a) compare on the canonicalised triple set, not raw strings; (b) treat a triple present in one path but not the other as a parity miss only if it recurs across N≥3 runs (transient single-run variation is logged, not failed); (c) pin the extraction model + prompt (the prompt-cache key already pins prompt bytes). The deterministic lanes (1)+(2) are the hard gate; lane (3) is the empirical confidence check on the live LLM seam and may run --dry-run first.

Decision: extend, don’t rewrite, eval-holder-rule-ts.ts. Its fetchHoldsRelationships / fetchEntityMentions / threshold scaffolding (four thresholds, env-driven client org + positive-control entity) is exactly the read+compare machinery the cross-path eval needs. Add a --path=cocoindex|legacy|both mode and a triple-set comparator; keep the existing --mode=run legacy behaviour intact (it is still the TS oracle of record).

PC-7 — Test-surface updates (Inv-3, Inv-7, Inv-15)

Section titled “PC-7 — Test-surface updates (Inv-3, Inv-7, Inv-15)”
  • Update test_cocoindex_flow_write_path.py: TestMountEachArityContract + test_ingest_file_signature_matches_mount_each_extra_args (:537, :637) for the new er_target positional arg; add a TestIngestFileRelationshipWritePath asserting the declare-row payload shape (canonical endpoints, confidence=1.0, deterministic PK, zero-rows-on-empty) and a holder-stamp assertion (cert→metadata.holder, non-cert untouched, span keys preserved).
  • Best-effort / non-blocking (Inv-7, Inv-15): the relationship write + holder stamp run inside the same flow-scope try/except that already guards ingest_file (app_main :3140-3149 swallows per-item exceptions → batch continues, emitting a structured stage-error log via _emit_stage_error_log / _classify_stage_exception). A relationship/holder failure for one document must NOT abort that document’s other declares (em/qa/classification) nor the batch, and MUST be logged (not silently swallowed). Add a test asserting a raised relationship-write error is logged + the em/qa declares still happen (mirror the existing stage-counter silent-noop test at :448).
  • Guard tests: pipeline-parity.test.ts and mcp-fixture-sync.test.ts run on every test (CLAUDE.md). Adding an extractor/prompt may trip pipeline-parity — update its fixture if the parity guard enumerates extractors/prompts.

Maps each PRODUCT invariant to a concrete check (PRODUCT.md has no Validation section by design — it lives here).

InvWhat it assertsVerification
1@coco.fn direct-Anthropic mechanism, no ExtractByLlmGrep test: extract_relationships decorated @coco.fn(memo=True), calls anthropic.AsyncAnthropic; static assert no ExtractByLlm/LlmSpec reference (extend TestNoFictionalApiSurvives, :1233).
2Triple-set parity with legacy for same inputPC-6 lane 3 (cross-path eval, canonicalised triple-set equality over pinned corpus, N≥3 runs).
3One row/triple, same column set, confidence defaultPC-7 declare-row payload test (column set == legacy, confidence=1.0).
4relationship_type ∈ 10-type set; out-of-set dropped not crashedPydantic Literal unit test (invalid predicate → ValidationError); PC-4 defensive-drop test (out-of-set rel → skipped + logged, batch survives).
5Canonical endpoints byte-identical to legacyPC-6 lane 1 (cross-language golden test — the hard gate).
6Idempotent across re-ingestPC-7 test: two ingest runs of one doc → identical PKs, no duplicate rows (mirror TestStablePrimaryKeysAcrossRuns :698).
7Relationship write best-effort/non-blocking + loggedPC-7 failure-injection test (raise on relationship declare → em/qa declares still happen, error logged).
8Zero rows when no relationshipsPC-7 test: extractor returns [] → no er_target.declare_row call.
9Holder metadata parity with deriveHolderMetadataPC-6 lane 2 (holder-rule unit parity) + lane 3 (e2e holder-state match).
10Untouched-not-self when no signalPC-6 lane 2 explicit case (cert with no holds/synonym → metadata has no holder key).
11S196 synonym fallback parityPC-6 lane 2 synonym cases (accept complies_with/evidences only under all three conditions; holds wins on tie).
12LLM attributes 3rd-party holds at sourcePrompt-content test: RELATIONSHIP_PROMPT contains the verbatim trigger-phrase + disclaimer rules (string-presence assertions); lane-3 supplier-disclaimer fixture produces holder: 'supplier'.
13Holder-source comparison on canonical/lowercased formPC-6 lane 1 (shared canonicaliser) + lane 2 (self/supplier split on canonicalised source).
14Holder stamp only on certification mentionsPC-6 lane 2 non-cert case (org/regulation mention → never stamped).
15Holder stamp best-effort/non-blocking + loggedPC-7 failure-injection test (raise in derive_holder_metadata → em declare still happens with span-only metadata, error logged).
16Cross-path row shape indistinguishableAggregate: lanes 1+2 (canonical + holder shape) + PC-7 column-set test prove a consumer cannot tell which path wrote a row.

Run commands. Python: python3 -m pytest scripts/tests/test_cocoindex_flow_write_path.py scripts/tests/test_holder_rule_parity.py scripts/tests/test_canonicalisation_parity.py (run from the worktree CWD — CLAUDE.md namespace-package hazard). TS golden + eval: bun run test __tests__/.../canonicalisation-parity.test.ts and bun run scripts/eval-holder-rule-ts.ts --path=both --dry-run (the eval needs dangerouslyDisableSandbox: true — Bun fetch hangs behind the SOCKS proxy on Supabase reads, per the script header).

R1 (HIGH — the central risk) — the two canonicalisers do not agree today. PRODUCT Inv-5 / Inv-13 state byte-identical canonical strings, and the carry-over note framed this as “verify the two canonicalisers agree”. The empirical check shows they do not: scripts/cocoindex_pipeline/canonicalisation.py:canonicalise_entity_name is NFKD-fold + lowercase + ISO-only rules, with no company-suffix normalisation, no WCAG, no ABBREVIATIONS map, no multi-word title-case, and no resolveAlias alias layer — all of which the TS resolveAlias(canonicalise(x)).toLowerCase() chain applies. They coincide for a plain ISO27001iso 27001 case but diverge for e.g. Acme Ltd (TS → acme limited, Python → acme ltd), Wcag 2 1 Aa, abbreviation-map entries, and any DB-backed alias. Mitigation: PC-3 ports the full TS chain into a dedicated canonicalise_for_relationship, and PC-6 lane 1 pins it with a shared cross-language golden fixture that MUST pass before ID-45 re-ingest. Do not reuse canonicalise_entity_name for relationship endpoints. This single decision is the difference between cross-path triples colliding correctly and silently forking into duplicate rows post-re-ingest.

R2 (MEDIUM) — entity-mention canonical_name vs relationship-canonical mismatch in the holder Pass-2 set membership. deriveHolderMetadata Pass-2 builds certTargets/orgSources from row.canonical_name (TS) and compares against resolveAlias(canonicalise(rel.x))…. In the cocoindex path, entity_mentions.canonical_name is the canonicalise_entity_name form (ISO-only), while relationship endpoints are the canonicalise_for_relationship form. If these two forms differ for the same raw entity, the Pass-2 certTargets.has(targetLower) check will miss and the synonym fallback silently won’t fire. Mitigation: in derive_holder_metadata, build the cert/org membership sets by applying canonicalise_for_relationship to each mention’s entity_name (raw) — i.e. compare both sides in the relationship-canonical space — rather than trusting the stored canonicalise_entity_name canonical_name. Document this divergence-bridge explicitly in the Python docstring; add a lane-2 fixture where the two canonicalisers would otherwise disagree to lock it.

R3 (MEDIUM) — ingest_file arity change is a breaking signature change. Adding er_target shifts the positional contract that mount_each and three arity tests assert (:537, :637). Mitigation: PC-7 updates the arity tests in the same slice as the signature change; insert er_target after em_target, before the keyword-only * to keep the mount-each extra-args order stable and minimise churn.

R4 (LOW) — client-org env knob unset → mass mis-attribution. If the pipeline’s client-org name resolves to a fallback, every holds source ≠ fallback → every cert stamped supplier (the exact failure eval-holder-rule-ts.ts:756 guards against). Mitigation: fail-fast guard in derive_holder_metadata (raise if the client-org env is unset), and the best-effort wrapper logs the raise rather than corrupting metadata silently.

R5 (LOW) — prompt-cache invalidation churn. RELATIONSHIP_PROMPT is a prompt-cache key (_cached_system_block); any byte edit invalidates the server-side cache for that extractor. Mitigation: port the prompt once, verbatim, and freeze it; the PC-6 lane-1 fixture covers canonicalisation, not prompt text, so prompt tweaks don’t cascade into canonical-string tests.

Decomposition recommendation ({101.4} PLAN)

Section titled “Decomposition recommendation ({101.4} PLAN)”

A {101.4} PLAN decomposition IS warranted (PRODUCT estimated > 2h, multi-slice; this TECH confirms it: new extractor + new prompt + canonicaliser port + write target/loop + holder-stamp port + a three-lane parity oracle, with R1/R2/R3 each touching a different file). Recommended slice boundaries (all sibling-only within ID-101 — see below), ordered by dependency:

  1. Canonicaliser port + golden test (PC-3, PC-6 lane 1). Foundational — every later slice canonicalises through it. Ships canonicalise_for_relationship + the shared TS/Python golden fixture. No dependency.
  2. Relationship extractor + prompt (PC-1, PC-2). The @coco.fn + Pydantic model + ported RELATIONSHIP_PROMPT. No dependency (parallelisable with slice 1).
  3. Write target + declare-row loop (PC-4, PC-7 arity/write-path tests). Depends on slices 1 (canonicaliser) and 2 (extractor output shape).
  4. Holder-rule port + stamp wiring (PC-5, PC-6 lane 2). Depends on slices 1 (canonicaliser, incl. R2 bridge) and 2 (relationship inputs); touches the em declare loop slice 3 also edits — sequence after slice 3 to avoid a merge collision on the declare loop, OR fold the em metadata-merge into slice 3 and keep the holder logic (holder_rule.py) as slice 4.
  5. Cross-path eval extension (PC-6 lane 3). Depends on slices 2+3+4 (needs both paths wired). The empirical confidence gate; can run last.

This is 5 slices, well within the 25-Subtask soft ceiling. I am NOT authoring the PLAN — this is the recommendation for the Orchestrator to dispatch a {101.4} Planner.

Sibling-only dependency check (§3.3 / A6 forcing function): PASS. Every slice dependency above is intra-ID-101 (slice→slice within the same Task). No cross-Task Subtask dependency surfaced. The ID-45 relationship is a Task-level ordering gate (the C1 pre-re-ingest gate in Task.dependencies), not a Subtask-level dependency — consistent with the PRODUCT author’s note. No escalation required.

Per the Q-EX2 pre-ratification empirical-verification forcing function (external-library API citations import-and-call verified against the installed pin before ratification):

  • Date: 09/06/2026.
  • Pinned versions: cocoindex[postgres]==1.0.7 (requirements.txt:54), anthropic==0.79.0 (requirements.txt:3).
  • Symbols checked (cocoindex): cocoindex.ExtractByLlm, cocoindex.LlmSpec — re-confirmed ABSENT (PRODUCT §Verification carried the import-and-call result; hasattr both False). The mechanism this TECH specs (@coco.fn(memo=True) + direct anthropic.AsyncAnthropic + Pydantic TypeAdapter) is the only viable one on the pin and is the shipped ID-94 pattern (extract_qa_form / extract_entity_mentions, extraction.py:1016-1068). Result: PRESENT (the precedent extractors + their helpers _anthropic_retry, _cached_system_block, _guard_not_truncated, _strip_code_fence, module-level TypeAdapters are all present and used exactly as PC-1 reuses them).
  • Symbols checked (anthropic): anthropic.AsyncAnthropic, client.messages.create (wrapped by _anthropic_message) — already exercised by the three shipped extractors on this pin; no new anthropic symbol is introduced by this Task. Result: PRESENT (in-use on the pinned version).
  • Canonicaliser divergence (empirical, the R1 evidence): grep over scripts/cocoindex_pipeline/canonicalisation.py confirms no alias / abbreviation / company-suffix / WCAG / title-case handling — the Python canonicalise_entity_name does NOT reproduce the TS resolveAlias(canonicalise(x)).toLowerCase() chain. Result: SIGNATURE/BEHAVIOUR DRIFT between the two canonicalisers → PC-3 ports the TS chain; the PRODUCT Inv-5/Inv-13 “byte-identical” claim is satisfiable only via that port (not by reusing the existing Python canonicaliser). This is a within-KH parity finding, not an external-API drift — no spec-blocking escalation, but it reframes the carry-over “verify agreement” as “build agreement” and is called out as R1.
  • Schema preconditions (confirmed present, NOT created by this Task): entity_relationships 10-type CHECK + entity_relationships_unique_tuple NULLS NOT DISTINCT index (supabase/migrations/20260421171520_entity_relationships_unique_tuple_constraint.sql); entity_mentions.metadata JSONB holds the { holder, supplier_name } shape (lib/ai/classify.ts:584-589). managed_by=ManagedBy.USER on er_target = rows only, no DDL.