Lifecycle Callbacks
Edictum provides two lifecycle callbacks on the `Edictum` constructor for reacting to
Right page if: you need to react to allow/block decisions in real time using `on_block`, `on_allow`, or `on_postcondition_warn` callbacks, or configure a HITL `approval_backend`. Wrong page if: you need persistent decision logs -- see https://docs.edictum.ai/docs/reference/audit-sinks. For production observability with OTel, see https://docs.edictum.ai/docs/reference/telemetry. Gotcha: `on_block` does not fire in per-rule observe mode (the call is allowed through, so `on_allow` fires instead). If `action: ask` fires but no `approval_backend` is configured, the pipeline raises `EdictumDenied` immediately.
Edictum provides two lifecycle callbacks on the Edictum constructor for reacting to
allow/block decisions in real time. Unlike postcondition findings, which
fire after tool execution, these callbacks fire before the tool runs -- at the
moment the pipeline decides whether to allow or block.
Signatures
guard = Edictum(
rules=[...],
on_block=lambda tool_call, reason, decision_name: ...,
on_allow=lambda envelope: ...,
)| Callback | Signature | When it fires |
|---|---|---|
on_block | (tool_call: ToolCall, reason: str, decision_name: str | None) -> None | A tool call is blocked in enforce mode |
on_allow | (tool_call: ToolCall) -> None | A tool call passes all pre-execution checks |
Both callbacks are sync. If a callback raises an exception, it is caught and logged -- the pipeline continues normally.
When They Fire (and Don't)
| Scenario | on_block | on_allow |
|---|---|---|
| Precondition blocks in enforce mode | Fires | -- |
| Session rule blocks | Fires | -- |
| Limit exceeded (max_attempts, max_tool_calls) | Fires | -- |
| All checks pass | -- | Fires |
| Per-rule observe mode converts block to allow | -- | Fires |
Approval granted (action: ask) | -- | Fires |
Approval blocked or timed out (action: ask) | Fires | -- |
| Postcondition warns after execution | -- | -- |
on_block does not fire in per-rule observe mode. In per-rule observe mode, the call is allowed
through (with a CALL_WOULD_DENY audit event), so on_allow fires instead.
In global observe mode (mode: observe on the Edictum instance), neither on_block nor on_allow fires on would-block paths. The pipeline emits a CALL_WOULD_DENY audit event but skips both callbacks.
Use Cases
Real-time alerting
React to blocks immediately instead of parsing audit logs after the fact:
def alert_on_block(tool_call, reason, decision_name):
slack.post(f"BLOCKED {tool_call.tool_name}: {reason} (decision: {decision_name})")
guard = Edictum.from_yaml("rules.yaml", on_block=alert_on_block)Metrics and dashboards
Track allow/block rates without OTel infrastructure:
from prometheus_client import Counter
blocked = Counter("edictum_blocked_total", "Blocked tool calls", ["tool", "decision"])
allowed = Counter("edictum_allowed_total", "Allowed tool calls", ["tool"])
guard = Edictum(
rules=[...],
on_block=lambda env, reason, name: blocked.labels(tool=env.tool_name, decision=name or "").inc(),
on_allow=lambda env: allowed.labels(tool=env.tool_name).inc(),
)Circuit breaker
Disable the agent after too many blocks in a window:
blocked_count = 0
def circuit_breaker(tool_call, reason, decision_name):
global blocked_count
blocked_count += 1
if blocked_count > 10:
raise SystemExit("Agent stuck in a block loop -- shutting down")
guard = Edictum(rules=[...], on_block=circuit_breaker)Development debugging
Print block events to the console during development:
guard = Edictum(
rules=[...],
on_block=lambda env, reason, name: print(f"BLOCKED {env.tool_name}: {reason} [{name}]"),
on_allow=lambda env: print(f"ALLOWED {env.tool_name}"),
)Callback Arguments
on_block
| Argument | Type | Description |
|---|---|---|
envelope | ToolCall | Full context: tool name, args, principal, side effect, environment |
reason | str | Human-readable block reason from the rule, workflow stage, approval path, or limit |
decision_name | str | None | Rule ID, workflow stage ID, or limit name that caused the block |
on_allow
| Argument | Type | Description |
|---|---|---|
envelope | ToolCall | Full context: tool name, args, principal, side effect, environment |
Works With All Entry Points
The callbacks are available on every way to create an Edictum instance:
# Constructor
guard = Edictum(rules=[...], on_block=my_handler, on_allow=my_handler)
# YAML
guard = Edictum.from_yaml("rules.yaml", on_block=my_handler, on_allow=my_handler)
# Template
guard = Edictum.from_template("file-agent", on_block=my_handler, on_allow=my_handler)
# Merged guards (inherits from first guard)
merged = Edictum.from_multiple([guard1, guard2])from_multiple() merges rulesets from all supplied guards, including observe-mode rulesets. Prior to edictum 1.10, observe-mode rulesets were silently dropped during the merge — they are now preserved.
All 8 Adapters
Lifecycle callbacks fire in every adapter -- they are invoked by the run() method and adapters that call it. This means the same on_block / on_allow
functions work regardless of which framework you use.
Relationship to Other Features
| Feature | Purpose | Fires when |
|---|---|---|
on_block | React to blocks in real time | Pre-execution block (enforce mode) |
on_allow | React to allowed calls in real time | Pre-execution allow |
on_postcondition_warn | Remediate bad tool output | Post-execution postcondition failure |
approval_backend | Human-in-the-loop approval for tool calls | Pre-execution when action: ask fires |
| Audit sinks | Persistent record of all decisions | Every decision (allow, block, execute, fail) |
| OTel spans | Production observability | Every decision (with full trace context) |
Lifecycle callbacks are the lightweight, zero-dependency option for users who need real-time reactions without setting up audit sink parsing or OTel infrastructure. For production observability at scale, use OTel. For persistent decision logs, use audit sinks.
Approval Backend
The approval_backend parameter enables human-in-the-loop approval workflows. When a
precondition with action: ask fires, the pipeline pauses and delegates to the
configured backend.
from edictum import Edictum, LocalApprovalBackend
guard = Edictum.from_yaml(
"rules.yaml",
approval_backend=LocalApprovalBackend(),
)The ApprovalBackend protocol requires two async methods:
| Method | Description |
|---|---|
request_approval(tool_name, tool_args, message, *, timeout, timeout_action, principal) | Creates an approval request and returns an ApprovalRequest |
wait_for_decision(approval_id, timeout) | Blocks until the request is approved, blocked, or times out. Returns an ApprovalDecision |
LocalApprovalBackend prompts on stdout and reads from stdin -- suitable for local
development and testing. For production use, implement ApprovalBackend with your
own backend (Slack bot, web control plane, approval queue).
If action: ask fires but no approval_backend is configured, the pipeline
raises EdictumDenied immediately.
Last updated on