Which vuer_oss code paths write a faceComparisons row, which ones compute a face match without persisting anything, and why compareFaceWith is not the universal switch earlier vault notes implied. Verified against vuer_oss at tag raiffeisen-1.9.11.100 (worktree /Users/levander/coding/facekom/vuer_oss-rel100, git describe = raiffeisen-1.9.11.99-7-g350d3e626b).
This note CORRECTS a half-truth in older vault notes
face-comparison-different-face-db-query and face-comparison-data-verdict-threshold-model state that liveness comparisons “only persist if the proto sets
compareFaceWith”. That is true only for theliveness-check-v2handler. There are three liveness handlers; two of them never persist a comparison at all, andcompareFaceWithis inert on those. Following the old advice sends you to addcompareFaceWithto a proto where it does nothing — which is exactly what happened during SLARAFIPI-84 triage on 2026-08-28.
For Agents
Decision rule when asked “why is comparison X missing from the DB / the export?”:
- Find the task’s
options.step.type. If it isliveness-checkorliveness-check-v1→ no row is possible, full stop. Do not suggestcompareFaceWith.- If it is
liveness-check-v2→ row requiresrecognitionOptions.compareFaceWithand a successful CV face extract.- If it is a
photo/infotask withrecognitionOptions→ coreFlowServicepath; row requires both sides to have aFaceRecognitionwithstatus:'success'ANDgetFaceCount() > 0.- Partner
customization/may compute a match in memory and never persist (Raiffeisen/myra does this twice). Always grepcustomization/flow/**/*.handler.jsandcustomization/listeners/*.jsbefore concluding.
TL;DR
- There is exactly ONE writer:
FaceRecognitionService.createFaceComparisonModel→FaceComparison.create(server/service/FaceRecognitionService.js:152). - Exactly three call sites reach it. Not four — the older “4 call sites” tables double-count self-service v1.
statusis always'success'in practice. A failed comparison is structurally unrepresentable.- A face match can be computed without any row existing — three separate mechanisms do this.
faceRecognitions.imageCategoryis not a setting; it is a copy of the task’sscreenshotCategory.
1. The single writer
server/service/FaceRecognitionService.js:
async compareFaceDetections (recognitionFrom, recognitionTo, faceCompareModelParams) { // :213
const distance = this.calculateCosineDistance(recognitionTo.convertDescriptors(), recognitionFrom.convertDescriptors())
const faceRecognition = await this.createFaceComparisonModel(Object.assign({}, faceCompareModelParams, {
distance, recognitionFromId: recognitionFrom.id, recognitionToId: recognitionTo.id
})) // :216
return faceRecognition
}- No threshold gate. The distance is persisted whatever its value. A “different face” result is not filtered out here — see face-comparison-distance-thresholds for why the verdict is read-time only.
createFaceComparisonModel(:140-160) destructuresstatus = 'success'as a default.
statusis effectively a constant — failures cannot be recordedThe model enum is
{created, failed, success}, but of the three call sites one passesstatus:'success'explicitly and the other two pass nostatusat all (so the default applies). Nothing invuer_ossever writesstatus:'failed'tofaceComparisons. Consequence: every guard upstream ofcompareFaceDetectionsfails by writing no row, not by writing a failed row. Any report built on this table therefore shows only successes, and “the export is missing the failures” is a category error — the failures were never rows. This is the root of complaint F1 in SLARAFIPI-84.
2. The three call sites
| # | Site | file:line | Gates (ALL must pass) | FK written |
|---|---|---|---|---|
| 1 | Core flow / portrait / ID-doc | server/flow/FlowService.js:2918-2968 (handleTaskRecognitionOptions, from submitTaskPhoto:2905) | config.faceRecognition truthy · recognitionOptions.compareFaceWith set · ≥1 named compare task exists · recognitionFrom has status:'success' and getFaceCount()>0 · same for recognitionTo | selfServiceRoomId |
| 2 | Liveness v2 only | server/service/SelfServiceV2Service.js:1416 gate → :1431 call, inside saveLivenessCheckV2Messages:1349 | CV result has status.checkGroups and face.encodings · task.options.recognitionOptions.compareFaceWith set · compare task has data.attachmentId · a matching FaceRecognition exists | selfServiceRoomId |
| 3 | Videochat close hook | server/faceRecognition/faceRecognitionHooks.js:41, hook videochat:close (:25-49) | deployment config faceRecognition.comparisonPairs yields a pair · both sides found via findBestScoreRecognitionForRoom · both getFaceCount()>0 | roomId |
Correcting the "4 call sites" count
Older notes list a 4th entry, “Self-service V1 — via the same
compareFaceDetections”. Atraiffeisen-1.9.11.100there is no such distinct site: a repo-wide grep forcompareFaceDetectionsreturns exactly the three call sites above plus the definition. Treat the count as 3.
Every guard on path 1 is a silent skip
FlowService.js:2918-2968 — the path that writes the overwhelming majority of self-service rows:
:2921—if (!config.faceRecognition || !recognitionOptions || !recognitionOptions.compareFaceWith) return:2933— compare task(s) not found →logger.warn+return:2937-2947—recognitionFrommissing /status !== 'success'/ zero faces →logger.warn+return:2954-2966—recognitionTomissing or zero faces → theifsimply doesn’t fire; the loop continues with no log at all
So a missing row can mean any of five different upstream conditions, and only two of them leave a log line.
3. Liveness has THREE handlers — only one can persist
This is the fact that makes the old compareFaceWith advice wrong. Dispatch is by task.options.step.type in server/queue/rpc_server/SelfServiceV2.js:208-219:
step.type | Handler | Persists a comparison? | Does compareFaceWith do anything? |
|---|---|---|---|
liveness-check | SelfServiceV2Service.handleLivenessCheck:1199 | No | No — inert |
liveness-check-v1 | handleLivenessCheckV1:1459 → mergeRecognitions:1310 | No | No — inert |
liveness-check-v2 | handleLivenessCheckV2:2114 → saveLivenessCheckV2Messages:1349 | Yes, if gated open | Yes |
handleLivenessCheckV1 calls mergeRecognitions on both its success and failure branches and never touches compareFaceDetections, createFaceComparisonModel, or createFaceRecognitionModel. The comparison-writing block lives only inside saveLivenessCheckV2Messages, which is called only from handleLivenessCheckV2 (:2146 success, :2181 failure).
Raiffeisen / myra runs
liveness-check-v1
customization/flow/myra-self-service-v2-phase-1/myra-self-service-v2-phase-1.flow.proto.js:123-133declares the liveness task asname: 'liveness-check-v1',step.type: 'liveness-check-v1', and gives it norecognitionOptionsat all. ⇒ It dispatches tohandleLivenessCheckV1. AddingcompareFaceWithto that task would change nothing — there is no reader for it on that code path. Delivering persisted liveness comparisons for Raiffeisen is feature work (either migrate the flow toliveness-check-v2, or add persistence to the v1 handler), not a config or script fix.
4. Three ways a face match happens with NO row
| Mechanism | Where | What it does instead |
|---|---|---|
| Raiffeisen liveness → vuer_cv | customization/listeners/self-service-v2.js, hook self-service:v2:liveness-check-v1:init | Loads the customer-portrait FaceRecognition and passes faceRecognition.faceDetection.descriptor into a CV LivenessTask. The match is computed inside vuer_cv and comes back as a CV recognition result. vuer_oss never sees a distance to persist. |
| Raiffeisen portrait check | myra-...flow.handler.js::_isSameFace:290-361, called from onTaskRecognitionSubmit:149 | Computes the cosine distance in memory, gets a verdict from getFaceComparisonResult(), returns a details object used for flow control (actions.recognitionValid). Never calls compareFaceDetections. |
| Any guard failing on paths 1–3 | see §2 | Silent skip; no row, not even status:'failed'. |
liveness-reference-faceFaceRecognitions are not comparisons
FaceRecognitionrows withimageCategory: 'liveness-reference-face'do exist for Raiffeisen. They are reference descriptors fed to CV, not evidence that afaceComparisonsrow was written. Do not infer persistence from their presence.
5. imageCategory is not a setting — and it lies about which side is which
faceRecognitions.imageCategory is a per-recognition column copied verbatim from the task’s screenshotCategory. It is not configurable anywhere in the OSS UI (a customer asked exactly this on SLARAFIPI-84).
Write sites:
server/service/RecognitionService.js:235and:363—imageCategory: ids.screenshotCategoryserver/service/SelfServiceV2Service.js:1405—imageCategory: task.options.screenshotCategory
FaceRecognitionService.js:194is a READ, not a write
:194(imageCategory: screenshotCategory) sits in thewhereclause offindBestScoreRecognitionForRoom— it is a query filter. It is good evidence that the codebase treats the two names as interchangeable, but citeRecognitionService.js:235,363/SelfServiceV2Service.js:1405for the actual assignment.
The myra mislabelling trap
In the myra proto both sides of the eMRTD↔selfie comparison carry the same category:
emrtdtask (:80-96) →screenshotCategory: 'customer-portrait'customer-portraittask (:100-121) →screenshotCategory: 'customer-portrait',recognitionOptions.compareFaceWith: ['emrtd']
⇒ Every resulting row is stored as customer-portrait ↔ customer-portrait. The comparison is correct; the labelling is not. Since faceComparisons has no step/task column, imageCategory on the joined recognitions is the only discriminator — and here it discriminates nothing.
Do not build reports that key on
imageCategoryFor myra it cannot tell the chip photo from the selfie. Join through
recognitionFromId/recognitionToId→sourceAttachmentId→Task.data.attachmentIdto recover which task each side came from.
6. Fail-open in the myra portrait check (partially UNVERIFIED)
myra-...flow.handler.js::_isSameFace:290-361 selects the largest distance across all compareFaceWith targets:
const compareTo = { score: 0, id: null } // :319-322
for (const compareWithTask of compareWithTasks) {
const compareAttachmentId = compareWithTask?.data?.attachmentId
if (!compareAttachmentId) { continue }
const recognitionTo = await ...FaceRecognition.findOne({ where: { status: 'success', sourceAttachmentId: compareAttachmentId } })
if (recognitionTo?.getFaceCount()) { // :337
const eucledianDistance = ...calculateCosineDistance(recognitionFrom, recognitionTo.convertDescriptors())
if (eucledianDistance > compareTo.score) { compareTo.score = eucledianDistance; ... } // :339-343
}
}
const faceComparisonResult = await ...getFaceComparisonResult({ euclideanDistance: compareTo.score }) // :347Taking the max is the conservative choice when several targets exist — the worst match wins. But the identity element is wrong:
Max-reduction over an empty set yields the BEST possible score
compareTo.scorestarts at0and is only ever written insideif (recognitionTo?.getFaceCount()). If nocompareFaceWithtask yields an attachment with a successful recognition, the score stays0→getFaceComparisonResult({euclideanDistance: 0})→0 <= perfect→CHECK_SUCCESS. Missing comparison data reads as a perfect match. In a distance metric the neutral element for a max-reduction isInfinity(or an explicit “no target” failure), never0. The failure is also silent:_logCVError(:357, defined:397) is gated behindconfig.get('raiffeisen.debug.cv', false)— off by default. This holds for any numericperfect >= 0, so it does not depend on the deployment’s threshold value.
Contrast — the no-face branch fails CLOSED. At :303-305, if no faceEncoding is found, _isSameFace returns the boolean false. The caller then evaluates faceCompareResult.success → undefined → isFaceMissmatch = true → recognitionValid = false. So a missing source face is rejected correctly; only a missing target fails open.
Verified vs unverified
| Claim | Status |
|---|---|
compareTo.score initialised to 0, only updated inside the getFaceCount() guard | VERIFIED (:319-343) |
getFaceComparisonResult({euclideanDistance: 0}) returns CHECK_SUCCESS | VERIFIED — SelfServiceCheckerService.js:132-152, 0 <= perfect with <= operators |
_logCVError off by default | VERIFIED — config.get('raiffeisen.debug.cv', false) |
myra emrtd step is required: true (mitigating factor) | VERIFIED — proto :86 |
Can a failed/skipped NFC read still reach _isSameFace with no target? | UNVERIFIED — trace this before escalating externally. Same family as the eMRTD fail-open discussed on SLARAFIPI-53. |
Raiffeisen production perfect threshold = 0.55 | UNVERIFIED as a repo fact. Code default is 0.5 (SelfServiceCheckerService.js:34-38). Raiffeisen overrides perfect from deployment config raiffeisen.customerPortrait.threshold via the self-service:v2:updated-settings hook (customization/listeners/self-service-v2.js:114-127) — the value is not in the repo. 0.55 appears only in a test fixture (RaiffeisenFaceComparisonExportService.test.js:69) and in the customer’s own observation. |
_isSameFacenever gets per-room thresholdsIt calls
getFaceComparisonResult(faceComparison)with noflowargument, soflowdefaults to{}andflow.selfServiceRoomIdisundefined.getSettingsFromSelfServiceRoomConfigStatethen callsgetActivityLog(null, 'selfService:v2:config:state', undefined); the helper’s default parameter (selfServiceRoomId = null,server/db/helpers.js:44) turns that intowhere: {roomId: null, selfServiceRoomId: null}— which matches no room’s config state, so it always falls back to the globalSetting. The in-memory myra check and the per-room threshold machinery described in face-comparison-data-verdict-threshold-model are therefore not using the same numbers. (Fallback path VERIFIED; whether the globalSettinghappens to equalraiffeisen.customerPortrait.thresholdis UNVERIFIED.)
Related
- SLARAFIPI-84 — the 2026-08-28 ticket whose triage this mechanism note was extracted from (ticket-specific findings F1–F6 live there, not here)
- SLARAFIPI-53 — the earlier eMRTD fail-open with the same customer
- face-comparison-data-verdict-threshold-model — canonical data-model note (FK linkage, videochat vs self-service, no
createdAtindex); its call-site table is superseded by §2 + §3 above - face-comparison-different-face-db-query — read-only SQL recipe; its “liveness rows are conditional” caveat is superseded by §3 above
- face-comparison-distance-thresholds — what the distance number means and the threshold ladder
- FKITDEV-8827 — the Raiffeisen face-comparison export this knowledge underpins
- FKITDEV-8655 — CV encoder preprocessing lands directly on stored distances
- vuer_oss · vuer_cv · database-schema · FaceKom