Change Control Patterns
Change control rulesets enforce process requirements around high-impact operations: ticket references, approval gates, blast radius limits, dry-run requirem...
Right page if: you need to enforce process gates around high-impact operations -- ticket requirements, human approvals, blast radius limits, or SQL safety. Wrong page if: you need cumulative rate limits across a session -- see https://docs.edictum.ai/docs/rulesets/patterns/rate-limiting. Gotcha: `action: ask` pauses the tool call and waits for human approval (default 300s timeout). If the timeout expires, the `timeout_action` field controls whether the call is denied or allowed -- the default is deny.
Change control rulesets enforce process requirements around high-impact operations: ticket references, approval gates, blast radius limits, dry-run requirements, and SQL safety. These are primarily preconditions that block before execution.
Ticket Requirement for Production Changes
Require a ticket reference on every production deployment. This ensures traceability -- every change can be linked back to an approved request.
When to use: Your organization requires that production changes are traceable to tickets in a project management system (Jira, Linear, etc.). The principal.ticket_ref field carries the ticket ID.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: ticket-requirement
defaults:
mode: enforce
rules:
- id: prod-requires-ticket
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- principal.ticket_ref: { exists: false }
then:
action: block
message: "Production deployments require a ticket reference. Attach a ticket_ref to the principal."
tags: [change-control, compliance]
- id: prod-requires-ticket-for-db
type: pre
tool: query_database
when:
all:
- environment: { equals: production }
- args.query: { matches: '\\b(INSERT|UPDATE|DELETE|ALTER)\\b' }
- principal.ticket_ref: { exists: false }
then:
action: block
message: "Production write queries require a ticket reference."
tags: [change-control, compliance]from edictum import Decision, precondition
@precondition("deploy_service")
def prod_requires_ticket(envelope):
if envelope.environment != "production":
return Decision.pass_()
if not envelope.principal or not envelope.principal.ticket_ref:
return Decision.fail(
"Production deployments require a ticket reference. "
"Attach a ticket_ref to the principal."
)
return Decision.pass_()
@precondition("query_database")
def prod_requires_ticket_for_db(envelope):
import re
if envelope.environment != "production":
return Decision.pass_()
query = envelope.args.get("query", "")
if re.search(r'\b(INSERT|UPDATE|DELETE|ALTER)\b', query):
if not envelope.principal or not envelope.principal.ticket_ref:
return Decision.fail("Production write queries require a ticket reference.")
return Decision.pass_()Gotchas:
exists: falsechecks whether the field is absent or null. It does not validate that the ticket reference is a real ticket ID. Your application should validate the ticket against your project management API before attaching it to the principal.- Non-production environments are unaffected because the
allcombinator short-circuits: ifenvironmentis notproduction, the entire expression evaluates tofalseand the rule does not fire.
Role-Based Approval Gates
Restrict high-impact tools to senior roles. This pattern is the simplest form of an approval gate -- only users with the right role can proceed.
When to use: Certain operations (deploys, migrations, infrastructure changes) should only be performed by experienced operators.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: approval-gates
defaults:
mode: enforce
rules:
- id: deploy-requires-senior-role
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. Current role: {principal.role}."
tags: [change-control, production]
- id: migration-requires-admin
type: pre
tool: query_database
when:
all:
- args.query: { matches: '\\b(ALTER|CREATE|DROP)\\b' }
- principal.role: { not_equals: admin }
then:
action: block
message: "DDL operations require admin role."
tags: [change-control, database]import re
from edictum import Decision, precondition
@precondition("deploy_service")
def deploy_requires_senior_role(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"Production deploys require admin or sre role. Current role: {role}."
)
return Decision.pass_()
@precondition("query_database")
def migration_requires_admin(envelope):
query = envelope.args.get("query", "")
if re.search(r'\b(ALTER|CREATE|DROP)\b', query):
if not envelope.principal or envelope.principal.role != "admin":
return Decision.fail("DDL operations require admin role.")
return Decision.pass_()Gotchas:
- If no principal is attached,
principal.roleis missing, the leaf evaluates tofalse, and theallblock evaluates tofalse. The rule does not fire. Add aprincipal.role: { exists: false }rule to catch unauthenticated calls.
Human Approval Gate
Pause high-impact tool calls and wait for a human to approve or deny them. Unlike role-based gates (which deny immediately if the role is wrong), approval gates pause the pipeline and request explicit human sign-off before proceeding.
When to use: Destructive operations (database drops, production deploys, bulk deletes) where even authorized users should confirm intent before the tool executes.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: approval-gates
defaults:
mode: enforce
rules:
- id: delete-requires-approval
type: pre
tool: "db_*"
when:
args.query:
matches: '\bDELETE\b'
then:
action: ask
message: "DELETE query requires human approval: {args.query}"
timeout: 120
timeout_action: blockfrom edictum import Edictum, LocalApprovalBackend
guard = Edictum.from_yaml(
"rules.yaml",
approval_backend=LocalApprovalBackend(),
)
# When the rule fires, the pipeline:
# 1. Calls approval_backend.request_approval(...)
# 2. Waits up to `timeout` seconds for a decision
# 3. If approved -> tool executes normally
# 4. If denied -> EdictumDenied is raised
# 5. If timeout -> applies timeout_action (deny or allow)Three outcomes:
| Outcome | What happens | Audit event |
|---|---|---|
| Approved | Tool call proceeds, on_allow fires | CALL_APPROVAL_GRANTED |
| Denied | EdictumDenied raised, on_block fires | CALL_APPROVAL_DENIED |
| Timeout | Applies timeout_action (default: deny) | CALL_APPROVAL_TIMEOUT |
Gotchas:
- If no
approval_backendis configured on theEdictuminstance,action: askraisesEdictumDeniedimmediately with the message "Approval required but no approval backend configured." - The
timeoutfield is in seconds. The default (300s / 5 minutes) is generous for interactive workflows. Reduce it for automated pipelines. timeout_action: allowshould only be used when the timeout indicates the operation is safe to proceed (e.g., a low-risk change that just needs acknowledgement).- The
tool: "db_*"selector uses glob matching to cover all database tools. See tool selectors in the YAML reference.
Blast Radius Limits
Cap the scope of batch operations to prevent agents from making changes that are too large to review or roll back.
When to use: Your agent performs bulk operations (batch inserts, mass notifications, bulk updates) where an unbounded scope could cause widespread damage.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: blast-radius-limits
defaults:
mode: enforce
rules:
- id: limit-batch-size
type: pre
tool: query_database
when:
args.batch_size: { gt: 500 }
then:
action: block
message: "Batch size {args.batch_size} exceeds the limit of 500. Reduce the batch."
tags: [change-control, blast-radius]
- id: limit-notification-recipients
type: pre
tool: send_email
when:
args.recipient_count: { gt: 50 }
then:
action: block
message: "Cannot send to more than 50 recipients at once. Split into smaller batches."
tags: [change-control, blast-radius]from edictum import Decision, precondition
@precondition("query_database")
def limit_batch_size(envelope):
batch_size = envelope.args.get("batch_size", 0)
if batch_size > 500:
return Decision.fail(
f"Batch size {batch_size} exceeds the limit of 500. Reduce the batch."
)
return Decision.pass_()
@precondition("send_email")
def limit_notification_recipients(envelope):
count = envelope.args.get("recipient_count", 0)
if count > 50:
return Decision.fail(
"Cannot send to more than 50 recipients at once. Split into smaller batches."
)
return Decision.pass_()Gotchas:
- The
gtoperator requires the selector value to be a number. Ifargs.batch_sizeis a string (e.g.,"500"), the operator triggers apolicy_errorand the rule fires (fail-closed). Ensure your tools pass numeric arguments. - Blast radius limits are a safety net, not a replacement for proper pagination in your tools.
Dry-Run Requirements
Force agents to perform a dry-run before executing destructive production operations. The agent must verify the plan before executing it for real.
When to use: Production deployments, migrations, and infrastructure changes where you want the agent to preview the impact before committing.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: dry-run-requirement
defaults:
mode: enforce
rules:
- id: prod-deploy-requires-dry-run
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- not:
args.dry_run: { equals: true }
then:
action: block
message: "Production deploys require dry_run=true first. Run a dry-run, verify output, then deploy."
tags: [change-control, production]from edictum import Decision, precondition
@precondition("deploy_service")
def prod_deploy_requires_dry_run(envelope):
if envelope.environment != "production":
return Decision.pass_()
if not envelope.args.get("dry_run", False):
return Decision.fail(
"Production deploys require dry_run=true first. "
"Run a dry-run, verify output, then deploy."
)
return Decision.pass_()Gotchas:
- This rule blocks all production deploys where
dry_runis nottrue. The agent must make two calls: first withdry_run=true, then withdry_run=false(or omitted) after verifying the output. However, this rule will block the second call too. In practice, you would either remove the dry-run gate after verification or use a session-aware approach that checks whether a dry-run was already executed. - The
notcombinator negates a single child expression.not: { args.dry_run: { equals: true } }fires whendry_runis either missing or nottrue.
SQL Safety
Block dangerous SQL patterns and require bounded queries. This prevents agents from accidentally running DDL statements or unbounded SELECTs that could crash the database.
When to use: Your agent generates SQL from natural language or constructs queries dynamically. You want to prevent destructive DDL and ensure all queries have a LIMIT clause.
apiVersion: edictum/v1
kind: Ruleset
metadata:
name: sql-safety
defaults:
mode: enforce
rules:
- id: block-ddl
type: pre
tool: query_database
when:
any:
- args.query: { matches: '\\bDROP\\b' }
- args.query: { matches: '\\bALTER\\b' }
- args.query: { matches: '\\bTRUNCATE\\b' }
- args.query: { matches: '\\bCREATE\\b' }
- args.query: { matches: '\\bGRANT\\b' }
- args.query: { matches: '\\bREVOKE\\b' }
then:
action: block
message: "DDL statements are not allowed. This agent can only run SELECT queries."
tags: [change-control, database, sql-safety]
- id: require-limit-on-select
type: pre
tool: query_database
when:
all:
- args.query: { matches: '\\bSELECT\\b' }
- not:
args.query: { matches: '\\bLIMIT\\b' }
then:
action: block
message: "SELECT queries must include a LIMIT clause to prevent unbounded result sets."
tags: [change-control, database, sql-safety]import re
from edictum import Decision, precondition
@precondition("query_database")
def block_ddl(envelope):
query = (envelope.args.get("query") or "").upper()
ddl_keywords = ["DROP", "ALTER", "TRUNCATE", "CREATE", "GRANT", "REVOKE"]
for kw in ddl_keywords:
if re.search(rf"\b{kw}\b", query):
return Decision.fail(
f"DDL statement '{kw}' is not allowed. "
"This agent can only run SELECT queries."
)
return Decision.pass_()
@precondition("query_database")
def require_limit_on_select(envelope):
query = (envelope.args.get("query") or "").upper()
if "SELECT" in query and "LIMIT" not in query:
return Decision.fail(
"SELECT queries must include a LIMIT clause "
"to prevent unbounded result sets."
)
return Decision.pass_()Gotchas:
- Regex matching is case-sensitive by default. The patterns above match uppercase SQL keywords. If your agent generates lowercase SQL, add case-insensitive patterns or normalize the query before evaluation.
- The
LIMITcheck usesmatchesto search anywhere in the query string. A subquery withLIMITin a comment would satisfy the check. For production use, consider a Python precondition that parses the SQL properly. - These rulesets protect against accidental DDL, not intentional abuse. A determined agent could encode SQL to bypass string matching. Defense in depth with database-level permissions is essential.
Last updated on