Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to Arkyc are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.4.0] - 2026-10-21

### Added

- **Liveness reject floor** (`livenessRejectThreshold`, per project, default
`0.2`, dashboard Settings → Verification thresholds). A failed liveness check
is declined only when it scores below the floor (a clear spoof); a failed check
scoring from the floor up is borderline and goes to manual review. Laptop
webcams often score just under 0.5 on a real person, which used to be an
automatic decline. It may not sit above the liveness threshold. A failed
challenge sequence now scores 0, so it is always declined.
- **Searchable country screen.** The widget's country step is now a search box
over a scrollable list, matching names from the start of any word and ignoring
accents ("cote" finds Côte d'Ivoire), or a two-letter code. The preselected
country sits at the top, highlighted.

### Fixed

- **The widget's country list was missing about 50 issuing countries and
territories** (Côte d'Ivoire, Palestine, Kosovo, Taiwan, Hong Kong, Puerto
Rico and others). It now lists every ISO 3166-1 country plus Kosovo,
alphabetically.

## [2.3.5] - 2026-10-21

### Fixed
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/support/risk-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const THRESHOLD_KEYS: (keyof VerificationThresholds)[] = [
'documentQualityThreshold',
'ocrConfidenceThreshold',
'livenessThreshold',
'livenessRejectThreshold',
'faceMatchThreshold',
'faceMatchRejectThreshold',
'addressThreshold',
Expand Down Expand Up @@ -122,5 +123,14 @@ export function mergeThresholds(
})
}

const livenessFloor = merged.livenessRejectThreshold ?? DEFAULT_VERIFICATION_THRESHOLDS.livenessRejectThreshold
const liveness = merged.livenessThreshold ?? DEFAULT_VERIFICATION_THRESHOLDS.livenessThreshold

if (livenessFloor > liveness) {
throw ValidationException.withMessages({
thresholds: [`livenessRejectThreshold (${livenessFloor}) must not be above livenessThreshold (${liveness}).`],
})
}

return merged
}
8 changes: 8 additions & 0 deletions apps/api/tests/project-thresholds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ describe('project verification thresholds', () => {
expect(res.status).toBe(422)
})

it('saves a liveness reject floor, but not above the liveness threshold', async () => {
const res = await update({ livenessRejectThreshold: 0.3 })
expect(res.status, res.text).toBe(200)
expect(await stored()).toMatchObject({ livenessRejectThreshold: 0.3 })

expect((await update({ livenessThreshold: 0.5, livenessRejectThreshold: 0.6 })).status).toBe(422)
})

it('rejects a score outside 0 to 1', async () => {
expect((await update({ livenessThreshold: 1.5 })).status).toBe(422)
expect((await update({ livenessThreshold: 'high' })).status).toBe(422)
Expand Down
19 changes: 19 additions & 0 deletions apps/dashboard/src/pages/projects/ProjectSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface FormState {
documentQualityThreshold: string
ocrConfidenceThreshold: string
livenessThreshold: string
livenessRejectThreshold: string
faceMatchThreshold: string
faceMatchRejectThreshold: string
addressThreshold: string
Expand Down Expand Up @@ -58,6 +59,12 @@ const THRESHOLD_FIELDS: { key: ScoreThreshold; label: string; description: strin
label: 'Liveness',
description: 'Selfies scoring below this as a live person go to review.',
},
{
key: 'livenessRejectThreshold',
label: 'Liveness reject floor',
description:
'Selfies that fail below this are declined, and those between this and Liveness go to a reviewer. Laptop webcams often score just under 0.5.',
},
{
key: 'faceMatchThreshold',
label: 'Face match',
Expand Down Expand Up @@ -116,6 +123,9 @@ function formFromProject(project: Project): FormState {
documentQualityThreshold: String(thresholds.documentQualityThreshold),
ocrConfidenceThreshold: String(thresholds.ocrConfidenceThreshold),
livenessThreshold: String(thresholds.livenessThreshold),
livenessRejectThreshold: String(
thresholds.livenessRejectThreshold ?? DEFAULT_VERIFICATION_THRESHOLDS.livenessRejectThreshold,
),
faceMatchThreshold: String(thresholds.faceMatchThreshold),
faceMatchRejectThreshold:
thresholds.faceMatchRejectThreshold != null ? String(thresholds.faceMatchRejectThreshold) : '',
Expand Down Expand Up @@ -216,8 +226,17 @@ function ProjectSettingsForm({ project }: { project: Project }) {
// The reject floor can't sit above the face-match threshold (blank = its default).
const faceMatchInput = Number(form.faceMatchThreshold.trim() || DEFAULT_VERIFICATION_THRESHOLDS.faceMatchThreshold)
const floorMax = Number.isFinite(faceMatchInput) ? Math.min(1, Math.max(0, faceMatchInput)) : 1
// The liveness floor can't sit above the liveness threshold either.
const livenessInput = Number(form.livenessThreshold.trim() || DEFAULT_VERIFICATION_THRESHOLDS.livenessThreshold)
const livenessFloorMax = Number.isFinite(livenessInput) ? Math.min(1, Math.max(0, livenessInput)) : 1

const thresholdErrors: Record<string, string | null> = {
...Object.fromEntries(THRESHOLD_FIELDS.map(({ key }) => [key, thresholdError(form[key])])),
livenessRejectThreshold: thresholdError(
form.livenessRejectThreshold,
livenessFloorMax,
`Keep this at or below Liveness (${livenessFloorMax}).`,
),
faceMatchRejectThreshold: thresholdError(
form.faceMatchRejectThreshold,
floorMax,
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ export class DecisionEngine {
if (input.document.expired) {
return verdict('rejected', 'DOCUMENT_EXPIRED')
}
if (!input.liveness.passed) {
// Only a clear spoof is declined; a failed check scoring at or above the floor
// is borderline and goes to a reviewer below.
if (!input.liveness.passed && input.liveness.score < t.livenessRejectThreshold) {
return verdict('rejected', 'LIVENESS_FAILED')
}

Expand Down Expand Up @@ -132,7 +134,7 @@ export class DecisionEngine {
if (input.document.ocrConfidence < t.ocrConfidenceThreshold) {
return verdict('requires_review', 'OCR_LOW_CONFIDENCE')
}
if (input.liveness.score < t.livenessThreshold) {
if (!input.liveness.passed || input.liveness.score < t.livenessThreshold) {
return verdict('requires_review', 'LIVENESS_LOW_CONFIDENCE')
}
if (input.faceMatch.similarityScore < t.faceMatchThreshold) {
Expand Down
33 changes: 32 additions & 1 deletion packages/core/tests/decision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,32 @@ describe('DecisionEngine.decide', () => {
reason: 'DOCUMENT_EXPIRED',
},
{
name: 'rejects failed liveness',
name: 'rejects failed liveness below the reject floor (a clear spoof)',
mutate: (i) => {
i.liveness.passed = false
i.liveness.score = 0.1
},
decision: 'rejected',
reason: 'LIVENESS_FAILED',
},
{
name: 'reviews failed liveness at or above the reject floor (borderline)',
mutate: (i) => {
i.liveness.passed = false
i.liveness.score = 0.46
},
decision: 'requires_review',
reason: 'LIVENESS_LOW_CONFIDENCE',
},
{
name: 'reviews a failed liveness check even when its score is high',
mutate: (i) => {
i.liveness.passed = false
i.liveness.score = 0.95
},
decision: 'requires_review',
reason: 'LIVENESS_LOW_CONFIDENCE',
},
{
name: 'rejects failed face match',
mutate: (i) => {
Expand Down Expand Up @@ -169,6 +188,7 @@ describe('DecisionEngine.decide', () => {
it('orders reject reasons: expired before liveness before face match', () => {
const input = goodInput()
input.liveness.passed = false
input.liveness.score = 0
input.faceMatch.passed = false
expect(DecisionEngine.decide(input).reason).toBe('LIVENESS_FAILED')
})
Expand Down Expand Up @@ -231,6 +251,7 @@ describe('DecisionEngine — face-match reject floor', () => {
it('keeps hard rejections ahead of the face-match floor', () => {
const input = idToSelfie()
input.liveness.passed = false
input.liveness.score = 0

expect(DecisionEngine.decide(input, { faceMatchRejectThreshold: 0.3 }).reason).toBe('LIVENESS_FAILED')
})
Expand Down Expand Up @@ -269,9 +290,19 @@ describe('risk signals', () => {
expect(DecisionEngine.decide(input)).toMatchObject({ decision: 'rejected', reason: 'SANCTIONS_MATCH' })

input.liveness.passed = false
input.liveness.score = 0
expect(DecisionEngine.decide(input).reason).toBe('LIVENESS_FAILED')
})

it('honours a per-project liveness reject floor', () => {
const input = goodInput()
input.liveness.passed = false
input.liveness.score = 0.3

expect(DecisionEngine.decide(input).reason).toBe('LIVENESS_LOW_CONFIDENCE')
expect(DecisionEngine.decide(input, { livenessRejectThreshold: 0.5 }).reason).toBe('LIVENESS_FAILED')
})

it('ignores info signals and orders by signal precedence', () => {
expect(DecisionEngine.decide({ ...goodInput(), signals: [signal('velocity_exceeded', 'info')] }).reason).toBe(
'AUTO_APPROVED',
Expand Down
3 changes: 2 additions & 1 deletion packages/liveness/src/drivers/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export class LocalLivenessDriver implements LivenessDriver {
const [cx, cy] = [face.box[0] + face.box[2] / 2, face.box[1] + face.box[3] / 2]
const faceNotCentered = Math.abs(cx / image.width - 0.5) > 0.3 || Math.abs(cy / image.height - 0.5) > 0.3

const score = sequenceOk ? realProbability : Math.min(realProbability, 0.3)
// A failed challenge sequence is no liveness at all: score 0, always declined.
const score = sequenceOk ? realProbability : 0

return this.result(
realProbability >= 0.5 && sequenceOk,
Expand Down
2 changes: 1 addition & 1 deletion packages/liveness/src/drivers/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class MockLivenessDriver implements LivenessDriver {

return {
passed,
score: sequenceOk ? score : Math.min(score, 0.3),
score: sequenceOk ? score : 0,
spoofSignals: {
screenReplay: active && !sequenceOk,
printedPhoto: false,
Expand Down
9 changes: 9 additions & 0 deletions packages/types/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export interface VerificationThresholds {
documentQualityThreshold: number
ocrConfidenceThreshold: number
livenessThreshold: number
/**
* Liveness score below which a failed check is declined outright. A failed
* check scoring from here up is borderline (a laptop webcam's noise can look
* like a screen to the anti-spoofing model) and goes to manual review instead.
* At or below `livenessThreshold`. A failed challenge sequence scores 0, so it
* is always declined.
*/
livenessRejectThreshold: number
faceMatchThreshold: number
/**
* Face-match similarity below which a session is rejected outright. Unset (the
Expand All @@ -39,6 +47,7 @@ export const DEFAULT_VERIFICATION_THRESHOLDS: VerificationThresholds = {
documentQualityThreshold: 0.75,
ocrConfidenceThreshold: 0.8,
livenessThreshold: 0.85,
livenessRejectThreshold: 0.2,
faceMatchThreshold: 0.75,
addressThreshold: 0.6,
}
Expand Down
Loading
Loading