Skip to content

Fleet autonomy contract (FLT-133)

Council 2026-07-18 (pre-build): before the safety substrate (breaker/freeze/observability = FLT-113/114/119) or the auto-spawn engine (FLT-115) gets built, freeze one contract so nothing guards the wrong chokepoint and everything implements against the same fixed shape. This document is that freeze, twinned with the C# types in src/UsageGovernor.Core/Contracts/Autonomy/.

This is a type + wire-schema freeze, not an implementation. There is no breaker logic, no freeze enforcement, and no spawn engine anywhere in this repo yet. Auto-spawn (FLT-115) is not built and not callable. Nothing here is wired into the running governor.

Cross-language authority. The C# types are the canonical shape for this repo; this document is the canonical shape for everyone else -- in particular the Python fleet-harness side (FLT-132). Where the two disagree, this document wins and the C# types get fixed, because the wire is the actual contract, not either language's idiom for expressing it.

All payloads carry "contractVersion": "autonomy-contract/v1" (AutonomyContract.ContractVersion in C#). Bump it on any breaking change to a shape below. All enums serialize as strings, never ordinal ints ([JsonConverter(typeof(JsonStringEnumConverter<T>))] on every enum in this package) -- a Python reader must never have to know that 2 used to mean Tainted.


1. Shared concurrency semaphore

There is exactly one fleet-wide concurrency limit, keyed by:

fleet-autonomy/v1

Not one per provider, not one per card kind. Every autonomy actor -- auto-spawn, a manually launched card, a local CLI invocation, a deploy action -- acquires against this same key (see §7, the single-gate-wire invariant).

Per-tier caps (frozen)

Tier Cap
GREEN 12
YELLOW 6
RED 2
BLACKOUT 0

BLACKOUT = 0 is load-bearing: it does not mean "very low", it means the breaker admits nothing.

AcquireRequest

{
  "contractVersion": "autonomy-contract/v1",
  "requesterId": "card-42",
  "requestedAtUtc": "2026-07-18T00:00:00Z",
  "tier": "YELLOW",
  "taint": "Suspect"
}

AcquireResult

{
  "contractVersion": "autonomy-contract/v1",
  "outcome": "Deferred",
  "slotId": null,
  "tier": "RED",
  "reason": "cap-exhausted",
  "retryAfterSeconds": 30
}

outcome is one of Granted / Deferred / Denied.

Deferred is skip-and-retry, never queue-and-flush. There is no queue-position field anywhere in this shape and there must never be one added. A caller that receives Deferred drops the request; it does not hold a reserved place in a line, because there is no line. retryAfterSeconds is advisory only -- not a promise of admission on retry.

ReleaseRequest

{
  "contractVersion": "autonomy-contract/v1",
  "slotId": "slot-1",
  "releasedAtUtc": "2026-07-18T00:05:00Z"
}

Intended to be idempotent in the eventual FLT-113 implementation: releasing twice, or releasing an expired slot, must not throw.


2. Freeze semantics

Two reversible stages plus an orthogonal kill modifier:

Running -> FreezeSpawn -> FreezeAll -> (back to Running, or --kill)
Stage Effect
Running Normal operation.
FreezeSpawn Deny new spawns and dispatch. Running work is untouched.
FreezeAll Also pause everything already running.

Both stages are reversible -- Running is reachable again from either. --kill is a separate command, not a third stage: it terminates rather than pauses, and this contract makes no reversibility promise for it.

FreezeAll is PAUSE, not drain. This contract deliberately does not model a full-state checkpoint/resume -- no saved call stack, no replay log, no partial-output capture. A paused actor sits idle until resumed or killed; it does not persist enough to survive a process restart. If a future ticket needs true drain-and-resume, that is a new contract, not an extension of this one.

FreezeCommand (the imperative)

{
  "contractVersion": "autonomy-contract/v1",
  "stage": "FreezeAll",
  "kill": true,
  "reason": "runaway spend",
  "issuedBy": "andrew",
  "issuedAtUtc": "2026-07-18T00:10:00Z"
}

kill is only meaningful alongside FreezeAll -- a kill on FreezeSpawn alone targets nothing running and FLT-114 must reject it.

FreezeState (the observed state)

{
  "contractVersion": "autonomy-contract/v1",
  "stage": "FreezeSpawn",
  "reason": null,
  "sinceUtc": "2026-07-18T00:00:00Z",
  "killed": false
}

killed = true is terminal for the affected actors -- a killed actor is replaced, not revived (the same pattern as taint's cleared-only-by-re-mint rule, §4).


3. Spend windows

Two window kinds, evaluated independently -- a trip on either kind trips the breaker. This is not a combined/weighted score.

  • DollarsPerMinute
  • TokensPerMinute

SpendWindowDefinition

{
  "contractVersion": "autonomy-contract/v1",
  "kind": "DollarsPerMinute",
  "windowSeconds": 60,
  "tripThreshold": 5.0
}

SpendSample

{
  "contractVersion": "autonomy-contract/v1",
  "kind": "TokensPerMinute",
  "amount": 12000,
  "sampledAtUtc": "2026-07-18T00:00:30Z",
  "sourceId": "spawn-7"
}

BreakerTripThreshold

Pinned at trip time so a later config edit can't rewrite history when the audit log is replayed:

{
  "contractVersion": "autonomy-contract/v1",
  "kind": "DollarsPerMinute",
  "observed": 7.2,
  "threshold": 5.0,
  "windowSeconds": 60
}

TelemetryHeartbeat and fail-closed on telemetry loss

{
  "contractVersion": "autonomy-contract/v1",
  "sourceId": "spawn-7",
  "emittedAtUtc": "2026-07-18T00:00:00Z",
  "maxAgeSeconds": 90
}

A lost heartbeat is HALT, not "assume zero spend" and not "assume last-known tier." An autonomy substrate gone quiet must be treated as the worst case, because "quiet" and "quietly spending unmetered" are indistinguishable from outside the source. BreakerHaltReason has exactly two values, and they are equally severe to every consumer:

  • ThresholdTrip
  • TelemetryLossHalt

TelemetryLossHalt is not a warning tier. FLT-113 must treat it exactly as blocking as a real trip.


4. Taint

Clean -> Suspect -> Tainted -> Contaminated

Monotonic: taint only ever increases along a lineage. There is no Downgrade/Clear operation anywhere in this contract, on purpose.

Level clampToSafe spawnBlocked cannotCrossFloor transitivePropagation
Clean false false false false
Suspect true false false false
Tainted true true false true
Contaminated true true true true
  • clamp-to-safe: cap tier at GREEN-equivalent privileges regardless of the fleet's actual tier.
  • spawn-blocked: the actor may finish its own turn but may not spawn children.
  • cannot-cross-floor: floored actions (§6) are refused outright regardless of tier.
  • transitive propagation: a child spawned by a tainted actor inherits at least the parent's taint level -- never less. A child may end up more tainted than its parent, never less.

Cleared only by re-mint

Taint is never cleared in place on an existing actor identity. The only way back to Clean is for a new identity to be minted (new session, new card, new spawn) that does not inherit the tainted lineage. This contract provides no "reset taint" operation anywhere, and FLT-114 must not add one -- the absence is the specification.


5. Audit event schema

Seven audited kinds. Every governed transition in the autonomy substrate must emit exactly one -- no silent transitions.

  • Spawn
  • Deny
  • Trip
  • Resume
  • TaintDemotion
  • Override
  • Freeze

AutonomyAuditEvent

{
  "contractVersion": "autonomy-contract/v1",
  "sequenceNumber": 42,
  "eventId": "evt-42",
  "priorEventId": "evt-41",
  "kind": "TaintDemotion",
  "timestampUtc": "2026-07-18T00:00:00Z",
  "actorId": "breaker",
  "subjectId": "card-9",
  "tier": "RED",
  "taint": "Tainted",
  "freezeStage": "Running",
  "reason": "untrusted-instruction-attempt",
  "details": { "parentTaint": "Suspect" }
}

Ordered and immutable. sequenceNumber is monotonic per-writer and breaks ties on timestampUtc, not the reverse. priorEventId chains back to the previous event for the same subject (subjectId), forming a per-subject chain -- null only for a subject's first-ever event. The chain is what makes the log reconstructible offline: a reader needs nothing but the log itself, no "current DB row," to make sense of any entry.

The record is deliberately flat rather than a kind-specific union -- every event carries every field that could matter for any kind (null where not applicable). A polymorphic union is exactly the shape that drifts between a C# writer and a Python reader over time. details is the escape hatch for kind-specific data that doesn't warrant its own column, and is intentionally string-keyed/string-valued so neither side has to agree on a nested schema.

details must never contain secret material -- see §6.


6. Floor-executor boundaries

Floored set (frozen)

  • Money
  • Secrets
  • ExternalSend
  • MergePublish
  • SharedDelete
  • GovernanceMutation
  • ForcePush

A floored action requires the elevated executor path (human-in-loop card, or an explicitly authorized override) regardless of tier or taint level -- GREEN + Clean does not waive a floor.

Free set

Everything not in the floored enum is free: internally reversible actions an autonomy actor may take without crossing to the floored path.

ExecutorCapability

{
  "contractVersion": "autonomy-contract/v1",
  "name": "wire-transfer",
  "floor": "Money"
}

free is derived (floor == null), not a separately-settable field -- there is no way to declare a capability both floored and free.

Secrets rule (contract note, not a serializable shape)

Secrets are injected at exec time into the floored executor's process/environment for the single call that needs them, and are never handed to a session, card, or subagent as a value it can read, log, echo, or pass along. A card that needs a secret gets the action performed on its behalf by the floored executor -- it never receives the secret material itself.

This has no enforceable JSON shape. It is a review-time invariant: any design (FLT-114's executor, FLT-119's observability) that puts a secret value into a session-readable field, a card prompt, or an AutonomyAuditEvent.details string violates this contract even if every type check passes.


7. The single-gate-wire invariant

Every spawn path and every spend path -- auto-spawn (FLT-115), a manually-launched card, a local CLI invocation, and a deploy action -- MUST acquire through the one breaker identified by the fleet-autonomy/v1 semaphore key (§1) before it runs, and MUST fail CLOSED if that acquisition cannot be completed (breaker unreachable, telemetry lost, ambiguous state).

There is no sanctioned second door. A code path that spawns or spends without going through the breaker is a contract violation even if it is faster, even if it is "just for local dev," and even if the breaker happens to be down at the time -- "the breaker is down" is exactly the moment fail-closed is supposed to matter.

Review rule: any PR that adds a spawn or spend call site must show it routing through the breaker. In C#, the marker interface UsageGovernor.Core.Contracts.Autonomy.IAutonomyGatedAction exists so a reviewer can grep for it across call sites; implementing it is a promise, not a mechanism -- it carries no members, because FLT-113 (not FLT-133) decides what the real gate call looks like. On the Python side, FLT-132 should adopt an equivalent tag (a decorator, a base class, or a lint rule) so the same grep works in both languages.


Versioning

AutonomyContract.ContractVersion = "autonomy-contract/v1" (AutonomyContractVersion in Python, once FLT-132 binds this). Every payload above carries it. Bump on any breaking change; additive, backward-compatible fields do not require a bump but should still update this document in the same change.


Where each piece lives (C#)

All under src/UsageGovernor.Core/Contracts/Autonomy/:

Doc section File Types
Version AutonomyContract.cs AutonomyContract.ContractVersion
§1 Concurrency ConcurrencySemaphore.cs ConcurrencySemaphore, AcquireRequest, AcquireOutcome, AcquireResult, ReleaseRequest
§2 Freeze FreezeState.cs FreezeStage, FreezeCommand, FreezeState
§3 Spend windows SpendWindow.cs SpendWindowKind, SpendWindowDefinition, SpendSample, BreakerTripThreshold, TelemetryHeartbeat, BreakerHaltReason
§4 Taint TaintLevel.cs TaintLevel, TaintDemotionRule (+ .Table), TaintClearingRule
§5 Audit AutonomyAuditEvent.cs AutonomyEventKind, AutonomyAuditEvent
§6 Floor ExecutorFloor.cs FloorCategory, ExecutorCapability, SecretsHandlingRule
§7 Single gate IAutonomyGatedAction.cs IAutonomyGatedAction (marker)

Tests: tests/UsageGovernor.Tests/Contracts/Autonomy/AutonomyContractTests.cs (50 tests -- JSON round-trips, string-enum pinning, frozen table assertions, marker-interface shape).

What FLT-113/114/119/132 build against this

  • FLT-113 (breaker): implements ConcurrencySemaphore acquire/release/defer semantics against CapByTier, plus §3's spend-window evaluation and fail-closed telemetry-loss halt. Owns turning IAutonomyGatedAction from a promise into an enforced gate.
  • FLT-114 (freeze): implements FreezeCommand handling and the FreezeStage state machine (§2), plus enforcing TaintDemotionRule.Table (§4) and the floored/free boundary (§6), including the secrets inject-at-exec rule.
  • FLT-119 (observability): consumes/renders AutonomyAuditEvent streams (§5) and surfaces FreezeState / breaker halt reasons; must never surface a secret through details.
  • FLT-115 (auto-spawn engine): the first (and, per this freeze, only sanctioned) automatic producer of AcquireRequests -- explicitly NOT built by FLT-133, and not callable until FLT-113 exists to gate it.
  • FLT-132 (Python fleet-harness): implements a Python mirror of every shape in this document, serializing/deserializing the exact JSON shown here, including string enums and the contractVersion field on every payload.