Skip to content

Lifecycle Callbacks

Edictum provides two lifecycle callbacks on the Edictum constructor for reacting to allow/deny 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 deny.

Signatures

guard = Edictum(
    contracts=[...],
    on_deny=lambda envelope, reason, contract_id: ...,
    on_allow=lambda envelope: ...,
)
Callback Signature When it fires
on_deny (envelope: ToolEnvelope, reason: str, contract_id: str \| None) -> None A tool call is denied in enforce mode
on_allow (envelope: ToolEnvelope) -> 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_deny on_allow
Precondition denies in enforce mode Fires --
Session contract denies Fires --
Limit exceeded (max_attempts, max_tool_calls) Fires --
All checks pass -- Fires
Observe mode converts deny to allow -- Fires
Approval granted (effect: approve) -- Fires
Approval denied or timed out (effect: approve) Fires --
Postcondition warns after execution -- --

on_deny does not fire in observe mode. In observe mode, the call is allowed through (with a CALL_WOULD_DENY audit event), so on_allow fires instead.

Use Cases

Real-time alerting

React to denials immediately instead of parsing audit logs after the fact:

def alert_on_deny(envelope, reason, contract_id):
    slack.post(f"DENIED {envelope.tool_name}: {reason} (contract: {contract_id})")

guard = Edictum.from_yaml("contracts.yaml", on_deny=alert_on_deny)

Metrics and dashboards

Track allow/deny rates without OTel infrastructure:

from prometheus_client import Counter

denied = Counter("edictum_denied_total", "Denied tool calls", ["tool", "contract"])
allowed = Counter("edictum_allowed_total", "Allowed tool calls", ["tool"])

guard = Edictum(
    contracts=[...],
    on_deny=lambda env, reason, cid: denied.labels(tool=env.tool_name, contract=cid or "").inc(),
    on_allow=lambda env: allowed.labels(tool=env.tool_name).inc(),
)

Circuit breaker

Disable the agent after too many denials in a window:

denial_count = 0

def circuit_breaker(envelope, reason, contract_id):
    global denial_count
    denial_count += 1
    if denial_count > 10:
        raise SystemExit("Agent stuck in denial loop -- shutting down")

guard = Edictum(contracts=[...], on_deny=circuit_breaker)

Development debugging

Print denials to the console during development:

guard = Edictum(
    contracts=[...],
    on_deny=lambda env, reason, cid: print(f"DENIED {env.tool_name}: {reason} [{cid}]"),
    on_allow=lambda env: print(f"ALLOWED {env.tool_name}"),
)

Callback Arguments

on_deny

Argument Type Description
envelope ToolEnvelope Full context: tool name, args, principal, side effect, environment
reason str Human-readable denial reason from the contract or limit
contract_id str \| None Name of the contract that caused the denial, or limit name (e.g. max_attempts)

on_allow

Argument Type Description
envelope ToolEnvelope 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(contracts=[...], on_deny=my_handler, on_allow=my_handler)

# YAML
guard = Edictum.from_yaml("contracts.yaml", on_deny=my_handler, on_allow=my_handler)

# Template
guard = Edictum.from_template("file-agent", on_deny=my_handler, on_allow=my_handler)

# Merged guards (inherits from first guard)
merged = Edictum.from_multiple([guard1, guard2])

All 7 Adapters

Lifecycle callbacks fire in every adapter -- they are invoked by the adapter's pre-execution path, not the pipeline itself. This means the same on_deny / on_allow functions work regardless of which framework you use.

Relationship to Other Features

Feature Purpose Fires when
on_deny React to denials in real time Pre-execution deny (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 effect: approve fires
Audit sinks Persistent record of all decisions Every decision (allow, deny, 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 audit trails, use audit sinks.

Approval Backend

The approval_backend parameter enables human-in-the-loop approval workflows. When a precondition with effect: approve fires, the pipeline pauses and delegates to the configured backend.

from edictum import Edictum, LocalApprovalBackend

guard = Edictum.from_yaml(
    "contracts.yaml",
    approval_backend=LocalApprovalBackend(),
)

The ApprovalBackend protocol requires two async methods:

Method Description
request_approval(tool_name, tool_args, message, *, timeout, timeout_effect, principal) Creates an approval request and returns an ApprovalRequest
wait_for_decision(approval_id, timeout) Blocks until the request is approved, denied, 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 dashboard, approval queue).

If effect: approve fires but no approval_backend is configured, the pipeline raises EdictumDenied immediately.