server/fraud_risk/OPERATIONS.md.
Turning it on for a customer
Grading and showing are separate steps, and they can be weeks apart. Switching evaluation on starts grading new applicants and storing the findings; a recruiter sees a finding only once the job setting, the org default setting andfraud_signals_tab are all on. Nothing is re-evaluated when
that flag flips, so whatever was collected in the meantime appears immediately and costs nothing.
Support users in Full Access mode see an org’s findings whether or not that org has opted in, which
is how support reads an org exactly as it will look once the flag goes on.
Four things are independent, and all four matter:
- The job setting.
FraudRiskSettings.enabled, set on the job or as the org default, and off until someone turns it on.enable_fraud_risk_evaluationturns it on across an org (dry run first; it skips any job that already auto-closes at a tier, since that job would start closing applications on its first new applicant). Nothing is evaluated or stored while it is off, and it is also one of the three switches that must be on before findings are shown. It lives in Cosmos as part of the job’s settings document, not in Postgres, so no SQL query will find it. - Provider credentials, per environment rather than per org. Each is named in the configuration
table in
server/fraud_risk/OPERATIONS.md. A missing credential silently disables the signals that need it: they report themselves unevaluated rather than guessing. - The feature flags, per org, set directly in
org_feature_flags. There is no admin UI. Allow up to 60 seconds per process for a change to take effect. - The auto-reject threshold,
auto_reject_minimum_fraud_risk_level. Leave it unset at first. With no minimum configured nothing is ever closed automatically, so the grades can be reviewed before they start moving anyone’s stage.
The feature flags
Both default to off with no global default row, so an org without an explicit row inherits off.
Cross-org matching is reciprocal. An org matches only against other orgs that have also turned it
on, and a global default row does not count. That means turning it on for a single org achieves
nothing: it needs at least two participants, and each has to have agreed to it.
The seven scoring candidate-scoped signals, the four in-org shared-contact signals, and advisory
resume-history comparison are not flag-gated. They run from application creation for jobs with fraud
risk evaluation enabled.
fraud_signals_tab gates whether a customer sees any of it, not whether it
is collected.
Choosing an auto-reject threshold
The scoring cutoffs mean a single signal rarely escalates an application on its own: one High finding scores 40, which buckets to Medium. Reaching High takes 80, so it takes two High findings, or one High plus two Mediums. This has a specific consequence when the shared-contact signals get turned on for an org that already has an auto-reject threshold. A shared-contact finding adds 20 or 40 to an application that previously scored below the cutoff, so evaluating an org that has auto-reject already configured can close applications that would not have been closed before. The effect is largest for orgs whose scored applications cluster just under a cutoff. Check that org’s distribution first:Results are cached, and re-deriving them costs money
A candidate-scoped signal is computed once and reused for the life of the candidate. Changing what a signal emits therefore does not reach the rows already stored. Each signal carries a rule version, and bumping it is what forces stored rows to be re-derived. Bump deliberately. Re-derivation costs one real provider call (LLM, Twilio, or Google) per candidate holding that signal. With roughly 24,000 stored rows each for the name-match and VOIP signals, a careless bump is a large bill. Where the new value can be derived from the stored one, therefresh_fraud_signal_copy job rewrites rows in place for free instead.
Display names and tooltips are read live off the evaluator, so editing a title or a description needs
no bump and reaches everywhere immediately.
Cross-application signals are recomputed on every evaluation, so the version gate never withholds
them.
The re-evaluation queue
When a candidate joins or changes a shared-contact cluster, every other member of that cluster is written to a durable queue. A worker claims up to 50 candidates every 30 seconds and re-evaluates them. A failed re-evaluation is re-queued rather than lost, up to five attempts, after which the candidate is dead-lettered witherror.kind: FraudReevalDeadLetter and nothing retries it later.
Health check: which signals are actually alive
The clearest signal of a misconfiguration is a check that has stopped writing rows. This query shows every signal, how often it fires, and when it was last written:
Two things stand out and are worth reading correctly:
- The shared-contact signals are near-zero because only Tenzo has the flag on. That is expected, not a fault.
- The LinkedIn signal was unconfigured when this production snapshot was captured. Four rows, none since 2026-06-25, while its resume-sibling GitHub signal has 628 rows and is still being written. The AKS deployment now provisions the Serper API key it needs; after the next worker rollout, confirm new rows and check the provider-auth and provider-quota error kinds below.
When a signal goes quiet
Check the row count above first, then Datadog. The LinkedIn signal binds a distincterror.kind per
failure mode so a monitor can route each to the right fix:
Not every failure path reports its bound kind: where an exception is attached, the exception type wins
and the log surfaces as an HTTP or JSON error instead. A monitor should match the kind or the
message.
A response that does not answer the query we sent is discarded rather than read as a profile that
does not exist. A not-found grades as High, so a cached or misrouted answer would otherwise become a
fraud finding against a real candidate.
One-time jobs
Five jobs exist, all run withpython -m one-time-jobs.<name> from server/, all defaulting to a
dry run that writes nothing.
backfill_fraud_risk_evaluation spends real provider money, per candidate rather than per
application: a Twilio line-type lookup, a billable Twilio Identity Match on US and Puerto Rico
numbers, a second billable caller-name lookup when that match comes back flagged, two to four Gemini
calls, and Google geocoding where the resume carries an address. Its dry run prints those counts per
org before anything is spent, so read them before widening past a pilot.
Its ordering dependency is not advisory. Shared-contact findings are cached for the life of the
candidate and no later run re-derives them, so a candidate graded while
candidate_contact_fingerprints is still filling stores “nobody shares this value” permanently.
--apply refuses to start until nearly every fingerprint-eligible candidate in scope has a row.
Dev tooling
An internal fraud data page can seed a candidate’s signals to any outcome an evaluator actually produces in production, or run a signal’s real comparison over inputs supplied by hand. It is the way to see the reviewer panel populated without waiting for a real fraudulent application. Be aware that the seeder’s preset copy has drifted from what the evaluators write, so copy verified against a seeded candidate is not necessarily the copy production produces.Known gaps
The full list, with file and function references, is in the Known gaps sections ofserver/fraud_risk/OPERATIONS.md and ui/src/components/transcript/FRAUD_SIGNALS.md. The items that
matter operationally:
- The LinkedIn signal needs post-rollout verification. It has never produced a finding in production: it ran on Bing until that API was retired, then on Google Programmable Search, which refuses the project the key belongs to. Confirm the search key is provisioned and that new LinkedIn signal rows are being written.
refresh_fraud_signal_copywould flatten four distinct address findings into one. It rewrites every stored address-validation finding to a generic “Invalid address” (metadata, including the checked address, is kept), contradicting its own stated contract. It has never been run against production, so nothing has been flattened. Fix the job before running it.- More applications are graded High than Medium, which is not the shape the current cutoffs predict, and 6,409 of the 6,545 High rows carry no stored score at all. The cause is not fully established. A corrective backfill for the part of it that is understood exists and has not been run. Treat the High population as unreliable until it has been.
- A stale signal can be dropped from the score but stay on screen. When a stored row cannot be reused and re-derivation returns nothing, the row is left in place. The score no longer counts it, but the panel still draws it, so reviewer and score disagree until the signal re-derives successfully.
- A dead-lettered re-evaluation is never retried. The queue model’s documentation describes a nightly sweep for stragglers. There is no such sweep.
- One concept has three names. The UI calls it “Auto-close applications”, the setting is
auto_reject_minimum_fraud_risk_level, and the stage isFRAUD_REJECTED. Expect to translate between a support conversation, a settings payload, and a stage query.