Hard Denial vs Soft Denial: Why How You Surface a Policy Block Matters
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":
- Kernel evaluates Rego →
denyresult - Kernel reads
step_definitions.denial_mode = "hard" - Kernel sends
PolicyDeniedwithdenial_mode = "hard"on gRPC stream - Plugin raises
PolicyDeniedError— the agent run terminates immediately - Saga compensation fires (in kernel-driven mode) — all completed steps rolled back
- Calling code receives a typed exception with verbatim reason and
rule_fired - 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":
- Kernel evaluates Rego →
denyresult withdenial_mode = "soft" - 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" } - LLM receives this as the tool's output
- 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?"
- 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_firedidentifier 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 / denyreason— verbatim kernel reasonrule_fired— the specific Rego ruledenial_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