Skip to main content

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 in apps/

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_cosine is now 0.25 (was 0.35). On an older-design CID, portrait_ghost_agreement fails, and the result is needs_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/ocr already refuses it as expired_document. Fraud detection now records expiry instead of judging it: field_validation.checks.not_expired is false and deterministic_checks.checks.field_validity passes with reason document_expired, so an otherwise clean expired document comes back clean. 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 version fraud-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_agreement now 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 with ghost_matches_neither_portrait_nor_qr and the result is needs_review. Its details give 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 and ocr_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_evaluated with edit_localisation_too_large, which is a review reason. The check now scores the centre of the portrait area and reports the share it covered as metrics.portrait_covered. edit_localisation_too_large remains 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/detect beyond that gets 503 fraud_service_busy immediately 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/detect must send the same front (and back, if one was scanned) that the verification's POST /v1/ocr call used, byte for byte. Otherwise it is refused with 400 validation_error; a decided verification gets 409 verification_closed and an expired one 404 not_found. All three are refused before analysis or billing.
  • The worst fraud verdict counts. A needs_review or fraudulent verdict 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.
  • fraudulent rejects whatever the policy's required_steps, and a face match that could not be computed (match_evidence_missing) always goes to review, even with review_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 forbidden on GET /v1/ocr, GET and DELETE /v1/ocr/{id}, every face gallery call (POST /v1/face/registrations, /registrations/verify, /search, /dedupe, GET /v1/face/registrations, GET and DELETE /v1/face/registrations/{id}), GET /v1/liveness/checks/{id}, GET /v1/fraud/checks/{id}, PATCH /v1/features/{name}, GET /v1/plan, GET /v1/usage and DELETE /v1/sessions/{jti}. A publishable key can no longer read GET /v1/verifications/{id}. See Authentication.
  • POST /v1/fraud/detect answers end users with the end-user view. A session token or publishable key gets view: "end_user" with the decision and summary.{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/ocr returns capture_integrity only to a secret key. The ocr.completed webhook 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/detect accepts ocr_id. Send the ocr_id of a stored POST /v1/ocr result 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, otherwise validation_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_busy after 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_error before 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 variable ocr_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_evidence rejections.

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 a verification.completed webhook. 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 pg and undici errors thrown inside the OCR handler used to escape Fastify's global handler as internal_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 typed EkycError envelopes:

    • Postgres errors (5-character SQLSTATE on .code) → internal_error with the pg message verbatim plus db_code in extra.
    • undici / Node network errors (UND_ERR_*, ECONNREFUSED, ENOTFOUND, ETIMEDOUT, etc.) → service_unavailable with network_code in extra.
    • Truly unknown errors still bubble to the global handler so the original stack is logged.

    Same defensive-coding gap exists in face.ts and liveness.ts — tracked as a follow-up.