Advanced Patterns
This page covers patterns that combine multiple Edictum features: nested boolean logic, regex composition, principal claims, template composition, wildcards,...
Right page if: you need to combine multiple Edictum features -- nested boolean logic, regex composition, wildcard selectors, template composition, or guard merging. Wrong page if: you need a single-purpose pattern -- start with the specific pattern page (access-control, data-protection, etc.) at https://docs.edictum.ai/docs/rulesets/patterns. Gotcha: `from_multiple()` merges multiple Edictum instances but rule IDs must be unique across all merged bundles. Duplicate IDs cause a load error, not a silent override.
This page covers patterns that combine multiple Edictum features: nested boolean logic, regex composition, principal claims, template composition, wildcards, dynamic messages, comprehensive rulesets, per-rule mode overrides, environment-based conditions, and guard merging.
Nested All/Any/Not Logic
Boolean combinators (all, any, not) nest arbitrarily. Use them to build complex access patterns from simple leaves.
When to use: Your access rule cannot be expressed as a single condition. You need AND, OR, and NOT logic combined.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: nested-logic
defaults:
mode: enforce
rules:
- id: complex-deploy-gate
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- any:
- principal.role: { not_in: [admin, sre] }
- not:
principal.ticket_ref: { exists: true }
then:
action: block
message: "Production deploy denied. Requires (admin or sre role) AND a ticket reference."
tags: [access-control, production]from edictum import Decision, precondition
@precondition("deploy_service")
def complex_deploy_gate(envelope):
if envelope.environment != "production":
return Decision.pass_()
# Requires (admin or sre role) AND a ticket reference
role_ok = envelope.principal and envelope.principal.role in ("admin", "sre")
has_ticket = envelope.principal and envelope.principal.ticket_ref
if not role_ok or not has_ticket:
return Decision.fail(
"Production deploy denied. Requires (admin or sre role) "
"AND a ticket reference."
)
return Decision.pass_()How to read this: The deploy is denied when the environment is production AND (the role is not admin/sre OR there is no ticket reference). In other words, production deploys require both a privileged role and a ticket.
Gotchas:
- Deeply nested trees become hard to read. If your
whenblock exceeds three levels of nesting, consider splitting into multiple rulesets with simpler conditions. nottakes a single child expression, not an array.not: [expr1, expr2]is a validation error.- Boolean combinators require at least one child in
allandanyarrays. An empty array is a validation error.
Regex with matches_any
Combine multiple regex patterns in a single postcondition to detect several categories of sensitive data at once.
When to use: You want one rule to catch multiple data patterns (PII, secrets, regulated content) rather than maintaining separate rulesets for each.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: regex-composition
defaults:
mode: enforce
rules:
- id: comprehensive-data-scan
type: post
tool: "*"
when:
output.text:
matches_any:
- '\\b\\d{3}-\\d{2}-\\d{4}\\b'
- '\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b'
- '\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b'
- '\\b\\d{3}[-.]?\\d{3}[-.]?\\d{4}\\b'
- 'AKIA[0-9A-Z]{16}'
- 'eyJ[A-Za-z0-9_-]+\\.eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+'
then:
action: warn
message: "Sensitive data pattern detected in output. Redact before using."
tags: [pii, secrets, compliance]import re
from edictum import Decision
from edictum.rulesets import postcondition
@postcondition("*")
def comprehensive_data_scan(envelope, tool_response):
if not isinstance(tool_response, str):
return Decision.pass_()
patterns = [
r"\b\d{3}-\d{2}-\d{4}\b", # SSN
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", # Email
r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", # Credit card
r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b", # Phone
r"AKIA[0-9A-Z]{16}", # AWS key
r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", # JWT
]
for pat in patterns:
if re.search(pat, tool_response):
return Decision.fail(
"Sensitive data pattern detected in output. Redact before using."
)
return Decision.pass_()Gotchas:
matches_anyshort-circuits on the first matching pattern. Order patterns from most likely to least likely for performance.- All patterns are compiled at load time. Invalid regex in any element causes a validation error for the entire bundle.
- Use single-quoted strings in YAML for regex. Double-quoted strings interpret backslash sequences (
\bbecomes backspace,\dis literald).
Principal Claims as Dicts
The principal.claims.<key> selector accesses custom attributes from the Principal.claims dictionary. Claims support any value type: strings, numbers, booleans, and lists.
When to use: Your authorization model needs attributes beyond role, user_id, and org_id. Claims let you attach domain-specific metadata like department, clearance level, or capability entitlements.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: claims-patterns
defaults:
mode: enforce
rules:
- id: require-clearance
type: pre
tool: read_file
when:
all:
- args.path: { contains: "classified" }
- principal.claims.clearance: { not_in: [secret, top-secret] }
then:
action: block
message: "Classified file access requires secret or top-secret clearance."
tags: [access-control, classified]
- id: entitlement-gate
type: pre
tool: send_email
when:
not:
principal.claims.can_send_email: { equals: true }
then:
action: block
message: "Email capability is not enabled for this principal."
tags: [entitlements]The entitlement-gate rule uses principal claims for identity-based gating -- it checks a per-principal boolean attribute, not a feature flag. This is capability enforcement: the principal either has the entitlement or they don't.
from edictum import Decision, precondition
@precondition("read_file")
def require_clearance(envelope):
path = envelope.args.get("path", "")
if "classified" not in path:
return Decision.pass_()
clearance = (
envelope.principal.claims.get("clearance")
if envelope.principal else None
)
if clearance not in ("secret", "top-secret"):
return Decision.fail(
"Classified file access requires secret or top-secret clearance."
)
return Decision.pass_()
@precondition("send_email")
def entitlement_gate(envelope):
enabled = (
envelope.principal.claims.get("can_send_email")
if envelope.principal else False
)
if not enabled:
return Decision.fail("Email capability is not enabled for this principal.")
return Decision.pass_()Setting claims in Python:
from edictum import Principal
principal = Principal(
user_id="user-123",
role="analyst",
claims={
"clearance": "secret",
"department": "engineering",
"feature_flags_email": True,
},
)Gotchas:
- Claims are set by your application. Edictum does not validate claim values against any external source.
- If a claim key does not exist, the leaf evaluates to
false. Useprincipal.claims.<key>: { exists: false }to explicitly require a claim. - Nested claims are supported. Dotted paths like
principal.claims.org.teamresolve through nested dicts in thePrincipal.claimsdictionary (e.g.,claims={"org": {"team": "backend"}}).
Template Composition
Edictum ships built-in templates that you can load directly. Templates are complete YAML bundles that go through the same validation and hashing path as custom bundles.
When to use: You want a ready-made ruleset for common agent patterns without writing YAML from scratch.
from edictum import Edictum
# Load a built-in template
guard = Edictum.from_template("file-agent")
# Load with overrides
guard = Edictum.from_template(
"devops-agent",
environment="staging",
mode="observe",
)Available templates:
| Template | Description |
|---|---|
file-agent | Blocks sensitive file reads and destructive bash commands |
research-agent | Rate limits, PII detection, and sensitive file protection |
devops-agent | Production gates, ticket requirements, PII detection, session limits |
nanobot-agent | Approval gates, workspace path enforcement, MCP tool gating |
To customize a template, copy its YAML source from src/edictum/yaml_engine/templates/ into your project and modify it. Load the customized version with Edictum.from_yaml().
Wildcards
Use tool: "*" to target all tools with a single rule. This is useful for cross-cutting concerns that apply regardless of which tool the agent calls.
When to use: Security scanning (PII, secrets), session limits, or any rule that should apply to every tool.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: wildcard-patterns
defaults:
mode: enforce
rules:
- id: global-pii-scan
type: post
tool: "*"
when:
output.text:
matches_any:
- '\\b\\d{3}-\\d{2}-\\d{4}\\b'
then:
action: warn
message: "PII detected in {tool.name} output. Redact before using."
tags: [pii]
- id: block-all-in-maintenance
type: pre
tool: "*"
when:
environment: { equals: maintenance }
then:
action: block
message: "System is in maintenance mode. All tool calls are denied."
tags: [maintenance]import re
from edictum import Decision, precondition
from edictum.rulesets import postcondition
@postcondition("*")
def global_pii_scan(envelope, tool_response):
if not isinstance(tool_response, str):
return Decision.pass_()
if re.search(r"\b\d{3}-\d{2}-\d{4}\b", tool_response):
return Decision.fail(
f"PII detected in {envelope.tool_name} output. Redact before using."
)
return Decision.pass_()
@precondition("*")
def block_all_in_maintenance(envelope):
if envelope.environment == "maintenance":
return Decision.fail("System is in maintenance mode. All tool calls are denied.")
return Decision.pass_()Gotchas:
- Wildcard rulesets run on every tool call. In a bundle with many wildcard rulesets, each tool call triggers all of them. Keep wildcard rulesets lightweight.
- If you need a wildcard rule to exclude specific tools, there is no built-in exclusion syntax. Use a
notcombinator withtool.name: { in: [...] }to skip certain tools.
Dynamic Message Interpolation
Messages support {placeholder} expansion using the same selector paths as the expression grammar. This makes denial messages specific and actionable.
When to use: Always. Generic messages like "Access denied" give the agent no guidance on how to self-correct. Specific messages with interpolated values help the agent understand what went wrong and what to do instead.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: dynamic-messages
defaults:
mode: enforce
rules:
- id: detailed-deny-message
type: pre
tool: read_file
when:
args.path:
contains_any: [".env", "credentials", ".pem"]
then:
action: block
message: "Cannot read '{args.path}' (user: {principal.user_id}, role: {principal.role}). Skip this file."
tags: [secrets]
- id: environment-in-message
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- principal.role: { not_in: [admin, sre] }
then:
action: block
message: "Deploy to {environment} denied for role '{principal.role}'. Requires admin or sre."
tags: [access-control]from edictum import Decision, precondition
@precondition("read_file")
def detailed_deny(envelope):
path = envelope.args.get("path", "")
for s in (".env", "credentials", ".pem"):
if s in path:
user = envelope.principal.user_id if envelope.principal else "unknown"
role = envelope.principal.role if envelope.principal else "none"
return Decision.fail(
f"Cannot read '{path}' (user: {user}, role: {role}). Skip this file."
)
return Decision.pass_()
@precondition("deploy_service")
def environment_in_message(envelope):
if envelope.environment != "production":
return Decision.pass_()
if not envelope.principal or envelope.principal.role not in ("admin", "sre"):
role = envelope.principal.role if envelope.principal else "none"
return Decision.fail(
f"Deploy to {envelope.environment} denied for role '{role}'. "
"Requires admin or sre."
)
return Decision.pass_()Available placeholders:
{args.<key>}-- tool argument values{tool.name}-- the tool being called{environment}-- the current environment{principal.user_id},{principal.role},{principal.org_id}-- principal fields{principal.claims.<key>}-- custom claims{env.<VAR>}-- environment variable values
Gotchas:
- If a placeholder references a missing field, it is kept as-is in the output (e.g.,
{principal.user_id}appears literally if no principal is attached). No error is raised. - Each placeholder expansion is capped at 200 characters. Values longer than 200 characters are truncated.
- Messages have a maximum length of 500 characters. Keep messages concise.
Combining Pre + Post + Session
A comprehensive ruleset combines all three rule types: preconditions block before execution, postconditions warn after execution, and session rulesets track cumulative behavior.
When to use: Production agent deployments where you need defense in depth across all three dimensions.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: comprehensive-governance
defaults:
mode: enforce
rules:
# --- Preconditions: block before execution ---
- id: block-sensitive-reads
type: pre
tool: read_file
when:
args.path:
contains_any: [".env", "credentials", ".pem", "id_rsa"]
then:
action: block
message: "Sensitive file '{args.path}' denied."
tags: [secrets, dlp]
- id: prod-deploy-gate
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- principal.role: { not_in: [admin, sre] }
then:
action: block
message: "Production deploys require admin or sre role."
tags: [access-control, production]
# --- Postconditions: warn after execution ---
- id: pii-in-output
type: post
tool: "*"
when:
output.text:
matches_any:
- '\\b\\d{3}-\\d{2}-\\d{4}\\b'
- '\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b'
then:
action: warn
message: "PII detected in output. Redact before using."
tags: [pii, compliance]
- id: secrets-in-output
type: post
tool: "*"
when:
output.text:
matches_any:
- 'AKIA[0-9A-Z]{16}'
- 'eyJ[A-Za-z0-9_-]+\\.eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+'
then:
action: warn
message: "Credentials detected in output. Do not log or reproduce."
tags: [secrets, dlp]
# --- Session: track cumulative behavior ---
- id: session-limits
type: session
limits:
max_tool_calls: 50
max_attempts: 120
max_calls_per_tool:
deploy_service: 3
send_email: 10
then:
action: block
message: "Session limit reached. Summarize progress and stop."
tags: [rate-limit]from edictum import Edictum, OperationLimits, Decision, precondition
from edictum.rulesets import postcondition
import re
@precondition("read_file")
def block_sensitive_reads(envelope):
path = envelope.args.get("path", "")
for s in (".env", "credentials", ".pem", "id_rsa"):
if s in path:
return Decision.fail(f"Sensitive file '{path}' denied.")
return Decision.pass_()
@precondition("deploy_service")
def prod_deploy_gate(envelope):
if envelope.environment != "production":
return Decision.pass_()
if not envelope.principal or envelope.principal.role not in ("admin", "sre"):
return Decision.fail("Production deploys require admin or sre role.")
return Decision.pass_()
@postcondition("*")
def pii_in_output(envelope, tool_response):
if not isinstance(tool_response, str):
return Decision.pass_()
patterns = [r"\b\d{3}-\d{2}-\d{4}\b", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"]
for pat in patterns:
if re.search(pat, tool_response):
return Decision.fail("PII detected in output. Redact before using.")
return Decision.pass_()
@postcondition("*")
def secrets_in_output(envelope, tool_response):
if not isinstance(tool_response, str):
return Decision.pass_()
patterns = [r"AKIA[0-9A-Z]{16}", r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"]
for pat in patterns:
if re.search(pat, tool_response):
return Decision.fail("Credentials detected in output. Do not log or reproduce.")
return Decision.pass_()
guard = Edictum(
rules=[block_sensitive_reads, prod_deploy_gate, pii_in_output, secrets_in_output],
limits=OperationLimits(
max_tool_calls=50,
max_attempts=120,
max_calls_per_tool={"deploy_service": 3, "send_email": 10},
),
)Gotchas:
- Rule evaluation order within a type follows the array order in the YAML. For preconditions, the first matching deny wins and stops evaluation.
- When a precondition denies a call in enforce mode,
run()raisesEdictumDeniedimmediately. The tool does not execute and postconditions are not evaluated. Postconditions only run when the tool actually executes. - Session rulesets are checked after preconditions, not before. The full pre-execution order is: 1. Attempt limit, 2. Before hooks, 3. Preconditions, 4. Session rulesets, 5. Execution limits.
Per-Rule Mode Override
Individual rules can override the ruleset's default mode. This lets you mix enforced and observed rules in a single ruleset.
When to use: You are adding a new rule to an existing production ruleset and want to test it in observe mode before enforcing.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: mixed-mode-ruleset
defaults:
mode: enforce
rules:
# Enforced (inherits ruleset default)
- id: block-sensitive-reads
type: pre
tool: read_file
when:
args.path:
contains_any: [".env", "credentials"]
then:
action: block
message: "Sensitive file denied."
tags: [secrets]
# Observe mode: observe-testing a new rule
- id: experimental-query-limit
type: pre
mode: observe
tool: query_database
when:
args.query: { matches: '\\bSELECT\\s+\\*\\b' }
then:
action: block
message: "SELECT * detected (observe mode). Use explicit column lists."
tags: [experimental, sql-quality]from edictum import Edictum, Decision, precondition
@precondition("read_file")
def block_sensitive_reads(envelope):
path = envelope.args.get("path", "")
for s in (".env", "credentials"):
if s in path:
return Decision.fail("Sensitive file denied.")
return Decision.pass_()
# In Python, per-rule mode override is done by running
# separate Edictum instances: one enforced, one in observe mode.
enforced_guard = Edictum(rules=[block_sensitive_reads])
# Or use a single observe-mode instance to observe-test:
import re
@precondition("query_database")
def experimental_query_limit(envelope):
query = envelope.args.get("query", "")
if re.search(r"\bSELECT\s+\*\b", query):
return Decision.fail("SELECT * detected. Use explicit column lists.")
return Decision.pass_()
observe_guard = Edictum(
mode="observe",
rules=[experimental_query_limit],
)Gotchas:
- Observe mode emits
CALL_WOULD_DENYaudit events. The tool call proceeds normally. Review these events before switching to enforce. - The mode override is per-rule. Other rules in the same ruleset continue to use the ruleset default.
- For postconditions,
mode: observedowngradesredact/blockeffects to a warning prefixed with[observe]. The tool output is not modified. Observe mode is meaningful for all rule types.
Environment-Based Conditions
Use env.* selectors to conditionally activate rulesets based on environment variables. The evaluator reads os.environ at evaluation time -- no adapter changes, no envelope modifications, no code changes.
When to use: You want a single YAML file with rulesets that activate based on runtime flags like DRY_RUN, ENVIRONMENT, or FEATURE_X_ENABLED. Set the env var, and the rule activates.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: env-conditions
defaults:
mode: enforce
rules:
# Block modifications when DRY_RUN is set
- id: dry-run-block
type: pre
tool: "*"
when:
all:
- env.DRY_RUN: { equals: true }
- tool.name: { in: [Bash, Write, Edit] }
then:
action: block
message: "Dry run mode — modifications denied."
tags: [dry-run]
# Block destructive commands in production
- id: prod-destructive-block
type: pre
tool: Bash
when:
all:
- env.ENVIRONMENT: { equals: "production" }
- args.command: { matches: '\brm\s+(-rf?|--recursive)\b' }
then:
action: block
message: "Destructive commands denied in {env.ENVIRONMENT}."
tags: [destructive, production]# Activate dry-run mode
DRY_RUN=true python agent.py
# Or set environment
ENVIRONMENT=production python agent.pyType coercion: Env vars are strings, but the evaluator coerces them automatically:
"true"/"false"(case-insensitive) becomeTrue/False- Numeric strings like
"42"or"3.14"becomeintorfloat - Everything else stays a string
This means env.DRY_RUN: { equals: true } works when DRY_RUN=true is set -- you compare against the boolean true, not the string "true".
Gotchas:
- Unset env vars evaluate to
false(the rule does not fire). This is consistent with how missing fields behave everywhere in Edictum. - Env vars are read at evaluation time, not load time. If an env var changes mid-process, the next tool call sees the new value.
- All 15 operators work with
env.*selectors. Use numeric operators (gt,lt, etc.) with coerced numeric env vars. {env.VAR_NAME}works in message templates for dynamic denial messages.
Ruleset Composition (Multi-File)
Edictum.from_yaml() accepts multiple paths, composing rulesets left-to-right with deterministic merge semantics. This is the preferred way to combine rules from multiple YAML files.
When to use: You have separate YAML files for different concerns (base safety, team overrides, environment-specific rulesets) and want to compose them into a single guard.
from edictum import Edictum
guard = Edictum.from_yaml(
"rules/base.yaml",
"rules/team-overrides.yaml",
"rules/prod-overrides.yaml",
)Merge semantics:
- Rulesets with the same
id: later layer replaces the entire rule - Rulesets with unique IDs: concatenated into the final list
defaults.mode,limits,observability: later layer winstools,metadata: deep merge (later keys override)
Use return_report=True to see what was overridden:
guard, report = Edictum.from_yaml(
"rules/base.yaml",
"rules/overrides.yaml",
return_report=True,
)
for o in report.overridden_rules:
print(f"{o.rule_id}: overridden by {o.overridden_by}")Testing with observe_alongside:
A second ruleset with observe_alongside: true evaluates as observed rules -- audit events are emitted but tool calls are never blocked:
guard = Edictum.from_yaml(
"rulesets/current.yaml", # enforced
"rules/candidate.yaml", # observe_alongside: true → shadow
)See Ruleset Composition for full reference.
Gotchas:
- Rule replacement is by
id, not position. No partial merging of conditions within a rule. - Single-path
from_yaml("file.yaml")is unchanged and backward compatible. - For composing Python-defined guards at runtime, use
Edictum.from_multiple().
Guard Merging (Python)
Use Edictum.from_multiple() to combine rulesets from multiple instantiated guards into a single guard. This is for runtime merging of Python-defined rulesets or conditionally loaded YAML guards.
When to use: You need to combine guards at the Python level -- for example, conditionally adding guards based on runtime state, or mixing YAML-loaded and Python-defined rulesets.
import os
from edictum import Edictum
guards = [Edictum.from_yaml("rules/base.yaml")]
if os.environ.get("DRY_RUN"):
guards.append(Edictum.from_yaml("rulesets/dry-run.yaml"))
guard = Edictum.from_multiple(guards)Prefer `from_yaml(*paths)` for YAML composition
If you are combining multiple YAML files, use from_yaml("base.yaml", "overrides.yaml") instead of from_multiple(). Multi-path from_yaml() provides deterministic merge semantics, composition reports, and observe_alongside support. from_multiple() is for cases where you need runtime conditional loading or mixing Python-defined rulesets.
Semantics:
- Rulesets are concatenated in order. The first guard's rulesets evaluate first.
- The first guard's audit config, mode, environment, and limits are used as the base.
- Duplicate rule IDs: first occurrence wins, duplicates skipped with a warning.
- The returned guard is a new instance. Input guards are not mutated.
Gotchas:
from_multiple([])raisesEdictumConfigError. At least one guard is required.- Hooks (before/after) are not merged -- only rulesets (preconditions, postconditions, session rulesets).
- Duplicate IDs are checked across all rule types. A precondition ID in guard A blocks a postcondition with the same ID in guard B.
Last updated on