📄 Committed snapshot of a design doc · live / shareable version: html-docs.com/s/3ab3e4be8c

Utkrushta · Product & Engineering · Draft for team review

Rev 3 · local-first · no ID-card at launch

Candidate Identity Verification & Fraud Signals

Make a candidate's identity continuous — the person who sits the assessment and the person who joins should be the same human — using the cover video we already record, with signals for recruiters and zero new friction for candidates.

Model: Signals · never a gate Matching: Local (our infra) ID-card scan: Deferred (Didit, optional) Effort: P1 3–4d · P2 ~21–24d
In this doc
  1. The case — demand, gap, our answer
  2. What changes for the candidate
  3. Our approach — local-first & the choices
  4. How the matching works
  5. Screens & recruiter signals
  6. Legal workstream (gates launch)
  7. Data model
  8. Pipeline internals
  9. Phases & effort
  10. Risks
  11. Files & verification
Each section is tagged For everyone or ⚙ Engineering · skippable. Zubin, Khushi — the ⚙ sections are tables, columns & architecture; skip them freely, you lose no product context.
§1For everyone

The case — what customers want, what's out there, and our wedge

Our customers — recruitment agencies and the clients they place into — keep asking one question in three forms: is this candidate who they say they are? Remote, global hiring has made the answer genuinely hard, and AI has made faking it easy.

1 · What customers ask for

Did they do their own work?

The person we assessed actually did it — not a proxy, not someone driving from another location.

Will the joiner be the same?

The person who signs on day one is the same human who took the assessment.

Is this a real, documented person?

Matched to a government ID — needed by some customers, not all. This is the part we defer (optional later phase).

Why now

Gartner projects 1 in 4 candidate profiles will be fake by 2028; assessment-cheating attempts are more than doubling year-on-year (highest in Asia-Pacific). Our candidates span four continents — the exact conditions where proxy test-takers and location-masking thrive.

2 · What competitors offer — and the gap

What's on the marketWhat it does wellThe gap it leaves
Background verification
SpringVerify, CFirst, AuthBridge, IDfy
Confirms documents, employment, education, criminal history.Runs before/after hire, off to the side — never sees the person taking the live test.
Assessment proctoring & “fake-candidate” tools
HackerRank, CodeSignal, Ropes.ai
ID check at start, integrity scores.Point-in-time & siloed — no identity carried from assessment to joining day.
Deepfake / liveness specialists
iProov, Talview, Pindrop
Real-time deepfake & injection defense.Enterprise-priced bolt-ons that sit beside the interview, not inside our assessment.
Utkrushta (this build)Identity continuity from a live, proctored reference we already hold — plus fraud signals — shown to the recruiter.The bridge — continuity + evidence, no added candidate friction, at our price point.
Our wedge

The assessment already captures a live, proctored cover video of the test-taker. We turn that into an identity anchor: at joining time, one selfie confirms it's the same human — matched on our own infrastructure, surfaced as signals for recruiters, never a gate for candidates.

3 · How we bridge the gap — phase by phase

Stage 1
Résumé

the claimed person

BGV — out of scope
Stage 2
Assessment

the test-taker

Cover video — captured today
Stage 3
Join

the joiner

Phase 2 closes this
PhaseThe customer question it answers
Phase 1 · IP intelligence“Is the candidate where they claim to be — or masking their location?”
Phase 2 · Offer→Join identity continuity (local)“Is the person who joins the same human we assessed?” — the headline, using the cover video we already hold. No ID card, no new candidate step.
Phase 3 · Government-ID verification (Didit) — deferred, optional“Is this a real, documented person?” — the stronger legal-identity anchor, added later for customers who need it.
Deferred / dropped — LinkedIn-photo corroboration (legal risk) · continuous re-verify · deepfake defense · paste “phase” (dropped — the existing wholesale-paste signal simply surfaces in the report's prompt-trail section)
§2For everyone

What changes for the candidate — nothing during the assessment

This is the biggest win of the local-first plan: Phase 2 adds zero screens, zero steps, zero seconds to the assessment. The identity anchor is the cover video the candidate already records.

The assessment — unchanged
1
Prerequisites + GitHub

combined · tasks

2
Device Check + Consent

camera · mic · network

3
Cover Video

already recorded today

= THE IDENTITY ANCHOR
Assessment

test / task

1
Agency mints link

unique link (± password)

2
Employer opens it

shared by the agency

3
Joiner's selfie

in the employer's presence · ~30s

NEW · the only new step
Recruiter sees signal

match / review / no-match

Consent & enablement

Biometric consent is added to the existing Device-Check consent screen (the same place camera/screenshare consent lives today), stating plainly that a face comparison happens only if the candidate reaches the offer stage. The whole feature requires the position to have cover video enabled, and the recruiter chooses per candidate whether to send a verification link — inherently opt-in on both sides. No candidate-facing fraud indicators appear during the assessment (deliberately — signals are for the recruiter).

§3For everyone

Our approach — local-first, and the choices behind it

The earlier draft of this doc led with a vendor (Didit) asking every candidate to scan a government ID during the assessment. Stakeholder feedback pushed back on that friction — and it turns out we don't need it for the headline question. “Same person end-to-end” only needs a trustworthy live reference of the test-taker — which the proctored cover video already is. So:

Decision A What anchors the candidate's identity?
✓ Our pick

The cover video (local-first)

A live, proctored recording we already capture. Face-match runs on our own infrastructure. Zero new candidate friction. Proves continuity (same person), not legal identity.

Deferred

Government ID (Didit)

Stronger — anchors to a legal identity, certified liveness, Didit stores the biometrics (not us). But adds an ID-scan step for every candidate. Becomes optional Phase 3 for customers who need it.

Deferred

LinkedIn profile photo

Corroboration idea — but LinkedIn's ToS bans scraping (litigated), biometric processing of a third-party photo has consent problems, and profile photos are old/stylized/absent. Not worth the legal exposure.

Decision B When do we create the face template?
✓ Our pick

Lazily — at offer time

Extract the template only when the recruiter mints a verification link. Only the few candidates who reach an offer ever get a biometric template (~10–20× fewer — data minimization), the 6-month retention clock starts when it's useful, and the ~10–20s job runs while the link is prepared.

Rejected

Eagerly — at upload

Hook the cover-video upload event and enroll everyone. Simple, but creates a biometric template for every assessed candidate — most of whom never reach an offer. Worse privacy posture for no benefit.

Decision C Which face-matching model? (all run locally)
✓ Our pick

SFace (OpenCV)

Apache-2.0 code AND weights, ~99.6% benchmark accuracy, one small ONNX file, shares a single runtime with our detector (YuNet) and liveness model (MiniFASNet). No compilation, CPU-fast.

dlib / face_recognition

Clean Boost license, mature — but needs a C++ build in the Docker image and is a notch weaker on hard pairs. Solid fallback.

Rejected

ArcFace / FaceNet / FaceMesh

ArcFace: best accuracy but weights are non-commercial. FaceNet: drags in ~1GB of PyTorch. FaceMesh: not a matcher at all — it maps face geometry (468 points), not identity; we use it class of tool only for alignment.

Decision D Who opens the verification link, and how is it protected?
✓ Our pick

Unique link + optional password

The agency mints an unguessable bearer link (“anyone with the link”, like a Google-Drive share) and forwards it to the employer / hiring manager — who often isn't a contact we know in advance. Optionally the recruiter sets a simple password shared out-of-band. The joiner takes the selfie in the employer's presence (Model B — a trusted third party witnesses the live capture).

Rejected as default

OTP to a known contact

Our first instinct — but OTP sends a code to a specific email/phone, and the agency frequently won't know the exact hiring-manager contact, so the gate can't even be created. Kept only as a fallback where a contact is known.

The locked parameters

ParameterDecision
Result modelSignals only — never a gate. Three bands: clear match / borderline → human review / no-match — the recruiter decides; nothing auto-rejects, nothing blocks joining.
What we storeEmbeddings only, never face images. The selfie is discarded right after its embedding is computed; cover-video frames are temp files, deleted after processing.
Retention6 months from extraction, then automatic purge. (Indian notice periods commonly run 30–90 days, so offer→join cycles are long; 6 months covers the hiring cycle. Counsel confirms.)
LivenessOne server-side passive check (MiniFASNet, Apache-2.0) on the submitted selfie — catches printed-photo / screen-replay attacks. The most basic effective thing; hardening later.
ConsentExtend the existing Device-Check consent screen with a biometric-identity item (extraction happens only if the candidate reaches offer).
Access controlBoth new tables are service-role-only — no client/browser role reads embeddings. Recruiter signals are served via a backend API route (service_role client), never a direct anon Supabase query from the recruiter app (avoids repeating the report_comments revert incident).
Join link & hostingA unique bearer link + optional recruiter-set password (not OTP — the agency may not know the recipient). Hosted in the recruiter app's existing share surface, reusing its bearer-token minting; we swap the OTP gate for an optional password gate.
§4For everyone

How the matching works — embeddings in plain language

A face embedding is a compact numerical fingerprint of a face: a model reads an aligned face crop and outputs 128 numbers (~1 KB) such that two photos of the same person land close together, and photos of different people land far apart. You cannot practically reconstruct the face image from those numbers — which is why storing embeddings instead of images is the privacy-minimizing choice. (Legally they still count as biometric data — hence §6, the legal workstream.)

Enrollment — when the recruiter mints a verification link (~10–20s, background)
1 · fetch
Cover video from S3

already recorded during the proctored assessment

2 · frames
Sample ~12 frames

ffmpeg, 1 frame/sec

3 · quality gate
Pick the best frontal frame

one face, sharp, well-lit, facing camera

4 · embed
Face → 128 numbers

SFace · stored; frames deleted

1 · liveness
Is a live person present?

server-side passive check — defeats photos/screens

2 · embed
Selfie → 128 numbers

selfie image then discarded

3 · compare
Cosine similarity

how close are the two vectors?

4 · band
Match / Review / No-match

a signal for the recruiter — never a gate

What this proves — and honestly doesn't

Proves: the joiner is the same human who sat the proctored assessment — the continuity customers actually ask for, from a reference that's hard to fake (recorded live under proctoring). Doesn't prove: that this human matches a government ID (no legal-identity anchor — that's exactly what the deferred Didit phase adds for customers who need it).

Fairness guardrail — why “borderline → human review” is load-bearing

Independent testing (NIST) shows 1:1 face-matching error rates run 10–100× higher for Asian and African faces on many models. On our India-heavy, global base, a hard threshold would mis-fire unevenly. So: thresholds calibrated on our own cover-video↔selfie pairs, validated per demographic, a deliberately wide review band, and no automatic rejection, ever — a borderline score creates a review task for a human, nothing more.

§5For everyone

Screens & where recruiters see the signals

Mockups reuse the real app look. The verification page reuses the existing bearer-link share surface (optional password, not OTP); the QR-on-PDF and report placement (“Verify at join” in the action row — Option A, chosen) carry over from the previous revision.

The joiner's flow (Surface 3 — the verification page)

…/verify-join/…
Recipient view · minimal — confirm + one selfie
Confirm your new joiner
Priya Sharma · Senior Backend Engineer
🔓 opened via shared link
Captured at assessment
Live reference on fileYes
Minimal view — an external link-holder sees only enough to confirm; location / network & other signals stay recruiter-only.
Confirm it's the same person
◱ selfie · processed on Utkrushta servers · image not kept
Take one selfie · ~30 seconds
…/verify-join/…
Result · loop closed

Same person confirmed

The joiner matches the person who took the assessment.

0.87 similarity · clear match
Assessment → Join · one identity, matched on our infrastructure
🛡 Same personLiveness ✓

Borderline scores show “Sent for review” to the recipient — never a red ✗ to the joiner; a human reviews before anything is concluded.

Recruiter surfaces — the same two homes as before

Candidate card · position dashboard
PS
Priya Sharma Completed ✓ Recommended 🛡 Same person at join 📍 Location OK
priya@email.com · +91 98••• ••••2 · updated 2d ago
84
Click a chip → detail popup

The report hero gets the same chips; the existing wholesale-paste signal surfaces where it belongs — in the report's prompt-trail section — not as a chip and never in any “red flags” channel. QR-on-PDF and the “Verify at join” action-row placement (Option A ✓) are unchanged from Rev 2.

§7⚙ Engineering · skippable

Data model — 2 new tables + 1 JSONB key, justified

Engineering detail — safe to skip. New tables only — no existing production data is touched; migrations ship as files via the normal PR + gated /db.push flow (dev first, prod explicit).

Reuse · no new table

consent JSONB

Add key identity: ISO|null

  • existing Device-Check consent flow
  • written by recordSessionConsent()
New table

face_enrollments

  • embedding float8[] (128-d) — no images
  • testsession_id (varchar) · tasksession_id (uuid) dual FK, CHECK one set
  • quality jsonb · model_name/version
  • status: PROCESSING/ENROLLED/NO_FACE/LOW_QUALITY/FAILED
  • consent_captured · retention_expires_at (6 mo)
New table

join_verifications

  • enrollment_id FK · token · recipient
  • similarity · band · liveness_score/pass
  • selfie_embedding float8[]no image
  • reviewed_by · review_decision · attempt_no
  • retention_expires_at (6 mo)
Unchanged from Rev 2

session_risk_signals

  • Phase-1 IP/VPN/geo signals
  • NOT red_flags (that channel auto-disqualifies + blocks certificates)

conventionsHow the tables are built

Both follow the extension_captures async-job template (uuid PK, created_at/updated_at, status CHECK, set_updated_at trigger) and the report_comments dual-FK migration pattern. RLS: service-role-only — no anon/authenticated access to embeddings. Access-path rule (learn from the report_comments incident): the recruiter Next app must not query these tables directly as anon — that direct-anon dependency is exactly what forced report_comments' service-role lock to be reverted (20260606090000_report_comments_anon_access.sql). So recruiter-facing identity signals are served through a backend Flask/FastAPI route using the service_role-keyed client, never a direct client query — which is also the correct posture for biometric-derived data. FKs: ON DELETE CASCADE on the session FKs (an enrollment is meaningless without its session; deleting the session/cover-video deletes the derived template — a legal requirement); user_id/position_id are plain-uuid scoping columns (the task_servers precedent). DAOs + Pydantic models per the repo's hard rule; registered in DAO_REGISTRY.

Rejected: pgvector (1:1 exact cosine in NumPy — no vector search needed); columns on session rows (two PK types + 1:N attempts); red_flags (disqualification channel); storing any raw face image (legal + minimization).

retentionPurge job

A scheduled sweep nulls embeddings past retention_expires_at (6 months from extraction), following the existing fail_stale_processing sweep shape. Deletion of a session cascades to its enrollment + verifications. Band/score results are non-biometric outcome records retained with the assessment like other results.

§8⚙ Engineering · skippable

Pipeline internals — grounded in the code

Engineering detail — safe to skip.

1 · Enrollment (lazy, at link-mint)

Recruiter mints the verify-join link → backend enrollment job runs via schedule_once (fastapi_service/concurrency.py ThreadPoolExecutor — the same pool that already runs 8–43s transcription/AI jobs; this job is ~5–20s, dominated by S3 download + ffmpeg). Fetch the cover video (canonical key {testsessions|tasksessions}/{class_id}/{session_id}/cover_video.mp4, boto3 — extend media_utils.download_and_extract_audio with a download_and_extract_frames sibling) → sample ~12 frames at 1 fps → YuNet detect + quality gate (single face, size, sharpness, frontal pose) → SFace embed best 1–3 frames → store mean vector via DAO → temp files deleted in finally. Idempotent via the schedule_once key; an Airflow-style sweep backstops jobs that die mid-flight. If no usable frame: status NO_FACE/LOW_QUALITY → the verify page shows “continuity unavailable” gracefully.

2 · Verification endpoint

New FastAPI route: validate the join token → run server-side MiniFASNet liveness on the submitted selfie → detect + embed → exact cosine vs the stored enrollment vector (NumPy — n=1, no pgvector) → compute band → persist join_verifications row (embedding + score, selfie image discarded) → return {similarity, band, liveness}. Sub-second on CPU.

3 · The join page (bearer link + optional password)

Hosted in the recruiter app's existing share surface. Link minting reuses the bearer-token pattern from share-report/route.ts (an unguessable sharedreport_id-style token — “anyone with the link”, since the agency often can't name the recipient). We replace the OTP gate with an optional recruiter-set password (a simple code shared out-of-band); the shareViewerToken session-cookie pattern still applies once opened. Selfie capture reuses the getUserMedia pattern from CoverVideoQuestion.tsx. Single-use / expiry; re-verify attempts via attempt_no, rate-limited.

4 · Dependencies (all new — nothing exists today)

No CV/face libs anywhere in the backend currently; video "analysis" today goes to Gemini (third-party) — this is the first local vision path. Add to the FastAPI image: opencv-python-headless, onnxruntime, Pillow, plus vendored ONNX models (YuNet detect, SFace embed, MiniFASNet liveness — all Apache-2.0; no InsightFace weights (non-commercial), no AGPL YOLOv8-face, no dlib build, no PyTorch). Pin versions.

Immediate TODO (separate from this feature)

Cover videos are uploaded with S3 ACL: public-read today (tus.py:176) — anyone holding the URL can fetch a candidate's video. Switch to private objects + presigned URLs at the consumption points. Tracked as a standalone security task to do right after this work.

§9For everyone

Phases & effort

Phase 1 · IP intelligence

3–4 eng-days

Capture the candidate's IP at session-start; enrich (proxycheck.io free tier + self-hosted MaxMind GeoLite2 — the geoip2 dep is already installed) → VPN / proxy / datacenter / location-mismatch signals in session_risk_signals → surfaced on the candidate card + report. Ships alone, no vendor, no biometrics.

Phase 2 · Offer→Join identity continuity (local)

~21–24 eng-days

The headline. Migrations + DAOs (2–3d) · face-match core + model vendoring (4–5d) · frame-extraction + lazy enrollment job (2d) · verification endpoint (2d) · link mint + join page on the existing share surface (3–4d) · server-side liveness (1–2d) · recruiter signal surfaces + review UI (3d) · threshold calibration + per-demographic validation (3–4d) · retention purge (1d). Launch gated on counsel approving the legal PR.

Legal workstream (parallel)

drafted ✓ + counsel

Drafts complete — Utkrusht-legal PR #1 (biometric category, explicit consent, 6-month retention schedule, roles, rights, DPIA). Counsel reviews while Phase 2 builds.

Phase 3 · Government-ID verification (Didit)

deferred · optional · 7–10d

The stronger legal-identity anchor for customers who need it: Didit embedded in Device Check (one checklist row, no redirect), certified liveness, native India Aadhaar, biometrics stored by Didit (not us), per-position + per-candidate gating. All design work from Rev 2 carries over intact.

Deferred / dropped

Paste “phase” dropped — the existing wholesale-paste signal surfaces in the report's prompt-trail section, nothing else to build. LinkedIn-photo corroboration deferred (ToS + biometric-consent exposure). Continuous re-verification + deepfake/injection defense — later, when a customer funds it.

Sequence: P1 → P2 (legal in parallel; launch on counsel sign-off) → P3 when customer demand justifies it. Immediate TODO after this work: S3 cover-video ACL → private + presigned.

§10For everyone

Risks (ranked)

  1. Legal gate — our docs currently disclaim face recognition; shipping before counsel approves the biometric clauses (PR #1) is indefensible under GDPR/DPDP/BIPA. Mitigation: drafts done, review runs in parallel, launch waits.
  2. Demographic bias — 1:1 face-match error rates run 10–100× higher for Asian/African faces on many models (NIST). Mitigation: calibrate on our own pairs, validate per demographic, wide review band, never auto-reject.
  3. Spoofing — basic passive liveness defeats printed photos/screen replays but not determined deepfakes. Accepted for v1 (signals + human review); hardening later.
  4. Licensing discipline — the accurate-and-tempting models are the forbidden ones (InsightFace weights non-commercial, YOLOv8 AGPL). Locked stack: YuNet + SFace + MiniFASNet, all Apache-2.0; enforce in review.
  5. Cover-video availability — positions without cover video have no anchor; the verify page degrades to “continuity unavailable”. Identity-enabled positions should require cover video.
  6. Public-read S3 videos — immediate TODO (§8): private ACL + presigned URLs.
  7. red_flags blast radius — IP/identity signals must never be written into red_flags (auto-disqualifies + blocks certificates). Separate tables; enforce in review.
  8. Bearer-link exposure — an “anyone with the link” URL can be forwarded. Mitigations: unguessable token + expiry + optional recruiter password, and the recipient page shows a minimal “same person?” view — location/IP and recruiter-only signals are never exposed to the external link-holder.
§11⚙ Engineering · skippable

Critical files & verification

Engineering detail — safe to skip. Grounded in the current repos.

Verification