TAOS
← White Papers
AI Engineering

Hard Denial vs Soft Denial: Why How You Surface a Policy Block Matters

May 1, 2026Taos Team
Denial ModePolicy EnforcementUX

Hard Denial vs Soft Denial: Why How You Surface a Policy Block Matters as Much as the Block Itself

Category: AI Engineering | Reading time: 6 min


The Problem Nobody Talked About

For the past two years, the AI governance conversation has focused on a binary question: does the policy allow this action, or does it deny it? The assumption has been that once you have a deny, the problem is solved.

It isn't. How the denial surfaces to the calling code — and whether the LLM ever sees it — matters as much as the denial itself.

Consider two scenarios involving the same OFAC denial rule:

Scenario A (governance-by-prompt): The LLM receives "I attempted to process payment INV-8821 but couldn't complete it due to vendor compliance status. You may want to review the vendor's eligibility or select an alternative supplier."

Scenario B (hard denial via kernel): The calling code receives PolicyDeniedError: deny_blocked_vendor — vendor OFAC-sanctioned (SDN-2024-0312). The audit log records the verbatim rule_fired and reason. The LLM was never involved.

Both scenarios "blocked the payment." Only one satisfies a compliance investigation.


Denial mode as a first-class declaration

The TAOS kernel registry models denial_mode on every step definition, tool definition, and workflow — workflow authors set it in the control plane or tenant manifests (not inside the Rego bundle). This behaviour aligns with the step-boundary policy model described in our architecture overview and policy enforcement documentation:

ALTER TABLE step_definitions
  ADD COLUMN denial_mode TEXT NOT NULL DEFAULT 'hard'
    CHECK (denial_mode IN ('hard', 'soft'));

The field is set by workflow authors in the control plane UI (or tenant JSON manifests) — not by the policy itself. This separation is deliberate: Rego decides whether to allow or deny; denial_mode declares how the denial should be surfaced. These are distinct concerns.

When the kernel evaluates a PolicyDenied event, it reads denial_mode from the step registry and stamps it on the PolicyDenied proto message:

message PolicyDenied {
  string correlation_id = 1;
  string reason         = 2;
  string rule_fired     = 3;
  string denial_mode    = 4;  // "hard" | "soft" — default "hard"
}

The client plugin (ADK Python or Genkit TypeScript) receives this and branches.


Hard Mode: Terminate Before the LLM Sees Anything

When denial_mode = "hard":

  1. Kernel evaluates Rego → deny result
  2. Kernel reads step_definitions.denial_mode = "hard"
  3. Kernel sends PolicyDenied with denial_mode = "hard" on gRPC stream
  4. Plugin raises PolicyDeniedError — the agent run terminates immediately
  5. Saga compensation fires (in kernel-driven mode) — all completed steps rolled back
  6. Calling code receives a typed exception with verbatim reason and rule_fired
  7. Audit log records the exact regulatory ground truth

The LLM never sees the denial. It cannot reformulate it, soften it, omit the rule identifier, or suggest workarounds. The regulatory fact reaches the audit log unmediated.

ADK plugin implementation:

result = await self._conn.run_tool_via_kernel(tool.name, tool_args)

if isinstance(result, dict) and result.get("error") == "policy_denied":
    if result.get("denial_mode", "hard") == "hard":
        raise PolicyDeniedError(
            tool_name=tool.name,
            reason=result["reason"],
            rule_fired=result["rule_fired"],
        )
    # soft path — return dict to LLM
    return result

PolicyDeniedError is a typed exception — not a generic error string. The rule_fired field is preserved verbatim from the kernel. There is no reformulation.


Soft Mode: Inform the LLM and Continue

When denial_mode = "soft":

  1. Kernel evaluates Rego → deny result with denial_mode = "soft"
  2. Plugin returns a structured dict as the tool's result:
    {
      "error":       "policy_denied",
      "reason":      "Vendor requires manager approval for this amount",
      "rule_fired":  "new_vendor_approval_gate",
      "denial_mode": "soft"
    }
    
  3. LLM receives this as the tool's output
  4. LLM reformulates: "I wasn't able to process this payment — the vendor is new and requires manager approval. Would you like me to submit an approval request?"
  5. The agent run continues

Soft denial is appropriate when:

  • The policy is a preference or guidance, not a regulatory floor
  • The interaction is conversational and the user should hear a natural-language explanation
  • The agent should suggest an alternative (approval request, different vendor, escalation)

Soft denial is not appropriate when:

  • The rule enforces a regulatory requirement (OFAC, AML, GDPR)
  • The rule_fired identifier must be preserved for audit
  • The denial should be terminal — no workaround, no alternative path

Which Mode for Which Rule?

Rule denial_mode Reason
deny_blocked_vendor (OFAC) hard Regulatory floor — no LLM reformulation
deny_budget_exceeded hard Financial control — must terminate cleanly
tier_cfo_approval hard Spending authority control
new_vendor_approval_gate soft Conversational — LLM can explain and offer to route
deny_outside_business_hours soft Informational — LLM can explain and suggest retry
require_additional_context soft LLM should ask a clarifying question

The fail-safe default is "hard". An unset or unrecognised denial_mode never silently downgrades to soft handling. This means a misconfigured step is safe — it terminates rather than letting the LLM handle a denial it shouldn't touch.


The Audit Trail Difference

Compare the audit records for the same OFAC rule under both modes:

Hard denial audit record:

{
  "event_type":    "POLICY_DENIED",
  "rule_fired":    "deny_blocked_vendor",
  "denial_mode":   "hard",
  "reason":        "Vendor is OFAC-blocked / sanctioned — payment prohibited",
  "tool_name":     "submit_payment",
  "llm_involved":  false,
  "agent_stopped": true,
  "compensated":   true
}

Soft denial audit record (hypothetical — wrong for OFAC):

{
  "event_type":    "POLICY_DENIED",
  "rule_fired":    "deny_blocked_vendor",
  "denial_mode":   "soft",
  "llm_response":  "I encountered an issue processing this vendor payment...",
  "agent_stopped": false
}

The hard denial audit record contains the exact regulatory reason, the rule identifier, and proof that the agent stopped. An auditor can verify the regulatory ground truth without any interpretation. The soft denial record contains whatever the LLM said — which may or may not be accurate.


Testing Denial Modes in the PolicyTestPanel

The Taos control plane's PolicyTestPanel lets you test a policy rule's outcome before deploying it. For each test run, the panel shows:

  • outcome — allow / deny
  • reason — verbatim kernel reason
  • rule_fired — the specific Rego rule
  • denial_mode — hard or soft (derived from the step definition)

This lets workflow authors verify that hard-denial steps are correctly configured before the first real payment evaluation.


The Bottom Line

Denial mode is a small feature with large compliance implications. Getting a hard deny for an OFAC violation and then letting the LLM rephrase it is not equivalent to a hard deny that terminates the agent run and writes the verbatim rule identifier to the audit log.

Set denial_mode = "hard" on every regulatory gate. Set denial_mode = "soft" on conversational guardrails. Use the PolicyTestPanel to verify both before going live. The default is safe — but the explicit declaration is what you can defend in an audit.


Tags: denial mode, hard denial, soft denial, policy enforcement, AI governance, OFAC, audit trail, Taos kernel