Fail-Closed Guarantees
Every failure mode in Edictum leads to deny. False positives are recoverable. False negatives may not be.
Right page if: you need to understand what happens when something goes wrong in the Edictum pipeline -- broken contracts, 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 contracts match" is ALLOW, not deny -- Edictum does not assume deny-all-by-default. Add a catch-all contract if you want that behavior. Contract evaluation errors are always deny (fail-closed), and audit events include policy_error: true to make broken contracts visible.
A tool call that should have been denied but was allowed cannot be undone. A tool call that should have been allowed but was denied can be retried. Edictum treats every ambiguous failure as a deny.
This is the fail-closed principle: false positives (denying 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.
Evaluation errors become denials. When a contract condition throws an exception during evaluation, the pipeline catches it and returns a Verdict.fail() with policy_error=True:
# yaml_engine/compiler.py — precondition evaluation
try:
result = evaluate_expression(when_expr, envelope, ...)
except Exception as exc:
# Fail-closed: evaluation error triggers the contract
return Verdict.fail(
msg, tags=tags, policy_error=True,
error_detail=str(exc), **then_metadata,
)Type mismatches become denials. The _PolicyError sentinel in the evaluator has __bool__ returning True, so any type mismatch or comparison error triggers the contract rather than silently passing:
# yaml_engine/evaluator.py
class _PolicyError:
def __bool__(self) -> bool:
return True # Errors trigger the contract (fail-closed)Both paths produce audit events with policy_error: true, making broken contracts visible in monitoring even when the system falls back to a safe default.
Scenario Table
Every failure mode in the pipeline has a defined outcome. Seven scenarios, one principle.
| Scenario | Outcome | Rationale |
|---|---|---|
| Contract has syntax error | deny | A broken contract cannot evaluate correctly. Denying is safer than guessing intent. |
| No contracts match tool call | allow (default) | No contract applies -- the tool call is outside contract scope. This is intentional: contracts are opt-in, not a global deny-all. |
| Contract evaluation timeout | deny (planned) | A contract that cannot complete evaluation in time is treated as failed. |
| Console server unreachable | local cache, then deny | The graceful degradation chain applies (see below). |
| Malformed contract YAML | reject load, keep previous | Bad YAML never replaces a working contract set. The previous contracts remain in effect. |
| Unknown contract type | skip (silently ignored) | The compiler has no handler for unrecognized types. They are silently skipped during pipeline compilation. |
| Session limit exceeded | deny | Session limits are hard caps. Exceeding them is a deny, not a warning. |
"No contracts match" is allow, not deny. This is deliberate. If you want deny-all-by-default, add a catch-all contract. Edictum does not assume you want to deny everything -- it assumes you want to deny what your contracts specify.
Unknown contract types are currently silently skipped by the compiler. This is a known gap -- a typo in the type field (e.g., typ: pre or type: prre) means the contract is never compiled or evaluated, with no warning. This behavior may be tightened in a future release to reject or warn on unrecognized types.
Graceful Degradation
When an agent connects to edictum-console via the server SDK, contract updates arrive over SSE. If connectivity is lost, the system degrades through a defined chain:
SSE connection (live updates)
|
v connection lost
In-memory contracts (last successful load)
|
v no contracts loaded
Embedded YAML (bundled with agent, if configured)
|
v no bundle
Deny allAt every step, the system prefers the most recent known-good contracts. When the SSE connection drops, the agent continues enforcing whatever contracts it last successfully loaded into memory -- no external cache is involved. If no contracts are available at all, every tool call is denied. The agent cannot silently operate without contract enforcement.
Edictum.reload() atomically swaps contracts from a new YAML bundle. If the new bundle fails to load (parse error, schema validation error), the swap is aborted and the previous contracts remain in effect. This is the same fail-closed principle applied to hot-reload.
Input Validation: First Line of Defense
Before any contract 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 contracts, 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 are handled with fail-closed semantics:
- HTTP 404 (key not found): returns
None-- this is normal "no value yet" behavior - Connection refused, timeout, HTTP 500: propagates as an exception to the pipeline, which converts it to a deny decision
The pipeline does not distinguish between "the server said no" and "the server is unreachable." Both result in a deny. This prevents a network partition from silently disabling contract enforcement.
Monitoring Contract Failures
When a contract fails to load or evaluate, the audit event includes policy_error: true. This field is the signal to alert on in your monitoring system.
{
"action": "CALL_DENIED",
"tool_name": "bash",
"policy_error": true,
"error_detail": "unsupported operand type(s) for >: 'str' and 'int'",
"contract_id": "rate-limit-deploys",
"timestamp": "2026-03-08T14:22:01Z"
}Filter your audit sink for policy_error: true to catch broken contracts before they affect agent operations. A high rate of policy errors means contracts need attention -- not that the system is failing, but that contracts are misconfigured and falling back to deny.
Design Rationale
The fail-closed default exists because of an asymmetry in consequences:
| Failure type | Impact | Recovery |
|---|---|---|
| False positive (safe call denied) | Agent retries or asks for help. Workflow is slowed. | Retry, fix the contract, redeploy. |
| False negative (dangerous call allowed) | Data deleted. Secrets leaked. Unauthorized action completed. | May not be recoverable. |
Every design decision in Edictum favors false positives over false negatives:
- Unregistered tools default to
SideEffect.IRREVERSIBLE(most restrictive classification) - Contract evaluation errors deny the tool call rather than silently allowing it
- Observe mode is opt-in per-contract or per-pipeline, never the default
- Postconditions default to
warn;redactanddenyeffects are enforced for READ/PURE tools but fall back towarnfor WRITE/IRREVERSIBLE tools (because the action already happened -- hiding the result removes context the agent needs)
Next Steps
- Pipeline architecture -- the full pipeline evaluation order and error handling
- Contracts -- all four contract types at a glance
- Sandbox contracts -- allowlist boundaries with
outside: deny - Defense scope -- what Edictum defends against and what it does not
- Testing contracts -- validating contracts before deployment
Last updated on