Edictum
Security & Compliance

Fail-Closed Guarantees

Every failure mode in Edictum leads to deny. False positives are recoverable. False negatives may not be.

AI Assistance

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.

ScenarioOutcomeRationale
Contract has syntax errordenyA broken contract cannot evaluate correctly. Denying is safer than guessing intent.
No contracts match tool callallow (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 timeoutdeny (planned)A contract that cannot complete evaluation in time is treated as failed.
Console server unreachablelocal cache, then denyThe graceful degradation chain applies (see below).
Malformed contract YAMLreject load, keep previousBad YAML never replaces a working contract set. The previous contracts remain in effect.
Unknown contract typeskip (silently ignored)The compiler has no handler for unrecognized types. They are silently skipped during pipeline compilation.
Session limit exceededdenySession 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 all

At 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 typeImpactRecovery
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; redact and deny effects are enforced for READ/PURE tools but fall back to warn for WRITE/IRREVERSIBLE tools (because the action already happened -- hiding the result removes context the agent needs)

Next Steps

Last updated on

On this page