ADR-0114: Case Supersede/Link Model — Link, Never Merge (#359)
Date: 2026-07-17 Status: Accepted Deciders: Adrian (Soft4U BV), Claude (Fable 5) — epic #350 Workstream D (entity duplication), building on the #356 detection tier and the PR #399 / ADR-0113 entity-disposition gate
Decision context:
- Latency: supersede/unsupersede are single-row UPDATEs plus one immutable audit_events append (advisory-lock-serialised per ADR-0109) — officer-interactive, estimated < 20 ms p50 / < 80 ms p95; the queue's default exclusion is one indexed
superseded_by_case_id IS NULLpredicate and one COUNT for the toggle label; not measured under load because every touched query is already per-tenant and indexed. - Dependency surface: zero new packages. One Alembic migration (090), three new nullable columns on
cases, three new endpoints incase_crud.py, additive fields onCaseResponse/case-list rows. Reusesentity_disposition.normalize_registration_number(the ONE entity-key function),AuditService.log_event(hash-chained),require_permission(Permission.CASE_DECIDE). - Debuggability: every state change is an immutable
case_superseded/case_supersede_revertedaudit event carrying who/what/canonical-target/entity-key/reason; the link itself is a plain FK column readable in one SELECT; a wrong link is visibly reversible (unsupersede) rather than destructively merged. - Reversibility: fully reversible at two levels — per case via the audited
unsupersedeendpoint (seconds, no data loss, columns simply cleared), and structurally viaalembic downgradedropping three nullable columns (~1 h). Nothing is deleted, no status ever changes, no workflow is touched. - Blast radius: additive.
casesgains 3 nullable columns;list_cases+ dashboard/analyticsgain a default exclusion WITH a labelled reversible toggle; the decision-time entity-disposition gate (ADR-0113),entity_case_count(#356), and intake duplicate detection (#356) are deliberately UNCHANGED and regression-pinned. The Temporal workflow layer is untouched. - Alternative considered: true case merge (move documents/findings/audit onto the canonical row, delete the duplicate) — rejected:
audit_events.case_idis FK RESTRICT under an immutability trigger (ADR-0064), AMLR 5-yr retention forbids destroying the duplicate's record, and a merge is irreversible exactly where a mis-identified "duplicate" would need undoing.
Context
Issue #356 (this workstream's detection tier) surfaced the entity-duplication problem honestly: 163 live case rows collapse to ~24 distinct entities — one merchant ("OB Holding 1 OÜ", registration 14975047/EE) accounts for 53 case rows across name variants. The queue now shows an "N duplicates" badge, intake warns-and-confirms, and the dashboard reports "N open cases across M entities". PR #399 (ADR-0113) closed the worst consequence: a REJECTED sibling 409-blocks approval of any duplicate via the entity-keyed disposition.
What is still missing is the remedy: once an officer establishes that case B is a
duplicate of case A, there is no affordance to record that determination. The duplicates
keep inflating the queue, the KPIs, and the SLA-overdue count forever; officers triage the
same merchant 53 times. The only case-lineage column, cases.review_of_case_id, is
RESERVED for the ADR-0083 periodic-review loop (written by periodic_review_service,
read by trigger_router_service) — overloading it for duplicate linkage would corrupt the
monitoring lineage, so a new mechanism is required.
The tension: any duplicate remedy is by definition a workload-reducing affordance — the
highest never-suppress risk in the workstream. A merge that moves or deletes rows would
destroy audit lineage (ADR-0064 makes audit_events immutable with FK RESTRICT), could
silently hide a terminal risk signal (a REJECTED duplicate vanishing from view), and is
irreversible precisely where mistakes ("these two are the same merchant" — are they?) need
undoing.
Decision
Adopt a link-not-merge supersede model: a duplicate case is linked to its canonical sibling and hidden from workload surfaces by default — never merged, never deleted, never status-mutated, always reversibly and audibly.
-
Schema (Alembic 090).
casesgains three nullable columns:superseded_by_case_id(String(255), FKcases.case_idON DELETE SET NULL, indexed),superseded_at(timestamptz),superseded_reason(Text). NULL means "not superseded" — every existing row is unaffected. -
Endpoints (both
require_permission(Permission.CASE_DECIDE), both writing immutable hash-chainedaudit_events, ADR-0064/0109):POST /cases/{workflow_id}/supersede {canonical_case_id, reason}— marks the case a duplicate of the canonical sibling.reason≥ 20 chars (mirrors the audited-override discipline). Validation, all fail-closed 4xx:- same tenant (tenant-scoped session + explicit filter; cross-tenant target → 404);
- same entity —
normalize_registration_number(reg)+ upper(country) must match on both rows and be non-empty (entity_disposition.normalize_registration_numberis the single key function; an unkeyable case can never be verified as the same entity → 422; name-only identity is forbidden, ADR-0073 R9); - no chains — the canonical target must not itself be superseded, and the case being superseded must not be a canonical target for other cases (422). The link graph is a star, depth 1, so "resolve the canonical case" is one column read;
- not self (422); not already superseded (409 — revert first).
POST /cases/{workflow_id}/unsupersede {reason}— clears the link (reason ≥ 20 chars; 409 if not superseded). The audit event records the prior link so the immutable trail keeps what was reverted.GET /cases/{workflow_id}/duplicates— the "Possible duplicates (N)" panel feed: an all-status variant of the entity-disposition sibling query (same entity key), each row carrying its own supersede state, plussuperseded_into_this(cases linked to this one) and their worst sibling status for the canonical-case banner.
-
The superseded case is left running. Its status is never mutated and its Temporal workflow is never signalled/cancelled in v1. Reasoning: status is the load-bearing input to the entity-disposition gate (ADR-0113 keys
blockingon sibling REJECTED status) and to the four decision gates — mutating it from a lineage affordance would create a second writer for decision state. Officers may still cancel/decide the superseded case manually; automatic workflow termination on supersede is explicitly follow-up scope (it needs its own design: which signal, what happens to an in-flight portal upload, who is notified). -
Workload surfaces exclude superseded cases by default — reversibly and labelled.
list_cases(queue + SLA-overdue) addssuperseded_by_case_id IS NULLby default;include_superseded=truerestores them, and the response always carriessuperseded_countso the UI renders "Show superseded (N)" — the hidden workload is never invisible-invisible.- Dashboard
/analyticsaggregates exclude superseded rows by default, exposesummary.superseded_casesalongside (the exclusion is disclosed on the same surface), and acceptinclude_superseded=true. - Never-suppress guards, regression-pinned: the entity-disposition sibling query
(ADR-0113) keys on status, which supersede never touches — a REJECTED sibling still
blocks after being superseded (test-pinned).
entity_case_count(#356) and intake duplicate detection keep counting superseded rows. The canonical case renders a loud banner "N superseded duplicate(s) — worst status: REJECTED" so a terminal signal on a hidden duplicate stays visible exactly where the officer is looking.
-
No four-eyes for v1 — a deliberate, recorded decision. The four-eyes control (ADR-0070) guards actions that change a decision outcome (high-risk approvals, fail-closed-gate overrides, downward risk overrides ADR-0100). Supersede changes no decision state: status is untouched, the disposition gate is unaffected, the action is single-click-reversible, every exclusion it causes is labelled and toggleable, and both directions are immutably audited with a mandatory rationale. Requiring a second approver to hide a duplicate row would price the affordance out of use (53 duplicates × 2 officers) while protecting nothing the gates don't already protect. If live use shows supersede being abused to park inconvenient cases, the audit trail makes that detectable, and four-eyes can be added then without schema change.
-
review_of_case_idis never reused for duplicate linkage (ADR-0083 lineage, consumed by the trigger-router); the two lineages are orthogonal and may coexist on one row.
Consequences
Positive
- Officers finally have a remedy for the 53-duplicate queue: mark duplicate → queue, KPI and SLA figures reflect distinct work items, honestly labelled.
- Nothing is destroyed or mutated: full AMLR-5yr/GDPR-compatible record retention; a wrong supersede is undone in seconds with a complete audit trail of both actions.
- The risk spine is provably untouched: ADR-0113 disposition, #356 detection/counting, and the four decision gates behave byte-identically (regression-pinned in tests).
- The link is queryable data: future person-360/entity-360 views can traverse
superseded_by_case_idto assemble an entity's full case history.
Negative
- Superseded cases' Temporal workflows keep running (v1): they hold worker slots, may send portal reminders, and can hit iteration timeouts — an accepted cost until the follow-up workflow-termination design; officers can cancel manually.
- A hidden-by-default row can still accrue SLA breach internally; the toggle + banner disclose it, but a tenant that never looks at "Show superseded (N)" will not see those rows age (mitigated: they chose to mark them duplicates, and the canonical case carries the worst-sibling banner).
- Star-only linkage (no chains) means re-pointing is manual: if A→B and B is later itself found duplicate of C, the officer must unsupersede A→B before B→C — two audited steps.
- KPI time series shift at adoption: totals drop as duplicates are superseded (disclosed
by
superseded_cases, but historical comparisons need that context).
Neutral
entity_case_countintentionally still counts superseded siblings — the badge reads "N cases exist for this entity", which remains true.- The 409-on-intake (#356) still counts open superseded siblings; creating yet another case for a fragmented entity still warns.
- RLS posture unchanged: new columns inherit the existing
casesrow policy.
Alternatives Considered
Alternative 1: True merge (move child records onto the canonical case, delete the duplicate)
- Consolidate documents/findings/signals onto one row; drop the duplicate.
- Why rejected:
audit_eventsis immutable with FK RESTRICT (ADR-0064) — the duplicate row cannot be deleted; moving evidence between cases falsifies provenance (EU AI Act Art. 12 traceability); irreversible where mis-identification needs undoing; and the Temporal workflow's history is keyed to the deleted case.
Alternative 2: Overload review_of_case_id as the duplicate link
- Zero-migration reuse of the existing lineage column.
- Why rejected: that column is the ADR-0083 periodic-review lineage, written by
periodic_review_serviceand read by the trigger router to route monitoring alerts — a duplicate link would be interpreted as review lineage and corrupt the monitoring loop. Explicitly forbidden by the workstream plan.
Alternative 3: Status-based hiding (new SUPERSEDED case status)
- Add a terminal-ish status value instead of a link column.
- Why rejected: status is the input to the entity-disposition gate, the SLA
terminal-set, the state machine, and dozens of consumers — a new value would need
auditing every
status IN (...)predicate (high blast radius) and destroys the case's real state (a REJECTED duplicate must KEEP reading REJECTED, or the disposition gate goes blind — the exact never-suppress failure).
Alternative 4: Do nothing (detection only)
- Ship #356 and stop.
- Why rejected: detection without remedy leaves 53 rows per entity permanently inflating queue/KPI/SLA; the epic's verified finding is that officers have acknowledge/dismiss affordances only, and the duplication itself was explicitly deferred to this issue.