Edictum

Pipeline Architecture

Edictum is a single pipeline that every adapter calls.

AI Assistance

Right page if: you need to understand Edictum's internal pipeline architecture, YAML compilation, adapter pattern, or fail-closed error handling. Wrong page if: you want a step-by-step walkthrough of what happens on a tool call -- see https://docs.edictum.ai/docs/concepts/how-it-works. Gotcha: the pipeline is deterministic and fail-closed -- any unhandled exception during evaluation results in a block, not a pass-through. There is no "soft block."

Edictum is a single pipeline that every adapter calls. Rulesets written in YAML compile to the same runtime objects as Python-defined rulesets. The pipeline is deterministic -- same input, same decision, every time.


Pipeline Overview

Every tool call passes through one pipeline, regardless of which framework adapter triggers it.

Agent Tool Call
Edictum Pipeline
Preconditions
YAML ruleset validation
Sandbox Rulesets
allowlist boundaries
Session Limits
max_attempts · max_tool_calls
Execution Limits
per-tool caps
ALLOW /
DENY
DENY
Denial Message
returned to agent
CALL_DENIED
audit event emitted
ALLOW
Tool Executes
Postconditions
output validated
CALL_EXECUTED
audit event emitted

In enforce mode, if any precondition fails, the tool call is blocked. The tool never executes. There is no "soft block" -- either every check passes or the call does not happen. (In observe mode, failures are logged as CALL_WOULD_DENY but the call proceeds.)


Pre-Execution Detail

GovernancePipeline.pre_execute() runs seven checks in order. The first failure in steps 1-5.5 short-circuits -- remaining checks are skipped. Step 6 (observed evaluation) always runs when the decision is "allow."

ToolCall
pre_execute()
1
Check Attempt Limit
max_attempts (all attempts, incl. blocked)
block
2
Run Before-Hooks
each hook returns allow or block
block
3
Evaluate Preconditions
Decision.pass_() or .fail(msg)
block / pending
3.5
Evaluate Sandbox Rulesets
paths, commands, domains allowlists
block / ask
4
Evaluate Session Rulesets
cross-turn limits, stateful checks
block
5
Check Execution Limits
max_tool_calls, max_calls_per_tool
deny
6
Evaluate Shadow Rulesets
observe_alongside — never affects decision
audit only
PreDecision
allowdenypending_approval

If the PreDecision.action is "allow", the adapter lets the tool execute.


Post-Execution Detail

Once a tool has executed, Edictum checks its output. Postcondition behavior depends on the declared action and the tool's side-effect classification.

Tool Response
(tool_response, tool_success)
post_execute()
1
Evaluate Postconditions
Decision.pass_() / .fail(msg)
warn
all tools
redact
READ / PURE only
deny
READ / PURE only
WRITE / IRREVERSIBLE tools → falls back to warn
Observe mode → falls back to warn
2
Run After-Hooks
fire-and-forget · cannot modify result
PostDecision
tool_successpostconditions_passedwarningsredacted_responseoutput_suppressed

For warn, the pipeline warns the agent and lets it decide how to proceed. For redact and block on READ/PURE tools, the pipeline modifies the response before it reaches the agent. WRITE/IRREVERSIBLE tools always get warn because the action already happened -- hiding the result only removes context the agent needs.


YAML Compilation

YAML rulesets go through a multi-stage pipeline that produces the same runtime objects as hand-written Python rulesets. When multiple files are passed to from_yaml(), a composition step merges them before compilation.

YAML file(s)
YAML Compilation
1
loader.py
parse · validate · SHA-256 hash
2
composer.py
merge bundles · observe_alongside
(when multiple files)
3
compiler.py
definitions → decorated callables
@precondition@postcondition@session_contract@sandbox_contract
Runtime Rule Objects
identical to Python-defined rulesets
edictum

There is no separate "YAML execution path." A precondition compiled from YAML and a precondition written as a Python function are indistinguishable to the pipeline. They produce the same decision objects, appear in the same rules_evaluated audit records, and are subject to the same observe-mode behavior.


Adapter Pattern

Adapters are thin translation layers between framework-specific hook APIs and the pipeline. Each adapter:

  1. Intercepts the framework's tool-call lifecycle event
  2. Builds a ToolCall via create_envelope()
  3. Calls pipeline.pre_execute() and translates the PreDecision into the framework's expected format
  4. If allowed, lets the tool execute
  5. Calls pipeline.post_execute() and forwards any findings
AdapterFrameworkIntegration Method
LangChainAdapterLangChainas_middleware(), as_tool_wrapper()
CrewAIAdapterCrewAIregister() -- global hooks
AgnoAdapterAgnoas_tool_hook() -- wrap-around hook
SemanticKernelAdapterSemantic Kernelregister(kernel) -- auto-invocation filter
OpenAIAgentsAdapterOpenAI Agentsas_guardrails() -- input/output guardrails
ClaudeAgentSDKAdapterClaude Agent SDKto_hook_callables() -- pre/post tool use hooks
NanobotAdapterNanobotwrap_registry() -- enforced ToolRegistry
GoogleADKAdapterGoogle ADKas_plugin(), as_agent_callbacks()

Adapters never contain enforcement logic. They translate formats. If you need to add a new rule, add it as a rule or hook -- not adapter code.


Design Decisions

Tool Call Immutability

ToolCall is a frozen dataclass. Once created, no field can be modified.

This is enforced at two levels: @dataclass(frozen=True) raises FrozenInstanceError on assignment, and create_envelope() deep-copies args and metadata via json.loads(json.dumps(...)) so the caller cannot mutate the original dicts. create_envelope() also validates tool_name -- rejecting empty strings, null bytes, newlines, and path separators that could corrupt session keys or audit records.

Always create tool calls through create_envelope(), never by constructing ToolCall(...) directly. The Principal dataclass is also frozen.

Session and Storage Model

Sessions track execution state across multiple tool calls within an agent run.

CounterSemantics
attemptsIncremented on every pre_execute call, including blocked calls
execsIncremented only when a tool actually executes
tool:{name}Per-tool execution count
consec_failConsecutive failures; resets on success

All counter operations go through the StorageBackend protocol:

class StorageBackend(Protocol):
    async def get(self, key: str) -> str | None: ...
    async def set(self, key: str, value: str) -> None: ...
    async def delete(self, key: str) -> None: ...
    async def increment(self, key: str, amount: float = 1) -> float: ...

increment() must be atomic. This is the fundamental requirement for correctness under concurrent access.

MemoryBackend enforces atomicity via asyncio.Lock on increment() and delete(). Single dict reads (get(), set()) are not locked -- they do not have read-modify-write patterns.

MemoryBackend stores counters in a Python dict -- one process, one agent. This covers the vast majority of use cases: a single agent process enforcing session limits on its own tool calls. For multi-agent coordination across processes, the optional server surface handles centralized session tracking. See the roadmap.

Operation Limits

OperationLimits defines three cap types:

LimitDefaultCounts
max_attempts500All pre_execute calls (including blocked calls)
max_tool_calls200Successful executions only
max_calls_per_tool{}Per-tool execution count

max_attempts fires first because it counts blocked calls too. An agent stuck in a retry loop hits the attempt cap without ever incrementing the execution counter. The block message tells the agent to stop and reassess rather than keep retrying.

Claude Agent SDK: Intentional Decoupling

to_hook_callables() returns callables using Edictum's own calling convention (snake_case keys, (tool_name, tool_input, tool_use_id) signature) rather than the SDK-native HookCallback signature ((input_data, tool_use_id, context) with PascalCase event keys and HookMatcher wrappers).

This is intentional. The claude-agent-sdk package is pre-1.0 and its types (HookMatcher, HookContext, HookCallback) may change. Importing them would add a runtime dependency to Edictum's zero-dep core and couple releases to SDK breaking changes. The ~10-line bridge recipe in the Claude SDK adapter docs lives in user-land where the claude-agent-sdk coupling already exists.

If the SDK stabilizes at 1.0, a to_native_hooks() convenience method that returns dict[HookEvent, list[HookMatcher]] directly could be added without breaking to_hook_callables().

Error Handling: Fail-Closed

Edictum follows a fail-closed default with explicit opt-in to permissive behavior:

  • Unregistered tools default to SideEffect.IRREVERSIBLE (most restrictive classification)
  • Rule evaluation errors block the tool call rather than silently allowing it
  • Observe mode is opt-in per-rule or per-pipeline, never the default
  • Postconditions default to warn; redact and block effects are enforced for READ/PURE tools but fall back to warn for WRITE/IRREVERSIBLE tools
  • Backend errors propagate as exceptions. ServerBackend.get() returns None only for HTTP 404 (key not found); all other errors (connection refused, timeout, 500) propagate to the pipeline which converts them to blocked decisions
  • Input validation in create_envelope() rejects tool names with null bytes, newlines, or path separators before any pipeline processing
  • Concurrent access is safe: MemoryBackend.increment() and delete() use asyncio.Lock to prevent lost updates under asyncio.gather

Audit events record policy_error: true when rule loading fails, ensuring that broken rulesets are visible in monitoring even when the system falls back to a safe default.


Where It's Heading

Edictum is currently an in-process library -- rulesets are loaded and enforced within the same process as the agent. This covers single-agent deployments and most production use cases today.

The server SDK (pip install edictum[server]) is shipped and provides client-side connectivity to the optional server surface. It implements the core protocols (ApprovalBackend, AuditSink, StorageBackend) over HTTP, letting agents use server-managed approvals, centralized audit ingestion, distributed session state, and SSE-pushed ruleset updates. Edictum.from_server() wires all five server components from a single URL and API key. Edictum.reload() atomically swaps rules from new YAML input (fail-closed on errors). The SSE watcher passes env, bundle_name, and policy_version as query params so the server can filter events per ruleset and detect drift.

The hosted control plane is the coordination layer that sits outside the agent process. The current public surface is app.edictum.ai + api.edictum.ai. That layer provides ruleset storage, approvals, audit feeds, API keys, notification channels, run/agent views, replay, and hot-reload via SSE. The current public docs do not promise Docker Compose, Railway, or a local /dashboard shell. See the control plane docs for the live surface.

The Boundary Principle

The split between core and server follows one principle: evaluation = core library, coordination = server.

CapabilityCore (pip install edictum)Server (optional server surface + edictum[server])
Rule evaluation (pre, post, session, sandbox)Yes--
outside: blockYes--
outside: ask (development)Yes (LocalApprovalBackend)--
outside: ask (production)--Yes (ServerApprovalBackend)
Audit to stdout/file/OTelYes--
Centralized audit app--Yes
Session tracking (single process)Yes (MemoryBackend)--
Session tracking (multi-process)--Yes (ServerBackend)
Atomic rule reload (reload())Yes--
SSE-driven hot-reload--Yes (ServerContractSource + from_server())
8 framework adaptersYes--
Canonical CLI (edictum-go)Yes--

The pipeline that takes a tool call and returns allow/block/warn runs entirely in-process with zero external dependencies. Anything that requires coordination across processes, networking to external systems, or centralized management is server scope.


Source Layout
src/edictum/
  __init__.py              Edictum facade (registers rulesets, hooks, sinks)

  envelope.py              ToolCall, Principal, ToolRegistry, BashClassifier
  rules.py                 @precondition, @postcondition, @session_contract, Decision
  pipeline.py              GovernancePipeline -- PreDecision, PostDecision
  evaluation.py            RuleResult, EvaluationResult (dry-run evaluation API)
  findings.py              Finding, PostCallResult, classify_finding (postcondition output)
  hooks.py                 HookResult, HookDecision (allow/block)
  session.py               Session (atomic counters via StorageBackend)
  storage.py               StorageBackend protocol, MemoryBackend
  limits.py                OperationLimits (max_attempts, max_tool_calls, per-tool)
  audit.py                 AuditEvent, AuditAction, AuditSink, RedactionPolicy, CollectingAuditSink
  approval.py              ApprovalBackend protocol, ApprovalRequest/Decision, LocalApprovalBackend
  telemetry.py             GovernanceTelemetry (OTel spans + metrics, no-op fallback)
  builtins.py              deny_sensitive_reads() built-in precondition
  types.py                 HookRegistration, ToolConfig (internal shared types)

  yaml_engine/
    loader.py              Parse YAML, validate against JSON Schema, SHA-256 hash
    evaluator.py           Condition evaluation (match, principal checks, etc.)
    compiler.py            YAML rulesets -> @precondition/@postcondition/@sandbox objects
    composer.py            Ruleset composition (compose_bundles, observe_alongside)

  otel.py                  configure_otel(), has_otel(), get_tracer() (OTel spans)

  cli/
    main.py                Click CLI entry point (validate, check, diff, replay, test)

  adapters/
    langchain.py           LangChain tool-calling middleware
    crewai.py              CrewAI before/after hooks
    agno.py                Agno async hook wrapper
    semantic_kernel.py     Semantic Kernel filter pattern
    openai_agents.py       OpenAI Agents guardrails
    claude_agent_sdk.py    Anthropic Claude Agent SDK hooks
    nanobot.py             Nanobot enforced ToolRegistry
    google_adk.py          Google ADK plugin and agent callback adapter

  server/                  pip install edictum[server]
    client.py              EdictumServerClient (async HTTP, auth, retries, env, bundle_name)
    approval_backend.py    ServerApprovalBackend (ApprovalBackend via HTTP)
    audit_sink.py          ServerAuditSink (batched event ingestion, bundle_name in payload)
    backend.py             ServerBackend (StorageBackend via HTTP)
    contract_source.py     ServerContractSource (SSE with env/bundle_name/policy_version params)

Last updated on

On this page