Skip to main content

ADR-0123: OSINT investigation golden-record survivorship (demote Synthesis to narrative)

Date: 2026-07-23 Status: Accepted Deciders: Adrian (Soft4U BV), Claude (Opus 4.8) Extends: ADR-0024 (Entity matching, blocking keys & trust-weighted survivorship), ADR-0073 (Round-2 entity-resolution & signal hardening — R9 name-collision guard)

Context

Two internal adversarial audits (2026-07-23) confirmed the OSINT engine (app/agents/osint_agent.py + sub-agents, ~6,158 lines) is real and fail-closed (ADR-0067). Epic #511 raises it from real & honest to real, honest & auditable. The flagship (#512) targets the canonical entity an AML examiner challenges first ("why is the canonical registration number X, and what disagreed?").

A full read of the engine reframes the problem. The Synthesis LLM (synthesis_agent.py) decides no entity-identity fields — its OsintAgentOutput (app/agents/models.py:57-67) contains only risk_score, findings, discrepancies, summary. The canonical legal name / registration number / address / directors / VAT / LEI are copied verbatim from registry_data by a truthy-guard "first-non-empty-wins" loop (osint_agent.py:2169-2178), and directors are merged by two deterministic helpers (_consolidate_directors, _dedup_director_roster).

So the defect is not "the LLM decides field winners" — it is that the OSINT investigation result has no golden-record resolver at all: identity resolution is an ad-hoc heuristic across ~5 sites with no trust ordering (an implicit first-truthy-wins that can let a lower-trust earlier value survive), no retained alternatives, and confidence hardcoded to 1.0 in every evidence bundle.

A proven trust-weighted survivorship resolver already exists — app/services/survivorship.py (SurvivorshipResolver, PROVIDER_TRUST, PROTECTED_FIELDS, FieldProvenance, ConflictRecord, NameCollisionError), the runtime home of ADR-0024/0073 — but it is wired only into the Neo4j graph-ETL path (graph_etl.py:785-913, activity populate_knowledge_graph), never the case's investigation result. The golden record an examiner reads in the dossier is therefore not produced by it; the graph node is. This is an unreconciled-code-paths split. Two further parallel designs of the same concept exist: an unwired app/services/mutation_queue/ package (its own FieldProvenance with retained previous_values; its own, different, PROTECTED_FIELDS) and a second entity matcher (verification/name_matcher.py).

The runtime PROVIDER_TRUST table has also drifted from ADR-0024's published table (e.g. GLEIF 0.97 vs 0.95; added NBB/VIES/EORI/PEPPOL/OpenCorporates/sanctions_resolver/pep_resolver). ADR-0024's body is immutable; this ADR records and pins the current runtime values as canonical.

Decision

Extend the ADR-0024/0073 trust-weighted survivorship resolver into the OSINT investigation path, as a deterministic step inside the run_osint_investigation activity, between source collection and synthesis.

  1. Structured claims. Every source adapter emits Claim(entity_ref, field, value, source, confidence, source_trust, outcome, collected_at) instead of dropping values into an untyped dict. source_trust is stamped centrally by the resolver from a (source, field) trust table (a source never asserts its own authority — an integrity property). confidence (the source's own certainty in the value) and source_trust (institutional authority) are distinct axes; this replaces the hardcoded 1.0.

  2. Deterministic resolution. SurvivorshipResolver.resolve(entity_ref, field, claims) → ResolvedField. Winner selection is a total order with no wall-clock: (-source_trust, -completeness(value), source_rank(source), source). Losing values are retained as alternatives; a real source-vs-source disagreement is always recorded as a ConflictRecord (M11 — trust delta only tiers prominence). Given identical claims the resolved values, winners, alternatives and conflicts are byte-identical across runs (a golden_record_hash excludes timestamps).

  3. Per-field trust. Add SOURCE_FIELD_TRUST[(source, field)] as an override layer over the flat PROVIDER_TRUST; base values are unchanged (the graph path stays byte-identical until an override is declared under calibration review).

  4. Typed absence (seam for #514). An Outcome enum (value_present / verified_clear / not_found / not_applicable / source_unavailable / not_assessed / inconclusive) travels on every claim. When no value-bearing claim exists, the resolved outcome is the strongest gap present (fail-closed precedence: a gap outranks an absence; verified_clear only when no gap is present). This distinguishes "checked and clear" from "could not check."

  5. Protected-field authority (seam for #516). Enforcement lives in resolve(): a claim from a source outside a field's allowed-writer set is dropped and logged as an integrity event; the two divergent PROTECTED_FIELDS registries are reconciled into one. A registry scrape, LLM extraction or web source can never set or clear is_sanctioned/is_pep. This makes the structured screening connector (#513) the authoritative writer by construction. The topic/badge meaning stays the sole responsibility of screening_derivation._classify / screening_hit_topic.

  6. Demote Synthesis to narrative. The LLM narrates over the already-resolved golden record and no longer adjudicates source-vs-source field winners (those become deterministic ConflictRecords). Its document-vs-source discrepancy behavior is untouched (that is the separate cross_reference_evidence step). risk_score remains LLM-owned until #515; after #512 + #515 the LLM is fully narrative-only.

  7. Reconcile to one path. survivorship.py is the single resolver; graph_etl.py migrates to resolve(); the unwired mutation_queue/ package is deleted (its previous_values idea is preserved as ResolvedField.alternatives); the entity-matcher duplication is tracked as separate debt. Identity merges route through entity_matcher.has_corroborating_identifier (R9 — name similarity is never an identity).

  8. Fail-closed preserved (ADR-0067). No benign-by-default; screening_error/coverage-gap categories stay indeterminate; presence ≠ evidence; every downstream investigation_result dict key and the 8-key frontend field_provenance shape are preserved. Dark-launched behind golden_record_resolver_enabled (default off), flip Calibration-Review + live-OB-Holding gated.

Decision context:

  • Latency: pure in-process resolution over ~10–30 fields × ~2–5 claims; sub-millisecond, no network. Not measured because it replaces an in-memory pass-through of comparable cost.
  • Dependency surface: no new packages; deletes mutation_queue/. Owned code only, no new transitive deps.
  • Debuggability: every winner decision is logged in provenance_log + 8-key rows; a 3am failure surfaces as a named field with its candidate list and reason.
  • Reversibility: a single config flip (golden_record_resolver_enabled=False) restores the pass-through path — hours to undo.
  • Blast radius: additive at Step 2.5 + one graph_etl call-site migration + a Synthesis output-shape trim; behind a flag.
  • Alternative considered: a new OSINT-only resolver (rejected — adds a fourth parallel design; reconciling onto survivorship.py is the point).

Consequences

Positive

  • The canonical entity becomes deterministic, reproducible and per-field auditable — an examiner sees which source won each field and what disagreed.
  • Confidence is real (per-value), not a hardcoded 1.0.
  • Three parallel designs collapse to one resolver, one provenance shape, one protected-field registry.
  • #514 (typed absence) and #516 (protected-field authority) become drop-ins; #513 (OpenSanctions) becomes the authoritative sanctions writer by construction.

Negative

  • survivorship.py, graph_etl.py, the OSINT fleet adapters and the Synthesis output shape all change in one epic — a wide (if flag-gated) blast radius.
  • A per-field trust table and a per-field completeness metric are new configuration surfaces that must be maintained under calibration review.
  • Deleting mutation_queue/ discards an in-progress (unwired) DB-persistence design; if durable provenance history is later needed it must be rebuilt on the resolver.

Neutral

  • The graph-ETL golden record and the investigation golden record now share one engine; behavior there is unchanged until per-field overrides are declared.
  • risk_score stays LLM-owned until #515; the LLM is only entity-field narrative-only after #512 alone.

Alternatives Considered

Alternative 1: Build a new OSINT-only golden-record resolver

  • A fresh resolver dedicated to the investigation result, leaving survivorship.py for the graph.
  • Why rejected: adds a fourth parallel design of the same concept; the whole defect is unreconciled paths. Reconciling onto the proven survivorship.py is the goal.

Alternative 2: Keep the LLM as the reconciler, add an audit log around it

  • Log the LLM's field choices for auditability without moving resolution to code.
  • Why rejected: the LLM does not actually resolve identity fields today (they pass through registry_data); and an LLM merge is non-deterministic and non-reproducible — it fails the examiner-defensibility bar (#512 acceptance requires identical output for identical inputs).

Alternative 3: Do nothing (status quo pass-through)

  • Leave the truthy-guard first-non-empty-wins merge in place.
  • Why rejected: no trust ordering, no retained alternatives, confidence hardcoded 1.0, two competing director merges — not defensible as an AML master-data control (EBA/GL/2021/02, golden-record survivorship).