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: in enforce mode, caught pre-execution rule, hook, and workflow evaluation errors become block decisions. With guard.run() and wrap-around adapters, other pre-execution failures abort before the tool runs. Hook adapters can hand an exception back to the host, whose error behavior controls whether the tool runs.
Edictum is a single pipeline that every adapter calls. Rulesets written in YAML compile to the same runtime objects as Python-defined rulesets. Built-in YAML expressions are deterministic for the same canonical inputs and state; custom callbacks can depend on external state.
Pipeline Overview
Every tool call submitted through a supported Edictum integration passes through one pipeline.
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 reports a finding. On integrations that can replace
tool results, redact and block modify READ/PURE responses before they reach
the agent. Hook integrations that cannot replace results are warning-only.
WRITE/IRREVERSIBLE tools always get warn because the action already happened.
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 do not decide policy. They translate pipeline decisions into the framework's enforcement mechanism, so that translation is security-critical. 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) - 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; integrations that can replace results enforce
redactandblockfor READ/PURE tools, while other integrations and WRITE/IRREVERSIBLE tools warn - Backend errors inside normal session-rule or workflow evaluation create block decisions with
policy_error: true; guard-level observe mode can turn that block into would-deny, while separately registered observe-alongside session rules allow and currently omit the flag. Denial-audit construction rereads session state, so a persistent outage can replace the block outcome with an exception. Other backend errors propagate.ServerBackend.get()returnsNoneonly for HTTP 404 (key not found). Withguard.run()and wrap-around adapters, an uncaught pre-invocation failure stops the run before tool execution. Backend writes after invocation can fail after the tool has run. Hook adapters return control to the host on an uncaught exception, so host error behavior applies - 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 for caught enforced rule, before-hook,
and workflow evaluation failures that propagate the flag. Rule load failures,
after-hook failures, and some observe-alongside failures do not create that
signal.
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 reference API/app stack 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 reference API/app stack 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)
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)
rule_source.py ServerContractSource (SSE with env/bundle_name/policy_version params)Last updated on