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.
-
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_trustis 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) andsource_trust(institutional authority) are distinct axes; this replaces the hardcoded1.0. -
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 asalternatives; a real source-vs-source disagreement is always recorded as aConflictRecord(M11 — trust delta only tiers prominence). Given identical claims the resolved values, winners, alternatives and conflicts are byte-identical across runs (agolden_record_hashexcludes timestamps). -
Per-field trust. Add
SOURCE_FIELD_TRUST[(source, field)]as an override layer over the flatPROVIDER_TRUST; base values are unchanged (the graph path stays byte-identical until an override is declared under calibration review). -
Typed absence (seam for #514). An
Outcomeenum (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_clearonly when no gap is present). This distinguishes "checked and clear" from "could not check." -
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 divergentPROTECTED_FIELDSregistries are reconciled into one. A registry scrape, LLM extraction or web source can never set or clearis_sanctioned/is_pep. This makes the structured screening connector (#513) the authoritative writer by construction. The topic/badge meaning stays the sole responsibility ofscreening_derivation._classify/screening_hit_topic. -
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 separatecross_reference_evidencestep).risk_scoreremains LLM-owned until #515; after #512 + #515 the LLM is fully narrative-only. -
Reconcile to one path.
survivorship.pyis the single resolver;graph_etl.pymigrates toresolve(); the unwiredmutation_queue/package is deleted (itsprevious_valuesidea is preserved asResolvedField.alternatives); the entity-matcher duplication is tracked as separate debt. Identity merges route throughentity_matcher.has_corroborating_identifier(R9 — name similarity is never an identity). -
Fail-closed preserved (ADR-0067). No benign-by-default;
screening_error/coverage-gap categories stay indeterminate; presence ≠ evidence; every downstreaminvestigation_resultdict key and the 8-key frontendfield_provenanceshape are preserved. Dark-launched behindgolden_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_etlcall-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.pyis 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_scorestays 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.pyfor the graph. - Why rejected: adds a fourth parallel design of the same concept; the whole defect is unreconciled paths. Reconciling onto the proven
survivorship.pyis 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).