Platform changelog
The platform's per-component changelogs live in their respective repositories:
- api-gateway — currently published as commits; aggregator coming
- control-plane
- ML services (
svc-ocr,svc-face,svc-liveness) — published as commits inapps/
For high-level rollout milestones, see the rebuild roadmap and per-sub-project design specs.
Recent notable changes
2026-10-29 — fraud
- Fewer genuine older CIDs are held for a weak ghost match. The default of
ocr_portrait_ghost_min_cosineis now 0.25 (was 0.35). On an older-design CID,portrait_ghost_agreementfails, and the result isneeds_review, only when the front ghost scores below this against the main portrait. Genuine older cards scored as low as forged ones (0.06 to 0.35 on 1,140 labelled bank uploads), so the higher floor mostly held real customers. A tenant that set its own value keeps it; the range is still 0.25 to 0.5. 2026 cards are unaffected. - An expired document is no longer sent to fraud review. An expired document is not a forged one, and
POST /v1/ocralready refuses it asexpired_document. Fraud detection now records expiry instead of judging it:field_validation.checks.not_expiredisfalseanddeterministic_checks.checks.field_validitypasses with reasondocument_expired, so an otherwise clean expired document comes backclean. Impossible dates (birth after issue, issue after expiry, a date in the future) are still a review reason. Keep refusing expired documents from the OCR result. Rules versionfraud-v12-expiry-recorded-2026-10-29.
2026-10-22 — fraud
- A 2026 CID's ghosts must match its portrait or its QR photo. On a CID whose back QR carries a photo,
portrait_ghost_agreementnow judges both ghost prints, front and back: each one found must match the main portrait or the QR photo at a cosine of 0.25, otherwise it fails withghost_matches_neither_portrait_nor_qrand the result isneeds_review. Itsdetailsgive each ghost's cosine to both photos and the one it matched. A ghost that is not found is not evidence. Older CIDs keep the portrait-versus-front-ghost check andocr_portrait_ghost_min_cosine, which no longer applies to 2026 cards; its default and range are unchanged. Measured on 231 genuine 2026 cards, fewer fail a portrait check (17.3 % → 10.0 %), and more simulated photo substitutions are caught. - Large card photos are no longer sent to review by the edit check. On a CID, passport or work permit, a photo whose portrait area alone is over 1 megapixel (a close capture from a good phone camera, about 2600 × 1600 px or more) used to come back
not_evaluatedwithedit_localisation_too_large, which is a review reason. The check now scores the centre of the portrait area and reports the share it covered asmetrics.portrait_covered.edit_localisation_too_largeremains only for a large upload in which no portrait was found. - A full fraud queue answers at once. svc-fraud runs four analyses and queues four more; a
POST /v1/fraud/detectbeyond that gets503 fraud_service_busyimmediately instead of after waiting up to 20 s. Retry with backoff, as for any 503.
2026-10-20 — verification sessions
- A bound fraud check must use the scanned images. With a verification's session token,
POST /v1/fraud/detectmust send the same front (and back, if one was scanned) that the verification'sPOST /v1/ocrcall used, byte for byte. Otherwise it is refused with400 validation_error; a decided verification gets409 verification_closedand an expired one404 not_found. All three are refused before analysis or billing. - The worst fraud verdict counts. A
needs_revieworfraudulentverdict and every fraud-memory collision from any scan in a verification stay in the decision; a later clean check or a re-scan cannot erase them. fraudulentrejects whatever the policy'srequired_steps, and a face match that could not be computed (match_evidence_missing) always goes to review, even withreview_blocks_approval: false. See Verification sessions.
2026-10-20 — credentials
- Tenant-wide calls need a secret key. A session token or publishable key now gets
403 forbiddenonGET /v1/ocr,GETandDELETE /v1/ocr/{id}, every face gallery call (POST /v1/face/registrations,/registrations/verify,/search,/dedupe,GET /v1/face/registrations,GETandDELETE /v1/face/registrations/{id}),GET /v1/liveness/checks/{id},GET /v1/fraud/checks/{id},PATCH /v1/features/{name},GET /v1/plan,GET /v1/usageandDELETE /v1/sessions/{jti}. A publishable key can no longer readGET /v1/verifications/{id}. See Authentication. POST /v1/fraud/detectanswers end users with the end-user view. A session token or publishable key getsview: "end_user"with the decision andsummary.{decision, action, reason, reason_codes}; reason codes only for a retake. A secret key, the stored check and the webhook keep the full result. See Fraud detection.POST /v1/ocrreturnscapture_integrityonly to a secret key. Theocr.completedwebhook still carries it.- Idempotency keys are per credential for session tokens and publishable keys, so they never replay a response cached for another caller.
2026-09-25 — fraud
POST /v1/fraud/detectacceptsocr_id. Send theocr_idof a storedPOST /v1/ocrresult with the same image bytes and the check reuses that run's document evidence instead of reading the document again, which removes the OCR pass (10 s or more) from the call. The images must match the scanned bytes exactly, otherwisevalidation_error; a result stored before this release still gets a fresh OCR pass. See Fraud detection.- The fraud analysis budget is 60 s (was 15 s), still capped by the 85 s route deadline, so two-sided phone captures no longer end in
fraud_service_timeout. - svc-fraud runs four analyses at once and queues bursts for up to 20 s instead of answering
fraud_service_busyafter a quarter second. - Send captures unmodified. The docs now say so explicitly: a capture that is resized or re-encoded on the client loses the container, EXIF and screen-size evidence the screenshot and metadata checks rely on. The demo sends files as saved and only downscales past the 10 MB upload limit. The screen-size table now includes phones released since 2023.
- Genuine phone captures no longer land in review on optical heuristics. The photograph-of-screen composite is supporting evidence now; a check goes to review on moiré or an enforced recapture classifier. Pixel-grid periodicity requires a repeat on both axes across nine patches, so barcode rows and guilloche lines no longer read as a display grid. Backlight variation, frequency aliasing and colour consistency are recorded but not scored: each measured a card's own design or lighting. Exif validation no longer treats XMP namespace URLs as editing software and compares HEIC dimensions in either orientation, and the quality gate measures sharpness at a fixed 1500 px scale, so untouched 12 MP captures are not called blurry.
2026-09-23 — liveness
- Challenge checks score the most frontal frames. Frames are decoded and face-detected first; the anti-spoof models then run on the frames turned least, and the face-match binding frame comes from that set. A held head turn no longer decides the verdict.
- Baselines come from the sequence. Blink is measured against the widest-eye frame, turns against the most frontal frame, wherever they sit in the sequence.
- Sideways frames are turned upright before scoring when they arrive with no orientation tag.
- A challenge frame that loses the face is dropped while two usable frames remain, and a background face less than half the size of the subject no longer counts as a second face.
- A blink challenge needs three frames, refused with a
validation_errorbefore inference. - The replay guard remembers pixels. A re-encoded copy of a scored capture is
frame_replayed, whatever token it is sent with. See Liveness checks.
2026-09-07 — liveness
- Shorter replay window for test keys. Frames scored under a test key stay in the replay guard for 60 seconds instead of 24 hours, so integration tests can reuse a fixture image. Live keys are unchanged. See Liveness checks.
2026-09-07 — OCR
- Black-and-white captures are rejected. A grayscale scan or photocopy fails with
monochrome_image; the floor is the tenant variableocr_min_chroma. - Resolution floor lowered to 550 × 350 px (was 800 px on the long side), and the floors became tenant variables.
- Decision thresholds are tenant-editable on the dashboard, with a change history. The OCR side-matching and resolution controls joined the liveness, fraud and face thresholds there.
- CID side matching works on phone photos with the card small in the frame: the card is located and cropped before the front and back ghost portraits are compared, and the crops are kept for review when source images are stored.
- Faster two-sided CID scans. Typical processing time fell from 16 to 18 seconds to 7 to 9 seconds on production hardware.
- Bilingual title lines read correctly. Dzongkha text the recogniser cannot read no longer masks the Latin document title, which removed a class of false
missing_document_evidencerejections.
2026-09-03 — platform
- Verification sessions (
vf_objects) bind a document scan, a liveness check and a face match into one decision under a tenant policy, with averification.completedwebhook. See Verification sessions. - Liveness frame replay guard. Frames are single use per tenant; a reused frame returns
frame_replayed. The challenge action is now always chosen by the server. - Fraud cross-submission memory. Repeated documents, portraits and numbers across submissions surface as review evidence.
2026-08-18 — OCR
- CID ghost binding. The front and back of a citizenship card must carry the same ghost portrait; mismatched pairs are rejected as
side_mismatch.
2026-05-13 — api-gateway
-
OCR route: defensive IO-error mapping. Raw
pgandundicierrors thrown inside the OCR handler used to escape Fastify's global handler asinternal_error / unexpected, hiding the actual cause (database error, upstream unreachable, etc.) from the caller and the audit log. The route now maps known IO shapes to typedEkycErrorenvelopes:- Postgres errors (5-character SQLSTATE on
.code) →internal_errorwith the pg message verbatim plusdb_codeinextra. - undici / Node network errors (
UND_ERR_*,ECONNREFUSED,ENOTFOUND,ETIMEDOUT, etc.) →service_unavailablewithnetwork_codeinextra. - Truly unknown errors still bubble to the global handler so the original stack is logged.
Same defensive-coding gap exists in
face.tsandliveness.ts— tracked as a follow-up. - Postgres errors (5-character SQLSTATE on