Execution Decision Model

Audience: operators and policy authors enabling graded execution decisions — #1310. A graded policy returns more than allow/deny: it can authorize a modified action (“yes, but at 0.08 m/s near an obstacle”, “yes, but capped at 8 replicas”) and record both what was proposed and what was authorized.

The model is domain-neutral — it governs any executable action (a ROS 2 Twist, an AI-agent tool call, a Kubernetes scale) through one pipeline:

Proposed Action → Policy → Verdict → Effect → (Transformation) → Authorized Action → Adapter → Enactment Receipt

The worked walkthrough (Steps 1–3) uses ROS 2 Twist; the cross-domain examples below show the identical contract governing an AI-agent tool call and a Kubernetes scale — only the provider and action shape differ.

Model

A graded decision has these parts on the /v1/tool response, all optional (an ungraded decision omits them, so a consumer that reads only decision is unaffected):

Field

Meaning

verdict

The verdict the policy assigned (a name from the declared vocabulary).

authorized_action

The concrete, complete action the runtime authorized.

reason_codes

Machine-readable reason enums.

Three layers own three things, and no layer reaches into another’s:

  • Verdict vocabulary is manifest-defined. The runtime hardcodes no verdict names; a deployment declares its verdicts in the signed manifest, so an operator adds or retunes them per deployment without recompiling.

  • Effects are runtime-defined and finite. Each verdict maps to one effect — the runtime’s fixed set of enforcement behaviors. The runtime never infers behavior from a verdict name.

  • Transformations are provider-defined. Computing an authorized action (clamp a velocity, cap a replica count) is owned by a domain provider bound in the manifest; the generic runtime resolves and calls it but never understands its action shape.

Effects

Effect

Meaning

pass_through

The original proposed action is authorized as-is; the verdict rides the response as metadata.

replace

The original action is not authorized; exactly one replacement — an authorized_action or a transformation — takes its place. The runtime returns decision: "deny" so an un-upgraded adapter (which enacts only on allow) drops the original fail-closed, while an upgraded adapter enacts the replacement.

reject

Nothing is authorized (deny, no alternative).

Fail-closed by construction: a policy that returns a verdict on a deployment that declares no vocabulary, a verdict outside the declared set, a verdict whose effect maps to an unknown value, a replace verdict carrying zero or more than one replacement, or a pass_through/reject verdict carrying a replacement, is denied and its graded fields dropped. A graded policy can never actuate on a deployment that has not explicitly enabled and defined its vocabulary.

Step 1 — Declare the vocabulary (deployment manifest, schema v1.8)

Add an execution_decisions block (verdict → effect) and, if the policy selects transformations, an action_transformations block binding each transformation to a provider. Both require an explicit schema_version: "1.8" — a runtime too old to enforce the model refuses the bundle rather than silently ignoring it.

schema_version: "1.8"
execution_decisions:
  verdicts:
    - { name: ALLOW,     effect: pass_through }
    - { name: CONSTRAIN, effect: replace }
    - { name: AVOID,     effect: replace }
    - { name: HOLD,      effect: replace }
    - { name: DENY,      effect: reject }
action_transformations:
  providers:
    - { id: builtin.ros2_twist, version: "1" }
  enabled:
    - name: clamp_velocity
      provider: builtin.ros2_twist
      provider_version: "1"
      action_kind: tool.ros2.topic.publish
      input_schema: ros2://geometry_msgs/msg/Twist
      output_schema: ros2://geometry_msgs/msg/Twist

Verdict names are operator-defined — ALLOW/CONSTRAIN/AVOID/HOLD/DENY above are an example, not a runtime default. A replace effect determines the replacement contract on its own, so there is no separate “requires an authorized action” flag. The manifest reaches the runtime via the signed release lock; see the runtime start --deployment-manifest / release activate --deployment-manifest flows.

Step 2 — Author a graded policy (hand-authored Rego)

A policy expresses a graded result with top-level document keys in package autonomy, read the same way as allow:

package autonomy
import rego.v1

# The base action is permitted; the verdict NARROWS it near an obstacle.
default allow := true

caution if input.scene.lidar.fields.front_m < 1.0

verdict := "CONSTRAIN" if caution

# EITHER hand-compute the authorized action …
authorized_action := {
	"linear_x":  0.08,
	"angular_z": input.params.angular_z,
} if caution

reason_codes := ["obstacle_caution_band", "speed_limit"] if caution

A verdict transforms an already-permitted action. The allow chain decides whether the base action is permissible; the verdict then narrows or replaces it. So allow must be true on the graded path — a verdict never elevates a denied or deferred decision:

  • allow=true + a replace verdict → the runtime denies the original and surfaces the authorized replacement.

  • allow=true + a pass_through verdict → allow stands; the verdict is metadata.

  • allow=false (or a Defer) + any verdict → the policy outcome stands and the verdict is audit metadata only; any replacement is dropped so it can never re-enable a blocked action.

Verdict names are matched exactly and case-sensitively against the declared vocabulary. OPA surfaces numeric values with their precision preserved, so a velocity like 0.08 round-trips intact; the runtime structurally validates that authorized_action is a JSON object of serializable values before it reaches an adapter.

Selecting a transformation instead of computing the action

Rather than hand-compute authorized_action, a policy can select a named, provider-owned transformation + inputs and let the provider compute the authorized action. The policy expresses intent; the provider owns the transformation math:

verdict := "CONSTRAIN" if caution
transformation := {
	"name":   "clamp_velocity",
	"inputs": {"max_linear": 0.08, "max_angular": 0.2},
} if caution

clamp_velocity (the builtin.ros2_twist provider) caps each velocity axis of the proposed command to the input (preserving direction); stop_motion zeros all axes (a HOLD); avoid_obstacle replaces the proposal with a scene-directed escape. A replace verdict carries exactly one of authorized_action or transformation; the transformation must be in the manifest’s action_transformations.enabled allowlist and its action_kind must match the request kind, or the decision denies fail-closed. provider may be omitted in the policy — the manifest’s enabled entry resolves it; if supplied it must match. The provider’s output flows through the same encode, receipt, and faithfulness pipeline as a hand-computed action.

The provider — not the runtime — owns each transformation’s domain shape, so a non-ROS provider (e.g. builtin.kubernetes cap_replicas, which caps replicas to inputs.max_replicas on a tool.k8s.scale action) plugs in the same way, with no change to the runtime. A provider always returns the complete authorized action — including routing identity (which deployment, which topic) — so the enactment receipt proves what was enacted, not just the changed field.

Capping transformation inputs (constraint ceilings)

The operator’s manifest is the outer safety envelope over the inputs a policy may pass to a transformation. Add a constraint_ceilings block under action_transformations (transformation → field → maximum):

action_transformations:
  # providers + enabled as above …
  constraint_ceilings:
    clamp_velocity: { max_linear: 0.5, max_angular: 0.8 }

A policy that requests max_linear: 5.0 then has it capped to 0.5 at decision time — because a policy can compute an input from live data, the cap is the runtime’s authority, enforced where the concrete value exists. A literal over-ceiling input hardcoded in the Rego is additionally refused at bundle load, and the runtime records each capped field on the decision as transformation_constraints_applied. Only fields a provider declares a numeric maximum (higher = less safe) are ceiling-able; a floor like clearance_threshold (higher = safer) is rejected at load rather than capped the wrong way.

Cross-domain examples

The same three-layer contract — manifest-defined verdicts, runtime-defined effects, provider- or policy-computed authorized actions — governs any executable action. The runtime code does not change between these; only the manifest, the policy, and the enforcement adapter differ.

1. ROS 2 Twist (robotics)

The walkthrough above: a CONSTRAIN (replace) verdict selects the builtin.ros2_twist clamp_velocity transformation, which caps a proposed Twist to the policy’s max_linear/max_angular; the authorized command rides the response as authorized_action + authorized_payload_b64 (CDR bytes), and the governed bridge publishes those bytes verbatim.

2. OpenClaw tool call (AI agent)

A policy governs an agent’s tool call and, when it can’t be allowed as-is, authorizes a sanitized replacement it computes itself — no provider needed, because the replacement is expressed directly as authorized_action:

schema_version: "1.8"
execution_decisions:
  verdicts:
    - { name: EXECUTE,  effect: pass_through }
    - { name: SANITIZE, effect: replace }
    - { name: BLOCK,    effect: reject }
verdict := "SANITIZE" if risky_flag
# strip the dangerous flag; authorize the cleaned command directly.
authorized_action := object.remove(input.params, ["force"]) if risky_flag

The runtime denies the original on the wire and surfaces the sanitized authorized_action; the agent’s tool-executor adapter runs that instead. An approval-required verdict is the runtime’s existing Defer outcome (require_approval), not a replace.

3. Kubernetes scale (remediation)

A CAP (replace) verdict selects the builtin.kubernetes cap_replicas transformation, capping a scale action’s replica count; the operator’s manifest ceiling caps how high the policy may set the limit:

schema_version: "1.8"
execution_decisions:
  verdicts:
    - { name: PROCEED, effect: pass_through }
    - { name: CAP,     effect: replace }
    - { name: REJECT,  effect: reject }
action_transformations:
  providers: [ { id: builtin.kubernetes, version: "1" } ]
  enabled:
    - name: cap_replicas
      provider: builtin.kubernetes
      provider_version: "1"
      action_kind: tool.k8s.scale
      input_schema: k8s://apps/v1/Scale
      output_schema: k8s://apps/v1/Scale
  constraint_ceilings:
    cap_replicas: { max_replicas: 8 }
verdict := "CAP" if over_budget
transformation := {"name": "cap_replicas", "inputs": {"max_replicas": 8}} if over_budget

The authorized action is the complete scale command — the target deployment rides along with the capped replicas — so the enactment receipt proves which deployment was scaled, verified by the generic canonical-action comparison (no ROS/CDR decoding on this path).

Step 3 — Verify

POST /v1/tool a command that trips the caution band and inspect the response:

{
  "decision": "deny",
  "reason": "runtime execution-decision: CONSTRAIN blocks the proposed action; authorized replacement provided",
  "verdict": "CONSTRAIN",
  "authorized_action": { "linear_x": 0.08, "angular_z": 0.2 },
  "reason_codes": ["obstacle_caution_band", "speed_limit"],
  "audit_id": "..."
}
  • decision: "deny" — the replace effect drops the original fail-closed for un-upgraded adapters.

  • verdict + authorized_action — the upgraded adapter enacts the replacement instead.

The WAL autonomy.decision frame for that audit_id records both proposed_action (the original request) and authorized_action plus their content digests (proposed_digest, authorized_digest), so the audit trail shows the proposed-vs-authorized diff. Inspect it with autonomy wal inspect or GET /v1/audit/{audit_id}.

Enactment receipts

Surfacing the authorized action isn’t enough to prove the adapter enacted it. After an enforcement adapter acts, it reports back what it actually enacted:

POST /v1/audit/{audit_id}/enactment
{
  "enacted_action":    { "linear_x": 0.08 },
  "enforcer_id":       "governed_ros2_bridge:node_a",
  "authorized_digest": "sha256:…"          // echoed from the /v1/tool response
}

The runtime correlates the receipt by audit_id, computes the digest of the enacted action, and compares it to what it authorized (authorized_action for a replace, else the original proposed_action):

{ "audit_id": "…", "verifiable": true, "faithful": true, "enacted_digest": "sha256:…" }

A faithful: false response — and a deviation on the recorded autonomy.enactment_receipt WAL frame — means the adapter enacted something other than what ADK authorized (or the authorized action was altered in transit, caught via the echoed authorized_digest). This is what makes “the enforcement adapter has no independent decision authority” a checkable claim. GET /v1/audit/{audit_id} surfaces the receipt and an enactment summary (faithful, enforcer_id, deviation).

The authorized payload (ROS 2)

For a known ROS actuator type (currently geometry_msgs/msg/Twist), a replace response also carries authorized_payload_b64 — the exact CDR bytes the ros2_twist provider authorized the adapter to publish. The provider, not the adapter, produces the wire bytes, so the governed ROS 2 bridge is purely mechanical: base64-decode authorized_payload_b64 and publish it. When the field is absent (an unknown type, or the command couldn’t be encoded) the adapter falls safe — it publishes the zero/neutral message rather than reconstructing the command — and the receipt then shows a deviation, which is the truth. This payload is provider-scoped: a non-ROS action carries no authorized_payload_b64, and its receipt is verified by comparing the canonical authorized action.

Failure modes

Symptom

Cause

Fix

deny + ... declares no execution_decisions vocabulary

Policy returns a verdict but the manifest has no execution_decisions block.

Declare the vocabulary (Step 1).

deny + ... is not in the declared execution_decisions vocabulary

Policy returned a verdict name that isn’t declared (often a typo).

Add the verdict, or fix the policy’s verdict string.

deny + ... is a replace effect and requires exactly one replacement

A replace verdict returned neither (or both) an authorized_action and a transformation.

Return exactly one.

deny + ... is a pass_through effect and must not carry a replacement

A pass_through/reject verdict carried an authorized_action or transformation.

Drop the replacement, or change the verdict’s effect.

deny + ... is not in the deployment's declared action_transformations allowlist

Policy selected a transformation the manifest doesn’t enable.

Add it to action_transformations.enabled, or fix the policy.

deny + ... selects provider ... but the manifest binds it to ...

The policy’s transformation.provider disagrees with the manifest binding.

Omit provider in the policy, or match the manifest.

deny + ... could not compute an authorized action

Bad inputs, an unresolvable/version-mismatched provider, or an action the transformation can’t operate on.

Fix the inputs; confirm the provider + version are registered and the action_kind/schema match.

Manifest rejected: ... requires an explicit schema_version >= 1.8

A block is on too-old a manifest.

Set schema_version to 1.8.