Skip to content

Receiver Autonomy

Receiver autonomy controls whether an agent may move an inbox message from received to accepted without interactive human confirmation. It governs message admission only — it does not grant runtime tool permissions and does not relax agent safety defaults. The auto_review mode arrived in v0.3.1; v0.3.2 added the scope envelope — first-class side-effect booleans, a post-acceptance threshold checkpoint, a pinned terminal-state taxonomy, and default-off continuation grants. v0.3.5 recalibrated the gates — sender-marked guardrails fences, negation and declaration-aware demotion, the allow_pr_artifacts policy action, a pinned reason-code taxonomy backed by executable conformance fixtures, schema-v2 audits with human-outcome recording, and hook-enforced envelope compilation. v0.4.0 made merge authority and issue-filing first-class declarables (merges_pr, files_issues), pinned completion_kind to a four-value enum, and instrumented checkpoints with paused_at_utc and breach_basis; v0.4.1 scoped envelope enforcement to the compiling session and made the evaluator persist its own audit records. v0.4.2 moves signature verification to message intake (under enforce, only a signed-verified message reaches the gates), bounds profileless admissions with a documented default scope envelope, names every envelope’s origin in scope_envelope_source, and binds receiver policy files to authorized signers via the audit policy_auth block. v0.4.3 extends continuation grants to the review loop — a human-approved grant can authorize in-scope follow-up review rounds — and specifies checkpoint re-authorization end to end: three ranked answer channels and a consumption rule binding each answer to the pause it clears.

Phase 1 ships two modes:

ModeBehavior
always_pauseDefault. The receiver pauses for human review before accepting any work.
auto_reviewThe receiver may auto-accept a message only when it clears every deterministic gate below. Anything uncertain pauses.

Sender trust is messenger-bound and is not part of autonomy v1. Sender identity is logged for traceability but never gates a decision — since v0.4.0, message signing can verify that identity cryptographically. In warn mode the verified result is telemetry only; under enforce (since v0.4.2) the gate invokes verification at message intake, before any evaluation — an unverified message is quarantined to dead_letter/ with an intake_rejected decision and never reaches the gates. Even then, a verified signature identifies the messenger; it grants no authority.

Policy lives at agents/<receiver>/config.yaml:

autonomy:
default_mode: always_pause
auto_review_thresholds:
max_estimated_minutes: 45
max_expected_files_touched: 5
destructive_ops: pause
external_side_effects: allow_pr_artifacts
auth_config_or_secrets: pause
dependency_changes: pause
public_visibility: pause
git_push_or_deploy: pause
allow_without_task_profile:
- brainstorm_request
private_repo_allowlist:
- example-org/private-repo
continuation_grants:
enabled: false

With no config file, the receiver behaves as always_pause. Malformed config also pauses, and oacp doctor surfaces it. Autonomy is opt-in: drop in a config to enable auto_review, remove it to revert. The continuation_grants block is default-off (see Continuation grants below).

Since v0.3.5, external_side_effects accepts three policy actions (every other knob is pause-only):

ActionBehavior
pausePause every declared external side effect.
allow_pr_artifactsAllow PR creation/update, review comments, issue comments — and, since v0.4.0, declared files_issues issue-filing — only when the declared target_repo appears in the receiver-controlled private_repo_allowlist; direct main pushes, merges, deploys, and publishes still pause.
allowAllow declared ordinary external side effects; non-demotable hard stops still pause.

A private PR-artifact profile must set external_side_effects: true, target_repo: owner/repo, public_visibility: false, and at least one of creates_or_updates_pr / comments_on_github / files_issues (since v0.4.0) to true. The sender declaration is necessary but never sufficient — target_repo must also match the receiver’s own private_repo_allowlist. A profile that falls outside the private artifact class — an unlisted or missing target_repo, or declared external effects that do not satisfy the PR/review/issue-comment/issue-filing class — pauses with external_side_effects_not_pr_artifact; a contradictory profile (a declared PR, comment, issue, or commit alongside external_side_effects: false) pauses with declaration_error instead. A declared public_visibility: true never reaches the PR-artifact check — it pauses earlier with its own per-knob code, public_visibility_pause. Direct-main pushes and effective deploy/publish wording never reach that check either — Gate 3 hard-stops them first with hard_stop_external_side_effect. Merge authority is different since v0.4.0: a declared merges_pr is not a hard stop but always pauses at admission with its own code, merges_pr_pause (grant-coverable); undeclared merge wording still hard-stops. Branch commits and pushes needed to create the allowlisted artifact are folded into external_side_effects.

A message may carry a top-level autonomy_hint: auto_proceed. It is advisory only — the receiver’s own config and evaluator are authoritative, and a hint never escalates a verdict.

Under auto_review, task_request and question messages must include a machine-parseable task_profile block in the message body:

task_profile:
estimated_minutes: 20
risk_tier: P3
expected_files_touched: 3
destructive_ops: false
external_side_effects: false
touches_auth_config_or_secrets: false
touches_dependencies: false
public_visibility: false
# side-effect booleans (v0.3.2) + target_repo (v0.3.5)
target_repo: ""
creates_or_updates_pr: false
comments_on_github: false
commits_changes: false
sends_oacp_reply_only: true
# capability granulars (v0.4.0)
merges_pr: false
files_issues: false
continuation_grants: {}

The core declaration is complete only when it includes both numeric fields, risk_tier, and all five legacy risk booleans. The granular side-effect booleans are optional but must agree with external_side_effects — a profile that declares a PR, comment, or commit while declaring external_side_effects: false pauses with declaration_error.

A missing block pauses with task_profile_missing; an unparsable one pauses with task_profile_unparsable — that is a pause, not a fatal schema error. Types listed in allow_without_task_profile (just brainstorm_request by default) may auto-accept without it. Since v0.4.2 that exemption is admission-only: a profileless admitted request receives a documented default scope envelope — 25 minutes, 2 files, reply-only, every capability and risk flag false, risk_tier mirroring the message’s declared priority (else P2) — instead of running unbounded, so the threshold checkpoint is always evaluable. A sender-attached voluntary profile on an exempt type replaces the default entirely, and every decision names its envelope origin in scope_envelope_source (task_profile / default_profileless).

The side-effect booleans added in v0.3.2 make the profile’s GitHub-facing footprint explicit: whether the work opens or updates a PR, comments on GitHub, commits changes, or only sends an OACP reply. The evaluator normalizes them into the scope envelope it checks in Gate 2, and the threshold checkpoint compares them against what actually happened after acceptance. A missing-profile message that obviously requests PR, GitHub, commit, push, or public work pauses with risk_obvious_no_profile rather than the generic task_profile_missing.

The capability granulars added in v0.4.0 — merges_pr and files_issues — make PR-landing authority and issue-filing declarable. A declared merges_pr always pauses at admission (merges_pr_pause) so merge authority passes a human at least once (a human-approved continuation grant may cover it); a declared files_issues joins the allow_pr_artifacts private-repository artifact class. Both are embedded in the compiled envelope and consulted by its gh classifier, and the declarable keys, grant-coverable keys, and the checkpoint’s side_effects_actual keys are the same set by construction — the checkpoint can never observe a capability the schema cannot declare.

Since v0.3.5, senders may isolate non-operative safety language in a fenced body section:

```oacp-guardrails
Do not merge, deploy, publish, or touch credentials.
```

Gate 3 excludes well-formed oacp-guardrails fence contents from ordinary side-effect, auth/config/secrets, and ambiguous-scope pause classification, but records every matching term as a lexical_advisory — fenced text is never invisible to the audit. Destructive commands, direct main pushes, credential rotation, dependency installation, public-repository text, memory-SSOT text, and pricing/commercial content are scanned across the raw body and remain hard even inside the fence. An unclosed or differently labeled fence is not skipped.

Under auto_review the receiver runs four gates in order and pauses on the first failure. If any required signal is missing or uncertain, it pauses.

  1. Message integrity — the message validates against the OACP schema, has not expired, and its raw-YAML message_sha256 is recorded before processing. The message ID must not already have been auto-accepted by this receiver (replay guard). autonomy_hint stays advisory.
  2. Declared task profile — required for task_request and question. The profile is normalized into a scope envelope — time, files, risk booleans, side-effect booleans, and optional continuation grants. estimated_minutes (45-minute standard cap), expected_files_touched (5-file standard cap), destructive scope, sensitive scope, and side-effect scope are cross-checked against auto_review_thresholds. Since v0.3.5, each breach emits its own pinned reason code (estimated_minutes_exceeds_threshold, dependency_changes_pause, …) rather than one generic code, allow_pr_artifacts is applied only to the declared private-repository artifact class when target_repo matches the receiver’s allowlist, and contradictory profile fields pause with declaration_error.
  3. Receiver classification — the hard stops below, plus a pause whenever file scope is ambiguous or broader than the declared profile. For message types in allow_without_task_profile, side-effect verbs in the body (deploy, publish, merge) are logged as notes rather than hard stops — destructive command tokens still pause. Path-like tokens such as packets/deploy/ are not treated as deploy verbs. Since v0.3.5, three demotion mechanisms reduce lexical false positives without weakening the always-hard categories: well-formed oacp-guardrails fences are skipped (with every match logged as an advisory), demotable matches in clauses headed by no / not / never / do not demote to lexical_advisory_negated, and with a complete profile a side-effect or sensitive-scope match whose corresponding declaration is false demotes to a logged advisory. Since v0.4.0, a complete profile declaring merges_pr: true routes the lexical merge verb to the granular merges_pr_pause admission pause instead of the blanket hard stop — undeclared merge wording still hard-stops.
  4. Runtime / workspace — the worktree is clean or the task can be isolated to a fresh branch, no conflicting task is running on the same repo, and the required tools are available.

LLM judgment may only reduce false positives after all four deterministic gates pass. It can never override a hard stop.

Regardless of mode — and regardless of any autonomy_hint — a receiver must pause when the message body contains:

  • Destructive command tokensrm -rf, --force, --no-verify, --dangerously-skip-permissions.
  • Non-demotable side-effect actions — push to main, credential rotation, or dependency install.
  • Non-demotable sensitive text — public repos or memory SSOT.
  • Content sensitivity — pricing or commercial content, reported since v0.3.5 as its own hard_stop_content_sensitivity category (content class, not action risk): no fence, negation, or declaration demotion applies.

For these always-hard categories the body wins over the declaration: the token pauses the message even if the task_profile marked the matching risk flag false, and even inside a guardrails fence. Ordinary side-effect verbs (deploy, publish, merge) and declared auth/config/secrets scope are hard by default but demotable since v0.3.5. The only standard external-side-effect exception is the configured allow_pr_artifacts private-repository class. Continuation grants never override these hard stops either — even when enabled, a grant may only cover declared external side effects for the scoped PR, comment, or commit fields it marks true.

Every decision — accept or pause — writes one YAML file to agents/<receiver>/audit/autonomy_decisions/<YYYYMMDDTHHMMSSZ>_<message-id>.yaml. The write is mandatory; a partial record beats none.

schema_version: 2
spec_version: "0.4.3"
created_at_utc: "2026-05-12T13:23:25Z"
receiver: codex
sender: iris
message_id: msg-20260512132325-iris-de62
message_type: task_request
message_subject: "Small docs cleanup"
conversation_id: conv-20260512-iris-001
parent_message_id: null
message_path: agents/codex/inbox/20260512132325_iris_task_request.yaml
message_sha256: "..."
decision: auto_accepted
mode: auto_review
policy_path: agents/codex/config.yaml
policy_sha256: "..."
policy_auth: # v0.4.2 — binds policy_sha256 to an authorized signer
status: verified
signer_agent: codex
signer_kid: "..."
reason: null
scope_envelope_source: task_profile # v0.4.2 — task_profile | default_profileless
reason_codes:
- task_profile_present
- risk_threshold_passed
co_occurring_reason_codes: [] # v0.4.0 — Gate-2 breaches masked by an earlier hard stop; [] means passed, never unevaluated
evaluator: # v0.4.0 — self-stamped provenance, copied verbatim from the gate
source: scripts/autonomy_gate.py
content_sha256: "..."
executed: true
breached: []
thresholds:
max_estimated_minutes: 45
max_expected_files_touched: 5
task_profile:
estimated_minutes: 20
expected_files_touched: 3
destructive_ops: false
# ... the full declared profile, including granulars when present
runtime:
agent: codex
model: gpt-5
result:
final_state: done
completion_kind: auto_accepted
actual_minutes: null
actual_files_touched: null
predicted_risk_materialized: false
completed_at_utc: null
envelope_enforcement: none
threshold_checkpoint:
evaluated: false
actual_minutes: null
actual_files_touched: null
side_effects_actual: {}
breached: false
breached_fields: []
declaration_errors: []
breach_basis: null
paused_at_utc: null
action: not_evaluated
human_outcome:
recorded: false
actor: null
decision: null
decided_at_utc: null
decision_latency_seconds: null
pause_reason_codes: []
grant:
decision: not_recorded
request_present: false
reply_message_id: msg-...
artifacts: []

The result block records what actually happened: completion_kind, actual_minutes, actual_files_touched, predicted_risk_materialized, and a threshold_checkpoint sub-record. policy_path and policy_sha256 are null when the pause is caused by missing or malformed config; since v0.3.5 policy_sha256 is the SHA-256 of a canonical key-sorted serialization of the parsed policy, so comments and formatting don’t produce false drift. Audit schema_version: 2 (v0.3.5) adds thread identity and the structured result.human_outcome block; standing continuation grants trust only v2 records. The accompanying received -> accepted transition records accepted_by: autonomy_policy, human_confirmed: false, and the same reason_codes, so an auto-acceptance is always distinguishable from a human approval.

Since v0.4.0, result.completion_kind is a pinned four-value enumauto_accepted, admission_paused, checkpoint_paused, config_malformed — naming the terminal shape of the evaluation only; the pause cause lives in reason_codes, the run state in result.final_state, and human decisions in result.human_outcome. Receivers copy the evaluator’s value verbatim. Two more v0.4.0 additions: the evaluator self-stamps its provenance into every decision, and when a message is signed, oacp verify --attach-audit records the canonical result.message_auth block — the one supported path for stamping signature outcomes into an audit. Since v0.4.1, the evaluator itself persists the complete, lock-serialized audit record to its --audit-dir destination. Since v0.4.2, the writer hardens the record further: scope_envelope: null on an admitted record is a schema violation it refuses to persist, result.completion_kind is validated against the pinned enum at write time, and runtime.model resolves at the writer — caller-first, then the OACP_RUNTIME_MODEL environment variable, normalized with provenance in model_source; a record with no signal carries an explicit model_unknown_reason, never a silent null. The policy_auth block records whether the consulted config.yaml was signed by an authorized signer — a tampered policy fails closed with policy_auth_invalid before anything reads it (see policy-file signing).

When a paused task is approved, modified, or declined by a human, record the decision in the same audit file with oacp autonomy-outcome (v0.3.5):

Terminal window
oacp autonomy-outcome <audit.yaml> --decision approved

The recorder copies the pause reason codes, computes decision latency from the audit’s created_at_utc, locks the read-modify-write, and refuses to overwrite a recorded outcome unless --replace is explicit. Grant handling is separate (--grant-decision), so a task approval never silently creates a standing grant. Since v0.4.0, checkpoint-paused records are accepted too — latency is measured from the checkpoint’s paused_at_utc rather than admission time — and the --actor convention is pinned to one canonical, whitespace-free handle per human, fleet-wide.

A receiver with default_mode: auto_review receives:

## Task
Clean up the build directory: `rm -rf dist/ && rebuild`.
task_profile:
estimated_minutes: 5
expected_files_touched: 1
destructive_ops: false

Decision trace:

  • Gate 1 passes — schema valid, not expired, hash recorded.
  • Gate 2 passes — profile present and within thresholds.
  • Gate 3 fails — the body matches rm -rf.
  • Verdict: paused, reason hard_stop_destructive_command, matched_pattern: "rm -rf".

The receiver pauses before any action runs. The destructive_ops: false declaration is irrelevant — no mode overrides a hard stop.

Auto-acceptance is not a blank cheque. If work expands past the declared scope envelope after acceptance, the receiver evaluates a threshold checkpoint — and self-pauses, notifying the sender with a canonical opener:

  • Blocked: autonomy threshold exceeded — files_touched expected 3, now 12
  • Blocked: autonomy threshold exceeded — prompt was docs-only, now requires credential access
  • Blocked: autonomy threshold exceeded — task expanded into untyped/unconfigured capability

A breach is instrumented in the audit result:

result:
final_state: paused
completion_kind: checkpoint_paused
actual_minutes: 25
actual_files_touched: 4
predicted_risk_materialized: true
threshold_checkpoint:
evaluated: true
actual_minutes: 25
actual_files_touched: 4
side_effects_actual:
creates_or_updates_pr: true
comments_on_github: true
commits_changes: true
breached: true
breached_fields:
- actual_files_touched
breach_basis: realized
paused_at_utc: "2026-08-04T09:12:00Z"
action: paused_for_reauthorization

Since v0.3.5, an undeclared side effect about to materialize — the profile said creates_or_updates_pr: false and the work now needs to open a PR — pauses with declaration_error, and threshold_checkpoint.declaration_errors identifies the offending field. This checkpoint is mandatory before performing any newly discovered capability or outward action.

Since v0.4.0, a breached checkpoint stamps paused_at_utc (the moment it fired — the basis for human decision latency) and breach_basis: declared_intent | realized, so a prospective declaration-correction pause — caught before the undeclared action materialized, declared via declared_intent_fields with every realized effect still false — is distinguishable from realized drift.

Since v0.4.3, re-authorization is specified end to end. Three channels can carry an answer to a paused checkpoint, and rank — never arrival order — decides conflicts: a receiver-side human (authoritative, recorded via oacp autonomy-outcome); a signature-verified sender reply, bounded by the receiver’s own admission policy — numerics are capped at the receiver’s thresholds, boundary actions only within the admission predicate, and merge authority is never sender-grantable; and GitHub comments, which are advisory-only. Clearing authority belongs to the first two alone — a GitHub comment never clears a checkpoint. A consumption rule binds each answer to the pause it clears: a scope-less approval clears exactly one checkpoint and is then spent (reuse against a later pause records checkpoint_reauthorization_stale), a scoped numeric budget stands for the rest of the task up to the granted value, and a boundary-action grant durably authorizes one granular capability until task completion — no answer shape waives future checkpoints wholesale. The audit surface separates the requested scope from the effective policy-capped grant, under the pinned codes checkpoint_reauthorized and checkpoint_reauthorization_stale.

v0.3.5 turns the declared task_profile from reviewed intent into enforced runtime constraints. After a message is admitted, the receiver compiles the profile plus its own autonomy config into a runtime envelope:

Terminal window
oacp envelope compile <message.yaml> --receiver <agent>

The envelope lands at agents/<receiver>/state/active_envelope.json and is enforced at the tool-call layer by a static runtime shim — for Claude Code, a PreToolUse hook (oacp-envelope-hook) registered once by oacp setup claude. Per-task constraints live only in the envelope file: no per-task settings mutation, effective mid-session, and a strict no-op while no envelope is active. The receiver compiles at task pickup and clears with oacp envelope clear at completion.

Compilation fails closed: a missing, unparsable, or invalid profile — or malformed receiver config — pauses the task with envelope_compile_error instead of executing unenforced. The compiler reuses the gate evaluator’s normalization and pattern constants directly, so admission spec and runtime enforcement cannot drift, and the receiver-side private_repo_allowlist is embedded at compile time — runtime enforcement never trusts sender declarations alone. A drift deny arrives with the canonical Blocked: autonomy threshold exceeded opener and is handled as a threshold checkpoint. The audit outcome block records envelope_enforcement: hooks | none — degradation on older runtimes is recorded, never silent. See oacp envelope.

Since v0.4.0, the completion-time oacp envelope clear executes from inside the enforced session — the hook sanctions it against the task’s newest audit record once result.final_state is terminal — and protocol-mandated bookkeeping (audit records, the receiver’s own inbox/outbox, the runtime scratchpad) no longer consumes the declared file budget. Since v0.4.1, enforcement is session-scoped: the envelope records the harness session that compiled it and no-ops for every other session, so a concurrent interactive session in the same repository neither inherits the task’s constraints nor consumes its budget.

Two v0.4.2 additions close the loop with message signing. Under signing.verify_mode: enforce, the compiler refuses to compile from a message that is not signed-verified, and the envelope’s message_sha256 names the verified snapshot bytes. And an admitted public_visibility: true task whose record carries a human admission approval gets an explicit, recorded enforcement branch: oacp envelope compile --audit <admission-record> deliberately does not compile — a compiled public envelope would deny the entire approved chain — and instead stamps envelope_enforcement: none with the named reason public_visibility_admission_approved into the audit record, under the audit lock. The degradation is a documented mode with the human as the bound, not a silent absence; unapproved public tasks and all private tasks keep the fail-closed compile path.

Continuation grants are default-off — receivers ignore them unless their config sets autonomy.continuation_grants.enabled: true. The one supported grant kind is approved_thread_continuation, declared under task_profile.continuation_grants:

task_profile:
estimated_minutes: 20
expected_files_touched: 1
external_side_effects: true
creates_or_updates_pr: true
comments_on_github: true
commits_changes: true
continuation_grants:
approved_thread_continuation:
scope:
max_actual_minutes: 30
max_actual_files_touched: 3
creates_or_updates_pr: true
comments_on_github: true
commits_changes: true

A grant is considered only when all hold: receiver config enables continuation grants; the message has same-thread evidence via parent_message_id or conversation_id; the grant includes an explicit scope; and actual work stays inside that scope. If grants are disabled, the receiver logs continuation_grant_ignored_disabled and evaluates the message normally. If enabled but actual work drifts outside the grant, the receiver pauses at the threshold checkpoint. Grants never relax destructive, auth/secrets, dependency, public-scope, pricing, or memory-SSOT hard stops.

Since v0.3.5, a sender-declared grant block is a request, never authority: a standing grant resolves only from a prior schema-v2 audit in the same sender/thread with a human-recorded grant approval (via oacp autonomy-outcome). Without that recorded approval the receiver pauses with continuation_grant_missing_approval; a later denial revokes (continuation_grant_denied); and a declaration beyond the granted scope re-pauses with continuation_grant_scope_exceeded.

Since v0.4.3, a grant may also carry a review_loop scope — one repository, PR, and sender thread; the allowed lifecycle types; a round ceiling; a wall-clock expiry; and the permitted review side effects. With it, an in-scope same-thread follow-up review_request auto-invokes the reviewer instead of pausing for a per-round confirmation, recorded in a review_continuation audit block; everything else pauses under a pinned reason code — review_continuation_*, or review_loop_invalid for a malformed scope. The grant authorizes running a round, never its verdict — the live PR head stays authoritative, declared heads are compared by exact full-SHA equality, expiry and revocation bind at evaluation time, and only rounds that actually ran consume the round budget. See the review loop for the lifecycle the grant covers.

Audit result.final_state is pinned to a fixed set — done, paused, blocked, superseded, error. The terminal shape of the evaluation lives in result.completion_kind, pinned since v0.4.0 to exactly four values — auto_accepted, admission_paused, checkpoint_paused, config_malformed — with the pause cause left to reason_codes. Pinning both enums keeps senders like iris and codex able to pattern-match outcomes across runtimes.

auto_review is the OACP analogue of Claude Code’s acceptEdits mode: class-based pre-approval inside a local trust domain, bounded by bright-line hard stops. The analogy is about the user contract, not the mechanism — OACP decides pre-execution from message content and the declared task_profile, while runtime tools still enforce their own permissions at action time.

The canonical conformance fixtures — clean tasks, ambiguous scope, hard-stop triggers, and malformed config — live in tests/conformance/autonomy/.