ADR-0158: The graph path asserts never-suppress directly, overriding ADR-0123's byte-identical guarantee
Date: 2026-08-01 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus (implementation + analysis), Codex (review findings K1/K2/K3 and the round-2 reachability finding on PR #888) Amends: ADR-0123 (OSINT investigation golden-record survivorship). ADR-0123 stays Accepted; this ADR records a scoped, deliberate exception to one of its guarantees.
Context
ADR-0123 unified three competing merge designs onto one resolve() and, to make that
migration safe, guaranteed that the graph-ETL path stays byte-identical — legacy_compat=True
reproduces the previous top-two-only conflict check exactly. That guarantee was correct for the
migration and is why the change could land without re-validating every historical case.
Codex's review of PR #888 found that the guarantee had become a liability on one specific seam, and the finding is right: ADR-0123 currently tells a reader that graph behaviour is unchanged, while the code deliberately changes it. An accepted ADR that contradicts production invites a later maintainer — or a calibration pass — to "restore" the legacy ordering and silently remove a safety control. This ADR is that record.
The behaviour being excepted sits on the never-suppress doctrine (ADR-0067), which is a compliance safety path, not a performance or ergonomics concern.
What forced the exception
The resolver's never-suppress ordering key (_protected_never_suppress_key) runs after
the #516 protected-field authority gate. The gate needs to know who wrote a retained value.
The graph node carried ONE node-wide _data_source, overwritten on every update — so the
recorded writer of a retained value was whatever provider last touched any field.
Reproduced against live Neo4j through the resolver: KBO writes is_sanctioned=True; an
unrelated NorthData legal_name update flips _data_source to northdata; a later KBO
is_sanctioned=False then wins, because the retained hit is re-presented under a foreign
label and rejected as an unauthorised claim before the never-suppress key can see it.
Fixing the label alone is not sufficient: a legacy node written before the per-field marker existed has no marker to read, and the first-write path establishes a value with no authority check at all.
Decision
Two changes, and this ADR records the second as a deliberate exception to ADR-0123.
-
Durable per-field provenance. Each survivorship-resolved field carries
_src_<field>, stamped at establishment, so a later unrelated update cannot relabel it._data_sourceis retained as the legacy fallback, so nodes written before this change keep their exact previous behaviour until next resolved. The durable owner is alwayswinner.source, never inferred from whether the two values happened to differ — equal values are corroboration, and stamping the incoming source there laundered a corroborated high-trust value down to the corroborator's lower trust. -
The graph path asserts the invariant directly, on the VALUES. A protected field whose current value asserts scrutiny is never overwritten by a value asserting a clear — independent of source labels, so it holds on a legacy node and on the first-write path, which (1) cannot reach. Monotonic by construction: the branch can only RETAIN a signal, never introduce one. Dismissing a hit remains the evidence-based FP-suppression process's job (ADR-0045/0131), never a side effect of a merge.
asserts_scrutiny is made public so the resolver and the graph path share ONE definition of
"what counts as a hit". A second, drifting copy is how a never-suppress guarantee becomes
true in one layer and false in the next.
Honest scope: this guards a path production does not yet use
Verified for this ADR, and stated because the opposite would be a false claim of coverage:
is_sanctioned/is_pep do not reach the resolver on the live OSINT path today. The
single caller builds props from eight identity fields (country, legal_form, legal_name, lei,
nace_codes, normalized_name, status, vat_number), and ingest_media_data writes
Finding/Evidence/Sanction nodes without calling the resolver at all.
The identity-field ratchet is live — legal_name and its siblings flow through on every
case, which is where finding (1) actually bites. The protected-field branch is the guard that
must already exist the day screening status is routed through the company upsert.
Routing it there is a real design decision with its own blast radius — it changes what a
Company node carries, what the #516 authority gate arbitrates, and what the ADR-0089 ratchet
observes — so it is deliberately not done as a side effect of a review round. It is
tracked, and tests/test_protected_field_reachability_888.py pins the current field set in
both directions so the transition is visible: adding a protected field to props fails that
test and tells the author the ratchet has just become live.
Consequences
Positive
- A retained sanctions/PEP hit survives an unrelated update from another provider. That was reproducible against live Neo4j before this change.
- Corroboration no longer weakens a field. A lower-trust source repeating a higher-trust source's value keeps the higher-trust owner, so a later third source cannot beat a fabricated trust.
- The invariant holds on legacy nodes and on first writes, where label-based reasoning cannot.
- One definition of "asserts scrutiny", shared by both layers.
Negative
- ADR-0123's byte-identical graph-path guarantee no longer holds unconditionally. It holds for every unprotected field; on a protected field the graph path now diverges deliberately. A reader of ADR-0123 alone would be misled, which is precisely why this ADR exists and why ADR-0123 should be read with it.
- Nodes carry additional
_src_<field>properties — a small storage and write cost on every resolved field. - The protected-field branch is currently unreachable on the production path, so it carries maintenance cost without present benefit. Accepted deliberately: the alternative is to add the guard at the same moment as the wiring, which is exactly when it is most likely to be forgotten.
Neutral
_data_sourceremains on the node as the legacy fallback; it is no longer read as the source of a retained value.- No migration. Existing nodes acquire
_src_markers as they are next resolved.
Alternatives Considered
Alternative 1: Keep ADR-0123's byte-identical guarantee; fix only the label
- Stamp durable per-field provenance and rely on the resolver's existing never-suppress key.
- Why rejected: it does not hold. A legacy node has no marker and falls back to the node-wide label; the first-write path performs no authority check at all. In both, a retained hit is still rejected as unauthorised and the incoming clear wins. The guarantee would have been preserved on paper while the defect stayed live in exactly the cases where the data is oldest.
Alternative 2: Move the never-suppress key ahead of the #516 authority gate
- Reorder inside
resolve()so scrutiny-assertion outranks writer authority. - Why rejected: it changes resolution for every caller, including the investigation path, to fix a defect specific to how the graph stores provenance. It would also let an unauthorised writer's "hit" outrank an authorised writer's determination, which inverts #516. The blast radius is much larger than the defect.
Alternative 3: Route screening status through the company upsert now, so the guard is live
- Add
is_sanctioned/is_pepto the company props in this change. - Why rejected: a real design decision, not a review-round side effect. It changes the Company node's contract, what the authority gate arbitrates and what the ADR-0089 ratchet observes, and it deserves its own review with its own live validation. Deferred and tracked; the reachability test makes the deferral visible rather than silent.
Alternative 4: Do nothing and record the defect
- Why rejected: the failure mode is a sanctions hit disappearing from the graph because an unrelated provider touched an unrelated field. Under ADR-0067 that is a suppressed signal, which is the one class of defect this codebase treats as never acceptable to defer.
Decision context
- Latency: negligible. One extra dict write per resolved field and one value comparison on the protected subset; no additional query.
- Dependency surface: none. No new packages; one existing private helper made public within the same module boundary.
- Debuggability: improved. The ratchet emits a LOUD warning naming the retained value, its source, the rejected value and its source, so a retention is legible in the log rather than inferred from an absent change.
- Reversibility: hours. The ratchet is one guarded branch; the
_src_markers are additive properties that a revert would simply stop reading. - Blast radius: the graph-ETL company merge only. Unprotected fields are byte-unchanged; the investigation path is untouched. Pinned by the existing survivorship suite plus 9 live-Neo4j tests and the 2-test reachability pin.
- Alternative considered: reordering the never-suppress key ahead of the #516 authority
gate inside
resolve()— rejected because it would change every caller to fix a graph-storage-specific defect, and would let an unauthorised writer outrank an authorised one.