Guides

Decisions and challenges

Every authorization request produces exactly one decision for one registered action: allow, challenge, hold, or deny. The decision arrives with the Assurance Profile it was made from, the reason codes that explain it, and, for allow in enforce mode, a Proof of Agency. Your application enforces the decision; GrayPass never executes anything.

The four decisions

Decision Meaning What your application does
allow The active policy is satisfied by the current evidence. In enforce mode, verify the proof where the action runs, execute, then report the outcome.
challenge A requirement is unmet and the policy names a safe, action-bound recovery. Show the exact action and ask the person to approve it with a listed method.
hold A hold condition matched, or a positive detector fired. Queue the request for an attributed manual review. Nothing executes until a reviewer approves it.
deny A deny condition matched. Refuse the action. A challenge cannot recover a denial.

The decision is shown to people in plain words: allow as "Approved", challenge as "Verify again", hold as "Paused for review", and deny as "Blocked". Your application may use its own wording. The response carries the phrases in human_readable.

How a decision is reached

The policy version active for the action is evaluated in a fixed order. The first branch that applies produces the decision.

  1. Deny conditions. Any reason code listed in the policy's deny_on present in the profile produces deny with decision.deny_condition.
  2. Hold conditions. Any code listed in hold_on produces hold with decision.hold_condition. operator_change.detected and automation.detected produce a hold even when a policy omits them, so a positive signal never turns into a recoverable challenge because coverage was also thin.
  3. Evidence sufficiency. Coverage below coverage_min, freshness older than freshness_max_ms, or an insufficient_evidence continuity status produce the decision named in the policy's insufficient_evidence field: challenge, hold, or deny. The reason codes carry decision.insufficient_evidence plus the specific shortfall.
  4. Per-claim requirements. Each claim in requirements.claims must reach its minimum status. Detection claims (operator_change, automation) pass only in their exact good state. If explicit_approval_required is set, the request also needs a completed action-bound approval. Any unmet requirement produces challenge with decision.requirement_unmet and the unmet codes.
  5. Allow. Everything above passed: decision.policy_satisfied, or decision.challenge_completed when a challenge supplied the approval.

Shadow and test modes run the same evaluation and record the same decision, but they never issue a proof and your application must not execute on them. Their traces carry decision.shadow_mode or decision.test_mode.

Insufficient evidence is a status, not a decision

The Assurance Profile can report coverage.sparse, freshness.stale, or operator_continuity.insufficient_evidence. These describe the evidence. The policy decides what they mean for this action. A payout change might route thin evidence to a challenge; an administrative privilege change might hold it for review. Neither path can map insufficient evidence to allow.

Challenges

A challenge decision includes a challenge object with an id, the methods the policy permits, and an expiry five minutes after the decision. Two methods exist:

Completing a challenge re-runs the original authorization request as a child authorization with explicit_action_approval.confirmed added to the profile. The approval compensates only for the shortfalls listed in the policy's challenge_compensates (for example operator_continuity, coverage, or freshness). It never compensates for:

The child authorization is the one that carries the proof. Retrieve it by id, recompute the action hash from your own request material, verify the proof, execute, and report the outcome. Completing a challenge twice, after expiry, or against a different request fails with error.challenge_mismatch or error.challenge_expired.

Holds and review

A held authorization waits for an attributed decision by a reviewer with the security reviewer, admin, or owner role in the Console. Approval re-runs the original request as a child authorization under the credential that made it, with the approval recorded as decision.manual_review_approved and approval_method.manual_review. In enforce mode that child carries the proof your application verifies. The behavioral limitations in the profile remain unchanged. A reviewer's approval is authoritative over missing or stale evidence, but not over a live attack signal: if a policy deny condition or a hard indicator (automation.detected, operator_change.detected, operator_continuity.not_confirmed) is firing when the approval is processed - including one that fired after the hold was created - the fresh evaluation stands, the attempt is recorded as decision.manual_review_superseded, and no enforcement proof is issued. Rejection records decision.manual_review_rejected and the outcome manually_rejected. If the action's policy or mode changed while the request was held, the review fails with error.policy_conflict and the request stays pending.

Outcomes

Report what your application actually did after each decision with one immutable outcome per authorization: executed, not_executed, manually_approved, manually_rejected, confirmed_attack, false_challenge, customer_override, or unknown. Repeating the same report is idempotent; a contradicting second report is rejected. Outcomes are the labels shadow evaluations are measured against. They are supplied by you and are not verified ground truth.

Reading a response

JSON
{
  "id": "auth_...",
  "decision": "challenge",
  "mode": "enforce",
  "reason_codes": ["decision.requirement_unmet", "explicit_action_approval.not_present"],
  "human_readable": { "status": "Verify again", "detail": "Approve this exact action to continue." },
  "assurance": {
    "coverage": "sufficient",
    "freshness_ms": 780,
    "claims": [
      { "claim": "operator_continuity", "status": "likely", "coverage": "sufficient", "reason_codes": ["operator_continuity.likely"] },
      { "claim": "operator_change", "status": "not_detected", "coverage": "sufficient", "reason_codes": ["operator_change.not_detected"] },
      { "claim": "automation", "status": "not_detected", "coverage": "sufficient", "reason_codes": ["automation.not_detected"] },
      { "claim": "human_presence", "status": "unsupported", "coverage": "none", "reason_codes": ["human_presence.unsupported"] }
    ],
    "limitations": ["limitation.enrollment_unanchored", "limitation.experimental_assessment", "limitation.no_calibrated_confidence"]
  },
  "challenge": { "id": "chl_...", "methods": ["passkey"], "expires_at": "..." },
  "proof": null
}

Match on decision and reason_codes. The human-readable strings are for people and may change wording within a contract version. Every code is listed in the reason code reference.

Generated from the API contract in contracts/ and the source in docs/. The build fails if this page disagrees with the running API.