# Run controls

Controls that act on a managed agent's run as it runs, from a plan made before anything untrusted is read to the rule of two, each off or audit-only until you switch it on.

Run controls act on a managed agent's run while it runs, between the model's proposal and the tool call. Each one is set on the agent's runtime (`PUT /api/agents/{key}/runtime`, or the `runtime` block of a bundle). An agent that does not set a control runs exactly as before: Liya in Teams, the copilot, the HR and SAP agents, workflows and scheduled runs are unchanged until you switch a control on.

Every decision a control makes is written to the audit log.

## Plan guard

The plan guard splits planning from execution, following the dual-LLM and CaMeL designs. Before the run's first model call, a planner turns the run's authorised task (the user's message and the agent's instructions) into a typed plan. The planner never sees a tool result or the conversation, so nothing the run reads later can add a step to the plan.

Each plan step names one tool, how many times it may run, and constraints on its arguments. The steps are checked before the run starts. A tool the agent is not offered is dropped, so a plan can never widen the agent's allow-list. Each remaining step becomes a capability token. Its digest comes from the same function that binds an approved write to what is executed. That digest covers the agent, server, tool, the approved argument values and the run's authority (run-as and permission ceiling). It is signed with a vault-derived key.

Every MCP, subagent and remote-agent call must then match an unused capability. On each use the digest is computed again, the signature is checked, and the server's data-policy fingerprint is compared with the one approved. A call with no matching capability is **out of plan**. So is a call whose arguments go beyond the approved values, or one made after the server's data policies changed.

```json
{"plan_guard": {
  "mode": "enforce",
  "plan": [
    {"tool": "jira.get_issue", "max_calls": 2, "args": {"key": {"from_task": true}}},
    {"tool": "jira.add_comment", "max_calls": 1, "args": {"key": {"from_task": true}}}
  ]
}}
```

| Field | Meaning |
| --- | --- |
| `mode` | `off`, `monitor` (record out-of-plan calls and let them run) or `enforce` (refuse them) |
| `planner` | `static` (the `plan` you wrote) or `model` (a governed model call writes the plan for each run). Defaults to `static` when a `plan` is given, otherwise `model` |
| `plan` | For `static`: up to 20 steps of `{tool, max_calls, args, purpose}` |
| `provider`, `model`, `timeout_s` | For `model`: who plans. Defaults to the agent's own binding and 15 seconds |

Argument constraints: `{"equals": value}`, `{"one_of": [values]}`, `{"pattern": "regex"}` (must match the whole value) and `{"from_task": true}`. With `from_task`, the value must appear word for word in the user's task, so an address or ticket key cannot come from a tool's output. Arguments the plan does not name are unconstrained.

The planner fails closed. If it times out, fails or answers outside its contract, the plan is empty, and in `enforce` mode every governed call of the run is refused. A stored setting that cannot be read is enforced with an empty plan.

Audit rows: `agent.plan.created` (the steps, each capability's digest, the plan's digest, and any steps dropped with the reason), `agent.plan.refused` for every out-of-plan call, and `agent.plan.failed`.

A run that waits for an approval and resumes gets a new plan from the same task. Its capability counts start again.

Start in `monitor` mode. Once a week of `agent.plan.refused` rows shows only calls you would refuse, switch to `enforce`.

## Rule of two

At save time, the agent posture check flags an agent that can both read a system of record and send outward. The runtime rule of two applies the same rule *during* a run, as data enters the run's context. A run may hold any two of these three, but never all three:

- **Untrusted input.** Text an outsider wrote: an inbound mail, a chat message or a ticket body (Gmail, Slack, Teams, Discord, Jira, ServiceNow, Zendesk, Freshservice, Pylon, incident.io, Salesforce), anything a remote or local-process MCP server returns, or a task that came in through a webhook, an A2A caller, or an email or Slack trigger.
- **Sensitive data.** Records from a system of record: ticketing, HR, finance, CRM and file stores.
- **An action that can move data out.** A write on a messaging or publishing connector (Gmail, Teams, Discord, Pylon, SNS, SQS, Pub/Sub, NATS, OpenAPI), any call to a remote or local-process server, a delegation to a remote agent, or any call whose arguments contain a URL or an email address.

Once both data legs are in the run's context, every call that can move data out is held:

| Mode | What happens |
| --- | --- |
| `off` | Nothing |
| `monitor` (the instance default) | The call runs and is recorded as `agent.ruleoftwo.flagged`. No call is refused and no human step is added |
| `approve` | An MCP call is queued for approval and the run waits where it can. Other calls are refused |
| `enforce` | The call is refused, and the tools that can move data out are removed from what the model is offered for the rest of the run |

Set `rule_of_two_default` (environment `TICKETIQ_RULE_OF_TWO_DEFAULT`, or `PATCH /api/settings`) to change the default for every managed agent without a `rule_of_two` of its own. Lowering it needs `governance.weaken` and a reason. To override the default for one agent:

```json
{"rule_of_two": {"mode": "approve", "trigger": "both",
                 "untrusted_servers": ["partner-portal"], "sensitive_servers": ["hr-db"],
                 "egress_servers": ["webhook-out"]}}
```

`trigger: either` is stricter: it holds outbound calls once *either* data leg is in context. The server lists add to the built-in classification.

Audit rows: `agent.ruleoftwo.tainted` once per leg per run (with the call it came from), `agent.ruleoftwo.flagged`, `agent.ruleoftwo.blocked` (with the approval id when queued) and `agent.ruleoftwo.stripped`.

A run that waits and resumes starts with a clean context. For an agent that waits mid-run in `approve` or `enforce` mode, use `trigger: either`.

## Done gate

An agent's answer can say "I've updated the ticket" when no successful call shows it. The done gate makes "done" something the harness checks. You give the agent success criteria. When its run is about to answer, every criterion is checked against what the run actually did.

Each criterion is one of two kinds:

- **A tool criterion** (`tool`) is met by a successful call of that tool in this run, including calls made by its subagents. `args` can require exact argument values, and `result_matches` (a regex) can require something in the result.
- **A check criterion** (`check`) is a verifier call the harness makes itself when the model says it has finished. It is separate from the calls that did the work. It goes through the same governed path as any call: the tool allow-list, Cedar and data policies. It passes when the call succeeds and its result matches `expect`. The verifier must be a tool the agent's runtime attaches; this is checked when you save.

```json
{"done_gate": {"mode": "enforce", "max_pushes": 2, "criteria": [
  {"id": "resolved", "tool": "servicenow.update_incident", "args": {"state": "6"}},
  {"id": "visible", "check": {"server": "servicenow", "tool": "get_incident",
                              "args": {"number": "INC0012345"}, "expect": "\"state\":\\s*\"6\""}}]}}
```

| Mode | Unproven finish |
|------|-----------------|
| `monitor` | The answer goes out, and the shortfall is recorded. |
| `enforce` | The model is told which criteria are unproven and asked to carry on, up to `max_pushes` times (default 2, at most 5). If they are still unproven, the run ends with no answer: `done gate: not proven — …`. |

Every gated finish writes `agent.run.proof`. The row holds each criterion's verdict, the evidence it rests on and the proof's sha256. Evidence is the tool name, the sha256 of the arguments and of the result, and which loop made the call. Only these hashes are stored, never the values. Where the trust ledger is enabled, the proof is also a receipt on the tenant's hash-chained ledger (kind `event`, action type `run/done`, subject the run's correlation id), and the audit row names that receipt. Each piece of evidence names the ledger's action receipt for the same call, so the proof points at the tamper-evident record of the write. An `enforce` push writes `agent.run.unproven`.

The gate is off unless an agent sets `done_gate`, so existing agents finish exactly as before. A stored gate that cannot be read is enforced with a criterion nothing meets, so it fails closed. Start in `monitor` mode and read `agent.run.proof` before you enforce.
