Skip to main content

ADR-0151: Finding-level survivorship — trust-weighted corroboration annotation (additive-only)

Date: 2026-07-27 Status: Accepted Deciders: Adrian (Soft4U), Claude (Opus 4.8)

Decision context:

  • Latency: negligible — a single in-process pass over the already-materialised findings list at the end of finalize_investigation_result; O(n) with two dict lookups per finding, no I/O. Dark-launched OFF; flag-off is a byte-identical no-op.
  • Dependency surface: no new package. Reuses survivorship.trust_for (ADR-0024) for the per-source trust lookup. One new flag (finding_corroboration_enabled). No migration.
  • Debuggability: stamps four legible keys on each finding dict — source_trust, corroboration_count, corroborated, uncorroborated — visible in the persisted investigation result and every downstream consumer, so a display-ranking decision is traceable to the annotation that drove it.
  • Reversibility: single flag flip; flag-off restores the pre-#780 finding stream exactly. No schema change to unwind.
  • Blast radius: additive and INERT to the verdict. The pass only ADDS dict keys; it never removes a finding, never edits severity, and never feeds the escalator/verdict. A single-source criminal/sanctions hit still floors CRITICAL.
  • Alternative considered: rerank/dedupe the finding stream by trust (rejected — reranking or collapsing findings can drop or demote a real signal; the never-suppress guardrail requires the layer to be strictly additive, so it annotates and leaves ordering/severity to existing deterministic logic).

Context

survivorship.py (ADR-0024/0073/0123) resolves entity-identity fields — company name, address, registration number — by per-source trust, picking one winning value and retaining the losers as alternatives. That machinery is wired only to the identity-field merge and the Neo4j graph-ETL path.

OSINT findings were never run through any trust-weighted layer. A low-trust, single-source, low-relevance finding and a high-trust, multiply-corroborated CRITICAL finding carried no comparable trust or corroboration signal — they surfaced with the same prominence. The OB Holding deep-analysis review surfaced the cost: a dozen low-relevance single-source candidate hits appeared alongside the corroborated material findings with no way for the display/precision layer to tell them apart.

The naive fix — reuse identity survivorship and "pick the winner" across findings — is wrong and dangerous. Findings are evidence, not mutually-exclusive values: two different sources reporting the same matter is corroboration (higher confidence), not a conflict to be resolved by dropping one. Any layer that removes or demotes a finding risks suppressing a real signal, which the epic #730 / ADR-0067 never-suppress doctrine forbids.

Decision

Add a pure, dark-launched, additive-only finding-annotation pass (app/services/finding_survivorship.py::annotate_finding_corroboration), invoked LAST in finalize_investigation_result (app/agents/osint_post_processing.py) behind settings.finding_corroboration_enabled (default False).

Per finding dict, in place, it stamps:

  • source_trust — the per-source trust for the finding's provider. The adverse-media provider names the agents actually emit (tavily / exa / brightdata_serp / opensanctions) are mapped onto their calibrated score first (OpenSanctions → authoritative 0.96, the search aggregators → a recall-oriented 0.70–0.72 tier), because the registry-oriented survivorship.PROVIDER_TRUST does not carry those names and would otherwise collapse them all to the 0.75 unknown-source default. Unmapped sources fall through to trust_for(source, "finding"). The source is the honest _provider stamp (ADR-0077) if present, else the display source.

Corroboration is measured ACROSS distinct-source findings about the same key; within-finding multi-provider support (a single synthesised finding backed by e.g. Tavily and Exa) is not captured — the synthesiser records only the primary provider in source (an upstream data limitation, tracked, never silently claimed as covered).

  • corroboration_count — the number of DISTINCT sources that reported the same (target, attribution-lane, category, normalized-title) finding. The target (the ADR-0093 Tier-B recall entity) and the ADR-0078 attribution lane are IN the key, so two findings about different entities never corroborate each other and a name-only unverified_candidate can never be marked corroborated by verified/subject evidence.
  • corroboratedcorroboration_count >= 2.
  • uncorroboratedcorroboration_count < 2 (the display down-weight label).

Load-bearing invariants (epic #730 / ADR-0067):

  • Never removes a finding, never edits severity, never lowers a deterministic escalator floor. The pass only ADDS keys. A single-source criminal/sanctions hit keeps its CRITICAL severity and its escalator floor — the annotation labels it uncorroborated, it does not down-weight the escalator.
  • Conservative / UNDER-groups. Corroboration requires an EXACT (category, normalized-title) match across distinct sources. Under-grouping is the fail-safe direction: a finding that is not grouped stays uncorroborated (→ MORE display scrutiny), never falsely corroborated (which would lower scrutiny). Presence ≠ evidence.
  • Two-lane attribution (ADR-0078) preserved. Corroboration counts distinct source strings; it does not fold a name-only unverified candidate into subject risk.

The corroboration labels are produced here; a display/precision consumer that ranks a corroborated finding above a single-source low-trust one is a follow-up surface (the flag stays off until a consumer reads the labels).

Consequences

Positive

  • Findings now carry a per-source trust + a corroboration count, so the display/precision layer can distinguish a corroborated CRITICAL from a single-source low-relevance hit — the root of the OB deep-analysis noise-candidate defect.
  • The additive-only, never-drop design makes the never-suppress guarantee structural: the pass has no code path that removes a finding or touches severity.

Negative

  • The annotation is inert until a consumer reads it; shipped alone it changes no visible behaviour (by design — dark-launched).
  • Conservative under-grouping means genuinely-corroborated findings whose titles differ textually read as uncorroborated. That is the deliberate fail-safe direction (more scrutiny), but it undercounts corroboration.

Neutral

  • Introduces four finding dict keys (source_trust, corroboration_count, corroborated, uncorroborated) with backward-compatible absence (consumers that do not read them are unaffected).

Alternatives Considered

Alternative 1: Reuse identity survivorship — resolve findings to a winner per group

  • Why rejected: identity survivorship picks ONE value and discards the rest. Findings are evidence, not mutually-exclusive values; discarding a "losing" finding suppresses a real signal, violating ADR-0067 never-suppress. Corroboration must ADD confidence, not remove rows.

Alternative 2: Rerank / dedupe the finding stream in place by trust

  • Why rejected: reranking or collapsing the stream can demote or drop a real finding and couples the annotation to ordering that other deterministic logic owns. Annotating and leaving ordering/severity to existing logic keeps the layer strictly additive.

Alternative 3: Aggressive fuzzy grouping to maximise corroboration matches

  • Why rejected: over-grouping risks falsely marking distinct findings as corroborated, which LOWERS scrutiny — the forbidden direction. Exact (category, normalized-title) under-grouping fails toward more scrutiny.