Skip to content

Manual verification call flow - #306

Merged
24c02 merged 23 commits into
mainfrom
feat/manual-verification-calls
Oct 6, 2026
Merged

24c02 merged 23 commits into
mainfrom
feat/manual-verification-calls

Conversation

@leowilkin

@leowilkin leowilkin commented Aug 6, 2026 •

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 — call-capture disclosure must be acknowledged (calls are not recorded; staff take one screenshot during the call), 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 (requires uploading one screenshot showing the user and their document, stored as a case document behind break-glass) → checklist + confidence + approve/deny. Decision creates the verification, closes the case, revokes the flag. Alternative-docs approvals are not time-limited (decided against a 12-month re-verification).

  6. Off-ramps — a reviewer can request redo (user gets a fresh link and the reviewer's message, case drops back to link_sent, evidence kept) or withdraw any open case (flag revoked, link dead, audited; a new case can then be opened). Denials email the user without the internal reason. A daily good_job cron sends one reminder with a fresh link to anyone still in link_sent after three days.

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 biometric consent line promises encrypted storage + logged access, which the encrypted storage service and break-glass audit log back; the call screenshot lands in the same place. 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).
  • Every state-changing backend action runs under a row lock, so two reviewers racing on the same button get one success and one warning.

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 (~118 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 spec/mailers/verification_case_mailer_spec.rb spec/jobs/verification_case/send_link_reminder_job_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)

  • Backend nav entry — the cases index (/backend/verification_cases) is only reachable via per-identity links
  • Retention policy — documents are kept indefinitely after decision; needs a real policy pass before prod
  • QA sampling — every 15th decision (by id) lands in a qa queue (/backend/verification_cases/qa); a second verifier records agree/disagree, disagreements post a case comment. Any decided case can be second-looked from its page.
  • 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 23 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.
…red-purge path

- schema.rb rebuilt from main + migrations (main's dump was two
  migrations stale); CreateVerificationCases renamed to 20260806000001
  to sort after main's newer migrations
- main refactored DeletionService to collect/detach in-transaction and
  purge blobs after commit — case document coverage moved into
  collect_attachments/detach_attachments accordingly
Add a CASE shortcode and a home-page nav item for the verification cases
queue, both gated to manual document verifiers and super admins, with
request specs covering the link, the kbar entry, and the role gate.
decided not to re-verify alt-docs approvals on a timer, so remove the
half-wired plumbing rather than ship an unenforced column: the
verifications.expires_at column, the constant, #expired?, the backend
expiry badges, and the 'in a year' user copy.
recordings aren't ingested by the app yet, so we can't vouch for how
they're stored or who accesses them. keep the disclosure that calls are
recorded and why; leave the biometric consent line, which the encrypted
storage service and break-glass audit log actually back.
- withdraw: a terminal state reachable from any open state, so a case
  opened on the wrong identity or abandoned by the user can be closed
  without a decision. revokes the flag, invalidates the link, audited.
- request redo: reviewer can send the user back to resubmit documents
  from docs_submitted. keeps existing evidence, clears the persona
  inquiry, rotates the link, emails the reviewer's message.
- denied email: denials now tell the user, without relaying the
  internal rejection reason.
- reminder: a daily good_job cron sends one fresh link to anyone still
  in link_sent after three days, recorded as a case event.
every fifth decision (by id) lands in a qa queue; a second verifier who
didn't make the decision records agree/disagree with notes. disagreements
post a case comment and every sample is an audit event. any decided case
can also be second-looked voluntarily from its page.
…ansitions

calls are no longer recorded. marking a call held now requires the
reviewer to upload one screenshot showing the user and their document,
stored as a case document behind break-glass like the rest of the
evidence. the recording acknowledgement becomes a call-capture
acknowledgement and the user and email copy say what actually happens.

also: every state-changing backend action runs under a row lock so two
reviewers racing on the same button get one success and one warning,
and a qa sample refuses to overwrite one that landed first.
@leowilkin
leowilkin marked this pull request as ready for review October 6, 2026 20:18
@leowilkin
leowilkin requested a review from 24c02 October 6, 2026 20:18
@leowilkin

Copy link
Copy Markdown
Member Author

@24c02 this is out of draft and ready for a look when you have a sec. the body is up to date; the "env vars" section lists the three prod vars and the cal.com webhook that need setting up alongside the merge.

@24c02
24c02 merged commit deb8649 into main Oct 6, 2026
7 of 8 checks passed
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.

2 participants