Fail-Closed Guarantees
How Edictum handles rule errors, load failures, server outages, and other failure paths without silently allowing a tool call.
Right page if: you need to understand what happens when something goes wrong in the Edictum pipeline -- broken rulesets, server outages, type mismatches, or session limit overflows. Wrong page if: you need to understand what Edictum defends against at the threat-model level -- see https://docs.edictum.ai/docs/security/defense-scope. For the pipeline architecture, see https://docs.edictum.ai/docs/architecture. Gotcha: "no rulesets match" is ALLOW, not block -- Edictum does not assume block-all-by-default. Add a catch-all rule if you want that behavior. In enforce mode, pre-execution rule evaluation errors block and audit events include policy_error: true. Observe mode records the denial but allows execution.
A tool call that should have been blocked but was allowed cannot be undone. A tool call that should have been allowed but was blocked can be retried. Edictum therefore closes known pre-execution failure paths. Caught pre-execution evaluation errors become block decisions. Load failures stop startup or preserve active rules. The normal pipeline converts backend failures inside session-rule or workflow evaluation into block decisions. A guard running in observe mode can later convert that block to would-deny. Separately registered observe-alongside session rules record a would-deny result and allow execution. Other failures propagate: uncaught pre-invocation failures stop execution, while failures recording state after invocation surface after the tool has run. Hook integrations also depend on the host's hook-error behavior.
This is the fail-closed principle: false positives (blocking safe calls) are recoverable. False negatives (allowing dangerous calls) may not be.
How It Works in Code
The fail-closed behavior is enforced at two levels in the pipeline.
Pre-execution evaluation errors become block decisions. When a precondition,
sandbox rule, session rule, before hook, or workflow evaluation raises, the
pipeline catches it and returns a block decision with policy_error=True.
An enforce-mode guard blocks on that decision; guard-level observe converts it
to would-deny. Separately registered observe-alongside rules record their result
and continue:
# yaml_engine/compiler.py — precondition evaluation
try:
result = evaluate_expression(when_expr, envelope, ...)
except Exception as exc:
# Fail-closed: evaluation error triggers the rule
return Decision.fail(
msg, tags=tags, policy_error=True,
error_detail=str(exc), **then_metadata,
)Type mismatches trigger the rule. The _PolicyError sentinel in the
evaluator has __bool__ returning True, so any type mismatch or comparison
error triggers the rule and sets policy_error rather than silently passing.
In enforce mode, a precondition, sandbox rule, or session rule then blocks:
# yaml_engine/evaluator.py
class _PolicyError:
def __bool__(self) -> bool:
return True # Errors trigger the rule (fail-closed)Both paths produce audit events with policy_error: true, making broken rulesets visible in monitoring even when the system falls back to a safe default.
Scenario Table
The outcome depends on where the failure happens.
| Scenario | Outcome | Rationale |
|---|---|---|
| Rule fails to load or compile | reject load | Initial construction fails. During reload, the active rules stay in effect. |
| No rulesets match tool call | allow (default) | No rule applies -- the tool call is outside rule scope. This is intentional: rulesets are opt-in, not a global block-all. |
| Rule evaluation timeout | not implemented | This remains planned behavior, not a shipped guarantee. |
| Reference stack API unavailable during startup | reject startup | The server-backed guard is not returned without an initial ruleset. |
| SSE connection lost after startup | keep active rules | The last successfully loaded in-memory rules remain active while the watcher reconnects. |
| Malformed rule YAML | reject load, keep previous | Bad YAML never replaces a working rule set. The previous rulesets remain in effect. |
| Unknown rule type | reject load | The ruleset schema only allows pre, post, session, and sandbox. Unknown types fail validation and do not load. |
| Session limit exceeded | block | Session limits are hard caps. Exceeding them is a block, not a warning. |
"No rulesets match" is allow, not block. This is deliberate. If you want block-all-by-default, add a catch-all rule. Edictum does not assume you want to block everything -- it assumes you want to block what your rulesets specify.
Unknown rule types are rejected at load time by schema validation. A typo in the type field (for example type: prre) is a validation error, not a silent skip.
Server Ruleset Availability
When an agent connects to the optional server surface via the server SDK, it must receive an initial ruleset before startup completes:
Initial fixed-bundle fetch or SSE assignment
|
+-- unavailable or invalid --> startup fails
|
v
In-memory rulesets (startup succeeds)
|
+-- later SSE loss --> active rules stay loaded
|
+-- invalid update --> update rejected; active rules stay loadedThere is no embedded-YAML fallback or automatic block-all stage in the server
factory. A fixed-bundle fetch or parse failure raises EdictumConfigError.
Server-assigned mode waits for its initial SSE assignment and raises if it times
out. After startup, an SSE outage does not clear the active in-memory rules.
Edictum.reload() atomically swaps rulesets from new YAML. If the new ruleset fails to load (parse error, schema validation error), the swap is aborted and the previous rulesets remain in effect. This is the same fail-closed principle applied to hot-reload.
Input Validation: First Line of Defense
Before any rule evaluation begins, create_envelope() validates the tool call itself. Tool names containing null bytes, newlines, or path separators are rejected immediately. This prevents injection attacks that could corrupt session keys or audit records.
# envelope.py — create_envelope()
# Rejects: empty strings, null bytes (\x00),
# newlines (\n, \r), path separators (/, \)This validation runs before the pipeline, before rulesets, before session checks. A malformed tool name never reaches the evaluation layer.
Backend Errors
The StorageBackend protocol defines how session state is read and written. When using the server SDK (ServerBackend), HTTP errors do not become missing state:
- HTTP 404 (key not found): returns
None-- this is normal "no value yet" behavior - Connection refused, timeout, HTTP 500: propagates as an exception
The outcome depends on where the backend operation runs. The normal pipeline
catches failures inside a session rule or workflow evaluation and creates a
block decision with policy_error: true. A guard running in observe mode can
later convert that block to would-deny while preserving the flag if its denial
audit succeeds. Separately registered observe-alongside session rules instead
record a would-deny result and allow execution; that record currently does not
preserve the policy_error flag.
The block decision is not an end-to-end guarantee while the backend remains unavailable. Direct runs and adapters build the denial audit event before they return or raise the denial, and that audit construction rereads session state. If the reread fails, the exception propagates instead of returning the structured denial.
Other backend failures also propagate. With guard.run() and wrap-around
adapters, an uncaught pre-invocation failure, such as the initial attempt-counter
increment, aborts before the tool is invoked. A failure while recording session
state after invocation propagates after the tool has run. Hook adapters such as
the Claude Agent SDK adapter re-raise uncaught exceptions to the host instead of
returning a deny response. Whether the tool runs after that hook error is
controlled by the host, not Edictum.
Monitoring Rule Failures
Caught enforced rule, before-hook, and workflow evaluation failures that
propagate the flag produce audit events with policy_error: true. After-hook
failures and some observe-alongside failures are logged without that flag. Load
failures happen before tool-call evaluation. Monitor application errors and
warnings alongside policy_error events.
{
"action": "CALL_DENIED",
"tool_name": "bash",
"policy_error": true,
"error_detail": "unsupported operand type(s) for >: 'str' and 'int'",
"decision_name": "rate-limit-deploys",
"timestamp": "2026-03-08T14:22:01Z"
}Filter your audit sink for policy_error: true to catch broken rulesets before they affect agent operations. A high rate of policy errors means rulesets need attention -- not that the system is failing, but that rulesets are misconfigured and falling back to block.
Design Rationale
The fail-closed default exists because of an asymmetry in consequences:
| Failure type | Impact | Recovery |
|---|---|---|
| False positive (safe call blocked) | Agent retries or asks for help. Workflow is slowed. | Retry, fix the rule, redeploy. |
| False negative (dangerous call allowed) | Data deleted. Secrets leaked. Unauthorized action completed. | May not be recoverable. |
These implemented choices favor false positives over false negatives:
- Unregistered tools default to
SideEffect.IRREVERSIBLE(most restrictive classification) - Pre-execution rule evaluation errors block the tool call in enforce mode rather than silently allowing it
- Observe mode is opt-in per-rule or per-pipeline, never the default
- Postconditions default to
warn; result-capable integrations can enforceredactandblockfor READ/PURE output, while warning-only integrations and WRITE/IRREVERSIBLE tools do not replace results
Next Steps
- Pipeline architecture -- the full pipeline evaluation order and error handling
- Rulesets -- all four rule types at a glance
- Sandbox rulesets -- allowlist boundaries with
outside: block - Defense scope -- what Edictum defends against and what it does not
- Testing rulesets -- validating rulesets before deployment
Last updated on