Change Control Patterns
Change control contracts 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/contracts/patterns/rate-limiting. Gotcha: `effect: approve` pauses the tool call and waits for human approval (default 300s timeout). If the timeout expires, the `timeout_effect` field controls whether the call is denied or allowed -- the default is deny.
Change control contracts 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: ContractBundle
metadata:
name: ticket-requirement
defaults:
mode: enforce
contracts:
- id: prod-requires-ticket
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- principal.ticket_ref: { exists: false }
then:
effect: deny
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:
effect: deny
message: "Production write queries require a ticket reference."
tags: [change-control, compliance]from edictum import Verdict, precondition
@precondition("deploy_service")
def prod_requires_ticket(envelope):
if envelope.environment != "production":
return Verdict.pass_()
if not envelope.principal or not envelope.principal.ticket_ref:
return Verdict.fail(
"Production deployments require a ticket reference. "
"Attach a ticket_ref to the principal."
)
return Verdict.pass_()
@precondition("query_database")
def prod_requires_ticket_for_db(envelope):
import re
if envelope.environment != "production":
return Verdict.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 Verdict.fail("Production write queries require a ticket reference.")
return Verdict.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 contract 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: ContractBundle
metadata:
name: approval-gates
defaults:
mode: enforce
contracts:
- id: deploy-requires-senior-role
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. 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:
effect: deny
message: "DDL operations require admin role."
tags: [change-control, database]import re
from edictum import Verdict, precondition
@precondition("deploy_service")
def deploy_requires_senior_role(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"Production deploys require admin or sre role. Current role: {role}."
)
return Verdict.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 Verdict.fail("DDL operations require admin role.")
return Verdict.pass_()Gotchas:
- If no principal is attached,
principal.roleis missing, the leaf evaluates tofalse, and theallblock evaluates tofalse. The contract does not fire. Add aprincipal.role: { exists: false }contract 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: ContractBundle
metadata:
name: approval-gates
defaults:
mode: enforce
contracts:
- id: delete-requires-approval
type: pre
tool: "db_*"
when:
args.query:
matches: '\bDELETE\b'
then:
effect: approve
message: "DELETE query requires human approval: {args.query}"
timeout: 120
timeout_effect: denyfrom edictum import Edictum, LocalApprovalBackend
guard = Edictum.from_yaml(
"contracts.yaml",
approval_backend=LocalApprovalBackend(),
)
# When the contract 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_effect (deny or allow)Three outcomes:
| Outcome | What happens | Audit event |
|---|---|---|
| Approved | Tool call proceeds, on_allow fires | CALL_APPROVAL_GRANTED |
| Denied | EdictumDenied raised, on_deny fires | CALL_APPROVAL_DENIED |
| Timeout | Applies timeout_effect (default: deny) | CALL_APPROVAL_TIMEOUT |
Gotchas:
- If no
approval_backendis configured on theEdictuminstance,effect: approveraisesEdictumDeniedimmediately 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_effect: 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: ContractBundle
metadata:
name: blast-radius-limits
defaults:
mode: enforce
contracts:
- id: limit-batch-size
type: pre
tool: query_database
when:
args.batch_size: { gt: 500 }
then:
effect: deny
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:
effect: deny
message: "Cannot send to more than 50 recipients at once. Split into smaller batches."
tags: [change-control, blast-radius]from edictum import Verdict, precondition
@precondition("query_database")
def limit_batch_size(envelope):
batch_size = envelope.args.get("batch_size", 0)
if batch_size > 500:
return Verdict.fail(
f"Batch size {batch_size} exceeds the limit of 500. Reduce the batch."
)
return Verdict.pass_()
@precondition("send_email")
def limit_notification_recipients(envelope):
count = envelope.args.get("recipient_count", 0)
if count > 50:
return Verdict.fail(
"Cannot send to more than 50 recipients at once. Split into smaller batches."
)
return Verdict.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 contract 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: ContractBundle
metadata:
name: dry-run-requirement
defaults:
mode: enforce
contracts:
- id: prod-deploy-requires-dry-run
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- not:
args.dry_run: { equals: true }
then:
effect: deny
message: "Production deploys require dry_run=true first. Run a dry-run, verify output, then deploy."
tags: [change-control, production]from edictum import Verdict, precondition
@precondition("deploy_service")
def prod_deploy_requires_dry_run(envelope):
if envelope.environment != "production":
return Verdict.pass_()
if not envelope.args.get("dry_run", False):
return Verdict.fail(
"Production deploys require dry_run=true first. "
"Run a dry-run, verify output, then deploy."
)
return Verdict.pass_()Gotchas:
- This contract 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 contract 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: ContractBundle
metadata:
name: sql-safety
defaults:
mode: enforce
contracts:
- 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:
effect: deny
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:
effect: deny
message: "SELECT queries must include a LIMIT clause to prevent unbounded result sets."
tags: [change-control, database, sql-safety]import re
from edictum import Verdict, 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 Verdict.fail(
f"DDL statement '{kw}' is not allowed. "
"This agent can only run SELECT queries."
)
return Verdict.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 Verdict.fail(
"SELECT queries must include a LIMIT clause "
"to prevent unbounded result sets."
)
return Verdict.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 contracts 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