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
findingslist at the end offinalize_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-orientedsurvivorship.PROVIDER_TRUSTdoes not carry those names and would otherwise collapse them all to the 0.75 unknown-source default. Unmapped sources fall through totrust_for(source, "finding"). The source is the honest_providerstamp (ADR-0077) if present, else the displaysource.
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-onlyunverified_candidatecan never be marked corroborated by verified/subject evidence.corroborated—corroboration_count >= 2.uncorroborated—corroboration_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 ituncorroborated, 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 staysuncorroborated(→ MORE display scrutiny), never falselycorroborated(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.