ADR-0117: Case-scoped Network Intelligence view — no whole-tenant fallback for an entity-less case
Date: 2026-07-21 Status: Accepted Deciders: Adrian (Soft4U), Claude (Opus 4.8)
Decision context:
- Latency: none — the fix removes a fetch (the whole-tenant
getGraphExplorerData+/api/graph/motifs) for entity-less cases; those views now render from already-loadedcaseDetail. p50/p95 strictly improve for that path. - Dependency surface: none — no new packages; pure control-flow guard in
useGraphData+ one derivation change inVerdictStep. - Debuggability: high — the guard keys on a single observable (
case_idpresent ∧entityabsent). A wrong attribution now fails toward an empty graph + case-scoped verdict, which is visibly honest, not silently wrong. - Reversibility: single flag — remove the
opts.caseScopedargument at the one call site to restore prior behaviour. ~3 lines. - Blast radius: additive/substitutive at 3 files (
useGraphData.ts,NetworkIntelligenceHub.tsx,VerdictStep.tsx); the entity-anchored path (the common company case) is byte-unchanged. - Alternative considered: server-side case-scoped graph endpoint — rejected for now (larger surface; the client already holds the case-scoped findings in
caseDetail).
Context
The Network Intelligence view (/dashboard/network) serves two distinct modes
through one component:
- Explorer mode — reached from the dashboard sidebar as
/dashboard/networkwith no parameters. Here the whole-tenant graph is the intended content. - Case mode — reached from a case-detail page as
/dashboard/network?entity=<reg>&case_id=<wf>. Here the content is that one subject's network.
The case-detail link is built as
?entity=${company_registration_number}&case_id=${workflowId}. For an
individual KYC case (ADR-0101), company_registration_number is the empty
string, so the link degrades to ?entity=&case_id=<wf>. The hook
useGraphData(entityParam || undefined) then receives undefined and — unable
to distinguish "explorer mode" from "case mode without an entity anchor" — fell
back to fetching the whole-tenant explorer graph (getGraphExplorerData)
plus the tenant-wide fraud motifs (/api/graph/motifs).
Everything the case view derives from that graph — the Verdict's red flags, the "companies/persons/findings" KPIs, the risk-map, and the "hidden links (motifs)" count — was therefore computed over every other case in the tenant. Live example (2026-07-21): opening the individual case "Sofie Vermeersch" (BE, medium, approved) surfaced red flags about Avast (a $16.5m FTC settlement) and Škoda / Volkswagen, a "companies in network" count of 16, and a risk-map edge reading "Sofie → contagion from → Olympic Entertainment" — none of which have anything to do with the subject. This is a false-attribution defect: an MLRO reviewing one subject sees another subject's adverse findings rendered as the reviewed subject's own. It is the inverse of a defensible compliance artifact, and it silently violated the cardinal "traceable to this subject" requirement.
The same class also mildly affected company cases whose verdict red flags were read purely from graph nodes rather than the authoritative case record.
Decision
Make the Network Intelligence view case-scoped by construction whenever a
case_id is present, and source the Verdict from the case record.
-
No tenant fallback for a case with no entity anchor.
useGraphDatatakesopts.caseScoped(the hub passes!!caseIdParam). WhencaseScoped ∧ !initialEntity, the hook fetches nothing — it leaves the graph empty and clears the loading spinner — instead of loading the tenant explorer graph. It also skips the tenant-wide motif fetch on that path (a second bleed vector). Explorer mode (!caseScoped) and the entity-anchored path are unchanged. -
The case-scoped shell still renders. The hub's "navigate here from a case" empty state is gated to explorer mode only (
allNodes.length === 0 && !caseIdParam), so a case with an empty graph still renders its Verdict/Evidence (which readcaseDetail), rather than blanking. -
The Verdict sources red flags from the case record, unioned with the graph.
VerdictStepred flags are the UNION of (a)caseDetail.investigation_results[].findings(critical/high — always this case's own, correct even when the graph is empty) and (b) the entity-network graph Finding/SanctionMatch nodes. Union, never replacement: a graph-only finding must never be dropped (cardinal doctrine — never suppress a signal). The findings KPI is floored by the case finding count so an empty graph never understates real findings.
Consequences
Positive
- A subject's case view can no longer display another subject's findings, contagion, KPIs, or motifs. Attribution is correct by construction.
- Individual KYC cases (no company registration) get a truthful view: an empty network + their own case-scoped verdict, instead of tenant-wide noise.
- The Verdict is now anchored to the authoritative per-case record, not an incidental property of graph scoping.
- Fewer network calls on the entity-less path.
Negative
- An entity-less case shows an empty graph canvas on the Network/Ownership steps. This is honest (there is no company network to draw) but is a blank area rather than a richer "no network" illustration. Acceptable; a nicer empty-state is a follow-up.
- The Verdict red-flag union can now surface a critical/high case finding that is not a graph node (e.g. a document-scan finding). This is more scrutiny, never less — consistent with the doctrine — but it means the Verdict and the drawn graph can legitimately differ in which findings they show.
Neutral
- Company cases with a real registration number are functionally unchanged; the entity-network fetch already produced a subject-scoped graph.
- The tenant-wide motif endpoint is untouched; only its use on the entity-less case path is suppressed. Subject-scoping motifs for company cases (so the "hidden links" count excludes cross-case motifs) is tracked separately (relates to the name-collision director-matching issue #482).
Alternatives Considered
Alternative 1: Subject-connected-component filter over the tenant graph
- Keep loading the tenant graph, but filter every derivation to the subject's connected component (a BFS from the subject node).
- Why rejected: for an entity-less case there is no reliable subject node in the tenant graph, and the tenant graph genuinely contains cross-case contagion edges — the live "Sofie → Olympic" edge proves the BFS would still bleed. The filter mitigates but does not eliminate the defect; not loading the tenant graph does.
Alternative 2: Server-side case-scoped graph endpoint
- Add
GET /api/graph/case/{case_id}returning only that case's network. - Why rejected (for now): larger surface for a defect whose correct data
(
caseDetail.investigation_results) the client already holds. Worth revisiting if the case graph needs richer server-side assembly; the client guard is the minimal correct fix today.