Skip to content

ID-109 PRODUCT — internal-function holder attribution (Option C, extraction-side tag)

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

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

Spec chain: {109.1} RESEARCH (done, ratified S348) → {109.2} PRODUCT (this document){109.3} TECH → {109.4} PLAN (TECH/PLAN pending PRODUCT ratification). Artefact kind: {N.2} PRODUCT — defines desired behaviour and numbered, testable invariants; mechanism choice and implementation live in {109.3} TECH. Provenance: promoted from bl-297 (S345); supersedes ID-101 {101.11} (cancelled, re-homed here). Product call ratified by Liam S348 (recorded in the {109.1} ledger journal): adopt Option C (attribute internal-function certs to self via an extraction-side tag), disclaimer-dominant + abstain-on-ambiguity, explicit-possessive-only trigger, keep holder_basis=internal_function, lean on disclaimer + possessive signals, dedicated source_scope field.

When a Knowledge Hub document states that the author organisation’s own internal function holds a certification — e.g. “Our internal IT team is compliant to ISO 27001” — the certification is attributed to the client (holder=self) rather than left silently unattributed. The classifier tags the holder relationship with an explicit internal-function signal at extraction; the holder rule keys off that tag to stamp { holder: "self", holder_basis: "internal_function" }. The rule is supplier-disclaimer- dominant (a disclaimer to a third party always wins), fires only on explicit first-person possessive framing, and abstains to holder=null whenever the signal is absent or ambiguous — closing the 7e511dbc-class coverage gap without re-introducing the false-self-attribution risk that a naïve string match would carry.

Unqualified internal-function compliance statements currently yield holder=null after the {101.9}/{101.10} alias fix. “Internal IT is compliant to ISO 27001” is captured as a certification mention but receives no holder signal, so a genuine client certification is silently unattributed in every downstream consumer (“what certs does the client hold?” returns nothing for it). This is a genuine coverage gap — a class of subject the holder rule was never designed to attribute — and is distinct from the {101.9} resolution bug (there the subject was the client but its short name failed to resolve; here the subject is an internal function deliberately excluded from organisation extraction, so no alias resolution can ever help). Full mechanics, options A–D, and the disambiguation-signal analysis live in the ratified {109.1} RESEARCH.md; this PRODUCT defines the behaviour for the ratified Option C.

GitNexus (repo: knowledge-hub, branch canonical-pipeline-setup) was run against the ID-109 domain vocabulary; ast-dataflow is TS-only, so the Python pipeline surfaces were covered by grep over scripts/cocoindex_pipeline/*.py and supabase/migrations/*.sql.

  • gitnexus_context({name: 'deriveHolderMetadata'}) — verbatim: symbol Function:lib/ai/classify.ts:deriveHolderMetadata (lines 524–595); incoming.calls: classifyContent (sole caller, lib/ai/classify.ts); outgoing.calls: resolveAlias (lib/entities/entity-aliases.ts), canonicalise (lib/entities/entity-dedup.ts); processes: [] (the holder rule is a pure function with no indexed execution-flow process). This is the TS holder-rule edit locus; the Python port is scripts/cocoindex_pipeline/holder_rule.py derive_holder_metadata.
  • gitnexus_query({query: 'holder attribution certification compliance internal function self supplier'})processes: [] (no indexed call-chain), definitions surfaced (all verbatim from the query): the cross-path eval scripts/eval-holder-rule-ts.ts (classifyHolderDivergence 833–867, fetchLegacyHolderStates 1413–1436); the parity tests scripts/tests/test_parity_driver.py (test_build_holder_states_no_signal_cert_absent 161–165, test_build_holder_diagnostics_only_keys_certifications 210–221); the prompt-contract tests scripts/tests/test_cocoindex_prompts.py (TestRelationshipPromptHolderRules 277–335, asserting the verbatim disclaimer paragraph
    • supplier-attribution example are present in the Python prompt); the write-path tests scripts/tests/test_cocoindex_flow_write_path.py (TestHolderStampWiring 3400–3622, test_cert_metadata_merges_span_and_holder_keys 3466–3508, test_supplier_cert_carries_supplier_name 3510–3549). These are the suites the new internal-function cases must extend.
  • Downstream consumer types (OUT of scope — read-only, confirm additive-safety only): CertificationMetadata (components/dashboard/certification-summary-card.tsx:29–39) currently types holder?: 'self' | 'supplier'; CertificationReportEntry (lib/mcp/formatters/entities.ts:132–141). The new holder_basis key is an additive optional metadata field — it must not break these read sites (no enum value is removed from holder; holder_basis is new and ignorable). No consumption change is in scope (see Non-goals).
  • grep sweep (cited file:line). TS holder rule lib/ai/classify.ts: HOLDS_SYNONYMS (522), deriveHolderMetadata (524–595), client org BRANDING.organisationName.toLowerCase() (529), Pass-2 synonym gate sourceIsClientOrg/sourceIsExtractedOrg (570–572), self/supplier stamp (583–590). Python holder rule scripts/cocoindex_pipeline/holder_rule.py: Pass-2 gate source_is_client_org/source_is_extracted_org (164–171), Inv-10 absent-on-no-signal (if holds_source is None: continue, 181), self/supplier stamp (187–193). Prompt surfaces — TS lib/ai/skills/classification.md holder-disambiguation block (719–778), “Internal departments” extraction exclusion (390); Python scripts/cocoindex_pipeline/prompts.py holder-disambiguation block (226–272), ENTITY_MENTION_PROMPT (139). Confirmed latent divergence: the Python ENTITY_MENTION_PROMPT carries no “Internal departments” exclusion line analogous to classification.md:390 — TECH must align this (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). source_scope and holder_basis are net-new fields — repo-wide grep returns zero existing references for either, so both are greenfield additions to the relationship contract / mention metadata respectively.

Goals. Attribute a certification to holder=self when the document author’s own internal function holds it, declared via explicit first-person possessive, with no supplier-disclaimer in scope — at byte-stable TS↔Python prompt parity and identical holder-rule behaviour on both extraction paths.

Non-goals (explicitly out of scope).

  • Consumption / KG. No change to how metadata.holder is read downstream — no edits to get_certification_status / MCP tools, the compliance dashboard (components/dashboard/compliance-status-section.tsx), the entity-management UI, or any knowledge-graph projection. This Task changes only what gets stamped at capture.
  • Promoting internal functions to organisation mentions. “Internal IT” stays excluded from organisation extraction on both paths; the signal rides on the relationship.
  • Bare / non-possessive internal-department attribution. Bare “Internal IT” or “the IT team” with no first-person possessor does not fire (see invariant 6); the authorship-is-client prior alone is not a sufficient trigger (ratified OQ-3/OQ-5).
  • Mixed-provenance ingest robustness. The rule is specified for the client’s own corpus (document author = client). Hardening for ingested RFP responses / due-diligence packs that quote suppliers is deferred (see Open question 1).

The certification holder pipeline has two stages — extraction (the LLM emits entity mentions + relationship triples) and the holder rule (a pure function that stamps metadata.holder per certification mention). Both stages are duplicated TS (classifyContentderiveHolderMetadata) and Python (extractionderive_holder_metadata) and MUST behave identically. The invariants below are written from the perspective of the holder-rule consumer (the code/operator reading entity_mentions.metadata.holder after capture) and the extraction-contract consumer (the holder rule reading the relationship triples).

  1. Positive case — internal-function self-attribution. When a certification’s holder is the document author’s own internal function, declared with explicit first-person possessive framing (e.g. “our internal IT team”, “our in-house security team”, “we maintain ISO 27001 through our internal IT”), and no supplier/third-party disclaimer is in scope for that certification, the certification mention is stamped { holder: "self", holder_basis: "internal_function" }. This is the 7e511dbc-class case (“Our internal IT team is compliant to ISO 27001 / Cyber Essentials Plus”) that today yields holder=null.

  2. The signal is produced at extraction, on the relationship. The classifier emits the internal-function signal as an explicit field on the holds/complies_with/evidences relationship whose source is the internal function — a dedicated source_scope field with value "internal" (ratified mechanism OQ-6; the exact wire shape — field name, enum values, schema/Zod surface — is a TECH decision, but it MUST be a dedicated field, not a reserved/sentinel source string value). When the source is an ordinary third party or the named client org, source_scope is "external" or absent/null. The internal function (“Internal IT”) is not emitted as an organisation entity mention — it remains excluded from organisation extraction on both paths.

  3. The holder rule consumes the tag as a third stamp branch, before the supplier branch. In both deriveHolderMetadata (lib/ai/classify.ts:579–593) and derive_holder_metadata (scripts/cocoindex_pipeline/holder_rule.py:176–194), when the resolved holder source for a certification target carries source_scope="internal" (and the supplier-disclaimer guard did not scope the certification to a third party — see invariant 4), the mention is stamped { holder: "self", holder_basis: "internal_function" }. Otherwise the existing self/supplier logic is unchanged: a source equal to the client org → { holder: "self" } (no holder_basis); any other resolved org source → { holder: "supplier", supplier_name: <source> }.

Supplier-disclaimer dominance (the dominant guard)

Section titled “Supplier-disclaimer dominance (the dominant guard)”
  1. A supplier/third-party disclaimer always wins over the internal-function tag. When a certification falls within the scope of an existing supplier-disclaimer signal — the trigger phrases and disclaimer paragraphs already defined in the holder-disambiguation block (lib/ai/skills/classification.md:719–778 / scripts/cocoindex_pipeline/prompts.py:226–272), e.g. “held by [party]”, “managed by [party]”, or a “…are held by [party], not [author]” disclaimer paragraph — the certification is attributed to that named third party (holder=supplier, supplier_name=<party>), and the internal-function tag MUST NOT fire, even if the same sentence/section also uses “internal” phrasing. The disclaimer establishes source_scope="external" (or names a third-party source), and the internal branch is never reached. This preserves the existing supplier-disclaimer behaviour exactly.

  2. Named third-party internal functions are external, not self. When the internal function belongs to a named third party rather than the document author — e.g. “Example Datacentre’s internal security team is compliant to ISO 27001” — the certification is attributed to that third party (holder=supplier, supplier_name=example datacentre) or, if the third party is not resolvable as a holder under the existing rules, left holder=null. It is never holder=self. A possessor other than the document author (“their”, a named org’s possessive) disqualifies the internal-function trigger.

  1. Bare / non-possessive internal-department phrasing abstains to null. “Internal IT is compliant to ISO 27001”, “the IT team holds ISO 27001”, or any internal-department subject without an explicit first-person possessive (“our”/“we”/“our own”) does not fire the internal-function trigger and stamps no holder signal → holder=null. The authorship-is-client prior alone is deliberately not sufficient (ratified OQ-3/OQ-5): the precision bar requires explicit possessive framing. This is a deliberate recall sacrifice for precision.

  2. Ambiguous or conflicting signals abstain to null. When the internal-vs-external signal is absent, ambiguous, or in conflict (e.g. first-person possessive present but a third-party party is also named in scope; or the disclaimer scope is unclear), the holder rule stamps no signal → holder=null. Abstention — never defaulting to self on a missing or uncertain signal — is mandatory and is what makes this rule safe where a naïve string match is not.

  3. Inv-10 preserved — no-signal certifications remain unstamped. A certification mention with no resolved holder source (no holds/synonym relationship, or a relationship whose source the holder rule rejects) is absent from the holder map and keeps its span-only metadata: metadata.holder is unset (null). The new internal branch MUST NOT change this — it adds a stamp only for the explicit internal-tagged + disclaimer-free case; it never converts a previously-null certification to self except via that explicit path. The existing guard (if holds_source is None: continue — “never default to self”) stays in force.

  4. Inv-14 preserved — only certification mentions are stamped. The holder rule stamps metadata.holder only on mentions whose entity_type == "certification". The internal-function branch does not stamp non-certification mentions.

  1. holder_basis is an additive provenance breadcrumb. Internal-function self-attribution stamps holder_basis: "internal_function" alongside holder: "self" (ratified OQ-4). This distinguishes internal-function self from name-resolved self for downstream audit and lets a future consumption Task treat them differently. holder_basis is a new, optional metadata key: name-resolved self (source == client org) and supplier stamps do not carry it, and the addition MUST be non-breaking for existing metadata.holder read sites (the holder value space {self, supplier, <absent=null>} is unchanged; consumers that do not read holder_basis are unaffected).

  2. holder=self is the same observable holder value for both self paths. A name-resolved-self certification and an internal-function-self certification both report holder: "self" to any consumer that reads only holder. The distinction lives solely in the optional holder_basis breadcrumb. No consumer is required to special-case internal-function self to obtain correct holder attribution.

  1. TS↔Python parity is mandatory. The internal-function signal MUST be emitted identically by both prompt surfaces (classification.md + prompts.py, byte-stable mirrors of the holder-disambiguation block) and consumed identically by both holder rules (deriveHolderMetadata + derive_holder_metadata). For every input, the two paths MUST produce the same holder / holder_basis / supplier_name outcome. A divergence between paths MUST be caught by the cross-path parity harness (scripts/eval-holder-rule-ts.ts + scripts/cocoindex_pipeline/parity_driver.py) and fail CI. The latent ENTITY_MENTION_PROMPT divergence (Python lacks the “Internal departments” exclusion that classification.md:390 carries) MUST be resolved as part of this change so the internal-function subject stays a non-organisation mention on both paths.

  2. Composition with the PI-15 fail-closed alias model — no regression. Internal-function self-attribution does not depend on the client name appearing in the document (that is the point), so it neither relies on nor regresses the {101.9}/{101.10} alias-driven self-match. The internal tag is consumed after canonicalisation and does not read or write the alias snapshot. The PI-15 fail-closed deploy gate (flow.py _generate_client_alias_snapshot: configured client + zero provenance='client' rows → RuntimeError, deploy fails) stays untouched. The new rule MUST NOT add any path that, on a missing or ambiguous signal, silently self-attributes — abstain-to-null (invariants 6–8) is the only permitted no-signal behaviour. The R4 fail-fast (unset PIPELINE_CLIENT_ORGValueError, holder_rule.py:124–131) continues to hold; the internal branch runs after the client-org guard, not around it.

  3. Short-term holder=null stays until shipped on both paths. Until the Option-C rule lands on both extraction paths with parity proven, internal-function compliance continues to stamp holder=null. Behaviour MUST NOT flip partially on one path only — no interim state where TS attributes internal-function self but Python does not (or vice versa). The honest interim signal is null.

  1. The following synthetic, de-identified examples (placeholders {CLIENT_ORGANISATION_NAME}, “Example Datacentre”, “Internal IT”) are normative for the invariants above:

    • Fires (self). “Our internal IT team is compliant to ISO 27001.” → holder: "self", holder_basis: "internal_function". (Invariant 1.)
    • Disclaimer wins (supplier). “Note: the certifications below are held by Example Datacentre, not {CLIENT_ORGANISATION_NAME}. ISO 27001 is maintained by our internal team.” → holder: "supplier", supplier_name: "example datacentre" — the disclaimer scopes the cert; the internal phrasing does not override it. (Invariant 4.)
    • Named third party (not self). “Example Datacentre’s internal security team is compliant to ISO 27001.” → not self; supplier/null per existing rules. (Invariant 5.)
    • Bare internal department (abstain). “Internal IT is compliant to ISO 27001.” → holder: null (no explicit possessive). (Invariant 6.)
    • Ambiguous (abstain). “The IT team holds ISO 27001” with no possessive, no disclaimer, no authorship cue → holder: null. (Invariant 7.)
    • Name-resolved self (unchanged). “{CLIENT_ORGANISATION_NAME} is certified to ISO 9001.” → holder: "self", no holder_basis. (Invariant 11.)

These are residual product questions surfaced for Liam by this PRODUCT pass. None block TECH authoring on the ratified Option-C behaviour above; they refine edges.

  1. Mixed-provenance ingest (deferred scope confirmation). The “document author = client” prior is safe for the client’s own corpus but weaker for ingested RFP responses / due-diligence packs that quote suppliers. This PRODUCT specifies the client-corpus case and defers mixed-provenance hardening (Non-goals). Confirm mixed-provenance ingest is out of scope for ID-109 (the explicit-possessive + disclaimer-dominant rule already de-risks it, but a named-third-party-in-scope-plus-our-possessive sentence abstains to null per invariant 7 rather than guessing — confirm that abstention is the desired behaviour for such mixed sentences, vs attributing to the named third party).

  2. Possessive surface breadth (extraction-prompt tuning, informs TECH). Invariant 1 lists “our internal IT team”, “our in-house security team”, “we maintain … through our internal IT” as positive triggers. Confirm the possessive surface to instruct the LLM on: is “our” / “we” / “our own” the complete trigger set, or should “in-house” alone (without “our”) count as first-person possessive? This is a precision/recall dial on the prompt wording; TECH will encode whatever set is confirmed. Default if unanswered: require an explicit first-person possessor (“our”/“we”/“our own”); treat bare “in-house” as abstain-to-null.