Turn an assessment into a decision
NoResponse returns one recommendation per assessment, and most platforms wrap it in a few rules of their own: a hard stop on certain reasons, a step-up on others, and a policy for answers that are not final. This page gives you a rule set to copy and tune, the values each rule reads, and what to store so you can explain a decision later.
The values you read
Section titled “The values you read”Three enums drive every rule.
| Field | Values | Meaning |
|---|---|---|
recommendation and risk.recommendation |
allow, step_up, review |
The same value in both places. There is no deny. A pending answer has neither. |
confidence |
low, medium, high, unavailable |
Confidence in the recommendation. unavailable appears only on a pending answer or beside review. |
risk.confidence |
low, medium, high |
How much of the planned evidence answered. |
risk.band |
low, medium, high, critical |
low is 0 to 29, medium 30 to 59, high 60 to 84 and critical 85 to 100. |
risk.reasons lists every reason that fired, highest weight first, with the identifier it concerns. A summary fact that is absent was not answered yet: risk.summary.email.breach missing means the breach lane has not settled. false and 0 are definite answers.
Ordered rules
Section titled “Ordered rules”Apply the rules in order. The first rule that matches decides.
| Order | When | Decision | Why |
|---|---|---|---|
| 1 | status is pending |
Your fallback | There is no recommendation and no risk yet. |
| 2 | risk is absent, or confidence is unavailable |
review |
There is nothing to base an allow on. |
| 3 | abuse_ecosystem_member fired |
review |
The email or username appears in a hacking-forum, abuse-service or dark-web table. |
| 4 | risk.band is high or critical |
review |
The score is 60 or more. |
| 5 | breach_name_mismatch, breach_phone_mismatch, proxy_ip, recent_password_exposure or credential_list_exposure fired |
step_up |
The name or phone disagrees with breach records, the IP is a VPN, proxy, Tor or hosting address, or the credentials are exposed. |
| 6 | Both many_recent_signups and shared_ip fired |
review |
Several signups came from the same IP on your platform within 24 hours. |
| 7 | status is partial and risk.confidence is low |
step_up |
Most of the planned evidence did not answer. |
| 8 | Otherwise | risk.recommendation |
allow, step_up or review. |
Rule 7 is where most tuning happens. A partial answer at low confidence is the case where the score moves most: one real signup scored 8 and became 33 five seconds later. Treating it as step_up costs the person one step; treating it as allow means re-checking when the final assessment arrives.
import type { Assessment } from "@noresponse/node";
export type Decision = "allow" | "step_up" | "review" | "fallback";
const STEP_UP = new Set(["breach_name_mismatch", "breach_phone_mismatch", "proxy_ip", "recent_password_exposure", "credential_list_exposure"]);
export function decide(assessment: Assessment): Decision { if (assessment.status === "pending") return "fallback"; const risk = assessment.risk; if (!risk || assessment.confidence === "unavailable") return "review"; const fired = new Set(risk.reasons.map((reason) => reason.code)); if (fired.has("abuse_ecosystem_member")) return "review"; if (risk.band === "high" || risk.band === "critical") return "review"; if ([...fired].some((code) => STEP_UP.has(code))) return "step_up"; if (fired.has("many_recent_signups") && fired.has("shared_ip")) return "review"; if (assessment.status === "partial" && risk.confidence === "low") return "step_up"; // An unrecognised value from a newer API version gets your review queue. if (risk.recommendation === "allow") return "allow"; if (risk.recommendation === "step_up") return "step_up"; return "review";}from noresponse import Assessment
STEP_UP = {"breach_name_mismatch", "breach_phone_mismatch", "proxy_ip", "recent_password_exposure", "credential_list_exposure"}
def decide(assessment: Assessment) -> str: if assessment.status == "pending": return "fallback" risk = assessment.risk if risk is None or assessment.confidence == "unavailable": return "review" fired = {reason.code for reason in risk.reasons} if "abuse_ecosystem_member" in fired: return "review" if risk.band in ("high", "critical"): return "review" if fired & STEP_UP: return "step_up" if {"many_recent_signups", "shared_ip"} <= fired: return "review" if assessment.status == "partial" and risk.confidence == "low": return "step_up" return risk.recommendationvar stepUp = map[components.ReasonCode]bool{ components.ReasonCodeBreachNameMismatch: true, components.ReasonCodeBreachPhoneMismatch: true, components.ReasonCodeProxyIP: true, components.ReasonCodeRecentPasswordExposure: true, components.ReasonCodeCredentialListExposure: true,}
func decide(a *components.Assessment) string { if a.Status == components.StatusPending { return "fallback" } if a.Risk == nil || a.Confidence == components.ConfidenceUnavailable { return "review" } fired := map[components.ReasonCode]bool{} for _, reason := range a.Risk.Reasons { fired[reason.Code] = true } switch { case fired[components.ReasonCodeAbuseEcosystemMember]: return "review" case a.Risk.Band == components.BandHigh || a.Risk.Band == components.BandCritical: return "review" } for code := range fired { if stepUp[code] { return "step_up" } } if fired[components.ReasonCodeManyRecentSignups] && fired[components.ReasonCodeSharedIP] { return "review" } if a.Status == components.StatusPartial && a.Risk.Confidence == components.RiskConfidenceLow { return "step_up" } return string(a.Risk.Recommendation)}Store the decision
Section titled “Store the decision”Write one row per decision with these fields, so a reviewer can see what your code saw.
| Field | Why |
|---|---|
assessment_id |
Links the decision to the assessment for polling, webhooks and outcomes. |
assessment_version |
The revision you acted on. A later webhook carries a higher version. |
policy_version |
The weights that produced the recommendation. The weights change over time and the version tells you which applied. |
risk.score, risk.band, risk.recommendation and risk.reasons |
What the rules read. Store them as returned. |
| Your decision | allow, step_up, review or your fallback. Store a fallback as a fallback, so you can find the accounts that were never scored. |
The weights are versioned, and policy_version names the set that produced each score, so compare scores within one policy version.
Re-run the rules on a webhook
Section titled “Re-run the rules on a webhook”An assessment.updated event carries the whole assessment at a new assessment_version. Verify the event, keep it only if its version is higher than the one you stored, then run the same decide function and reconcile the account. Webhooks covers verification and the receiver.
const event = NoResponse.webhooks.constructEvent(rawBody, headers, secrets);if (event.type === "assessment.updated" && !isExpiredEvent(event)) { const stored = await decisions.get(event.data.assessmentId); if (!stored || event.data.assessmentVersion > stored.assessmentVersion) { await decisions.update(event.data.assessmentId, decide(event.data), event.data); }}from noresponse import Assessment, Webhook
event = Webhook.construct_event(raw_body, headers, secrets)data = event.dataif event.type == "assessment.updated" and isinstance(data, Assessment): stored = decisions.get(data.assessment_id) if stored is None or data.assessment_version > stored.assessment_version: decisions.update(data.assessment_id, decide(data), data)event, err := noresponse.ConstructEvent(rawBody, r.Header, secrets)if err != nil { return err}if a, ok := event.Data.(*components.Assessment); ok { stored, found := decisions.Get(a.AssessmentID) if !found || a.AssessmentVersion > stored.AssessmentVersion { decisions.Update(a.AssessmentID, decide(a), a) }}