Advanced Patterns¶
This page covers patterns that combine multiple Edictum features: nested boolean logic, regex composition, principal claims, template composition, wildcards, dynamic messages, comprehensive contract bundles, per-contract 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 contract cannot be expressed as a single condition. You need AND, OR, and NOT logic combined.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: nested-logic
defaults:
mode: enforce
contracts:
- 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:
effect: deny
message: "Production deploy denied. Requires (admin or sre role) AND a ticket reference."
tags: [access-control, production]
from edictum import Verdict, precondition
@precondition("deploy_service")
def complex_deploy_gate(envelope):
if envelope.environment != "production":
return Verdict.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 Verdict.fail(
"Production deploy denied. Requires (admin or sre role) "
"AND a ticket reference."
)
return Verdict.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 when block exceeds three levels of nesting, consider splitting into multiple contracts with simpler conditions.
- not takes a single child expression, not an array. not: [expr1, expr2] is a validation error.
- Boolean combinators require at least one child in all and any arrays. 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 contract to catch multiple data patterns (PII, secrets, regulated content) rather than maintaining separate contracts for each.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: regex-composition
defaults:
mode: enforce
contracts:
- 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:
effect: warn
message: "Sensitive data pattern detected in output. Redact before using."
tags: [pii, secrets, compliance]
import re
from edictum import Verdict
from edictum.contracts import postcondition
@postcondition("*")
def comprehensive_data_scan(envelope, tool_response):
if not isinstance(tool_response, str):
return Verdict.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 Verdict.fail(
"Sensitive data pattern detected in output. Redact before using."
)
return Verdict.pass_()
Gotchas:
- matches_any short-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 (\b becomes backspace, \d is literal d).
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: ContractBundle
metadata:
name: claims-patterns
defaults:
mode: enforce
contracts:
- id: require-clearance
type: pre
tool: read_file
when:
all:
- args.path: { contains: "classified" }
- principal.claims.clearance: { not_in: [secret, top-secret] }
then:
effect: deny
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:
effect: deny
message: "Email capability is not enabled for this principal."
tags: [entitlements]
The entitlement-gate contract 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 Verdict, precondition
@precondition("read_file")
def require_clearance(envelope):
path = envelope.args.get("path", "")
if "classified" not in path:
return Verdict.pass_()
clearance = (
envelope.principal.claims.get("clearance")
if envelope.principal else None
)
if clearance not in ("secret", "top-secret"):
return Verdict.fail(
"Classified file access requires secret or top-secret clearance."
)
return Verdict.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 Verdict.fail("Email capability is not enabled for this principal.")
return Verdict.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. Use principal.claims.<key>: { exists: false } to explicitly require a claim.
- Nested claims are supported. Dotted paths like principal.claims.org.team resolve through nested dicts in the Principal.claims dictionary (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 contract bundle 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 |
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 contract. 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 contract that should apply to every tool.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: wildcard-patterns
defaults:
mode: enforce
contracts:
- id: global-pii-scan
type: post
tool: "*"
when:
output.text:
matches_any:
- '\\b\\d{3}-\\d{2}-\\d{4}\\b'
then:
effect: 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:
effect: deny
message: "System is in maintenance mode. All tool calls are denied."
tags: [maintenance]
import re
from edictum import Verdict, precondition
from edictum.contracts import postcondition
@postcondition("*")
def global_pii_scan(envelope, tool_response):
if not isinstance(tool_response, str):
return Verdict.pass_()
if re.search(r"\b\d{3}-\d{2}-\d{4}\b", tool_response):
return Verdict.fail(
f"PII detected in {envelope.tool_name} output. Redact before using."
)
return Verdict.pass_()
@precondition("*")
def block_all_in_maintenance(envelope):
if envelope.environment == "maintenance":
return Verdict.fail("System is in maintenance mode. All tool calls are denied.")
return Verdict.pass_()
Gotchas:
- Wildcard contracts run on every tool call. In a bundle with many wildcard contracts, each tool call triggers all of them. Keep wildcard contracts lightweight.
- If you need a wildcard contract to exclude specific tools, there is no built-in exclusion syntax. Use a not combinator with tool.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: ContractBundle
metadata:
name: dynamic-messages
defaults:
mode: enforce
contracts:
- id: detailed-deny-message
type: pre
tool: read_file
when:
args.path:
contains_any: [".env", "credentials", ".pem"]
then:
effect: deny
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:
effect: deny
message: "Deploy to {environment} denied for role '{principal.role}'. Requires admin or sre."
tags: [access-control]
from edictum import Verdict, 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 Verdict.fail(
f"Cannot read '{path}' (user: {user}, role: {role}). Skip this file."
)
return Verdict.pass_()
@precondition("deploy_service")
def environment_in_message(envelope):
if envelope.environment != "production":
return Verdict.pass_()
if not envelope.principal or envelope.principal.role not in ("admin", "sre"):
role = envelope.principal.role if envelope.principal else "none"
return Verdict.fail(
f"Deploy to {envelope.environment} denied for role '{role}'. "
"Requires admin or sre."
)
return Verdict.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 contract bundle combines all three contract types: preconditions block before execution, postconditions warn after execution, and session contracts track cumulative behavior.
When to use: Production agent deployments where you need defense in depth across all three dimensions.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: comprehensive-governance
defaults:
mode: enforce
contracts:
# --- Preconditions: block before execution ---
- id: block-sensitive-reads
type: pre
tool: read_file
when:
args.path:
contains_any: [".env", "credentials", ".pem", "id_rsa"]
then:
effect: deny
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:
effect: deny
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:
effect: 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:
effect: 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:
effect: deny
message: "Session limit reached. Summarize progress and stop."
tags: [rate-limit]
from edictum import Edictum, OperationLimits, Verdict, precondition
from edictum.contracts 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 Verdict.fail(f"Sensitive file '{path}' denied.")
return Verdict.pass_()
@precondition("deploy_service")
def prod_deploy_gate(envelope):
if envelope.environment != "production":
return Verdict.pass_()
if not envelope.principal or envelope.principal.role not in ("admin", "sre"):
return Verdict.fail("Production deploys require admin or sre role.")
return Verdict.pass_()
@postcondition("*")
def pii_in_output(envelope, tool_response):
if not isinstance(tool_response, str):
return Verdict.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 Verdict.fail("PII detected in output. Redact before using.")
return Verdict.pass_()
@postcondition("*")
def secrets_in_output(envelope, tool_response):
if not isinstance(tool_response, str):
return Verdict.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 Verdict.fail("Credentials detected in output. Do not log or reproduce.")
return Verdict.pass_()
guard = Edictum(
contracts=[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:
- Contract 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() raises EdictumDenied immediately. The tool does not execute and postconditions are not evaluated. Postconditions only run when the tool actually executes.
- Session contracts are checked after preconditions, not before. The full pre-execution order is: 1. Attempt limit, 2. Before hooks, 3. Preconditions, 4. Session contracts, 5. Execution limits.
Per-Contract Mode Override¶
Individual contracts can override the bundle's default mode. This lets you mix enforced and observed contracts in a single bundle.
When to use: You are adding a new contract to an existing production bundle and want to shadow-test it before enforcing.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: mixed-mode-bundle
defaults:
mode: enforce
contracts:
# Enforced (inherits bundle default)
- id: block-sensitive-reads
type: pre
tool: read_file
when:
args.path:
contains_any: [".env", "credentials"]
then:
effect: deny
message: "Sensitive file denied."
tags: [secrets]
# Observe mode: shadow-testing a new contract
- id: experimental-query-limit
type: pre
mode: observe
tool: query_database
when:
args.query: { matches: '\\bSELECT\\s+\\*\\b' }
then:
effect: deny
message: "SELECT * detected (observe mode). Use explicit column lists."
tags: [experimental, sql-quality]
from edictum import Edictum, Verdict, precondition
@precondition("read_file")
def block_sensitive_reads(envelope):
path = envelope.args.get("path", "")
for s in (".env", "credentials"):
if s in path:
return Verdict.fail("Sensitive file denied.")
return Verdict.pass_()
# In Python, per-contract mode override is done by running
# separate Edictum instances: one enforced, one in observe mode.
enforced_guard = Edictum(contracts=[block_sensitive_reads])
# Or use a single observe-mode instance to shadow-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 Verdict.fail("SELECT * detected. Use explicit column lists.")
return Verdict.pass_()
observe_guard = Edictum(
mode="observe",
contracts=[experimental_query_limit],
)
Gotchas:
- Observe mode emits CALL_WOULD_DENY audit events. The tool call proceeds normally. Review these events before switching to enforce.
- The mode override is per-contract. Other contracts in the same bundle continue to use the bundle default.
- For postconditions, mode: observe downgrades redact/deny effects to a warning prefixed with [observe]. The tool output is not modified. Observe mode is meaningful for all contract types.
Environment-Based Conditions¶
Use env.* selectors to conditionally activate contracts 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 contracts that activate based on runtime flags like DRY_RUN, ENVIRONMENT, or FEATURE_X_ENABLED. Set the env var, and the contract activates.
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: env-conditions
defaults:
mode: enforce
contracts:
# 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:
effect: deny
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:
effect: deny
message: "Destructive commands denied in {env.ENVIRONMENT}."
tags: [destructive, production]
Type 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 contract 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.
Bundle Composition (Multi-File)¶
Edictum.from_yaml() accepts multiple paths, composing bundles left-to-right with deterministic merge semantics. This is the preferred way to combine contracts from multiple YAML files.
When to use: You have separate YAML files for different concerns (base safety, team overrides, environment-specific contracts) and want to compose them into a single guard.
from edictum import Edictum
guard = Edictum.from_yaml(
"contracts/base.yaml",
"contracts/team-overrides.yaml",
"contracts/prod-overrides.yaml",
)
Merge semantics:
- Contracts with the same
id: later layer replaces the entire contract - Contracts 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(
"contracts/base.yaml",
"contracts/overrides.yaml",
return_report=True,
)
for o in report.overridden_contracts:
print(f"{o.contract_id}: overridden by {o.overridden_by}")
Shadow-testing with observe_alongside:
A second bundle with observe_alongside: true evaluates as shadow contracts -- audit events are emitted but tool calls are never denied:
guard = Edictum.from_yaml(
"contracts/current.yaml", # enforced
"contracts/candidate.yaml", # observe_alongside: true → shadow
)
See Bundle Composition for full reference.
Gotchas:
- Contract replacement is by id, not position. No partial merging of conditions within a contract.
- 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 contracts from multiple instantiated guards into a single guard. This is for runtime merging of Python-defined contracts 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 contracts.
import os
from edictum import Edictum
guards = [Edictum.from_yaml("contracts/base.yaml")]
if os.environ.get("DRY_RUN"):
guards.append(Edictum.from_yaml("contracts/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 contracts.
Semantics:
- Contracts are concatenated in order. The first guard's contracts evaluate first.
- The first guard's audit config, mode, environment, and limits are used as the base.
- Duplicate contract IDs: first occurrence wins, duplicates skipped with a warning.
- The returned guard is a new instance. Input guards are not mutated.
Gotchas:
- from_multiple([]) raises EdictumConfigError. At least one guard is required.
- Hooks (before/after) are not merged -- only contracts (preconditions, postconditions, session contracts).
- Duplicate IDs are checked across all contract types. A precondition ID in guard A blocks a postcondition with the same ID in guard B.