Skip to content

The operation contract

An agent can read and edit files in its sandbox freely. Anything that touches a system outside the sandbox (a Terraform apply, a database restore, a posted message, a merged pull request) goes through one path: the operation contract. The agent proposes an exact operation, the control plane records it, a person or a standing rule approves exactly that, a runner executes it, and a separate read checks what happened.

The user-facing view is on Approvals and guardrails. This page is the code view. Everything here lives in api/src/modules/operations/.

Type File What it is
Capability domain/operations.ts { id, mode, input_schema }. mode is read or write.
Target domain/operations.ts One thing a connection can act on: resource_id, environment, service, repository.
NormalizedOperation domain/operations.ts The provider’s canonical form of a request: resource_id, resource_key, arguments.
Operation application/ports.ts The stored request, with its fingerprint, binding, status, review_target, result and verification.
OperationProvider application/ports.ts The port every integration implements: normalize, permits, read, review, dispatch, verify.
DispatchResult domain/operations.ts What dispatch returns: status running, failed or unknown.
Verification domain/operations.ts What verify returns: status verified, pending or not_observed, plus what was observed.

An operation’s status is one of awaiting_approval, running, succeeded, failed, unknown, rejected, canceled, expired (OperationStatus).

The runtime reaches this module only through POST /api/internal/v1/operations/commands. The body is one of six kinds (operationCommandSchema in application/contracts.ts):

Kind Purpose
list_operation_capabilities The connections, targets and capabilities this run’s grants cover
describe_operation_target The input schema for one capability on one target
read_operation Execute a read now and return a receipt
propose_operation Review a write and record it for approval
get_operation Read a request’s current state
verify_operation Run the verifying read

Every command carries command_id, tenant_id, task_id, run_id, attempt_fence and expected_run_version. The command is stored with a digest of its whole body. Repeating it returns the stored response; reusing the ID with a different body is refused.

OperationsService.prepare handles propose_operation in three steps.

Check. The capability must exist on the connection with mode write. The provider normalizes the arguments. The run’s current grants must cover that capability on that target, and the connection’s registered scope must permit it. An agent cannot propose against a target it was not granted.

Review. Outside any transaction, the service calls provider.review(...). For a runner-backed provider this is a signed request to the runner, using the runner’s read identity. Nothing is changed. The answer is evidence of exactly what would happen: for Terraform, a saved plan with its plan_id, plan_sha256, the commit it was made from and when it expires.

Record. The operation is inserted with status awaiting_approval. The review evidence is stored as review_target, and two digests are computed:

binding = operationDigest([provider, connection.id, endpoint, scope, credential])
fingerprint = operationDigest([tenant_id, task_id, run_id, id, binding,
capability_id, normalized, evidence])

operationDigest is SHA-256 over canonical JSON (keys sorted). The response returned to the agent is stored as the command’s idempotent answer.

An approval is not “yes to this task”. It is bound to five things, and a change in any of them invalidates it.

Bound to How
The operation The fingerprint covers the task, the run, the operation ID and the capability. The approve and reject routes require the caller to send the fingerprint they reviewed (decisionSchema); a mismatch is refused.
The target The binding covers the connection’s provider, endpoint, registered scope and sealed credential. If an administrator edits or replaces the connection, the binding no longer matches and the request must be proposed again.
The arguments The normalized arguments are inside the fingerprint.
A source revision or digest The arguments of a plan-bound capability name what will be applied. terraform.apply requires commit, plan_id and plan_sha256. CloudFormation binds a change set and its digest, an image publish binds archive and manifest digests, an MCP call binds the tool’s schema digest.
An expiry A request waits for ttl_hours of its approval rule, or the deployment default POLO_API_APPROVAL_TTL_HOURS. After that expireIfDue sets it to expired. The reviewed plan has its own expiry on the runner.

Who may approve, how many approvals are needed (one or two) and whether the requester may approve their own change come from the workspace approval policy (domain/approval-policy.ts), chosen by the operation’s environment.

OperationsService.decide does not trust the review it stored. When an approval completes the required count, it:

  1. calls provider.review(...) again and compares its digest with the stored review_target. If the runner now reports something different, the approval fails with “The runner review changed. Request a new reviewed operation”;
  2. re-checks, under a row lock, the fingerprint, the expiry, the policy, the approver’s role, and that the task and the requesting run are still running and still hold the grant;
  3. takes a lock on the target’s resource_key and refuses if another operation on that target is still unresolved (“Another operation on this target needs verification first”);
  4. sets the status to running, records who approved and when, and appends the decision to the decision log.

Only then, after that transaction commits, does it dispatch.

An administrator can create a standing approval for one exact operation (domain/preauthorization.ts): an exact environment, connection, capability, resource and arguments, with a daily limit and an expiry. A proposal that equals the rule on every field moves straight to running. A provider must opt in with preauthorizable; today only mcp.call does. Plan-bound and digest-bound changes cannot be pre-authorized, because what they apply is not known in advance.

provider.dispatch(connection, operation) sends the approved request. For a customer runner this is an HTTPS PUT to a path that ends in the operation ID, signed with the connection’s key:

signature = HMAC-SHA256(key, "PUT\n" + path + "\n" + timestamp + "\n" + body)
headers: X-Polo-Timestamp, X-Polo-Signature

The runner verifies the signature and rejects a timestamp more than 60 seconds off (signed_body in tools/common/runner_support.py). It then checks the request against its own configuration. The Terraform runner, for example, looks up the saved plan and requires the task, run, stack, commit and plan digest to match before it applies (_plan_row in tools/terraform/runner.py). The runner, not Polo, holds the cloud credentials, and it uses a separate identity for applying than for reading.

Because the operation ID is in the path, sending the same request twice does not apply twice. The runner returns the receipt it already has.

A runner answers with a receipt: the request’s id, the task_id and run_id, a status, a result, and a bounded reason code when it failed. Runners keep their receipts, so a later question about an old request can be answered from the runner’s own record.

On Polo’s side, dispatchRunning stores the outcome on the operation as result and error. A read returns its receipt directly to the agent as { id, connection_id, status: 'succeeded', result }.

A successful dispatch is not success. The operation becomes succeeded only after provider.verify(...) returns verified.

Verification is a different read from the write:

  • Terraform makes a refreshed plan after the apply and requires it to report no changes, then checks the state (_converged).
  • MCP calls a different, read-only tool that was granted as the write tool’s verifier, and compares one path of its result with an expected value that was part of the approved arguments.
  • Cloud machine changes read the provider’s record of the request and the resource again (tools/common/cloud_change.py).

The agent triggers verification with verify_operation; a person can trigger it from the task view, which calls POST /api/operations/requests/:id/verify. The result maps to a status like this:

Verification Operation becomes
verified succeeded, with result set to what was observed
rejected_before_dispatch set failed: the runner refused before changing anything
pending stays running
not_observed, more than 120 seconds after approval unknown

Sometimes Polo cannot tell whether a change happened: the connection dropped after the request was sent, or the runner never confirmed. The contract has a status for exactly this, and it is the part to preserve when you change this code.

  • If dispatch throws, the operation is recorded as unknown with the error “Dispatch could not be confirmed. Verify the existing receipt before another change.”
  • For providers that set requiresDispatchReceipt, a running operation with no receipt after 120 seconds becomes unknown: “The mutation has no dispatch receipt. It remains unresolved and will not be replayed.”
  • An unknown operation is never retried automatically. Temporal may retry an activity, but the activity only polls and verifies. The mutation itself is sent once.
  • An unknown operation blocks its target. The resource lock in step 3 of approval refuses a new change to the same target until this one is resolved.
  • Reconciliation is verification. Running the verifying read again settles it: verified makes it succeeded, a runner receipt that says it was refused makes it failed. Until then it stays unknown, and the UI says “Outcome unknown · Polo couldn’t confirm that it ran”.
  • If the connection changed in the meantime, verification refuses with “The operation connection changed. The receipt still requires reconciliation”, because a different endpoint or credential could not speak for the original request.

The same idea appears on the runner side. The cloud change kit writes down what it is about to send before sending it, so a request it has no record of provably asked nothing of the provider.

  • Tools and runners shows how to add a provider and a runner that follow this contract.
  • States lists the operation statuses and the outcome words shown in the app.
  • Runners and vault is the user’s view of runners.