Client adapter layer

Every external dependency is reached through an interface in src/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 requires liveness_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’s GET /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.
HNSW over IVFFLAT because enrollment is continuous, one-at-a-time linking with no batch window to periodically re-cluster IVFFLAT. One canonical vector per user is a structural guarantee, not application discipline: 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 standalone POST /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), that verify-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. 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 AppError subclass in src/lib/errors.ts carries a stable errorCode the error middleware surfaces verbatim — clients branch on error, never parse message.
  • Rate limiting: per-endpoint-class, Redis-backed (src/middleware/rateLimit.ts) — verify-face is throttled per validation_token specifically, 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.md in this repo for the account-deletion ordering and what’s purged vs. retained.