Skip to content

Change Control Patterns

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: false checks 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 all combinator short-circuits: if environment is not production, the entire expression evaluates to false and 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.role is missing, the leaf evaluates to false, and the all block evaluates to false. The contract does not fire. Add a principal.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: deny
from 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_backend is configured on the Edictum instance, effect: approve raises EdictumDenied immediately with the message "Approval required but no approval backend configured." - The timeout field is in seconds. The default (300s / 5 minutes) is generous for interactive workflows. Reduce it for automated pipelines. - timeout_effect: allow should 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 gt operator requires the selector value to be a number. If args.batch_size is a string (e.g., "500"), the operator triggers a policy_error and 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_run is not true. The agent must make two calls: first with dry_run=true, then with dry_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 not combinator negates a single child expression. not: { args.dry_run: { equals: true } } fires when dry_run is either missing or not true.


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 LIMIT check uses matches to search anywhere in the query string. A subquery with LIMIT in 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.