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.
Context
Section titled “Context”The certification holder pipeline runs in two stages, duplicated TS↔Python, both of which must stay at parity (PRODUCT §Behavior, invariant 12):
- Extraction — the LLM emits relationship triples (
source/relationship/target). TS:classifyContent→ the Claude tool schema +lib/ai/skills/classification.mdprompt. Python:extract_relationships→prompts.pyRELATIONSHIP_PROMPT. - Holder rule — a pure function stamps
metadata.holderper certification mention by reading theholds/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).definitionssurfaced verbatim — the edit/test loci:scripts/eval-holder-rule-ts.ts(printUsage382–421,main1782–1800 — the TS cross-path eval harness);lib/client-config.tsbuildDisambiguationBlock(224–228),ClientConfiginterface (47–98);lib/ai/classify.tsclassifyContent(1104–1844),isInternalDocument(291–295),buildValidationPrompt(841–955); the Python prompt testsscripts/tests/test_cocoindex_prompts.pyTestRelationshipPromptHolderRules.test_contains_verbatim_trigger_phrase_rules(285–305),test_contains_verbatim_disclaimer_paragraph_rule(307–323), classTestRelationshipPromptHolderRules(277–335); the parity testsscripts/tests/test_parity_driver.pytest_build_holder_states_no_signal_cert_absent(161–165),test_build_holder_states_non_cert_never_keyed(168–177); the write-path testsscripts/tests/test_cocoindex_flow_write_path.pyTestHolderStampWiring(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 eleventest_holder_rule_parity.pycases (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_branchis 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-widegrep -rn "source_scope\|holder_basis" lib/ scripts/ types/ components/ supabase/returns zero matches — bothsource_scopeandholder_basisare net-new additive fields (confirms PRODUCT §Code-intelligence-orientation). ccc fallback was unnecessary (gitnexus returned the full relevant symbol set).
Relevant code (read in full; file:line)
Section titled “Relevant code (read in full; file:line)”- TS relationship type —
lib/ai/classify.ts:653–667ExtractedRelationshipinterface (source/relationship10-enum /target). Used byderiveHolderMetadatasignature (527) andClassificationResult.relationships(724). - TS Claude tool schema —
lib/ai/classify.ts:1280–1311relationshipsarray item:properties { source, relationship (enum), target },required: ['source', 'relationship', 'target']. This is the wire contract the LLM fills. - TS holder rule —
lib/ai/classify.ts:524–595deriveHolderMetadata. Pass 1holds(536–542); Pass 2 synonym gatesourceIsClientOrg/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 writer —
lib/ai/classify.ts:1785–1805maps each rel to{ source_entity, relationship_type, target_entity, source_item_id, confidence }→entity_relationshipsupsert. Nosource_scopecolumn written (see §Persistence). - Python relationship model —
scripts/cocoindex_pipeline/extraction.py:399–427RelationshipExtraction(_ExtractionCore):source(min_length 1),relationshipLiteral[10],target(min_length 1). TypeAdapter at 812–813; extractorextract_relationships1108–1136 (_relationships_adapter.validate_json). - Python holder rule —
scripts/cocoindex_pipeline/holder_rule.py:98–194derive_holder_metadata(mentions, relationships, client_org_lower). R4 fail-fast (124–131); Pass 1holds(137–141); R2 membership sets (146–153); Pass 2 synonym gate (159–171); stamp loop (175–194): Inv-10if holds_source is None: continue(181–182), self{"holder": "self"}(188) else{"holder": "supplier", "supplier_name": holds_source}(190–193). - Python live consumer —
scripts/cocoindex_pipeline/flow.py:2255–2257callsderive_holder_metadata; the merge intometadatajsonb at 2305–2309 ({...span keys, **holder_md}); the er-declare loop 2344–2379 writes onlysource_entity / relationship_type / target_entity / source_item_id / confidencetoentity_relationships(line 2371–2375).source_scopeis NOT in the declared row. - Parity harness —
scripts/cocoindex_pipeline/parity_driver.py:121–143build_holder_statescalls the productionderive_holder_metadatadirectly and re-keys onper_doc_canonical; the fullholder_mddict (incl. a newholder_basis) flows through unchanged.build_holder_diagnostics146–199. - Prompt surfaces (byte-stable mirrors) — TS holder-disambiguation block
lib/ai/skills/classification.md:719–778; “Internal departments” extraction exclusionclassification.md:390. Python holder-disambiguation blockscripts/cocoindex_pipeline/prompts.py:226–272;ENTITY_MENTION_PROMPT139,RELATIONSHIP_PROMPT194. Confirmed latent divergence (PRODUCT invariant 12): the PythonENTITY_MENTION_PROMPTcarries no “Internal departments” exclusion line analogous toclassification.md:390. - Schema (no DDL) —
entity_mentions.metadataisjsonb DEFAULT '{}'::jsonb(supabase/migrations/20260416102457_pre_squash_reconciliation.sql:3623).holder_basisis a new jsonb key — no migration.entity_relationshipshas nosource_scopecolumn and gains none —source_scopenever persists.
Proposed changes
Section titled “Proposed changes”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).
| Surface | Change |
|---|---|
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.
Proposed-change → invariant mapping
Section titled “Proposed-change → invariant mapping”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
sourcestring (e.g."__internal__") — rejected by PRODUCT invariant 2 (must be a dedicated field). It would also collide with canonicalisation (canonicalise_for_relationshipwould mangle it) and pollute the persistedentity_relationships.source_entity. - Persisting
source_scopetoentity_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).
Testing and validation
Section titled “Testing and validation”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: aninternal-taggedholds/complies_withrel for “Internal IT compliant to ISO 27001”, no disclaimer → assert{holder:"self", holder_basis:"internal_function"}. TSderiveHolderMetadata+ Pythonderive_holder_metadata. - Inv 2 (signal on relationship; subject not an org) + Inv 12 (prompt parity). Prompt-
contract tests: extend
test_cocoindex_prompts.pyTestRelationshipPromptHolderRulesto assert the verbatim internal-function rule + worked examples are present inRELATIONSHIP_PROMPT; add a test asserting the new “Internal departments” exclusion line is present inENTITY_MENTION_PROMPT(PC-2). Add a TS counterpart assertingclassification.mdcarries 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"(orsource_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 (supplierornullper 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_scopeabsent + 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_keyandtest_non_cert_never_stamped(and TS equivalents) — the new branch must not regress them. A non-cert mention carryingsource_scope="internal"must still never be stamped. - Inv 11 (name-resolved self unchanged). “{CLIENT_ORGANISATION_NAME} is certified to
ISO 9001” →
{holder:"self"}with noholder_basis— assert the existingtest_self_attributionstill passes unchanged. - Inv 12 (cross-path golden parity). Add an internal-function row to the cross-path
comparator (
parity_driver.pybuild_holder_statesflows the fullholder_mdincl.holder_basis; extendtest_parity_driver.py) and to theeval-holder-rule-ts.tsfixture matrix so a TS↔Python divergence onholder/holder_basis/supplier_namefails CI. - Inv 13 (PI-15 / R4 composition). Assert the internal branch runs after the R4
fail-fast (
test_unset_client_org_raisesstill 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.
Risks and mitigations
Section titled “Risks and mitigations”- Threading
source_scopethrough two passes asymmetrically. TS builds membership sets fromrow.canonical_name(per-mention space) while Python builds them in relationship-canonical space (the deliberate R2-bridge fix,holder_rule.py:25–43). The newscopeByTarget/scope_by_targetmap must key on the same space each path already uses forholdsRelsByTarget/holds_by_targetso 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
internalonly 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–2267best-effort try/except — a malformedsource_scopemust not raise there. Mitigation: the PydanticLiteral[...] | Nonevalidates 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:
- 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". - 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).