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.
1. What a policy decides
Section titled “1. What a policy decides”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.
2. Resolution
Section titled “2. Resolution”resolveAutonomyPolicy (packages/worker/src/lib/resolveAutonomyPolicy.ts) walks from the most
specific scope to the least:
template override → team default → global default → built-in fallbackEach 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.tsis 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 scopewhose rule for the requested class isrequire_approval. The run stops and the reason names the problem, rather than a coin toss deciding whether a message goes out.
3. Failing closed
Section titled “3. Failing closed”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.
4. Where it is enforced
Section titled “4. Where it is enforced”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.
4.1 The mutation boundary
Section titled “4.1 The mutation boundary”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
publishdecision for the run that required approval is read. If noapprovewas recorded after it, the step is refused with a non-retryableAPPROVAL_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
validateSpecwarns for it (WRITE_WITHOUT_POLICY) on eachwriteOutcomestep ormcpnode that nopublishOutcomereaches. It is reachability, not dominance — a write that is also reachable by a route skipping the decision passes the check — andrunToolis 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.policyGatedWriteemits 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
writeOutcomehas 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:
publishOutcomereadsconfig.actionand ignores anactioninput, andvalidateSpecrefuses 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’sreadOnlyHintannotation. Every other call — including an MCP tool with no annotation, whosereadOnlyHintdefaults 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 thewriteOutcomeandrunToolhandlers’ 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
mcptool key calls MCP tools inside its own loop, throughlib/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.
shellandcontainerStepreach 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).
5. The decision log
Section titled “5. The decision log”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.
6. Managing policies
Section titled “6. Managing policies”| 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.
Limitations
Section titled “Limitations”- Two actions only.
autoandrequire_approvalare 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. approverCountis 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
writeOutcomeandrunToolhandlers release a claim asnotSent. 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 withOUTCOME_AMBIGUOUSuntil a person resolves it.
© 2026 Jorge Barnaby
Source-available under FSL-1.1-MIT