Why we answer partners wrong

Derived from SLARAFIPI-84, where we sent Raiffeisen two claims the customer refuted with evidence he had already given us. Neither was a reasoning failure. Both were verification failures, and they are of different kinds.

The one-line version

In every case the fact we needed was already in our possession. Nothing was missing. What failed was where we looked, when we last checked, and which question the fact answered.


Mode 1 — verified in the place we expected the answer

The most common. The claim is checked somewhere real, the check passes honestly, and the answer lives somewhere else.

Instance A — customization layering. We read server/service/SelfServiceCheckerService.js, which registers perfect: 0.5 / match: null / probable: 0.6 and the ladder consuming it. Read alone that file makes “the rejection boundary is probabletrue. The overwrite is fourteen lines in customization/listeners/self-service-v2.js, a hook setting euclideanDistances.perfect from raiffeisen.customerPortrait.threshold = 0.55.

Why it stayed hidden: the claim was framed around probable, and nothing writes probable. Searching the base for “the boundary” finds a self-consistent ladder and stops. The overlay never mentions probable — it changes a different rung, which silently changes the ladder’s answer.

Instance B — table versus value. We searched for writers of the faceComparisons table, correctly found the only writer sits behind the submit gate, then generalised from “no row is written” to “the value is not stored”. The value is in tasks.data.candidates[].recognitionDetails and in three activity records.

The check that catches Mode 1

Ask “what else writes this?” and “what else could carry this value?” — not “does this file say what I think”. In a codebase built from customization/ overlays, a base-only read is never a complete read.


Mode 2 — the record was stale

face-comparison-persistence-paths §3 stated that myra runs liveness-check-v1, so compareFaceWith is inert there. True when written. By tag raiffeisen-1.9.11.100 the proto carries both liveness steps, selected per session by client SDK version, and the operative reason is that neither task has recognitionOptions at all.

The note was correct, present, and consulted. It produced a wrong first diagnosis anyway.

The check that catches Mode 2

A vault note is evidence about when it was written. Re-derive its load-bearing claims at the ref you are actually answering about, or treat the note as a lead rather than a source.


Mode 3 — a correct fact, scoped to a different question

The subtlest, and the one that defeats “check the prior record”.

face-comparison-distance-thresholds (2026-08-28, three days before we answered) contains a section titled “⚠️ The 0.55 trap — perfect is NOT the accept/reject boundary”, stating that 0.55 is only the SUCCESS/PROBABLE line and that rejection happens above probable. It even cites a prior ticket where conflating the two misframed an answer.

That note is correct. getFaceComparisonResult does return CHECK_PROBABLE between perfect and probable, and different_face only above probable.

And it is the wrong answer to the question we were asked. The myra flow does not use the ladder:

const success = faceComparisonResult === CHECK_SUCCESS   // handler.js:367

Anything short of SUCCESS rejects the photo. For the flow, perfect — 0.55 — is the accept/reject line, exactly as the customer said.

So the written record did not merely fail to save us. Consulting it would have increased our confidence in the wrong answer, because it is a true statement under a title that looks like the authoritative answer to the question being asked.

The check that catches Mode 3

Before reusing a documented fact, ask what question it was written to answer. A verdict-classifier fact is not a flow-control fact. A release-default is not a runtime value. A repo config value is not a deployed value.


What actually broke the tie

Not a document, and not a code reading. The customer’s own attachments. The 807-row export and the room export contained the maximum distance, the two rejected scores, and the absence of room 11651 from a 1202-wide id span. Those are observations of what ran, and no amount of reading what should run can overturn them.

This is why the partner’s data outranks our code reading when they disagree, and why an unopened attachment is a hard stop rather than a note in the margin.


Adjacent traps that cost real time on this ticket

Tools that fail silently while exiting 0. rtk returned a wrong commit for git log -1, garbled a git show | grep into a false no-match, zeroed find | wc -l and grep -c, dropped three comment lines from a file extraction (shifting line numbers), collapsed blank lines in cat -n (shifting them ~12), and ate a character from a path. Separately, download_attachments skips any extension outside its allowlist with status: "skipped-type" and still exits 0 — on this ticket that would have silently dropped the .xlsx and the .tgz, two of three attachments. See rtk-mangles-curl-and-pipes, rtk-git-log-hides-merge-commits.

Agent and summary reports are leads, not evidence. Two line offsets and one file path in this investigation came from agent summaries and were wrong; one cited server/service/CryptoService.js, which does not exist (the file is server/service/CryptoServices/CryptoService.js). The finding was real, the citation was not.

Consistency is not correctness. At one point two files agreed on a line offset and both were wrong — one said “+12 above line 136”, the other “0 below line 136 and +12 above” — the same error in two phrasings. A cross-file consistency check confirmed agreement and passed them.

A number in prose rots. Any count written beside the thing it counts — “the five-line block”, “two occurrences, both intentional” — is false after the next edit. Describe by shape, not by count.

A rule keyed on an input nothing produces. Three instances found in one artifact: a {{CLAIMS}} placeholder with no producer, a clause silently supplying another rule’s required text, and a resolution clause triggered by a value no template was instructed to return. Invisible reading top-to-bottom; visible only on asking who produces this input.


The checklist that follows from all of this

  1. Open every attachment before forming a conclusion. Aggregates over the whole file; absence proved by enumeration, never by not-finding.
  2. Ask what else writes this, and check the customization/ overlay for every base-code claim.
  3. Re-derive documented facts at the ref you are answering about, and ask what question the document was written to answer.
  4. Distinguish repo value from runtime value. A config file proves the release default. A Setting row, config/local.json or Spring Cloud can override it.
  5. Treat agent and summary citations as leads. Re-derive any file:line before it leaves the building.
  6. When the partner’s data and our code reading disagree, their data wins — it records what ran.

Round-2 additions (2026-09-08)

Four more traps, all from the SLARAFIPI-84 round-2 pass. Each one cost real time, and each one exits 0.

A note that says “sent” is not evidence of posting — fetch the thread. The investigation note carried a callout reading “This is the text that was sent” above a 90-line verbatim Hungarian reply. It was never posted. Verified by re-fetching the comment thread: 4 comments, the last one the customer’s 2026-09-04 refutation, ticket State = Blocked — meaning the partner had been waiting four days while our own record said the matter was answered. Nothing in the note was flagged as a draft; the falsehood was a single confident sentence in a callout. Before acting on “we already replied”, fetch the thread and count the comments. The same applies in reverse: when you write a draft into the vault, label it UNPOSTED in the heading, not just in the prose.

A lens can refute the wrong branch. Two independent adversarial passes each produced a confident refutation that did not survive re-reading the cited lines. (1) The STATIC lens declared _isSameFace “fails CLOSED at this ref, not open” and quoted handler :322-324 (if (!faceEncoding) return false) — which is the missing-source branch, and it does fail closed. The quarantined fail-open is a different branch: missing target at :338-367, where an unresolvable compareFaceWith leaves compareTo.score at 0, and 0 <= perfect reads as a perfect match. Both statements are true about different code; only one answers the question. (2) The ADVERSARIAL lens declared the config threshold “never reaches the comparison” because migrateConfigState uses the generated config “only as a change detector” — overlooking the Setting-row write at SelfServiceCheckerService.js:1360-1364, which is exactly how the value does reach it. Re-read the cited lines yourself before accepting a refutation — including, and especially, a refutation of a refutation. A lens that only lowers confidence can lower it about the wrong thing.

Agent notifications truncate silently — have every agent write its full report to a file, and read the file. Of six agent reports in this round, four arrived truncated mid-sentence in the notification channel, with no marker, no error and no indication that anything was missing. A truncated report reads exactly like a complete one that happened to end on a weak note, so the missing half is invisible unless you already know what should be there. The fix is mechanical: every dispatched agent writes its full output to a path in the scratchpad, reports only the path, and the caller reads the file. This is the same failure family as the tools below — plausible output, zero exit, no signal.

extract skips attachments with exit 0 — reproduced again. The attachment tool once more reported status: "skipped-type" for the .tgz and the .xlsx and exited 0. Those two were the partner’s server log and the 807-row export — the two attachments that decided the whole investigation. They had to be fetched by hand. This is the second recorded occurrence on this ticket, so treat it as behaviour rather than a fluke: enumerate the attachment list, then check off each file you actually opened.