# Reason codes

Every non-`PASS` Doberman decision carries one or more `ReasonCode` values plus a human explanation. This page catalogues every member of `ReasonCode` in `src/doberman/models.py`, the module where it is actually attached to a decision, and what it means in plain language. The enum currently defines 57 codes. `doberman log` and `doberman tune --json` (see [CLI reference](https://docs.trydoberman.dev/reference/cli/)) both surface these values directly, so matching on the name here is the stable way to script against a decision.

| Code | Group | Raised in | Meaning |
|------|-------|-----------|---------|
| `normalization_failed` | General | `engine/rules/normalization.py`, `proxy/normalize.py` | The proxy's `normalize()` step raised, so the action falls back to a conservative high-risk placeholder and escalates to authentication instead of passing through un-normalized. |
| `unknown_tool` | General | _(reserved, enum only)_ | Reserved for a requested tool name absent from the downstream tool list. Defined for that purpose but not wired to a raise site in shipped code. |
| `downstream_error` | General | `proxy/executor.py` | The downstream MCP tool call raised while being forwarded, so the result is denied and the failure is never exposed to the agent. |
| `objective_guardrail_error` | General | `engine/decision_engine.py`, `proxy/executor.py` | The objective guardrail, or a post-decision integrity check such as the output secret scan or artifact digest check, raised an unhandled exception, so the action fails closed to `BLOCK`. |
| `subjective_guardrail_error` | General | `engine/decision_engine.py`, `engine/subjective.py` | The subjective guardrail raised an unhandled exception after the objective guardrail already passed, so the action escalates to authentication instead of silently passing. |
| `subjective_block_clamped` | General | `engine/decision_engine.py`, `policy/drift.py` | The subjective guardrail returned `BLOCK`, but none of its reason codes sit on the hard-block allowlist, so the execution rule clamps that `BLOCK` down to `AUTH` and records that the clamp happened. |
| `proxy_handler_error` | General | `proxy/executor.py` | An unhandled exception escaped the proxy's top-level tools/call handler, outside `normalize()`'s and `decide()`'s own error handling, so the call is denied carrying only the exception's class name, never its message. |
| `secret_exfiltration` | Objective guardrail | `engine/rules/secrets.py`, `hosthooks/hookio.py`, `policy/modes.py`, `proxy/executor.py` | A known credential or secret pattern, or a base64/hex payload that decodes to one, appeared in an action headed to an external destination. The rule hard-blocks it in every mode. |
| `sensitive_secret_access` | Objective guardrail | `auth/challenge.py`, `engine/rules/secrets.py`, `proxy/executor.py` | The action reads or targets a file recognized as holding secrets, such as `.env` or a private key, with no evidence it is leaving the session, so it requires authentication. |
| `possible_high_entropy_secret` | Objective guardrail | `engine/rules/secrets.py` | Only the weak, purely statistical high-entropy heuristic matched: no known credential shape and no secret file, so the action requires authentication. It is a lower-confidence sibling of `sensitive_secret_access`, kept separate so output scanning can pass through likely-benign hashes, UUIDs, and base64 fragments instead of hard-blocking a benign read. |
| `protected_path_blocked` | Objective guardrail | `engine/rules/commands.py`, `engine/rules/paths.py`, `policy/modes.py` | The target path resolves inside a location explicitly protected by policy, such as Doberman's own control-plane files, so the rule hard-blocks the action. |
| `sensitive_path_access` | Objective guardrail | `auth/challenge.py`, `engine/rules/paths.py`, `policy/drift.py` | The target path matches a sensitive-but-not-protected location, so the action requires authentication before it proceeds. |
| `destructive_command` | Objective guardrail | `engine/rules/commands.py`, `policy/modes.py` | A shell or git command matched a pattern that irreversibly destroys data or history: a recursive force-delete, a history rewrite, or piping a downloaded payload into a shell. |
| `environment_dump_command` | Objective guardrail | `engine/rules/commands.py` | A shell command whose sole effect is to enumerate or print the process environment (bare `env`, any form of `printenv`, `export`/`export -p`, `declare -x`/`typeset -x` with no named variable, or a PowerShell `Env:` drive listing). This is a locally scoped read of a common secret carrier, so it requires authentication before it runs. |
| `bulk_operation` | Objective guardrail | `auth/challenge.py`, `engine/rules/commands.py`, `policy/drift.py` | A command deletes at or above the configured bulk-operand threshold, so it requires authentication before the mass operation proceeds. |
| `opaque_command` | Objective guardrail | `auth/challenge.py`, `engine/rules/commands.py` | A command's effect cannot be determined statically, an inline shell payload (`-c ...`) or a command that fails to parse safely, so it requires authentication before it runs. |
| `unknown_external_destination` | Objective guardrail | `auth/challenge.py`, `egress/local.py`, `engine/rules/destinations.py` | A network destination is not on the trusted-host allowlist and cannot otherwise be classified as safe, so it requires authentication, subject to mode thresholds and any proven-broker override. |
| `egress_requires_auth` | Objective guardrail | `engine/rules/destinations.py` | A shell, package-install, or git command has an external destination whose runtime route static parsing cannot prove, so it requires authentication regardless of how trusted the parsed host looks. |
| `encoded_exfiltration` | Objective guardrail | `auth/challenge.py` | Reserved for a base64/hex-encoded payload that decodes to a secret pattern. It is wired into auth tiering and policy, but the shipped secrets rule currently reports such decoded hits as `secret_exfiltration` or `possible_high_entropy_secret`, so this code is not presently emitted. |
| `tool_schema_changed` | Objective guardrail | `proxy/executor.py` | The tool's `(name, description, inputSchema)` fingerprint no longer matches the pinned one, a possible rug-pull. This is `AUTH` in Light/Balanced and `BLOCK` in Strict/Paranoid until a human re-approves the pin. |
| `rule_error` | Objective guardrail | `engine/objective.py`, `engine/subjective.py` | An individual objective or subjective rule raised, or returned something other than a `GuardrailResult`, while being evaluated. That one rule's result is isolated to `AUTH` instead of forcing a silent `PASS` or an unrelated hard `BLOCK`. |
| `role_blocked_target` | Role policy | `engine/rules/role_boundary.py`, `policy/modes.py` | The active agent role's boundary matcher explicitly denies this target, so the rule hard-blocks the action. |
| `role_out_of_scope` | Role policy | `auth/challenge.py`, `engine/rules/role_boundary.py`, `policy/drift.py` | The action falls outside the boundaries declared for the agent's active role without being on an explicit block list, so it requires authentication (a role elevation). |
| `policy_source_blocked` | Role policy | `engine/rules/policy_source.py`, `policy/modes.py` | The instruction's originating source is on a higher-authority policy source's block list, so the rule hard-blocks the action. |
| `policy_source_sensitive` | Role policy | `auth/challenge.py`, `engine/rules/policy_source.py` | The instruction's originating source is classified as sensitive, but not blocked, by the policy source, so it requires authentication. |
| `unusual_for_workflow` | Subjective layer | `engine/subjective.py`, `policy/drift.py` | The action deviates from this agent's own learned per-workflow behavioral baseline, raising the subjective verdict. |
| `unusual_for_deployment` | Subjective layer | `engine/subjective.py`, `policy/drift.py` | The action's surprise score, computed across the universal three-axis algebra, exceeds this deployment's care threshold, raising the subjective verdict. |
| `confidentiality_sensitive_destination` | Subjective layer | `engine/subjective.py` | An above-threshold action's dominant care dimension is confidentiality, and the destination itself is sensitive. |
| `irreversible_high_blast` | Subjective layer | `engine/subjective.py` | An above-threshold action's dominant care dimension is irreversibility and blast radius rather than confidentiality: it would be hard to undo and would affect a lot if it went wrong. |
| `lethal_trifecta` | Subjective layer | `engine/decision_engine.py`, `engine/subjective.py`, `subjective/revealed.py` | The action's algebra shows untrusted-data provenance, access to private data, and an external communication channel all at once, flooring the verdict to `AUTH` (`BLOCK` in Strict/Paranoid). It is the only subjective code allowed onto the hard-block allowlist. |
| `unclassified_action` | Subjective layer | `engine/subjective.py`, `policy/drift.py` | The action's algebra classification is still effectively unknown by the time the subjective layer runs, so it is flagged rather than treated as benign. |
| `pii_data_class_egress` | Objective guardrail | `engine/rules/data_classes.py` | The outbound payload contains checksum-valid personal or financial data: a payment card number (issuer prefix plus Luhn check), an IBAN (mod-97), or a dashed US SSN. Because the action has an external destination, every mode requires authentication. Only the class label is recorded, never the matched value. |
| `smuggled_token_channel` | Token-channel defense | `engine/rules/token_channels.py` | A deterministic, near-zero-false-positive objective token-channel signature matched: tokens smuggled through an unexpected channel. It produces `AUTH` or `BLOCK` depending on severity. |
| `anomalous_token_pattern` | Token-channel defense | `engine/detectors/token_channels.py` | The subjective token-channel detector found a soft, probabilistic out-of-distribution signal: homoglyph confusables, under-trained glitch fragments, NFKC-normalization smuggling, or anomalous control/private-use characters. It requires authentication. |
| `oversized_encoded_blob` | Encoded-exfiltration detector | `engine/detectors/base64_blob.py` | The subjective base64-blob detector found a suspiciously large base64-looking payload in a tool argument, a common encode-and-exfiltrate shape (file or secret contents smuggled out as one blob). It checks shape and size only, never decodes, and raises only to `AUTH`. |
| `multi_step_exfil` | Host-hook containment | `engine/taint_floor.py`, `hosthooks/hookio.py` | This egress action's session already carries taint from an earlier call that read a secret. Even though this call's own payload looks clean, the verdict is raised (`AUTH`, or `BLOCK` in Strict/Paranoid) as a potential cross-call exfiltration. |
| `confirmed_exfil` | Host-hook containment | `engine/taint_floor.py`, `hosthooks/hookio.py` | An outbound value's keyed-HMAC fingerprint matches a secret read earlier in this same session, confirming a read-then-send exfiltration. The rule hard-blocks it in every mode. |
| `turn_gate_error` | Turn gate | `engine/decision_engine.py`, `turngate/heuristics.py`, `turngate/hook.py` | The turn gate, either its Tier 0 signature scan or its Tier 1 heuristics, raised while evaluating a turn. It escalates to authentication (fails upward) instead of silently passing. |
| `instruction_nullification` | Turn gate | `turngate/repeat.py`, `turngate/signatures.py` | A turn's text matched a Tier 0 pattern telling the agent to ignore, disregard, or override its previous instructions. Doberman blocks it outright if issued or found in untrusted content, and requires authentication if merely discussed in typed text. |
| `authority_override` | Turn gate | `turngate/repeat.py`, `turngate/signatures.py` | A turn's text matched a Tier 0 authority-impersonation, jailbreak/mode-switch, or system-prompt-exfiltration pattern. Doberman blocks it outright if issued or found in untrusted content, and requires authentication if merely discussed. |
| `secret_export` | Turn gate | `turngate/repeat.py`, `turngate/signatures.py` | A turn's text matched a Tier 0 credential, key, or token export request, for example "send me the API key." Doberman blocks it outright if issued or found in untrusted content, and requires authentication if merely discussed. |
| `encoded_payload` | Turn gate | `turngate/repeat.py`, `turngate/signatures.py` | A turn contains a long, high-entropy base64/hex run or a punycode host, an encoded-carrier signature. Doberman blocks it in untrusted content and steps it up to authentication in typed content, since a user pasting base64 is plausibly legitimate. |
| `indirect_injection` | Turn gate | `turngate/repeat.py`, `turngate/signatures.py` | A Tier 0 signature matched inside an untrusted, pasted or tool-fetched, content segment. Doberman treats this as indirect prompt injection and blocks it unconditionally, regardless of issue or mention framing. |
| `embedded_instruction` | Turn gate | `turngate/heuristics.py` | Tier 1 heuristics detected an agent-directed imperative embedded in an untrusted content segment, escalating the turn to authentication. |
| `persona_override` | Turn gate | `turngate/heuristics.py` | Tier 1 heuristics matched a persona- or role-override phrase in the turn, requiring authentication. |
| `obfuscated_content` | Turn gate | `turngate/heuristics.py` | Tier 1 heuristics found obfuscated or sub-threshold encoded content that does not reach the hard `encoded_payload` carrier threshold, requiring authentication. |
| `urgency_secrecy_framing` | Turn gate | `turngate/heuristics.py` | Tier 1 heuristics matched language that combines urgency and secrecy framing, a common social-engineering pattern, requiring authentication. |
| `stylometric_outlier` | Turn gate | `turngate/heuristics.py` | The turn's writing style is an extreme statistical outlier for this session and coincides with a sensitive apparent intent, requiring authentication. Style alone never triggers this on its own. |
| `repeat_after_block` | Turn gate | `turngate/hook.py`, `turngate/repeat.py` | An identical, same-fingerprint turn was resubmitted after being blocked. This converts the automatic block into a one-time `AUTH` challenge for the first repeat instead of blocking again outright. |
| `turn_blocked_repeatedly` | Turn gate | `turngate/repeat.py` | A resubmitted turn's repeat challenge was already denied once, and the lockout threshold is reached. The rule hard-blocks further resubmission for a cooldown window instead of re-challenging. |
| `egress_route_divergence` | Egress broker | `engine/rules/destinations.py` | A broker-observed connection for this entity, within a bounded recent window, actually went somewhere that diverges from this rule's static host-trust classification. This retroactively raises the already-computed verdict. |
| `egress_broker_enforced` | Egress broker | `engine/rules/destinations.py` | A broker proven to enforce egress attests that this exact destination is allowlisted and will itself be enforced at the socket, so the rule contributes `PASS` instead of its usual `AUTH`. |
| `egress_blocked_by_mode` | Egress broker | `engine/rules/destinations.py` | In Paranoid mode only, a broker proven to enforce egress attests that this exact destination is not allowlisted and will itself drop it at the socket, so the rule hard-blocks instead of its usual `AUTH`. This code is dormant in every other mode, and with no broker present. |
| `anomalous_egress_velocity` | Egress broker | `engine/rules/destinations.py` | A bounded, in-memory per-entity velocity tracker detected a burst, volume, or fan-out anomaly across this entity's broker-observed connections in the same recent window `egress_route_divergence` uses. This retroactively raises the already-computed verdict. |
| `artifact_digest_mismatch` | Artifact digest verification | `proxy/executor.py` | A pinned expected sha256 digest exists for a previously fetched identity, and the freshly returned tool-result content's digest disagrees with it. The result is withheld from the agent post-fetch. |
| `correlated_trifecta` | Session correlator | `engine/correlator.py` | This session's recent decision history shows an untrusted-provenance ingress and a sensitive or secret read as separate earlier calls, and the current action is an external egress: a lethal trifecta assembled across calls rather than within one. |
| `correlated_destructive_flow` | Session correlator | `engine/correlator.py` | This session's recent decision history shows a broad read or enumeration and a shell command as separate earlier calls, and the current action is an external egress: a possible archive-then-exfiltrate flow. |

## Notes

- Match on the enum name in automation. Values are stable strings, but the name is what the code actually branches on.
- Evidence may be redacted before it reaches a log or a UI. Reason codes are the machine-stable half of explainability; the `explanation` string on a `Decision` is the human-readable half.
- When you add a `ReasonCode`, update this table in the same PR. `tests/unit/test_reason_codes_documented.py` fails the build otherwise.
