Skip to content

One rig-wide autonomy level (rc#2640)

Operator, 2026-10-04: "We should have a setting in the rig. Where lowest security setting is full automation with no humans and highest has human gate for all deploys. It can be like a bar slide in rig-cockpit that can be adjusted."

The operator approved the design the same day and recorded four decisions on #2640:

Decision Answer
Names of the ends Full speed (L0) and Full security (L3)
Credentials, spend and the gate machinery A hard floor: behind the operator at every level
A read-only level beyond Full security No
Rollout Shadow mode first. Behaviour stays as it is today and decisions are only logged; one final PR switches enforcement on

Why GitHub cannot hold the level

Agents act as the operator on GitHub. The orchestrator's gh identity is the operator's account, MergeGate merges with the operator's PAT (GITHUB_TOKEN, merge guard), and the rig-conductor pin PR is admin-merged by the same account. So no label, comment, Actions variable, environment approval or settings file can tell the operator from an agent. The level is therefore:

  • not stored on GitHub;
  • lowered only with a WebAuthn assertion from the operator's rig-approve passkey, which needs Face ID;
  • verified by the conductor itself, so nothing between the operator's phone and the conductor is trusted.

The autonomy endpoints trust no caller (operator-token gate): every autonomy write must prove itself with the operator token, GitHub OIDC or the passkey assertion, and autonomy reads expose only non-secret state.

The levels

A level moves human gates only. Review-E's review is required at every level, and Review-E is never told the level.

Level Slug Merges Production deploys
L0 Full speed full-speed Automatic: Review-E approved at the head and every check passed Automatic through every *-auto environment, destructive changes included
L1 Guarded guarded Automatic, except deploy PRs that are destructive or unclassifiable Automatic, except destructive or unclassifiable changes, which use the gated environment
L2 Supervised supervised Code PRs automatic; every deploy PR held Every deploy job uses the gated environment
L3 Full security full-security Every PR held after Review-E approves Every deploy job uses the gated environment

The floor, at every level:

  • Credentials: any run that reads a credential store or writes a live secret goes through rig-gitops credential-ops.
  • Spend: the RunPod caps live in control-plane config; subscriptions, pricing, App Store and billing stay under the forbidden-actions table.
  • The gate machinery: a PR that changes the autonomy code, MergeGate's guard, the deploy workflows' decide steps, deploy/k8s/gates.yaml or infra's gate files is held for the operator's Face ID.

Definitions:

  • Deploy PR: a PR whose merge changes what Flux applies: rig-gitops apps/** and clusters/**, rig-conductor deploy/** (the pin included), and every rig-agent-runtime pin.
  • Deploy job: a workflow job that changes production through a GitHub environment the level selects (rig-cockpit-worker deploy.yml, infra terraform.yml apply, dashe-cutie's Worker and Pages jobs). Both environments of each pair are provisioned in OpenTofu first; the level only picks a name and never edits a protection.
  • Destructive / unclassifiable: decided by a deterministic classifier per kind that fails closed (D1 migrations, tofu show -json, GitOps diffs).

Planner tiers are not on the slider: they gate when work starts, and T3 keeps its human sign-off at every level.

The chain

Status: live behind the API. ConductorE.Core.Autonomy.AutonomyChain seals entries and replays the log; AutonomyLevels holds the slugs (full-speed … full-security) and display names; AutonomyAnchor fingerprints the pin.

The level is an append-only, hash-chained log in the control store (IControlStore, schema allowlist), one record per entry, the same append-only pattern as the RunPod action log. Each entry carries seq, at, prevHash and hash:

Kind What it records
genesis Always L3 Full security, and the fingerprint of the passkey anchor
raised A stricter level, by the operator or a tripwire
lowered A looser level, its optional expiry, and the operator's WebAuthn assertion
reverted A time-box ended; back to the standing level

The state is a standing level plus an optional override {level, expiresAt}. The effective level is the override while it is active, else the standing level. Expiry is computed at read time.

Replaying the chain checks every link, that every raise goes up, that every lowering verifies under the genesis anchor and is bound to the entry before it, that the genesis anchor is the configured one, and that the head is at or past a Valkey high-water mark. Any failure, and any store that cannot be read, gives L3 Full security; the tripwires post it to #admin once.

Every read replays the whole log, from genesis. A replay that resumed from a head it verified earlier would not see rows deleted or edited below that head. The log grows only when the level changes, so the whole replay stays cheap. If it ever passes about 10,000 entries, the upgrade is a verified checkpoint together with a proof that the prefix before it is unchanged, never a replay that trusts the prefix.

The genesis entry is written the first time an anchor is configured, on the first read of the empty log. Like every other entry it goes through AutonomyWriter: the location fence, then insert-once on seq 1, so concurrent first reads write one genesis and post its fingerprint once. At a location the fence refuses, nothing is written: the log stays empty, which reads empty, Full security. An empty log, and a log someone deleted, start at Full security, so getting below it always needs the operator's Face ID.

Tripwires and time-boxes

Status: live, and off until the anchor is pinned. AutonomyExpiryWatcher (a background service) runs the tripwires and ends time-boxes; AutonomyTripwire names an incident and holds the level after it clears; AutonomyChange.RevertDue says when a time-box has ended; an incident is an AutonomyIncidentRecord in the control store.

Operator, decision 4 (2026-10-05): tripwires may raise the rig to Full security on their own; only the operator lowers it again, with Face ID.

Tripwire Fires when
chain_invalid a link, hash, proof or rule fails, or the entry at the high-water mark was rewritten
anchor_mismatch the genesis anchor is not the pinned one. Never while no anchor is pinned: nothing can mismatch
seq_regression the log ends before its high-water mark: rows deleted, or the store restored from an older backup
  • While a finding holds, the replay alone gives Full security. Nothing has to be written, least of all to the broken chain.
  • One incident per finding: one AutonomyIncidentRecord, keyed by its kind, seq and what it found, and one #admin post. Seeing it again, on any check or after a restart, adds neither.
  • After it clears, the level stays Full security (health tripped) until the chain records Full security: a raised entry by tripwire, or a lowering made since, which carries Face ID. Only the newest incident can hold it.
  • A time-box that ends is over at once, because expiry is computed at read time. A reverted entry by expiry records it, back to the standing level. Nothing here ever lowers the level.

The watcher does a whole replay every minute, on top of the replays every API call and deploy decision already make. It also records the last finding any of those reads saw, so a finding that is gone by its next replay still counts. It writes only through AutonomyWriter, the operator's path too: a whole re-read and replay, the location fence (autonomy.write for invotek; refused: 503 location_fenced), and a free seq. A refused write waits 15 minutes, not one tick.

Passkey verification

Status: pure Core, not wired. PasskeyAssertionVerifier checks an assertion; ApproveEnvelope builds the autonomy-lower params and the challenge; PasskeyLoweringProof.Verifier is the chain's lowering verifier. No anchor is pinned yet, so nothing can verify.

The anchor is the public key of the operator's rig-approve passkey: its SPKI, credential id, rpId (the rig-approve host) and origin. They are public values, pinned in deploy/k8s/gates.yaml and recorded in the genesis entry.

A lowering, a hold release or an anchor rotation is an envelope:

  • preimage rig-approve-v1\n<kind>\n<canonical JSON>, where kind is autonomy-lower, merge-release or anchor-rotate;
  • the WebAuthn challenge is the SHA-256 of the preimage.

The conductor checks the webauthn.get type, the challenge, the pinned origin and rpId hash, the user-present and user-verified flags, the credential id and the ES256 signature, with the .NET base library only.

Classifying a change

Status: pure Core, not wired. ConductorE.Core.Autonomy.GitOpsDiffClassifier classifies a deploy PR from each file's content before and after; MergeGate does not call it yet.

A deploy PR is additive, destructive or unclassifiable:

  • destructive: a removed file or YAML document (Flux prunes it), or any change to a Namespace, CRD, PVC or PV, volumeClaimTemplates, RBAC, a NetworkPolicy, a Kyverno policy, or a Flux Kustomization's prune, path or suspend;
  • unclassifiable: a diff that cannot be read or does not parse;
  • additive: everything else, validated image pins included.

API

Status: the two GET routes are live and read-only (Endpoints/AutonomyEndpoints.cs, API). The log is AutonomyEntryRecord in the control store; gates.yaml ships every point in shadow with no anchor, so the level reads Full security with health empty and nothing acts on it. Raise and the two lowering routes are live (AutonomyWriter). Until the anchor is pinned, a raise is a no-op and nothing can be lowered. The decision route is live (AutonomyDecisionService) and checks a GitHub Actions OIDC token (GitHubOidcVerifier: RS256 only, signature against GitHub's JWKS, cached for an hour, iss, aud rig-conductor-autonomy, exp/nbf with one minute of skew, repository_owner dashecorp, ref refs/heads/main). In shadow it answers the job's own legacy environment and records the decision. No workflow can reach it yet: the Access bypass for its path (plan A9) comes in its own change.

Route Auth Purpose
GET /api/autonomy none, read-only The effective level, standing level, override, head, anchor fingerprint, health and the enforcement mode of each point
GET /api/autonomy/history none The chain, for the Cockpit and for re-verification
POST /api/autonomy/raise operator token Instant
POST /api/autonomy/lower/challenge, POST /api/autonomy/lower operator token, then the assertion Face ID
POST /api/autonomy/decision GitHub Actions OIDC The environment a deploy job uses

Changing the level

Only the operator, from the slider in Rig Cockpit (web and iOS). Agents can read the level and cannot change it.

  • Raising is one tap, and Full security has a one-tap button on every view.
  • Lowering opens rig-approve, which shows the request in fixed text, computes the challenge from it and asks for Face ID. The duration (30 min, 2 h, 8 h, 24 h, or until changed; 2 h by default for Full speed) is inside the signed challenge.
  • The system changes the level only upward: when a time-box ends, and when a tripwire fires (a broken or rewritten chain, an anchor mismatch, or a held PR merged without a release).
  • Every entry and every refused attempt is posted to #admin with its seq and hash.

Enforcement points

Status: the table below is pure Core, not wired. AutonomyDecisionPolicy decides who waits, Apply turns that into a shadow or enforced outcome, and AutonomyPaths lists the deploy and control-plane paths. The deploy jobs' action (dashecorp/.github autonomy-decide, plan A12) still falls back to the gated environment in every mode; plan D0 changes it as below before any deploy job calls it.

Each point reads the level when it acts and has its own AUTONOMY_ENFORCE_<POINT> (shadow or enforce) in gates.yaml. Shadow keeps today's behaviour and records what enforce would have done, also when the level cannot be read: in shadow nothing waits that does not wait today. In enforce, a point that cannot read the level fails closed.

Point L0 Full speed L1 Guarded L2 Supervised L3 Full security
Review-E required required required required
MergeGate, code PRs merge merge merge hold
MergeGate, deploy PRs and image pins merge hold if destructive or unclassifiable hold hold
rig-conductor pin (publish-image.yml) admin-merge after diff validation same Review-E, then hold same
rig-cockpit-worker deploy.yml production-auto production if a pending D1 migration is not additive production production
dashe-cutie Worker and Pages production-auto today's rule production production
infra terraform.yml apply apply-auto apply on a delete or replace apply apply
Control-plane PRs hold, Face ID same same same
credential-ops operator same same same

A hold reuses the release-hold machinery (HUMAN_GATE_TRIGGERED, waiting_for_human, a merge pinned to the approved head). The operator releases one or many held PRs with a single Face ID.

When a deploy job cannot ask

A deploy job asks through autonomy-decide. When it cannot get an answer (no id-token permission, an OIDC failure, an unreachable conductor, a non-200 or unreadable answer), its fallback mode decides:

Fallback mode Environment Annotation
shadow the job's legacy environment: today's behaviour ::warning::, outcome fallback:<reason>
enforce the gated environment: fail closed ::warning::, outcome fallback:<reason>

Where the job learns the fallback mode:

  • The source. It is a file in the action itself: actions/autonomy-decide/fallback-mode in dashecorp/.github, which decide.sh reads from $GITHUB_ACTION_PATH. It is never an input or a vars value. Changing it is a reviewed PR to dashecorp/.github, a control-plane repo.
  • Trusted only to tighten. Only the exact word shadow (one trailing newline allowed) loosens the fallback. A missing file, an empty file or any other text means enforce.
  • The pin decides which file a job reads. Deploy jobs pin the action by commit SHA, as A12's README tells callers, so a job reads the file at the commit it pins. A merge to dashecorp/.github therefore changes no deploy until that deploy's repo moves its pin. Pinning an older or another ref is a change to the decide step, which is gate machinery and held once enforced. A looser file planted before that meets the drift rule below.
  • Why not a variable. A repo-level Actions variable overrides an org variable of the same name in vars. Any variable or input can also be set through the API by the operator's identity, which agents share, with no review. In an outage there is no conductor answer to compare against, so such a value would decide the deploy unseen. The conductor's own gates.yaml cannot be read when the conductor cannot be asked.

Drift. When the conductor answers, its answer wins, with one exception. The answer carries enforced for the point, and the job compares it with its fallback mode:

  • The fallback is enforce but the conductor is in shadow: the answer stands, with a ::warning::. This is the switch's planned window (below) and loosens nothing.
  • The fallback is shadow but the conductor enforces: the job uses the gated environment and raises an ::error::, outcome drift:fallback_looser, whatever the conductor answered. In this state a conductor outage would deploy ungated while the rig enforces, so either a repo pins a looser file or the switch ran in the wrong order. Both should stop deploys until fixed, and a gated deploy costs the operator one approval.

The switch, in order. The two modes live in different repos, and each deploy repo pins its own copy of the file:

  1. dashecorp/.github sets fallback-mode to enforce, and every deploy repo moves its pin to that commit (plan E3a);
  2. rig-conductor's gates.yaml sets AUTONOMY_ENFORCE_* to enforce (plan E3). It does not merge until the pin guard passes.

The pin moves are one reviewed PR per deploy repo rather than one switch, on purpose: no single merge to dashecorp/.github can change how every deploy is gated. They are mechanical. Until step 2 the conductor still answers in shadow, so a deploy keeps its legacy environment (with the drift warning), and only an outage gates, which is the behaviour about to start.

The pin guard is a required check in rig-conductor's CI. It runs on any change that sets an AUTONOMY_ENFORCE_* to enforce:

  • it reads, through the API, the workflow files on the default branch of every dashecorp repo;
  • it finds each step that uses dashecorp/.github/actions/autonomy-decide;
  • it fails unless every one of them pins a commit SHA whose fallback-mode reads exactly enforce.

A required check is stronger than a script whose output E3's PR shows: it runs again on whatever head is about to merge, and it cannot be skipped or pasted stale.

What the guard guarantees. As of its run on the head that merges, no deploy workflow's fallback is still shadow. Once E3 is live, moving a pin back is a change to a decide step, which is held for Face ID.

What it leaves open. A pin moved after that run and before E3 is live moves unheld. The drift rule then fails that repo closed at its next answered deploy, but a conductor outage before that deploy would send it to the legacy environment. Re-run the guard just before merging E3 to keep that window to minutes.

Rollback runs in the reverse order: E3 back to shadow first, then the pins and the file.

What stops an agent from lowering the level

Attempt Stop
Call the lowering route directly It needs an assertion from the anchored passkey over a challenge bound to the current head
Use the Cockpit's agent identity /v1/autonomy/* is outside AGENT_SCOPE
Act on GitHub as the operator The level is not on GitHub; a PR to the gate machinery is held for Face ID; an anchor swap gives Full security
Get Face ID on another origin Only the rig-approve origin and rpId verify
Replay an old assertion One-use challenge, 5 minutes, bound to seq, prevHash and a nonce
Edit or truncate the store The replay, the high-water mark and the genesis-at-L3 rule give Full security
Admin-merge a held PR The tripwire raises the rig to Full security
Make a deploy's decide step fail In enforce the fallback is the gated environment; only shadow in the action's own file, at the commit the deploy pins, loosens it, and a fallback looser than the conductor fails closed

What remains: an agent that approves a waiting GitHub environment run as the operator (unchanged from today, and still forbidden by CLAUDE.md), and, at L0 and L1, a reviewed conductor change outside the gate paths that subverts enforcement and deploys itself. L2 holds every conductor deploy.

Rollout

  1. The chain, the verifier, the classifiers and the read API land inert.
  2. The Cockpit slider and the rig-approve envelopes land.
  3. The operator registers the passkey; its anchor goes into gates.yaml; the genesis entry is written at Full security; the operator compares the fingerprint posted to #admin with the one rig-approve shows and lowers to L1 Guarded with Face ID.
  4. Every enforcement point lands in shadow mode.
  5. After at least seven days of shadow decisions, the switch. First, dashecorp/.github sets the action's fallback-mode file to enforce, and every deploy repo moves its pin to that commit. Then one PR sets AUTONOMY_ENFORCE_* to enforce, and it merges only once the pin guard passes (why in that order).

The full PR plan and the inventory of today's human gates are in the design on #2640.