# YAML Contract Reference

This is the complete reference for `edictum/v1` contract bundles. A contract bundle is a single YAML file that declares all the contracts for an Edictum instance.

---

## Document Structure {#document-structure}

Every contract bundle starts with four required top-level fields:

```yaml
apiVersion: edictum/v1
kind: ContractBundle

metadata:
  name: my-agent-policy
  description: "Optional human-readable description."

defaults:
  mode: enforce

contracts:
  - id: example-contract
    type: pre
    tool: read_file
    when:
      args.path:
        contains: ".env"
    then:
      effect: deny
      message: "Denied."
```

| Field | Type | Required | Description |
|---|---|---|---|
| `apiVersion` | string | yes | Must be `edictum/v1`. |
| `kind` | string | yes | Must be `ContractBundle`. |
| `metadata.name` | string | yes | Bundle identifier. Slug format: `[a-z0-9][a-z0-9._-]*`. |
| `metadata.description` | string | no | Human-readable description. |
| `defaults.mode` | string | yes | `enforce` or `observe`. Applied to every contract that does not set its own `mode`. |
| `tools` | object | no | Tool side-effect classifications. See [Tool Classifications](#tool-classifications). |
| `observe_alongside` | boolean | no | When `true`, contracts in this bundle become shadow copies that evaluate in parallel without affecting real decisions. See [Bundle Composition](#bundle-composition). |
| `contracts` | array | yes | Minimum one contract. Each item is a precondition, postcondition, or session contract. |

The bundle is loaded with `Edictum.from_yaml()`:

```python
from edictum import Edictum

guard = Edictum.from_yaml("contracts/my-policy.yaml")
```

Multiple bundles can be composed by passing multiple paths. Later bundles override earlier ones:

```python
guard = Edictum.from_yaml("contracts/base.yaml", "contracts/overrides.yaml")
```

See [Bundle Composition](#bundle-composition) for full details.

A SHA256 hash of the raw YAML bytes is computed at load time and stamped as `policy_version` on every `AuditEvent` and OpenTelemetry span. This gives you an immutable link between any audit record and the exact contract bundle that produced it. When multiple bundles are composed, the combined hash is derived from all individual bundle hashes.

---

## Tool Classifications {#tool-classifications}

The optional `tools:` section declares side-effect classifications for your agent's tools. This controls how postcondition `redact` and `deny` effects behave -- without it, all tools default to `SideEffect.IRREVERSIBLE` and redact/deny effects fall back to `warn`.

```yaml
tools:
  read_config:
    side_effect: read
  get_weather:
    side_effect: pure
    idempotent: true
  update_record:
    side_effect: write
  deploy_service:
    side_effect: irreversible
```

Each tool entry has the following fields:

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `side_effect` | string | yes | -- | `pure`, `read`, `write`, or `irreversible`. |
| `idempotent` | boolean | no | `false` | Whether repeated calls with the same arguments produce the same result. |

**Side-effect levels:**

| Level | Meaning | Postcondition redact/deny |
|-------|---------|--------------------------|
| `pure` | No side effects. Returns computed data. | Enforced |
| `read` | Reads external state but does not modify it. | Enforced |
| `write` | Modifies external state (can be undone). | Falls back to `warn` |
| `irreversible` | Modifies external state (cannot be undone). | Falls back to `warn` |

Tools not listed in the `tools:` section default to `irreversible`. This is the conservative default -- if Edictum does not know a tool's side effects, it assumes the worst.

You can also pass tool classifications as a parameter to `from_yaml()`. Parameter values override YAML values for the same tool name:

```python
guard = Edictum.from_yaml(
    "contracts.yaml",
    tools={"my_custom_reader": {"side_effect": "read"}},
)
```

Both sources are merged: tools defined in the YAML `tools:` section and tools passed via the `tools=` parameter are combined, with the parameter winning on conflict.

---

## Contract Types {#contract-types}

Every contract shares a common set of fields, plus type-specific fields determined by the `type` discriminator.

### Common Fields

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | string | yes | -- | Unique within the bundle. Format: `[a-z0-9][a-z0-9_-]*`. |
| `type` | string | yes | -- | `pre`, `post`, or `session`. |
| `enabled` | boolean | no | `true` | Set to `false` to skip during evaluation. The contract still participates in validation. |
| `mode` | string | no | `defaults.mode` | Per-contract override: `enforce` or `observe`. |
| `then` | object | yes | -- | Action block. See [Action Block](#action-block). |

### Precondition (`type: pre`) {#precondition}

Preconditions evaluate **before** tool execution. If the expression matches, the tool call is denied.

| Field | Type | Required | Description |
|---|---|---|---|
| `tool` | string | yes | Tool name to target, or `"*"` for all tools. |
| `when` | Expression | yes | Boolean expression tree. See [Expression Grammar](#expression-grammar). |

**Constraints:**

- `then.effect` must be `deny`. Preconditions block; they do not warn.
- The `output.text` selector is invalid in preconditions because the tool has not run yet. Using it is a validation error.
- When `mode: observe` is set (either on the contract or via `defaults.mode`), a matching precondition emits a `CALL_WOULD_DENY` audit event instead of blocking. The tool call proceeds.

```yaml
- id: block-sensitive-reads
  type: pre
  tool: read_file
  when:
    args.path:
      contains_any: [".env", ".secret", "credentials", ".pem", "id_rsa"]
  then:
    effect: deny
    message: "Sensitive file '{args.path}' denied. Skip and continue."
    tags: [secrets, dlp]
```

### Postcondition (`type: post`) {#postcondition}

Postconditions evaluate **after** tool execution. They inspect the tool's output and produce findings.

| Field | Type | Required | Description |
|---|---|---|---|
| `tool` | string | yes | Tool name to target, or `"*"` for all tools. |
| `when` | Expression | yes | Boolean expression tree. |

**Constraints:**

- `then.effect` must be `warn`, `redact`, or `deny`. See [Postcondition Effects](#postcondition-effects) for behavior.
- The `output.text` selector is available in postconditions. It contains the stringified tool response.

```yaml
- id: pii-in-output
  type: post
  tool: "*"
  when:
    output.text:
      matches_any:
        - '\b\d{3}-\d{2}-\d{4}\b'
        - '\b[A-Z]{2}\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{0,2}\b'
  then:
    effect: warn
    message: "PII pattern detected in output. Redact before using."
    tags: [pii, compliance]
```

#### Postcondition Effects {#postcondition-effects}

Postconditions support three effects that control what happens when the condition matches:

| Effect | Behavior | Side-effect constraint |
|--------|----------|----------------------|
| `warn` | Produces a finding. The tool result is unchanged. | All side effects. |
| `redact` | Replaces matched patterns in the output with `[REDACTED]`. | **READ/PURE only.** Falls back to `warn` for WRITE/IRREVERSIBLE tools. |
| `deny` | Suppresses the entire tool output with `[OUTPUT SUPPRESSED]`. | **READ/PURE only.** Falls back to `warn` for WRITE/IRREVERSIBLE tools. |

The side-effect constraint reflects a design decision: for tools that only read data (`SideEffect.READ` or `SideEffect.PURE`), the output can be safely redacted or suppressed because the tool has no lasting effect. For tools that write or mutate state (`SideEffect.WRITE` or `SideEffect.IRREVERSIBLE`), the action already happened -- hiding the result only removes context the agent needs.

**`effect: redact`** uses the regex patterns from the `when` clause to do targeted replacement. If the `when` clause uses `output.text` with `matches` or `matches_any`, those exact patterns are used to find and replace sensitive tokens:

```yaml
- id: secrets-in-output
  type: post
  tool: "*"
  when:
    output.text:
      matches_any:
        - 'sk-prod-[a-z0-9]{8}'
        - 'AKIA-PROD-[A-Z]{12}'
  then:
    effect: redact
    message: "Secrets detected and redacted."
    tags: [secrets]
```

**`effect: deny`** suppresses the entire output. Use this when partial redaction still leaks sensitive content (e.g., accommodation records, privileged legal documents):

```yaml
- id: accommodation-confidential
  type: post
  tool: "*"
  when:
    output.text:
      matches: '\b(504\s*Plan|IEP|accommodation)\b'
  then:
    effect: deny
    message: "Accommodation info cannot be returned."
    tags: [ferpa]
```

**Observe mode** takes precedence over `redact` and `deny`. When a postcondition is in observe mode, the effect is always downgraded to a warning regardless of the declared effect.

### Session Contract (`type: session`) {#session-contract}

Session contracts enforce session-level gates that apply across all tool calls. They track cumulative counters -- total calls, total attempts, and per-tool call counts.

| Field | Type | Required | Description |
|---|---|---|---|
| `limits` | object | yes | At least one limit field is required. |
| `limits.max_tool_calls` | integer | no* | Maximum successful tool executions in the session. |
| `limits.max_attempts` | integer | no* | Maximum contract evaluations, including denied ones. Catches denial loops. |
| `limits.max_calls_per_tool` | map | no* | Per-tool execution caps. Keys are tool names, values are integer limits. |

*At least one of `max_tool_calls`, `max_attempts`, or `max_calls_per_tool` must be present.

**Constraints:**

- `then.effect` must be `deny`.
- Session contracts do not have `tool` or `when` fields. They are tool-agnostic.

```yaml
- id: session-limits
  type: session
  limits:
    max_tool_calls: 50
    max_attempts: 120
    max_calls_per_tool:
      deploy_service: 3
      send_notification: 10
  then:
    effect: deny
    message: "Session limit reached. Summarize progress and stop."
    tags: [rate-limit]
```

---

## Expression Grammar {#expression-grammar}

The `when` field accepts a recursive expression tree. Each node is exactly one of: a boolean combinator (`all`, `any`, `not`) or a leaf comparison.

### Boolean Nodes

```yaml
# AND — all children must be true
all:
  - <expression>
  - <expression>

# OR — at least one child must be true
any:
  - <expression>
  - <expression>

# NOT — negation of one child
not: <expression>
```

Boolean nodes nest arbitrarily. Minimum one child in `all` and `any` arrays.

### Leaf Nodes

A leaf is a single selector-operator pair:

```yaml
<selector>:
  <operator>: <value>
```

Exactly one selector key per leaf. Exactly one operator per selector.

### Selectors {#selectors}

Selectors resolve fields from the `ToolEnvelope` and `Principal` at evaluation time.

| Selector | Type | Available In | Source |
|---|---|---|---|
| `environment` | string | pre, post | `ToolEnvelope.environment` |
| `tool.name` | string | pre, post | `ToolEnvelope.tool_name` |
| `args.<key>` | any | pre, post | `ToolEnvelope.args[key]` |
| `args.<key>.<subkey>` | any | pre, post | Nested dict access |
| `principal.user_id` | string or null | pre, post | `Principal.user_id` |
| `principal.service_id` | string or null | pre, post | `Principal.service_id` |
| `principal.org_id` | string or null | pre, post | `Principal.org_id` |
| `principal.role` | string or null | pre, post | `Principal.role` |
| `principal.ticket_ref` | string or null | pre, post | `Principal.ticket_ref` |
| `principal.claims.<key>` | any | pre, post | `Principal.claims[key]` |
| `env.<VAR>` | string, bool, int, or float | pre, post | `os.environ[VAR]` with type coercion |
| `output.text` | string | **post only** | Stringified tool response |

**Missing fields:** If a selector references a field that does not exist (missing key, null value, no principal, unset env var), the leaf evaluates to `false`. The contract does not fire. This is not an error.

**Nested args:** Dotted paths like `args.config.timeout` resolve through nested dicts: `envelope.args["config"]["timeout"]`. If any intermediate key is missing or the value is not a dict, the leaf evaluates to `false`.

**Environment variables:** The `env.<VAR>` selector reads from `os.environ` at evaluation time. No adapter changes or envelope modifications are needed -- set the env var and reference it in YAML. Values are automatically coerced: `"true"`/`"false"` (case-insensitive) become booleans, numeric strings become `int` or `float`, everything else stays a string. Unset env vars evaluate to `false` (same as any missing field). Because env vars are read at evaluation time, changing an env var mid-process takes effect on the next tool call.

---

## Operators {#operators}

Fifteen operators are available, grouped into five categories. Each leaf uses exactly one operator.

For detailed examples of every operator, see the [Operator Reference](operators.md).

| Category | Operator | Value Type | Semantics |
|---|---|---|---|
| Presence | `exists` | boolean | `true`: field is present and not null. `false`: field is absent or null. |
| Equality | `equals` | scalar | Strict equality (`==`). |
| Equality | `not_equals` | scalar | Strict inequality (`!=`). |
| Membership | `in` | array | Selector value appears in the array. |
| Membership | `not_in` | array | Selector value does not appear in the array. |
| String | `contains` | string | Substring match (`value in field`). |
| String | `contains_any` | array of strings | Any element is a substring of the field. |
| String | `starts_with` | string | Field starts with the value. |
| String | `ends_with` | string | Field ends with the value. |
| String | `matches` | string (regex) | Python `re.search(pattern, field)` is truthy. |
| String | `matches_any` | array of strings | Any regex pattern matches. |
| Numeric | `gt` | number | Greater than. |
| Numeric | `gte` | number | Greater than or equal. |
| Numeric | `lt` | number | Less than. |
| Numeric | `lte` | number | Less than or equal. |

**Regex notes:** Patterns use Python's `re` module with `re.search()` (not `re.match()`), so patterns can match anywhere in the string. Patterns are compiled once at load time. Invalid regex causes a validation error at load.

**YAML regex tip:** Always use single-quoted strings for regex patterns. In YAML, `'\b'` is a literal backslash-b (word boundary). Double-quoted `"\b"` is a backspace character.

---

## Action Block {#action-block}

The `then` block defines what happens when a contract's condition matches.

```yaml
then:
  effect: deny          # required: deny or warn
  message: "..."        # required: human-readable message, max 500 chars
  tags: [a, b]          # optional: classification tags
  metadata:             # optional: arbitrary key-value pairs
    severity: high
    runbook: "https://..."
```

| Field | Type | Required | Description |
|---|---|---|---|
| `effect` | string | yes | `deny` (block execution), `warn` (log only), or `redact`/`deny` for postconditions. Constrained by contract type. |
| `message` | string | yes | Human-readable message sent to the agent and recorded in audit. 1-500 characters. |
| `tags` | array of strings | no | Classification labels. Appear in audit events and can be filtered downstream. |
| `metadata` | object | no | Arbitrary key-value data stamped into the `Verdict` and audit event. |

### Effect Constraints

The allowed effect depends on the contract type:

| Contract Type | Allowed Effects | Rationale |
|---|---|---|
| `pre` | `deny` only | Preconditions exist to block dangerous calls. |
| `post` | `warn`, `redact`, `deny` | `warn` produces findings. `redact` replaces matched patterns. `deny` suppresses output. See [Postcondition Effects](#postcondition-effects). |
| `session` | `deny` only | Session limits gate further execution. |

Using an invalid effect for a contract type is a validation error at load time.

### Message Templating

Messages support `{placeholder}` expansion from the envelope context:

```yaml
message: "Read of '{args.path}' denied for user {principal.user_id}."
```

Available placeholders follow the same selector paths as the expression grammar: `{args.path}`, `{tool.name}`, `{environment}`, `{principal.user_id}`, `{principal.role}`, `{env.VAR_NAME}`, and so on.

If a placeholder references a missing field, it is kept as-is in the output (no crash, no empty string). Each placeholder expansion is capped at 200 characters.

---

## Error Handling {#error-handling}

Error behavior is hardcoded and not configurable. Edictum follows a fail-closed design: when in doubt, the contract fires.

| Scenario | Behavior |
|---|---|
| YAML parse error | `from_yaml()` raises `EdictumConfigError`. |
| Invalid regex in `matches` / `matches_any` | Validation error at load time. |
| Duplicate contract `id` within a bundle | Validation error at load time. |
| YAML contract evaluation throws | Contract yields `deny` (pre/session) or `warn` (post) with `policy_error: true`. Other contracts continue evaluating. |
| Python hook or precondition throws | Hook/contract yields `deny` with `policy_error: true`. Evaluation stops (first denial wins). |
| Python postcondition throws | Contract yields `warn` with `policy_error: true`. Other postconditions continue. |
| Selector references a missing field | Leaf evaluates to `false`. Not an error. |
| Type mismatch (e.g., `gt` applied to a string) | Contract yields `deny` or `warn` with `policy_error: true`. |
| Wrong `effect` for contract type | Validation error at load time. |
| `output.text` used in a precondition | Validation error at load time. |

---

## Audit Integration {#audit-integration}

YAML contracts integrate with the audit system automatically. Every contract evaluation stamps the following fields on `AuditEvent`:

| Audit Field | Source |
|---|---|
| `policy_version` | SHA256 hash of the raw YAML bytes. |
| `decision_name` | The contract's `id` field. |
| `decision_source` | `yaml_precondition`, `yaml_postcondition`, or `yaml_session`. |
| `contracts_evaluated[].tags` | From `then.tags` on each contract. |
| `policy_error` | `true` if contract evaluation threw an error. |

OpenTelemetry span attributes (when OTel SDK is installed):

- `edictum.policy_version` -- the bundle hash.
- `edictum.policy_error` -- set to `true` if any contract had an evaluation error.

This means you can trace any audit record or OTel span back to the exact YAML file that produced it, and to the specific contract `id` that fired.

---

## Observability Configuration {#observability}

The optional `observability` block configures how Edictum emits telemetry. Place it at the top level of your contract bundle, alongside `metadata`, `defaults`, and `contracts`:

```yaml
apiVersion: edictum/v1
kind: ContractBundle

metadata:
  name: my-policy

observability:
  otel:
    enabled: true
    endpoint: "http://localhost:4317"
    protocol: grpc
    service_name: my-agent
    resource_attributes:
      deployment.environment: production
  stdout: true
  file: /var/log/edictum/events.jsonl

defaults:
  mode: enforce

contracts:
  - id: block-sensitive-reads
    type: pre
    tool: read_file
    when:
      args.path:
        contains_any: [".env", ".secret", "credentials"]
    then:
      effect: deny
      message: "Sensitive file '{args.path}' denied."
      tags: [secrets]
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `observability.otel.enabled` | bool | `false` | Enable OpenTelemetry span emission. |
| `observability.otel.endpoint` | string | `"http://localhost:4317"` | OTLP collector endpoint. |
| `observability.otel.protocol` | string | `"grpc"` | Transport protocol: `"grpc"` or `"http"`. |
| `observability.otel.service_name` | string | `"edictum-agent"` | OTel service name resource attribute. |
| `observability.otel.resource_attributes` | object | -- | Additional OTel resource attributes (string values). |
| `observability.stdout` | bool | `true` | Emit audit events to stdout via `StdoutAuditSink`. |
| `observability.file` | string or null | `null` | Path to a JSON lines audit file. When set, a `FileAuditSink` is created automatically. |

When `observability.otel.enabled` is `true`, `Edictum.from_yaml()` calls `configure_otel()` with the provided settings. If `observability.file` is set and no explicit `audit_sink` is passed to `from_yaml()`, a `FileAuditSink` is created for that path. If `observability.stdout` is `false` and no `file` is set, audit emission is disabled entirely.

Standard OpenTelemetry environment variables override YAML values: `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`.

For detailed sink configuration and custom sinks, see [Audit and Observability](../audit/sinks.md).

---

## Bundle Composition {#bundle-composition}

When contracts grow beyond a single file, `from_yaml()` accepts multiple paths. Bundles are composed left-to-right with deterministic merge semantics: later layers have higher priority.

```python
from edictum import Edictum

guard = Edictum.from_yaml(
    "contracts/base.yaml",
    "contracts/team-overrides.yaml",
    "contracts/prod-overrides.yaml",
)
```

### Merge Rules

| Element | Merge Rule |
|---------|-----------|
| Contracts (same ID) | Later layer **replaces** earlier layer entirely |
| Contracts (unique ID) | Concatenated into final list |
| `defaults.mode` | Later layer wins |
| `defaults.environment` | Later layer wins |
| `limits` | Later layer wins (entire limits block replaced) |
| `tools` | Deep merge (tool configs from all layers combined) |
| `metadata` | Deep merge (later keys override earlier) |
| `observability` | Later layer wins |

Contract replacement is by `id`, not by position. If `base.yaml` has `id: deny-rm-rf` and `overrides.yaml` also has `id: deny-rm-rf`, the override version completely replaces the base version. No partial merging of conditions within a contract.

### Composition Report

Pass `return_report=True` to see what happened during composition:

```python
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} (was in {o.original_source})")

for s in report.shadow_contracts:
    print(f"{s.contract_id}: shadow from {s.observed_source} (enforced in {s.enforced_source})")
```

### Dual-Mode Evaluation with `observe_alongside` {#observe-alongside}

The `observe_alongside` flag enables running two versions of the same contract simultaneously -- one enforced, one observed. This is for shadow-testing contract updates against live traffic.

When a bundle has `observe_alongside: true`, its contracts are not merged by ID replacement. Instead, they become **shadow copies** that evaluate in parallel without affecting real decisions:

```yaml
# candidate-update.yaml
apiVersion: edictum/v1
kind: ContractBundle
observe_alongside: true

metadata:
  name: candidate-contracts

defaults:
  mode: enforce

contracts:
  - id: block-sensitive-reads
    type: pre
    tool: read_file
    when:
      args.path:
        contains_any: [".env", ".secret", "credentials", ".pem", "id_rsa", ".key"]
    then:
      effect: deny
      message: "Denied: read of sensitive file {args.path}"
      tags: [secrets, dlp]
```

Load both bundles:

```python
guard = Edictum.from_yaml(
    "contracts/base.yaml",            # enforced
    "contracts/candidate-update.yaml", # observe_alongside: true
)
```

The result:

- `block-sensitive-reads` from `base.yaml` makes real allow/deny decisions
- `block-sensitive-reads:candidate` from `candidate-update.yaml` evaluates in parallel and emits separate audit events with `mode: "observe"`

Shadow contracts produce `CALL_WOULD_DENY` or `CALL_ALLOWED` audit events but never block tool calls. This lets you compare the candidate's behavior against the enforced version before promoting it.

**What `observe_alongside` affects:**

- Shadow preconditions and session contracts are evaluated after real contracts
- Shadow contract IDs are suffixed with `:candidate` (e.g., `block-sensitive-reads:candidate`)
- Shadow audit events always have `mode: "observe"` regardless of the contract's declared mode
- Shadow session contracts do not affect real session limits

### Low-Level API: `compose_bundles()`

For advanced use cases, the composition primitive is available directly:

```python
from edictum.yaml_engine import compose_bundles, load_bundle

composed = compose_bundles(
    (load_bundle("base.yaml")[0], "base.yaml"),
    (load_bundle("overrides.yaml")[0], "overrides.yaml"),
)

# composed.bundle — the merged bundle dict
# composed.report — CompositionReport with overrides and shadows
```

---

## Complete Example {#complete-example}

The following bundle demonstrates all three contract types working together for a DevOps agent:

```yaml
apiVersion: edictum/v1
kind: ContractBundle

metadata:
  name: devops-agent
  description: "Governance for CI/CD and infrastructure agents."

defaults:
  mode: enforce

tools:
  read_file:
    side_effect: read
  bash:
    side_effect: irreversible
  deploy_service:
    side_effect: irreversible
  call_api:
    side_effect: write
  send_notification:
    side_effect: write

contracts:
  # --- File safety ---
  - id: block-sensitive-reads
    type: pre
    tool: read_file
    when:
      args.path:
        contains_any: [".env", ".secret", "kubeconfig", "credentials", ".pem", "id_rsa"]
    then:
      effect: deny
      message: "Sensitive file '{args.path}' denied. Skip and continue."
      tags: [secrets, dlp]

  # --- Bash safety ---
  - id: block-destructive-bash
    type: pre
    tool: bash
    when:
      any:
        - args.command: { matches: '\brm\s+(-rf?|--recursive)\b' }
        - args.command: { matches: '\bmkfs\b' }
        - args.command: { matches: '\bdd\s+' }
        - args.command: { contains: '> /dev/' }
    then:
      effect: deny
      message: "Destructive command denied: '{args.command}'. Use a safer alternative."
      tags: [destructive, safety]

  # --- Production gate: role-based ---
  - id: prod-deploy-requires-senior
    type: pre
    tool: deploy_service
    when:
      all:
        - environment: { equals: production }
        - principal.role: { not_in: [senior_engineer, sre, admin] }
    then:
      effect: deny
      message: "Production deploys require senior role (sre/admin)."
      tags: [change-control, production]

  # --- Production gate: ticket required ---
  - id: prod-requires-ticket
    type: pre
    tool: deploy_service
    when:
      all:
        - environment: { equals: production }
        - principal.ticket_ref: { exists: false }
    then:
      effect: deny
      message: "Production changes require a ticket reference."
      tags: [change-control, compliance]

  # --- Post-execution: PII detection ---
  - id: pii-in-output
    type: post
    tool: "*"
    when:
      output.text:
        matches_any:
          - '\b\d{3}-\d{2}-\d{4}\b'
          - '\b[A-Z]{2}\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{0,2}\b'
    then:
      effect: warn
      message: "PII pattern detected in output. Redact before using."
      tags: [pii, compliance]

  # --- Observe mode: shadow-test a new contract ---
  - id: experimental-api-rate-check
    type: pre
    mode: observe
    tool: call_api
    when:
      args.endpoint: { contains: "/v1/expensive" }
    then:
      effect: deny
      message: "Expensive API call detected (observe mode)."
      tags: [cost, experimental]

  # --- Session limits ---
  - id: session-limits
    type: session
    limits:
      max_tool_calls: 50
      max_attempts: 120
      max_calls_per_tool:
        deploy_service: 3
        send_notification: 10
    then:
      effect: deny
      message: "Session limit reached. Summarize progress and stop."
      tags: [rate-limit]
```

This bundle enforces six distinct concerns:

1. **Secret file protection** -- blocks reads of `.env`, credentials, and key files.
2. **Destructive command prevention** -- blocks `rm -rf`, `mkfs`, `dd`, and writes to `/dev/`.
3. **Role-based production gate** -- only senior engineers, SREs, and admins can deploy to production.
4. **Ticket-required production gate** -- production deploys must have a ticket reference.
5. **PII detection** -- warns when tool output contains SSN or IBAN patterns.
6. **Observe-mode experimentation** -- logs expensive API calls without denying, for cost analysis.
7. **Session limits** -- caps total calls at 50, attempts at 120, and per-tool limits on deploy and notification tools.
