Pipeline Architecture
Edictum is a single pipeline that every adapter calls.
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.
DENY
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."
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.
Observe mode → falls back to warn
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.
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:
- Intercepts the framework's tool-call lifecycle event
- Builds a
ToolCallviacreate_envelope() - Calls
pipeline.pre_execute()and translates thePreDecisioninto the framework's expected format - If allowed, lets the tool execute
- Calls
pipeline.post_execute()and forwards any findings
| Adapter | Framework | Integration Method |
|---|---|---|
LangChainAdapter | LangChain | as_middleware(), as_tool_wrapper() |
CrewAIAdapter | CrewAI | register() -- global hooks |
AgnoAdapter | Agno | as_tool_hook() -- wrap-around hook |
SemanticKernelAdapter | Semantic Kernel | register(kernel) -- auto-invocation filter |
OpenAIAgentsAdapter | OpenAI Agents | as_guardrails() -- input/output guardrails |
ClaudeAgentSDKAdapter | Claude Agent SDK | to_hook_callables() -- pre/post tool use hooks |
NanobotAdapter | Nanobot | wrap_registry() -- enforced ToolRegistry |
GoogleADKAdapter | Google ADK | as_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.
| Counter | Semantics |
|---|---|
attempts | Incremented on every pre_execute call, including blocked calls |
execs | Incremented only when a tool actually executes |
tool:{name} | Per-tool execution count |
consec_fail | Consecutive 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:
| Limit | Default | Counts |
|---|---|---|
max_attempts | 500 | All pre_execute calls (including blocked calls) |
max_tool_calls | 200 | Successful 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;
redactandblockeffects are enforced for READ/PURE tools but fall back to warn for WRITE/IRREVERSIBLE tools - Backend errors propagate as exceptions.
ServerBackend.get()returnsNoneonly 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()anddelete()useasyncio.Lockto prevent lost updates underasyncio.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.
| Capability | Core (pip install edictum) | Server (optional server surface + edictum[server]) |
|---|---|---|
| Rule evaluation (pre, post, session, sandbox) | Yes | -- |
outside: block | Yes | -- |
outside: ask (development) | Yes (LocalApprovalBackend) | -- |
outside: ask (production) | -- | Yes (ServerApprovalBackend) |
| Audit to stdout/file/OTel | Yes | -- |
| 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 adapters | Yes | -- |
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