Node-side rollout activation

autonomy runtime start --rollout-gated turns the release poller into a condition-gated rollout activator. Instead of hot-swapping a new policy bundle the moment the control plane advertises one, the node activates only when it is both cohort-eligible and operationally safe — for example idle, sufficiently charged, and with no human within safe distance.

This is opt-in. Without --rollout-gated, runtime start behaves exactly as before: a verified release is activated unconditionally on the next poll.

How it works

Each poll response may carry a rollout plan pointer (a plan_id and signature fields). Gating is layered onto the existing release poll loop so that activation never runs ahead of release verification:

  1. Refresh (every cycle, before verification): the poller’s rollout handler parses the plan pointer and fetches the full, signed plan and its status from GET /v1/rollouts/{plan_id}, refreshing the node’s plan cache. This step has no side effects — it never activates.

  2. Verify: the poller verifies the release artifact’s signature (cosign), as it does for any release.

  3. Gate (only after verification): the gated activator runs the rollout package’s cohort/integrity/slot/staleness evaluation, then the operational gate — the rollout.activate policy over the node’s live state (below).

  4. Activate: only if the gate allows does it hot-swap the verified release (pull → load → reload). On a deny the current policy is left untouched.

A deny at step 3 defers activation; the node retries on the next poll and emits a rollout.activation.deferred lifecycle event. Because a deferred node never advances its active fingerprint, the next poll re-evaluates the gate — so the node adopts the release as soon as it becomes safe.

The node-state contract

The node reports its live operational state through the existing scene-state channel, using the reserved source name node:

POST /v1/scene-state
{
  "source": "node",
  "fields": {
    "operational_state": "idle",
    "battery": 82,
    "hardware": "aarch64",
    "labels": {"site": "warehouse-3"},
    "human_within_safe_distance": false
  }
}

The gate passes this source’s fields verbatim to the policy as params.node — there is no field-by-field mapping in the runtime, so the policy contract can evolve without code changes. The authoritative, non-spoofable half of the input — params.rollout (plan, stage, slot_rank) — is filled in from the verified plan, not from the node.

If no node scene source has been posted (or it is empty), activation is deferred, fail-closed: a node that has not reported its state does not activate. The built-in policy requires at least operational_state and battery.

Customizing the activation policy

The gate evaluates against your existing policy bundle — the same bundle that governs /v1/tool. You do not produce a separate bundle for rollout activation.

  • Out of the box: if your bundle does not define rollout activation, the runtime injects a built-in default (the documented safe-distance / idle / battery policy) so gating works immediately.

  • To customize: add rollout.activate rules to your existing bundle. The contract is a top-level allow that delegates to a rule named allow_rollout_activation (see policy/rollout.rego for the reference policy). As soon as your bundle defines allow_rollout_activation, the runtime uses your rules and stops injecting the default.

A minimal in-bundle override (added to your package autonomy policy) that only activates idle nodes above 30% battery:

# rollout.activate delegation — lives in your EXISTING bundle.
allow if {
	input.kind == "rollout.activate"
	allow_rollout_activation
}

allow_rollout_activation if {
	input.params.node.operational_state == "idle"
	input.params.node.battery > 30
}

Because your bundle already declares default allow := false, do not add a second default allow for rollout — the delegation above is all that is needed.

Delivering a plan to the node (control-plane side)

The node learns about a rollout entirely through its release poll: when the channel’s latest release has an active rollout plan, the orchestrator advertises a plan pointer on GET /v1/releases/latest, and the gated poller fetches the full plan from GET /v1/rollouts/{plan_id}. There is no per-node plan endpoint — the poll response is the delivery channel.

To make a plan node-consumable:

  1. Publish the release to the channel the node polls (e.g. stable). The release the plan targets must be that channel’s latest — the orchestrator only advertises a plan for the release it currently serves on that channel.

  2. Create the rollout for that release:

    autonomy deploy <release_id> --fleet all
    

    Use the release id (not an image ref). deploy resolves the release from the control plane and stamps its full ReleaseRefartifact_ref, policy_ref, target_lock_fingerprint — into the plan, which the node-side gate binds against; a bare image ref that isn’t a registered release is rejected. A freshly created plan is published with its first stage open, so it is advertised on the next poll.

Once both hold, GET /v1/releases/latest?channel=stable carries a rollout field and the node begins gating it. If the poll shows no rollout field, the plan’s target release is not the channel’s latest, or the plan is in a terminal phase (cancelled/completed/rolled_back).

Flags

Flag

Purpose

--rollout-gated

Enable node-side, condition-gated activation. Requires a managed bundle and non-strict mode. Off by default.

--node-id

Node identity for slot-rank computation and telemetry. Default: hostname. Env: AUTONOMY_NODE_ID.

--rollout-plan-pubkey

Path to an Ed25519 PKIX PEM public key used to verify plan integrity before activation. Env: AUTONOMY_ROLLOUT_PLAN_PUBKEY. When unset, plan integrity is advisory only.

Worked example: defer until idle

# 1. (Control plane) Create the rollout for the channel's latest release so the
#    node's poll starts advertising a plan pointer (see "Delivering a plan" above).
autonomy deploy <release_id> --fleet all

# 2. Start the gated runtime pointed at your orchestrator.
autonomy runtime start --rollout-gated --orchestrator-url https://cp.example \
  --cosign-pubkey /etc/autonomy/cosign.pub

# 3. The node is busy — report task_active.
curl -s localhost:7777/v1/scene-state -d \
  '{"source":"node","fields":{"operational_state":"task_active","battery":90}}'
#    → next poll defers activation (telemetry: rollout.activation.deferred)

# 4. The node goes idle.
curl -s localhost:7777/v1/scene-state -d \
  '{"source":"node","fields":{"operational_state":"idle","battery":90}}'
#    → next poll activates: the new policy bundle is verified, pulled, and hot-swapped.

Scope

This flow ships the behavior / policy-bundle rollout kind end-to-end. Model (model) and OS-reconstruction (os_reconstruction) kinds remain fail-closed (no activation callback); host node-state collection is the node’s responsibility (self-report via /v1/scene-state); mesh-hint integration is tracked separately.