Skip to content

ID-109 TECH — internal-function holder attribution (Option C, source_scope tag)

ID-109 — internal-function holder attribution (TECH)

Section titled “ID-109 — internal-function holder attribution (TECH)”

Spec chain: {109.1} RESEARCH (done, ratified S348) → {109.2} PRODUCT (done, ratified S350) → {109.3} TECH (this document){109.4} PLAN (conditional — see §Decomposition note). Artefact kind: {N.3} TECH — translates the ratified Option-C behaviour into a concrete implementation plan against the live codebase. Fresh-planner discipline (Q-PLANNER-2): authored by a fresh task-planner instance, not the {109.2} author; PRODUCT.md + RESEARCH.md re-read in full before drafting. Behaviour (numbered invariants 1–15) is defined in PRODUCT.md; this document does not restate it — it maps each Proposed change to the invariant(s) it satisfies and defines the per-invariant test.

The certification holder pipeline runs in two stages, duplicated TS↔Python, both of which must stay at parity (PRODUCT §Behavior, invariant 12):

  1. Extraction — the LLM emits relationship triples (source/relationship/target). TS: classifyContent → the Claude tool schema + lib/ai/skills/classification.md prompt. Python: extract_relationshipsprompts.py RELATIONSHIP_PROMPT.
  2. Holder rule — a pure function stamps metadata.holder per certification mention by reading the holds/synonym relationships. TS: deriveHolderMetadata. Python: derive_holder_metadata.

Today an internal-function compliance subject (“Our internal IT team is compliant to ISO 27001”) yields holder=null because (a) “Internal IT” is an extraction exclusion so never an organisation mention, and (b) the Pass-2 synonym gate rejects any source that is neither the client org nor an extracted org. Full mechanics in RESEARCH.md §Problem.

Code-intelligence orientation (cited verbatim)

Section titled “Code-intelligence orientation (cited verbatim)”

GitNexus (repo: knowledge-hub, branch canonical-pipeline-setup) was run against the ID-109 vocabulary; ast-dataflow is TS-only so Python surfaces were covered by grep sweeps over scripts/cocoindex_pipeline/*.py. Guide refs: .gitnexus/CLAUDE.md, .ast-dataflow/CLAUDE.md.

  • gitnexus_query({query: 'holder_rule internal function classification'})processes: [], process_symbols: [] (the holder rule is a pure function with no indexed execution-flow process). definitions surfaced verbatim — the edit/test loci: scripts/eval-holder-rule-ts.ts (printUsage 382–421, main 1782–1800 — the TS cross-path eval harness); lib/client-config.ts buildDisambiguationBlock (224–228), ClientConfig interface (47–98); lib/ai/classify.ts classifyContent (1104–1844), isInternalDocument (291–295), buildValidationPrompt (841–955); the Python prompt tests scripts/tests/test_cocoindex_prompts.py TestRelationshipPromptHolderRules.test_contains_verbatim_trigger_phrase_rules (285–305), test_contains_verbatim_disclaimer_paragraph_rule (307–323), class TestRelationshipPromptHolderRules (277–335); the parity tests scripts/tests/test_parity_driver.py test_build_holder_states_no_signal_cert_absent (161–165), test_build_holder_states_non_cert_never_keyed (168–177); the write-path tests scripts/tests/test_cocoindex_flow_write_path.py TestHolderStampWiring (3400–3622), TestIngestFileRelationshipWritePath (830–1057).
  • gitnexus_context({name: 'deriveHolderMetadata', file_path: 'lib/ai/classify.ts'}) — verbatim: Function:lib/ai/classify.ts:deriveHolderMetadata, lines 524–595. incoming.calls: classifyContent (lib/ai/classify.ts) — sole caller. outgoing.calls: resolveAlias (lib/entities/entity-aliases.ts), canonicalise (lib/entities/entity-dedup.ts). processes: [].
  • gitnexus_context({name: 'derive_holder_metadata', file_path: 'scripts/cocoindex_pipeline/holder_rule.py'}) — verbatim: Function:scripts/cocoindex_pipeline/holder_rule.py:derive_holder_metadata, lines 98–193. incoming.calls (verbatim, 15 callers): _ingest_content_branch (flow.py — the live write path), build_holder_states (parity_driver.py), test_holder_self_with_short_name_alias + test_baseline_only_misattributes_short_name (test_holder_rule_alias_resolution.py), and the eleven test_holder_rule_parity.py cases (test_self_attribution, test_supplier_disclaimer_attribution, test_s196_synonym_fallback, test_canonical_holds_wins_over_synonym_on_tie, test_synonym_rejected_when_source_not_org, test_no_signal_cert_has_no_holder_key, test_unrelated_rel_does_not_stamp_cert, test_non_cert_never_stamped, test_r2_divergence_membership_in_relationship_canonical_space, test_unset_client_org_raises, test_derive_is_deterministic). outgoing.calls: canonicalise_entity_name + canonicalise_for_relationship (scripts/cocoindex_pipeline/canonicalisation.py). processes: []. (This caller set is richer than RESEARCH cited — note _ingest_content_branch is the live consumer, and the eleven parity cases are the regression baseline the new branch must not break.)
  • gitnexus_query({query: 'holder_rule internal function classification'}) greenfield check + grep: repo-wide grep -rn "source_scope\|holder_basis" lib/ scripts/ types/ components/ supabase/ returns zero matches — both source_scope and holder_basis are net-new additive fields (confirms PRODUCT §Code-intelligence-orientation). ccc fallback was unnecessary (gitnexus returned the full relevant symbol set).
  • TS relationship typelib/ai/classify.ts:653–667 ExtractedRelationship interface (source / relationship 10-enum / target). Used by deriveHolderMetadata signature (527) and ClassificationResult.relationships (724).
  • TS Claude tool schemalib/ai/classify.ts:1280–1311 relationships array item: properties { source, relationship (enum), target }, required: ['source', 'relationship', 'target']. This is the wire contract the LLM fills.
  • TS holder rulelib/ai/classify.ts:524–595 deriveHolderMetadata. Pass 1 holds (536–542); Pass 2 synonym gate sourceIsClientOrg/sourceIsExtractedOrg (570–572); stamp loop self/supplier (578–595): if (holdsSource === clientOrgLower) row.metadata = { holder: 'self' } (583–584) else { holder: 'supplier', supplier_name: holdsSource } (586–589).
  • TS relationship DB writerlib/ai/classify.ts:1785–1805 maps each rel to { source_entity, relationship_type, target_entity, source_item_id, confidence }entity_relationships upsert. No source_scope column written (see §Persistence).
  • Python relationship modelscripts/cocoindex_pipeline/extraction.py:399–427 RelationshipExtraction(_ExtractionCore): source (min_length 1), relationship Literal[10], target (min_length 1). TypeAdapter at 812–813; extractor extract_relationships 1108–1136 (_relationships_adapter.validate_json).
  • Python holder rulescripts/cocoindex_pipeline/holder_rule.py:98–194 derive_holder_metadata(mentions, relationships, client_org_lower). R4 fail-fast (124–131); Pass 1 holds (137–141); R2 membership sets (146–153); Pass 2 synonym gate (159–171); stamp loop (175–194): Inv-10 if holds_source is None: continue (181–182), self {"holder": "self"} (188) else {"holder": "supplier", "supplier_name": holds_source} (190–193).
  • Python live consumerscripts/cocoindex_pipeline/flow.py:2255–2257 calls derive_holder_metadata; the merge into metadata jsonb at 2305–2309 ({...span keys, **holder_md}); the er-declare loop 2344–2379 writes only source_entity / relationship_type / target_entity / source_item_id / confidence to entity_relationships (line 2371–2375). source_scope is NOT in the declared row.
  • Parity harnessscripts/cocoindex_pipeline/parity_driver.py:121–143 build_holder_states calls the production derive_holder_metadata directly and re-keys on per_doc_canonical; the full holder_md dict (incl. a new holder_basis) flows through unchanged. build_holder_diagnostics 146–199.
  • Prompt surfaces (byte-stable mirrors) — TS holder-disambiguation block lib/ai/skills/classification.md:719–778; “Internal departments” extraction exclusion classification.md:390. Python holder-disambiguation block scripts/cocoindex_pipeline/prompts.py:226–272; ENTITY_MENTION_PROMPT 139, RELATIONSHIP_PROMPT 194. Confirmed latent divergence (PRODUCT invariant 12): the Python ENTITY_MENTION_PROMPT carries no “Internal departments” exclusion line analogous to classification.md:390.
  • Schema (no DDL)entity_mentions.metadata is jsonb DEFAULT '{}'::jsonb (supabase/migrations/20260416102457_pre_squash_reconciliation.sql:3623). holder_basis is a new jsonb key — no migration. entity_relationships has no source_scope column and gains none — source_scope never persists.

The change is prompt + type/schema + holder-rule logic + tests, on both runtimes, at byte-stable parity. Zero database migration (see §Persistence). The source_scope field lives on the in-memory relationship object only, between extraction and the holder-rule consumption, and is dropped before the er-declare write. The only persisted new datum is the holder_basis key inside the schemaless entity_mentions.metadata jsonb.

Wire shape (ratified mechanism: dedicated field, PRODUCT invariant 2 / OQ-6)

Section titled “Wire shape (ratified mechanism: dedicated field, PRODUCT invariant 2 / OQ-6)”

source_scope is an optional field on the relationship triple with values "internal" | "external" | null (absent ⇒ external/unknown). It is a dedicated field, not a sentinel source string (PRODUCT invariant 2 forbids the sentinel).

SurfaceChange
TS ExtractedRelationship (classify.ts:653–667)Add source_scope?: 'internal' | 'external' (optional).
TS Claude tool schema (classify.ts:1280–1311)Add source_scope to properties (type: 'string', enum: ['internal','external'], description: …); leave required unchanged (stays ['source','relationship','target'] — optional).
Python RelationshipExtraction (extraction.py:399–427)Add source_scope: Literal['internal','external'] | None = None.

Parity bar: the TS enum values and the Python Literal values are byte-identical ('internal', 'external'); the field name source_scope is identical on both.

PC-1 — twin prompt-surface edits (extraction emits the tag). Extend the holder-disambiguation block in both classification.md:719–778 and prompts.py:226–272 (kept byte-stable mirrors) with an internal-function sub-rule: when a certification’s holder is the document author’s own internal function, declared with explicit first-person possessive (“our”/“we”/“our own”) and with no supplier/third-party disclaimer in scope, emit the holds/complies_with rel with source_scope: "internal"; the supplier-disclaimer rule takes precedence and sets source_scope: "external" (or names the third party); on a named third party’s internal function (“Example Datacentre’s internal security team”) set source_scope: "external"; on bare/non-possessive phrasing emit no source_scope (abstain). Add the three normative worked examples from PRODUCT invariant 15 (fires-self / disclaimer-wins / bare-abstain) to both blocks. Default possessive set (PRODUCT OQ-B, default-if-unanswered): require explicit first-person possessor "our" / "we" / "our own"; treat bare "in-house" as abstain. Satisfies invariants 1, 2, 4, 5, 6, 7.

PC-2 — resolve the ENTITY_MENTION_PROMPT exclusion divergence. Add the “Internal departments: IT Department, HR Team, the project team, senior management” extraction exclusion line to the Python ENTITY_MENTION_PROMPT (prompts.py:139) so it matches classification.md:390. The internal-function subject must remain a non-organisation mention on both paths; the new signal rides on the relationship, not on promoting “Internal IT” to an org. Satisfies invariants 2, 12.

PC-3 — TS holder-rule third branch. In deriveHolderMetadata (classify.ts:524–595): thread source_scope through Pass 1 + Pass 2 so the resolved holder source for a cert target carries its scope. The cleanest threading: alongside holdsRelsByTarget (Map<target, source>), build a parallel scopeByTarget: Map<string, 'internal' | 'external'> populated in the same Pass-1/Pass-2 loops from rel.source_scope. In the stamp loop (578–595), before the existing self/supplier split: if (scopeByTarget.get(target) === 'internal') row.metadata = { holder: 'self', holder_basis: 'internal_function' }. Else fall through unchanged: holdsSource === clientOrgLower{ holder: 'self' } (no holder_basis); else { holder: 'supplier', supplier_name }. A cert whose only signal is source_scope: 'external' and whose source is not an org follows the existing Pass-2 reject path → absent from map → null. Satisfies invariants 3, 8, 9, 10, 11, 13.

PC-4 — Python holder-rule third branch (byte-parallel to PC-3). In derive_holder_metadata (holder_rule.py:98–194): build a parallel scope_by_target: dict[str, str] in the Pass-1 (137–141) and Pass-2 (159–171) loops from rel.source_scope. In the stamp loop (175–194), after the Inv-10 if holds_source is None: continue guard (181–182) and before the if holds_source == client_org_lower split (187): if scope_by_target.get(target_c) == "internal": holder_by_mention_id[key] = {"holder": "self", "holder_basis": "internal_function"}; continue. The internal branch runs after the R4 client-org fail-fast (124–131) and after the no-signal guard, never around them (PRODUCT invariant 13). Satisfies invariants 3, 8, 9, 10, 11, 13.

Ordering subtlety (invariant 4, disclaimer dominance). Disclaimer dominance is enforced primarily at extraction (PC-1: a disclaimer sets source_scope: "external" or a named third-party source, so the relationship never arrives tagged internal). The holder rule keys off the tag it receives; it does not re-derive the disclaimer. This keeps the existing supplier-disclaimer behaviour byte-identical and means the internal branch is structurally unreachable for a disclaimer-scoped cert. Satisfies invariant 4.

PC-5 — holder_basis provenance, additive, no consumer break. holder_basis is stamped only on the internal-function self path; name-resolved self and supplier stamps do not carry it (PRODUCT invariants 10, 11). It rides the schemaless entity_mentions.metadata jsonb (merged at flow.py:2308; TS sets row.metadata directly). No change to the downstream read types CertificationMetadata (components/dashboard/certification-summary-card.tsx:29–39, holder?: 'self' | 'supplier') or CertificationReportEntry (lib/mcp/formatters/entities.ts:132–141) — holder_basis is new and ignorable, the holder value space is unchanged. Satisfies invariants 10, 11; respects PRODUCT Non-goals (no consumption change).

Persistence — zero migration (load-bearing decision)

Section titled “Persistence — zero migration (load-bearing decision)”

source_scope is consumed in-memory by the holder rule and is never written. The er-declare loop (flow.py:2366–2375) and the TS relationship writer (classify.ts:1787–1793) both write a fixed five-field row to entity_relationships (source_entity / relationship_type / target_entity / source_item_id / confidence) — we add source_scope to neither. holder_basis lands inside entity_mentions.metadata jsonb, which is schemaless (DEFAULT '{}'), so it needs no DDL, no REVOKE grant, no SET search_path, no RLS predicate, and no database.types.ts regen (the column type is already Json). This is why the feature is migration-free; it is the chief reason Option C/C1 (relationship-source scope) is cheaper than the C3 pseudo-entity.

REJECTED alternatives (ledger-field discipline, ID-34)

Section titled “REJECTED alternatives (ledger-field discipline, ID-34)”
  • Sentinel source string (e.g. "__internal__") — rejected by PRODUCT invariant 2 (must be a dedicated field). It would also collide with canonicalisation (canonicalise_for_relationship would mangle it) and pollute the persisted entity_relationships.source_entity.
  • Persisting source_scope to entity_relationships — rejected as unnecessary scope creep: nothing downstream reads it (consumption is out of scope per PRODUCT Non-goals), it would force a migration + types regen + the TS/Python writer changes, and the holder rule consumes it before the write anyway. If a future consumption Task needs it persisted, that is its migration to own.
  • Mention-level tag (C2) / pseudo-entity (C3) — rejected in RESEARCH §Option C (ambiguity re-introduction / entity-type churn).
  • String-match in the holder rule (Option B) — rejected in RESEARCH (false self-attribution of supplier certs).

Mirror the existing synthetic-fixture pattern in scripts/tests/test_holder_rule_parity.py (duck-typed _Mention/_Rel dataclass stand-ins; _CLIENT_ORG = canonicalise_for_relationship("Knowledge Hub Ltd"); pure-function asserts, no LLM/DB) and the TS vitest counterpart used by eval-holder-rule-ts.ts. All fixtures synthetic and de-identified — placeholders {CLIENT_ORGANISATION_NAME}, “Internal IT”, “Example Datacentre”, generic certs (ISO 27001 / Cyber Essentials Plus / ISO 9001). Run with bun run test (TS, never bun test) and python3 -m pytest scripts/tests/ (from the worktree CWD — see scripts/CLAUDE.md namespace-package hazard). Behaviour-first per test-philosophy.md. The new _Rel stand-in must expose source_scope.

Per-invariant mapping (each PRODUCT invariant → concrete test):

  • Inv 1 (internal → self) + Inv 3 (third branch) + Inv 10/11 (holder_basis). Both paths: an internal-tagged holds/complies_with rel for “Internal IT compliant to ISO 27001”, no disclaimer → assert {holder:"self", holder_basis:"internal_function"}. TS deriveHolderMetadata + Python derive_holder_metadata.
  • Inv 2 (signal on relationship; subject not an org) + Inv 12 (prompt parity). Prompt- contract tests: extend test_cocoindex_prompts.py TestRelationshipPromptHolderRules to assert the verbatim internal-function rule + worked examples are present in RELATIONSHIP_PROMPT; add a test asserting the new “Internal departments” exclusion line is present in ENTITY_MENTION_PROMPT (PC-2). Add a TS counterpart asserting classification.md carries the same block. A byte-diff/normalised-equivalence assertion across the two blocks guards parity.
  • Inv 4 (disclaimer dominance). Both paths: cert under “held by Example Datacentre, not {CLIENT_ORGANISATION_NAME}” that also uses “internal” phrasing, modelled as a rel with source="example datacentre" (or source_scope="external") → assert {holder:"supplier", supplier_name:"example datacentre"}, not self.
  • Inv 5 (named third-party internal fn). “Example Datacentre’s internal security team is compliant to ISO 27001” → rel source_scope="external" / named source → assert not self (supplier or null per existing rules).
  • Inv 6 (bare/non-possessive abstains). Rel with no source_scope (absent), source not an org → assert cert absent from map → holder=null.
  • Inv 7 (ambiguous abstains). Conflicting signal (e.g. source_scope absent + named party in scope) → holder=null.
  • Inv 8 (Inv-10 preserved) + Inv 9 (Inv-14 preserved). Re-run/extend the existing test_no_signal_cert_has_no_holder_key and test_non_cert_never_stamped (and TS equivalents) — the new branch must not regress them. A non-cert mention carrying source_scope="internal" must still never be stamped.
  • Inv 11 (name-resolved self unchanged). “{CLIENT_ORGANISATION_NAME} is certified to ISO 9001” → {holder:"self"} with no holder_basis — assert the existing test_self_attribution still passes unchanged.
  • Inv 12 (cross-path golden parity). Add an internal-function row to the cross-path comparator (parity_driver.py build_holder_states flows the full holder_md incl. holder_basis; extend test_parity_driver.py) and to the eval-holder-rule-ts.ts fixture matrix so a TS↔Python divergence on holder/holder_basis/supplier_name fails CI.
  • Inv 13 (PI-15 / R4 composition). Assert the internal branch runs after the R4 fail-fast (test_unset_client_org_raises still raises) and that no internal stamp occurs on a missing/ambiguous signal (no silent self-attribution path added).
  • Inv 14 (no partial flip). Both paths must land together; the cross-path parity test (Inv 12) is the CI gate that prevents one-path-only behaviour.
  • Inv 15 (worked examples). The three normative examples are encoded as the fixtures above (fires-self, disclaimer-wins, bare-abstain).

Regression baseline: the eleven existing test_holder_rule_parity.py cases (orientation caller set) plus the two test_holder_rule_alias_resolution.py cases must continue to pass unchanged — they are the proof the third branch is purely additive.

  • Threading source_scope through two passes asymmetrically. TS builds membership sets from row.canonical_name (per-mention space) while Python builds them in relationship-canonical space (the deliberate R2-bridge fix, holder_rule.py:25–43). The new scopeByTarget/scope_by_target map must key on the same space each path already uses for holdsRelsByTarget/holds_by_target so the scope lookup hits. Mitigation: key the scope map identically to the holds map within each path; the cross-path parity test (Inv 12) catches a space mismatch.
  • LLM tag reliability (precision). The whole rule’s safety rests on the LLM tagging internal only on explicit first-person possessive + disclaimer-free (RESEARCH §crux). Mitigation: abstain-to-null is mandatory (invariants 6–8); the prompt-contract tests pin the rule wording; OQ-B fixes the possessive set conservatively.
  • Best-effort stamp swallow (Inv-15). The Python holder call is inside the flow.py:2252–2267 best-effort try/except — a malformed source_scope must not raise there. Mitigation: the Pydantic Literal[...] | None validates at extraction; the holder rule treats any non-"internal" value (incl. None) as fall-through, never raises.

Decomposition note (for the Orchestrator — informs whether {109.4} PLAN is needed)

Section titled “Decomposition note (for the Orchestrator — informs whether {109.4} PLAN is needed)”

This is a single cohesive slice with no migration, no chain-dependent sub-slices, and no multiple-adapter surface — but it touches both runtimes and parity is the binding constraint, so the implementation must land atomically (invariant 14: no partial flip). Recommended shape: one implementation Subtask ({109.5}) covering PC-1…PC-5 + tests on both paths in one worktree, gated by the cross-path parity test. {109.4} PLAN is not required (no compound chain dependency; effort ~2h). If the Orchestrator prefers a split, the only safe seam is prompt-surface edits (PC-1/PC-2) vs holder-rule logic (PC-3/PC-4) — but they must merge together to honour invariant 14, so a single Subtask is preferred.

Open decisions needing Liam’s ratification

Section titled “Open decisions needing Liam’s ratification”

Both are PRODUCT open questions carried forward; neither blocks implementation (TECH encodes the documented defaults), but Liam may override:

  1. OQ-B — possessive surface breadth (PC-1). TECH adopts the PRODUCT default: trigger on explicit "our" / "we" / "our own"; treat bare "in-house" (without “our”) as abstain-to-null. Confirm, or widen to include bare "in-house".
  2. OQ-A — mixed-provenance ingest. TECH treats a sentence with a named third party in scope plus a first-person possessive as ambiguous → abstain-to-null (invariant 7), and defers mixed-provenance hardening. Confirm abstention is the desired behaviour for such mixed sentences (vs attributing to the named third party).