Client adapter layer
Every external dependency is reached through an interface insrc/clients/, selected by env var in src/clients/index.ts — business logic (modules/*/service.ts) only ever imports the interface type, never a concrete class.
The liveness gap
face-engine originally shipped with no liveness endpoint at all — a real gap against the spec, which requiresliveness_failed as a first-class outcome. POST /v1/liveness was added to face-engine (see its own CHANGES_NEEDED.md and docs/models.mdx) as a passive single-image heuristic — detector confidence + sharpness + face-size sanity, not certified presentation-attack detection. This service’s LivenessClient interface exists specifically so that gap doesn’t leak into business logic: StubLivenessClient (always passes, default) and HeuristicLivenessClient (calls the real endpoint) both implement the exact same interface, so retry-lockout enforcement, distinct-outcome logging, and per-stage tracing are all wired correctly regardless of which is active. Swapping to real PAD later touches only this one file plus the LIVENESS_MODE env var.
The photo-gating boundary
nin_bvn_simulator’sGET /api/nin/{nin} returns the enrollment photo in the same response as everything else — there’s no registry-side gate. SimulatorIdentityRegistryClient.validateNIN()/validateBVN() is where that boundary actually gets enforced: it calls the simulator’s /validate variant (photo-less, added specifically for this — see the simulator’s own CHANGES_NEEDED.md), strips any photo reference, and caches it server-side in Redis keyed by a validation_token this service mints. fetchPhoto() only resolves that reference at verify-face time, once ownership is actively being proven.
Vector index
canonical_embeddings is the one table Prisma doesn’t manage — vector(512) has no native Prisma type, so it’s a hand-written raw-SQL migration plus a single repository module (src/db/vectorRepository.ts) that’s the only place in the codebase allowed to touch it directly.
user_id is the table’s primary key, and every write is INSERT ... ON CONFLICT (user_id) DO UPDATE — upsert, never append. The row being replaced is archived to EmbeddingAudit first, in the same logical step.
Enrollment is a side effect, not an endpoint
There is no standalonePOST /enroll. The live-capture embedding computed during a passing identity/{nin,bvn}/verify-face is carried forward via the (single-use, short-expiry) link_token, and only actually written to canonical_embeddings when POST /identity/{nin,bvn}/link commits it — this is what makes link_token the API’s proof that a real face comparison happened, not just a client’s say-so. A later high-confidence POST /verify/self also upserts the canonical embedding (the freshness rule) — an aging enrollment photo shouldn’t degrade match quality once a fresher, proven capture exists.
Cross-registry consistency
When a user links a second registry (BVN after NIN, or vice versa), thatverify-face call also compares the live capture against the user’s existing canonical embedding, not just the second registry’s photo. A mismatch here — cross_registry_mismatch — is a distinct, higher-severity outcome than an ordinary face_mismatch, since it implies two different people’s documents converging on one account. Policy (hard_block vs. flag) is a named constant in src/config/constants.ts, not buried in the handler.
Merchant identify is a real 1:N search
POST /identify extracts the probe embedding once, then runs a genuine nearest-neighbor search (vectorRepository.searchNearest) against the full enrolled population via the HNSW index — not a loop of 1:1 /v1/compare calls, which doesn’t scale past a few hundred users. attestation: true is checked before any dependency call at all, the cheapest possible rejection path. A near-tie between the top two candidates (CONFIDENCE_GAP_THRESHOLD, a placeholder pending real accuracy-tuning input) returns too_close_to_call instead of silently picking the top score.
Cross-cutting
- Typed errors: every thrown
AppErrorsubclass insrc/lib/errors.tscarries a stableerrorCodethe error middleware surfaces verbatim — clients branch onerror, never parsemessage. - Rate limiting: per-endpoint-class, Redis-backed (
src/middleware/rateLimit.ts) —verify-faceis throttled pervalidation_tokenspecifically, a brute-force-identity-theft control, not just a UX nicety. - Audit trail: every mutating endpoint writes through
recordAuditEvent()(src/lib/audit.ts) — actor, action, entity, before/after state. - Retention: see
docs/RETENTION.mdin this repo for the account-deletion ordering and what’s purged vs. retained.