FaceSignals Service — Implementation Plan
Feature: Phase 2 · Offer→Join Identity Continuity (the "selfie verification tool")
Design doc: candidate-identity-verification-and-fraud-signals.md · live: https://www.html-docs.com/s/3ab3e4be8c
Live (this doc): https://www.html-docs.com/s/c08fb7de01
Status: ✅ Demo shipped 2026-07-15 (Phases A–F done, worked live on dev) · backend PR #650 open (CI green, awaiting review) · next: dev-merge → formal release (see Post-Demo → Production Backlog)
Legal gate: ✅ cleared — Utkrusht-legal PR #1 accepted + implemented
Scope for now: dev-only (devapp / devrecruiter + dev Supabase) · demo target = a working selfie match for 1–2 real task/test sessions on the dev DB. No prod, no release branch.
What changed vs. the design doc
The design doc wired Phase 2 into fastapi_service (enrolment on the shared schedule_once ThreadPoolExecutor; verify as "a new FastAPI route"). This plan instead builds it as a new, separate service — facesignals_service — in the same repo, following the notifications/ precedent.
Why separate:
1. Native-dep isolation. opencv-python-headless + ONNX model files is the first CV dependency in the backend. Keeping it out of the fastapi_service image means a broken CV build can never block a deploy of the core API (proctoring, tasks, sandboxes).
2. CPU isolation. Face embedding + liveness inference would otherwise share the same pool that runs transcription and Gemini analysis. A separate service isolates that load.
3. Biometric blast radius. "Face embeddings are only ever touched by one small service with service-role-only DB access" is a cleaner boundary for the DPIA/legal story.
The model choice makes this cheap: YuNet (detect) + SFace (embed) run via OpenCV's built-in cv2.FaceDetectorYN / cv2.FaceRecognizerSF, and MiniFASNet (liveness) via cv2.dnn.readNetFromONNX — so the whole stack is one opencv-python-headless wheel (prebuilt aarch64, no source compile) plus ~40 MB of ONNX files.
Architecture
Service shape (mirrors notifications/, but COPYs shared/)
facesignals_service/
├── facesignals.Dockerfile # mirror notifications.Dockerfile; COPY shared/ + set PYTHONPATH=shared:facesignals_service
├── requirements.txt # opencv-python-headless, numpy, fastapi, uvicorn, supabase, boto3, pydantic
├── main.py # FastAPI app factory (wires routes)
├── gunicorn.conf.py
├── core/ # HOST-AGNOSTIC — pure functions over image bytes, ZERO web/DB imports
│ ├── detect.py # YuNet (cv2.FaceDetectorYN)
│ ├── embed.py # SFace (cv2.FaceRecognizerSF) → 128-d vector
│ ├── liveness.py # MiniFASNet (cv2.dnn) → live/spoof score
│ └── match.py # cosine + banding (clear / borderline / no-match)
├── models_onnx/ # vendored ONNX files (YuNet, SFace, MiniFASNet)
├── enrollment.py # cover-video → frame → detect+embed → write face_enrollments (uses shared DAOs + S3)
├── routes.py # HTTP layer: /links (mint), /links/{token}, /verify
└── static/join.html # thin join page served by THIS service (webcam selfie → /verify → band)
Shared data layer lives in the backend shared/ tree (reused, not duplicated):
- shared/models/face_enrollment.py, shared/models/join_verification.py
- shared/daos/face_enrollment_dao.py, shared/daos/join_verification_dao.py
Boundaries — what lives where
The demo simplification
For tomorrow, facesignals_service serves its own minimal join page. The whole selfie→match loop is one service + the dev DB — no cross-repo frontend, no recruiter typed-client regen, no signed push to the frontends. The polished recruiter chip + candidate consent are post-demo.
Data model (dev DB)
Follows the extension_captures async-job template + report_comments dual-FK/CASCADE precedent. Service-role-only RLS, served only via the facesignals service (never direct anon Supabase). No pgvector (1:1 exact cosine). No raw images stored.
face_enrollments—embedding float8[](128-d),testsession_id varchar+tasksession_id uuiddual FK (CHECK exactly one set, CASCADE),quality jsonb,model_name/model_version,status(PROCESSING/ENROLLED/FAILED),retention_expires_at,created_at/updated_at+set_updated_attrigger.join_verifications—enrollment_idFK,token,recipient,similarity,band,liveness_score/liveness_pass,selfie_embedding float8[](no image),reviewed_by/review_decision,attempt_no,retention_expires_at.- Consent — extend the existing
consentJSONB with anidentitykey (app-level; no new table). Deferred for the dev demo.
Spec number: 024 (follows 023-ip-intelligence).
Demo scope — deferred / out of scope
Legitimately trimmed because it's a dev-only demo (all called out in the design doc as hardening/launch items):
- Recruiter-app integration (mint button, Match/Review/No-match chip, typed-client regen)
- Candidate biometric consent screen
- Automated 6-month retention purge cron
- Per-demographic fairness calibration + threshold tuning
- Hardened liveness / deepfake-injection defense
- Human-review workflow UI
- Anything prod (release branch, prod Supabase, prod Coolify)
§ Implementation TODO — Execution Checklist
Ordered by dependency. 🧑 YOU = needs you (a decision, a signed push, or infra). Everything else is mine.
Milestone A — Prep & decisions
- [ ] A1 · 🧑 YOU (+ me) — Identify the 1–2 dev sessions with a usable cover video to use as enrollment anchors. I'll query dev Supabase for task/test sessions that have a cover video recorded; you confirm which candidate(s) to demo.
- [ ] A2 · 🧑 YOU — Decide ONNX model storage: commit the ~40 MB files to
facesignals_service/models_onnx/(simplest for demo) vs git-lfs vs download-at-build. Recommendation: commit for the demo, revisit lfs later. - [ ] A3 · 🧑 YOU — Decide demo host: (i) new Coolify app
facesignals-dev+devfacesignals.utkrusht.ai, or (ii) runfacesignals_servicelocally against dev DB/S3 for the demo. Recommendation: (ii) to de-risk tomorrow; promote to (i) right after.
Milestone B — FaceSignals core module (host-agnostic)
- [ ] B1 — Scaffold
facesignals_service/(mirrornotifications/layout; DockerfileCOPYsshared/). - [ ] B2 — Vendor YuNet + SFace + MiniFASNet ONNX into
models_onnx/(per A2). - [ ] B3 — Write
core/detect.py,core/embed.py,core/liveness.py,core/match.py— pure functions (bytes → embedding / band).opencv-python-headlessonly. - [ ] B4 — Local unit test on sample images: same person → high cosine; different → low; printed-photo → liveness fail.
Milestone C — Data layer (dev)
- [ ] C1 — Migration
024…_create_face_enrollments.sql(extension_captures template; dual-FK CHECK + CASCADE; service-role RLS;retention_expires_at). - [ ] C2 — Migration
024…_create_join_verifications.sql. - [ ] C3 —
shared/models/+shared/daos/for both; add both toDAO_REGISTRYintest_model_imports.py. - [ ] C4 · 🧑 YOU —
/db.pushto DEV (signing/push is yours). I'll run the/db.pushpreflight (ledger-drift check) first.
Milestone D — Enrolment path
- [ ] D1 — Cover-video fetch + best-frame extraction (reuse
fastapi_service/media_utils.py/shared/common_utils.pyS3 helpers). - [ ] D2 —
enrollment.py: session → cover video → frame → detect+embed → writeface_enrollments. - [ ] D3 — Enroll the 1–2 demo sessions from A1 (against dev).
Milestone E — Verify API + link mint
- [ ] E1 —
POST /links— mint bearer token (+ optional password) →join_verificationsrow (share-report bearer semantics). - [ ] E2 —
GET /links/{token}— minimal candidate/position info to render the page. - [ ] E3 —
POST /verify— liveness → detect+embed selfie → cosine vs enrollment → band → persist; selfie discarded after embedding.
Milestone F — Thin join page (served by facesignals)
- [ ] F1 —
static/join.html: open via bearer link (+ optional password), webcam selfie capture, POST/verify, render band (Match / Review / No-match + similarity + liveness ✓).
Milestone G — Deploy (or run local)
- [ ] G1 —
facesignals.Dockerfile+.github/workflows/facesignals-dev.yaml(trigger onfacesignals_service/**). - [ ] G2 · 🧑 YOU — If host (i): create Coolify app
facesignals-devon udev-1 + env vars +devfacesignals.utkrusht.aiDNS. (Skip if demoing locally.) - [ ] G3 — Deploy (or local run) + smoke-test the full
/verifyloop on dev.
Milestone H — Demo dry run
- [ ] H1 · 🧑 YOU (+ me) — End-to-end run for the 1–2 sessions; capture the match result for the client.
Deferred (post-demo)
- [ ] Recruiter "mint link" action + Match/Review/No-match chip (reuse
risk-signal-chips.tsx; needs PR #288 merged) + typed-client regen. - [ ] Candidate biometric consent item in
DeviceCheck.tsx+recordConsent. - [ ] Retention purge cron · fairness calibration · hardened liveness · review workflow · prod rollout.
Your action-items at a glance
First 3 tasks
- A1 — Confirm the demo anchors. I query dev Supabase for 1–2 task/test sessions that already have a cover video recorded; you pick the candidate(s). (Unblocks all enrolment work.)
- B1–B4 — Build the host-agnostic core. Scaffold
facesignals_service/, vendor the three ONNX models, write detect/embed/liveness/band as pure functions, unit-test locally. (The irreducible engine; no infra needed.) - C1–C4 — Land the data layer on dev. Author the two migrations + DAOs/models, run the
/db.pushpreflight; you push to the dev DB. (Unblocks enrolment persistence + verification.)
Post-Demo → Production Backlog
The demo (A–F) proved the vertical works on dev, driven from a local process against the dev DB. Getting from there to merged-into-dev → formally released means paying back the corners we deliberately cut for speed. Grouped by what unblocks what: [dev] = needed to test on devapp/devrecruiter; [release] = needed before prod.
Verify-link creation — the recruiter-portal flow (design decision, 2026-07-15)
The only way a verification link is created and handed out is via the recruiter portal (never the open service):
- On a candidate's card → ⋮ (three-dot) menu → "Create verification link" — also surfaced in a tab + a dropdown on the candidate view.
- The action calls an authenticated mint endpoint (recruiter JWT); the returned
join_urlis copied to the clipboard (navigator.clipboard.writeText). - Only a signed-in recruiter can perform it; the action is logged via
user_activity_log(logUserAction— note it swallows errors, so never gate the mint on the log succeeding).
This single flow resolves two things at once: it fixes the "no auth on mint" gap, and the raw-signal-exposure gap — the recruiter gets the raw band/similarity through their authed portal, while the external link-holder only ever gets the softened display. The joiner-vs-recruiter view split falls out of where each party enters the system.
1 · Deploy the service — [dev] (nothing runs on devapp without this)
facesignals.Dockerfile(mirrornotifications, butCOPY shared/+opencv-python-headless; the ~40 MB ONNX files ride in the image)- CI
facesignals-dev.yaml(triggerfacesignals_service/**, ARM64 → GHCR → Coolify webhook) - New Coolify app
facesignals-devon udev-1 +devfacesignals.utkrusht.aiDNS + env (service-role key, both JWT secrets, S3 region) - ENV prod/dev switch — config currently hard-reads
*_DEVnames; add the pair pattern every other service uses.
2 · Recruiter portal integration — [dev] (the flow above; also closes security #1)
- Backend: auth-gate the mint endpoint (recruiter JWT — currently wide open)
- Recruiter app: ⋮ action + clipboard copy + tab/dropdown surface +
logUserAction - Match / Review / No-match result chip on the card/report (reuse
risk-signal-chips.tsxfrom now-merged Phase 1) - Regen typed
@ngm9/recruiter-clientif the mint route proxies through Flask/FastAPI
3 · Correctness / robustness — [dev→release] (PR #650 review findings)
- Background enrolment — mint blocks ~10–20 s downloading + embedding the cover video today; move to the
schedule_oncepattern so the recruiter isn't blocked - ONNX thread-safety — lock around each
cv2inference call (sync routes run in a threadpool; concurrent verifies share one instance → could silently corrupt a match decision) - Broaden
mint_linkerror handling (non-ValueError→ clean 422, not 500); constant-time password compare (hmac.compare_digest); atomic attempt-limit - Two-client RLS workaround — we split service-role (biometric tables) + anon (session reads) because dev never granted
service_roleontask_sessions/testsessions. Decide prod posture: grantservice_role SELECTon those two tables, or keep the anon client (one migration either way).
4 · Compliance & quality — [release] (the load-bearing ones the design doc calls out)
- Candidate biometric consent — extend the DeviceCheck consent screen. Legal prerequisite, not polish (the feature was gated on legal PR #1).
- Liveness spoof hardening — a flat photo can currently pass; can't claim spoof-catching until fixed (verify MiniFASNet class mapping vs a real spoof → binary anti-spoof model if needed)
- Threshold + per-demographic fairness calibration — the doc makes "borderline → human review" load-bearing because 1:1 face-match error runs 10–100× higher for some demographics; needs a real validation pass
- Retention purge cron —
retention_expires_at(6 mo) is stamped but nothing sweeps it yet - Prod migrations (currently dev-only) + a security review of the biometric surface
Critical path
- To test on devapp/devrecruiter: §1 (deploy) + §2 (recruiter mint flow + auth) — the minimum to actually use it.
- To release formally: add §3 (robustness) + §4 (consent, liveness, calibration, retention, prod migrations, security review).