Skip to content

Manual verification call flow - #306

Draft
leowilkin wants to merge 13 commits into
mainfrom
feat/manual-verification-calls
Draft

Manual verification call flow#306
leowilkin wants to merge 13 commits into
mainfrom
feat/manual-verification-calls

Conversation

@leowilkin

Copy link
Copy Markdown
Member

What this is

A staff-initiated fallback for identity verification when Persona doesn't work out: staff open a case, the user gets a single-use link to submit documents, books a video call on our self-hosted cal.com (cal.hackclub.com), and a reviewer decides on the call. The durable outcome is a Verification::ManualVerificationCall — the same polymorphic verification type as Persona approvals, so the rest of auth treats it identically.

The flow

  1. Staff opens a case from the identity backend page ("+ manual call case", optionally ticking skip persona). Enables a per-identity flipper flag + emails a single-use link.
  2. User submits documents at /verifications/manual (login + flag + one-time token). Two document tiers (government ID / alternative docs with a nudge back toward gov ID). Capture paths:
    • default: embedded Persona capture-only inquiry (photos + signal snapshot come back via the existing Persona webhook)
    • skip-persona cases: live camera capture on our page (getUserMedia widget, no file picker) — document + selfie, JPEG/PNG enforced server-side
  3. User books the call — recording disclosure must be acknowledged, then the booking link (CALCOM_MANUAL_VERIFICATION_BOOKING_URL + metadata[casePublicId] + email prefill).
  4. cal.com webhooks (/webhooks/calcom, HMAC via X-Cal-Signature-256) drive state: created/rescheduled → call_scheduled; cancelled & no-show → back to docs_submitted so the user can rebook (cal.com owns those emails).
  5. Reviewer marks the call held → checklist + confidence + approve/deny. Decision creates the verification (12-month expiry stamped for alternative docs), closes the case, revokes the flag.

Design decisions (deliberate, argued over)

  • No Slack integration — staff discussion happens in native case comments; ping people directly if you need eyes.
  • No escalation state, no auto-escalation rules — reviewer judgment stands; decisions strictly require call_held.
  • Documents are kept after decision (no auto-purge) until the retention policy is nailed down — the copy promises encrypted storage + logged access only. Account deletion (DeletionService) purges case document files.
  • Sealed evidence — raw documents are viewable only via break-glass (per-person, per-document, 24h, reason required, always audit-logged).
  • Cases have an append-only audit event trail (verification_case_events).

Env vars (undocumented elsewhere, ask Leo)

CALCOM_MANUAL_VERIFICATION_BOOKING_URL, CALCOM_WEBHOOK_SECRET, PERSONA_MANUAL_CAPTURE_TEMPLATE (+ DEV_HOST if testing webhooks through ngrok). Webhook events to subscribe: booking created / rescheduled / cancelled / no-show updated.

Testing

CI doesn't run rspec — run the feature specs locally (~75 examples, all green):

bundle exec rspec spec/models/verification_case_spec.rb spec/models/verification/manual_verification_call_spec.rb spec/policies/verification_case_policy_spec.rb spec/requests/manual_verifications_spec.rb spec/requests/webhooks/calcom_spec.rb spec/requests/backend/verification_cases_spec.rb spec/services/deletion_service_spec.rb

Note: .rspec's --pattern loads the whole suite alongside your named files; add -P "<same files>" to isolate. ~39 persona/identity spec failures exist on main and are unrelated.

Known TODOs (help welcome)

  • Call recording ingestioncall_recording document kind + consent exist, but nothing ingests recordings yet (google meet recordings land in Drive; ingestion would go via the Drive API, or cal.com RECORDING_READY if using cal video)
  • 12-month expiry on alternative-docs approvals isn't enforcedexpires_at is stamped and expired? exists, but Identity#verification_status never consults it
  • Backend nav entry — the cases index (/backend/verification_cases) is only reachable via per-identity links
  • Privacy review of consent copy — biometric consent + recording disclosure describe indefinite encrypted retention; needs a real policy pass before prod
  • QA sampling columns (sampled_at, sample_reviewer_id) are schema-only
  • Model annotate headers

End-to-end tested against cal.hackclub.com through an ngrok tunnel (booking, reschedule, cancel, decision) on 2026-07-27.

leowilkin added 13 commits July 27, 2026 14:01
Verification cases with document collection, cal.com-scheduled review
calls, backend review UI with four-eyes escalation, and persona
capture fallthrough.
…ion-calls

# Conflicts:
#	app/views/backend/identities/show.html.erb
#	config/initializers/active_record_encryption.rb
The previous dump came from the shared dev database and carried
churn from other branches (dropped pg_trgm/slack_idp_groups, stray
ahoy/yoti/two_factor columns, and a version pointing at a migration
that doesn't exist). Rebuilt from origin/main's schema + this
branch's migration.
…-escalation

- replace the slack thread integration with native staff comments on
  cases (new verification_case_comments table, comment action + UI)
- staff can open a skip-persona case for users who refuse persona:
  document capture happens live via getUserMedia on our page, no file
  picker, JPEG/PNG enforced server-side; the persona-selfie checklist
  item records as n/a on cases with no persona capture
- BOOKING_CANCELLED now returns the case to docs_submitted and emails
  the user a rebook link instead of stranding them on a stale page
- remove the hard auto-escalation rules; first-reviewer judgment
  stands, manual escalation + four-eyes stay
…ncellation email

- skip-persona submissions now capture a selfie (front camera widget)
  alongside the document, stored as a 'selfie' document kind
- the decision checklist item generalizes to 'document matches selfie
  (persona or live capture)'; still records n/a when a case has no
  selfie from either source
- drop the auth-side cancellation email — cal.com already emails the
  attendee with a rebook path; we only reset the case state
Marking a no-show reopens the case for booking (like a cancellation)
and logs call_no_show to the audit trail; un-marking is a no-op. The
no-show payload carries bookingUid and no metadata, so case lookup
now also matches on the stored booking uid.
…ifications/manual

- /verify/manual* becomes /verifications/manual* to match the site's
  route nomenclature (path helpers unchanged)
- both VerificationsController and Portal::VerificationsController now
  redirect to the open case, so 'ID Verification' in the portal lands
  users on their case instead of the legacy resubmission flow
Second opinions happen through case comments + a DM, not a state
machine. Drops the escalated state/event, the four-eyes decide policy,
escalated_to/escalation_reason on verifications, the escalate action
+ route + UI. Decisions now strictly require call_held. Also collapse
the actions row when no action applies (stray gap between separators).
…ipient

BreakGlassRecord's tracked recipient calls break_glassable.identity;
VerificationCase::Document reaches its identity through the case, so
delegate it.
Removes the post-decision retention stamp, the nightly purge job +
cron, and the retention/purged columns. User-facing copy no longer
promises deletion timelines (biometric consent + recording disclosure
now describe encrypted storage with logged access; needs privacy
review before prod). Account deletion via DeletionService now purges
case document files — previously uncovered, and now the only deletion
path for them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant