Skip to content

Autonomy policies

How a run decides whether it may act on its own or must stop and ask a person. Policies are resolved per run, cascade from template to team to global, and fail closed. For the human-facing side of a stop see hitl-workflows.md; for where the setting lives see configuration.md.


Approval gates in a workflow answer “should a person see this?” by being placed in the DAG. An autonomy policy answers the same question without the workflow author having to decide in advance, by classifying the kind of thing the run is about to do and looking up what that class requires.

A policy is a map from risk class to a rule:

{
"internal_read": { "action": "auto" },
"internal_write": { "action": "auto" },
"external_communication": { "action": "require_approval" },
"mass_communication": { "action": "require_approval", "approverCount": 2 }
}

action is one of auto or require_approval — those two values are the whole vocabulary. approverCount raises the bar to a number of distinct approvers; it defaults to one.

Risk classes are free-form strings, not an enum. The policy editor offers the known set (KNOWN_RISK_CLASSES in shared/src/lib/autonomyPolicy.ts: the four above plus external_write) as a picker and shows each by its plain-language name; a policy that already carries another class keeps it. The API accepts any name, so a deployment may use whatever its workflows publish. A class nobody has written a rule for resolves to require_approval, so adding a new class is safe by construction — it starts closed and an operator opens it deliberately.


resolveAutonomyPolicy (packages/worker/src/lib/resolveAutonomyPolicy.ts) walks from the most specific scope to the least:

template override → team default → global default → built-in fallback

Each level re-checks that the row it found really has the shape of that scope — a template policy must have a templateId, no teamId, and isDefault: false — so a row edited into an ambiguous state cannot be silently read as a broader default than it is.

Two things make this different from the config cascades elsewhere in the platform:

  • There is no “no policy” outcome. The built-in fallback in packages/shared/src/lib/autonomyPolicy.ts is the floor, so a deployment that has configured nothing still requires approval for external and mass communication.
  • Ambiguity fails closed, loudly. If more than one row matches a scope, resolution returns a policy literally named ambiguous policy scope whose rule for the requested class is require_approval. The run stops and the reason names the problem, rather than a coin toss deciding whether a message goes out.

getSafeAutonomyRules validates stored rules against AutonomyRulesSchema on every read. Stored JSON that no longer parses — an older shape, a hand-edited row, a bad import — does not throw and does not pass through:

Stored rules Result
Valid Used as written
null or absent The built-in fallback set
Malformed The fallback set, plus require_approval forced for the class being asked about

The last row is the one that matters. A run asking “may I send this external message?” against a corrupt policy gets require_approval for exactly that question, not a generic default that might happen to allow it.


Two call sites resolve a policy, and both are activities rather than workflow code, because the policy lives in the database and workflow code cannot read it:

Call site What it gates
packages/worker/src/activities/publishOutcome.ts A run about to publish an outcome — post a message, file a ticket, notify a channel
packages/worker/src/activities/runEvalNode.ts An eval node carrying a policy scorer, which evaluates a risk class as part of scoring

The policy scorer is one of the eval node’s scorer kinds and takes a riskClass. It lets a workflow assert that a given class would be allowed under current policy, without performing the action.

A require_approval decision does not fail the run. It converts the step into a wait, which the inbox and the Slack teammate both surface the same way any other human gate is surfaced.

The policy scorer is the exception: it never waits. It passes only when the class resolves to auto; any other action — require_approval, or a class the policy does not name — fails that floor scorer, which blocks the eval, and the eval node’s onFail decides what happens next (block fails the run, warn carries on). See evals.md §2.

A recorded decision is only a gate if the step that performs the action checks it. That check lives in packages/worker/src/lib/externalWrite.ts and covers the three workflow steps that change something outside the platform — writeOutcome, runTool and mcpCallTool:

  • Before a mutation, the latest publish decision for the run that required approval is read. If no approve was recorded after it, the step is refused with a non-retryable APPROVAL_REQUIRED. A run that recorded no such decision is not gated — the policy exists where something asked for it.
  • A write with no decision on the way in is ungated, so validateSpec warns for it (WRITE_WITHOUT_POLICY) on each writeOutcome step or mcp node that no publishOutcome reaches. It is reachability, not dominance — a write that is also reachable by a route skipping the decision passes the check — and runTool is left out because its tool name is normally a binding, so a static check cannot tell a read from a write; the runtime guard classifies that one at call time. policyGatedWrite emits the whole idiom, approval branch included.
  • The decision is matched to the run, not to the action: a run-wide requirement, from any risk class, holds every mutation until it is satisfied, and the guard cannot tell an approval for one human step from an approval for another. A template that gates one action and then performs an unrelated write is therefore held by the first gate. That is the conservative direction, and it is the same rule writeOutcome has always used. It also means a later decision that would have allowed something else cannot clear an earlier requirement: the guard reads the latest requirement, not the latest decision.
  • The risk class is the template’s to declare, not the run’s: publishOutcome reads config.action and ignores an action input, and validateSpec refuses a spec that supplies one (ACTION_CLASS_BOUND). A payload-fillable class would let whoever supplies the payload choose the class it is judged by — and since a requirement from any class holds every mutation, that is a gate the claimant sets for itself.
  • Connector tools that only read (fetchTicket, fetchIssue) are exempt: they change nothing, so they are neither gated nor recorded. So is an MCP tool its server declares read-only with the protocol’s readOnlyHint annotation. Every other call — including an MCP tool with no annotation, whose readOnlyHint defaults to false — is treated as a mutation.
  • A mutation is also claimed against the outcome ledger (see architecture.md §4): the claim is written before the provider is called and completed with what it returned. A retry that finds a completed row returns that result instead of repeating the effect, and one that finds a bare claim is refused with a non-retryable OUTCOME_AMBIGUOUS — the provider may have acted and the attempt that called it recorded nothing, so repeating it is how one effort becomes two. The approval guard and the connection checks (authorization, active, supported type, token decryption) run before the claim is taken, and the writeOutcome and runTool handlers’ payload and tool-name checks release it (notSent), so those refusals stay retryable. Two limits remain, stated because they are what this is not: a new run for the same work starts with an empty ledger, since a restart is a new workflow id, and a claim whose outcome was never learned has to be resolved by a person — nothing here clears it on its own.

Outside this boundary:

  • An agent’s own tool calls. An agent granted the mcp tool key calls MCP tools inside its own loop, through lib/mcpTools.ts, which does not consult this guard or the ledger. Governance for those is the agent’s tool grant and the connection’s operator switch.
  • Sandbox steps. shell and containerStep reach the network through the sandbox rather than a connector, so they are governed by the image allowlist and the egress allowlist instead. Egress filtering is DNS-based: an IP-direct connection is not filtered, and a wildcard entry is informational (see architecture.md §10).

Every evaluation writes an AutonomyDecision row against the run: the event (publish, approve, reject, approve_partial), the actor where there is one, the resolved policyName, the riskClass, and the requiredApprovers in force at that moment.

The record is deliberately a point-in-time snapshot. Editing a policy afterwards does not rewrite what a past run was held to, which is what makes the log answerable in an audit: it says what the rule was, not what it is now.

Browse it at /admin/policies/decisions, or read it over the API.


Surface Where
Policy list and editor /admin/policies
Decision log /admin/policies/decisions
API GET/POST /api/v1/admin/autonomy-policies, DELETE /api/v1/admin/autonomy-policies/:id, GET /api/v1/admin/autonomy-decisions

The same routes are mounted under /api/v1/platform as well as /api/v1/admin.

Policies are stored in autonomy_policies (AutonomyPolicy), keyed by an optional teamId and an optional templateId plus an isDefault flag, which together encode the scope the resolver expects.


  • Two actions only. auto and require_approval are the entire vocabulary. There is no “deny outright”, no time-boxed auto-approval, and no escalation path — a class is either open or it waits for a person.
  • approverCount is a count, not a list. A rule can demand two distinct approvers; it cannot demand a particular person, a role, or a group. Who is allowed to approve is whatever the run’s team membership and RBAC already permit.
  • Risk classes are unvalidated strings. The editor offers only the known set, but the API and a workflow step accept any name, and nothing checks that a class a workflow publishes matches a class a policy names. A typo does not error; it silently lands on the unknown-class default and requires approval, which is safe but is not the rule the author thought they wrote.
  • Scope is template or team, never channel or organization. The other config cascades in this platform resolve through five levels; this one has three, and a channel-resident run inherits its template’s or team’s policy with no channel-level override.
  • The decision log records outcomes, not deliberation. It captures what was decided and under which policy, not the diff, the message, or whatever the approver actually looked at before deciding.
  • Editing a policy does not disturb waiting runs. A run already parked at a gate keeps the approver count it was given. Loosening a policy will not release it, and tightening one will not raise its bar.
  • Some pre-send refusals still strand a claim. Only the checks in the writeOutcome and runTool handlers release a claim as notSent. A refusal raised elsewhere after the claim — a connection config that fails to parse, a connector’s own validation or URL check before it makes the request, or an MCP call that fails before reaching the server — is indistinguishable from an ambiguous failure, so the claim stays and the next attempt is refused with OUTCOME_AMBIGUOUS until a person resolves it.

© 2026 Jorge Barnaby

Source-available under FSL-1.1-MIT

Source on GitHub

Built with Astro and Starlight