Skip to main content

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-identicallegacy_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.

  1. Durable per-field provenance. Each survivorship-resolved field carries _src_<field>, stamped at establishment, so a later unrelated update cannot relabel it. _data_source is retained as the legacy fallback, so nodes written before this change keep their exact previous behaviour until next resolved. The durable owner is always winner.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.

  2. 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_source remains 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_pep to 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.