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.
DollarsPerMinuteTokensPerMinute
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:
ThresholdTripTelemetryLossHalt
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.
SpawnDenyTripResumeTaintDemotionOverrideFreeze
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)¶
MoneySecretsExternalSendMergePublishSharedDeleteGovernanceMutationForcePush
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
ConcurrencySemaphoreacquire/release/defer semantics againstCapByTier, plus §3's spend-window evaluation and fail-closed telemetry-loss halt. Owns turningIAutonomyGatedActionfrom a promise into an enforced gate. - FLT-114 (freeze): implements
FreezeCommandhandling and theFreezeStagestate machine (§2), plus enforcingTaintDemotionRule.Table(§4) and the floored/free boundary (§6), including the secrets inject-at-exec rule. - FLT-119 (observability): consumes/renders
AutonomyAuditEventstreams (§5) and surfacesFreezeState/ breaker halt reasons; must never surface a secret throughdetails. - 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
contractVersionfield on every payload.