0012: Version the Compiled-Artifact Contract
Status
Accepted
Elevates the fail-closed schema_version gate fixed in ADR-0010: Shape the Compiled Artifacts for flor Consumers (envelope decision) into the versioning policy for the compiled-artifact contracts: which surfaces carry a version on which ladder, how every consumer gates, what producers stamp, what relays may read, and how minors and majors evolve. Trust and verification semantics stay with ADR-0011: Bootstrap and Rotate Signer Trust. The producer/consumer landscape it is tested against is the C0/C1 mgmt- and ctrl-plane docs and the Coordinator sketch.
Amendment note (2026-07). Rewritten in place as the C0 Agent design firmed up, integrating three refinements of one design pass: the versioned surface split at the parse boundary (per-contract ladders instead of one global number — the original "one contract, one number" survives within each payload family, where the join rules live); mgmt content-versioning became one set-stream per node with
agent.jsonas the set root; and the routing core was pinned as the defined relay contract. This text is the integrated current decision.
Context
flor gates artifacts on schema_version (major.minor): it accepts its own major at any older-or-equal minor and rejects everything else with an explicit upgrade error. That rule was decided while implementing the C0 consumer. What it did not settle: which surfaces carry a schema version, on how many ladders, and whether one policy serves every plane, producer, and milestone. The parties diverge as the roadmap unfolds:
| Artifact | Producer | Consumer(s) | Skew reality |
|---|---|---|---|
Per-node mgmt set (agent.json + per-workload configs) | retectl compile on operator hardware (C0+) | flor agent (sole verifier) → vertex | The fleet lags the operator's tooling; a laptop can return after months offline |
| Rete-wide bounds + objectives | retectl compile (B1+) | the Coordinator | An on-prem Coordinator may be version-frozen; a managed one rolls ahead of every rete |
| Per-node ctrl streams | retectl compile (C1) → Coordinator service (B1+) | flor agent → mesh vertex | The producer is centrally upgraded, churns at seconds-to-minutes, and cannot assume any fleet state |
| Delegated publications (roadmap direction, not committed design) | developer tooling under attenuated authority — a scoped signer, or self-authenticated names still directory-published and routable | agents, service directory | Producers outside the operator's upgrade control entirely |
Alongside the consumers sit the relays — the coordinator today, the Coordinator in its store-and-serve role from B1+ — which handle every artifact without being party to its contents.
Three facts fix the shape of the problem:
- The artifact is the entire versioned surface. Artifacts are OS-agnostic by design and carry no filesystem references; everything else a workload needs is spawn-injected context, intra-node by construction. Nothing else crosses a machine or authority boundary.
- The mgmt↔ctrl join is itself contract. The vertex's cross-verification rules —
active_links⊆ the expandedlinksbound, label bindings ∈ the allow-set,obeys_mgmt_versionfreshness — span both planes. Any versioning scheme split along the plane axis leaves exactly those rules unversioned. - Consumers cannot half-trust. These are authorization artifacts: an unknown field may carry a restriction, and a consumer that ignores what it does not understand fails open.
Decision
Three versioned surfaces, each with its own ladder
- The envelope contract — a single
schema_versionclaim in the envelope, on a single ladder. mgmt and ctrl artifacts share one envelope schema, so no plane is involved at this level. The contract covers the claims, the signature canonicalization, and — as a frozen subset — the routing core below. Deliberately near-frozen; gated by the agent as sole verifier (any other consumer's gate is defence-in-depth). - Payload-family contracts — each family carries its own
schema_versioninside the payload, gated by that family's consumer, evolving at its own pace. A family is everything one consumer parses jointly — and this is the level where planes meet: the vertex family spans its mgmt payload, its C1+ ctrl payload, and the join rules between them — one ladder precisely because the join rules span both planes and would otherwise be unversioned; the agent family covers the agent payload the same way (its future ctrl payload included). The rule in one line: one number per family, never one number per plane — a per-plane split would leave the security-critical join unversioned, or demand a compatibility matrix between ladders. - Opaque workload configs — outside the flor contract entirely: schema and versioning belong to the producing/consuming pair. The coordinator's config exercises this path from C0 (both ends are flor's own, dogfooding the tenant contract); tenant configs follow.
All flor-contract schemas live in one module (flor::config::artifact), compiled into every producer and consumer — retectl serializes the same types flor deserializes, and the B1+ Coordinator does both — so a single build carries constants naming the newest contracts it speaks.
One ladder per family does not mean lockstep stamps. The number versions the rulebook; each artifact stamps the lowest minor its own content uses, so the planes a family spans move at their own pace within a major: a node routinely holds a 1.0 mgmt payload beside a 1.1 ctrl payload (an older retectl authoring beside a newer Coordinator emitting), or 1.2 beside 1.0. Every such mix is defined: family version v specifies both payload schemas and the join rules between them, so a consumer joins whatever pair of stamps it holds under the rules of its own minor — sound because minors are additive and meaning-preserving, including what a field's absence means. In other words, v is the compatible version set of the planes the family spans — closed downward by construction, so no cross-plane compatibility matrix ever needs writing. (Mixing across majors is a transition concern, owned by dual-major operation below.)
One gate rule for every contract
Every consumer of every contract above gates identically, fail-closed, one-directionally:
- a major this build does not speak is incompatible in either direction — reject (a build normally speaks exactly one major; a transition-window build may speak several, gating each independently — see the majors rule below);
- a newer minor is rejected with an explicit upgrade error (a lenient byte-probe runs if strict parse fails so the operator gets guidance rather than an unknown-field trace;
deny_unknown_fieldsremains the security backstop should a producer mis-stamp); - an older-or-equal minor within a known major is accepted — the rolling-upgrade direction.
This holds for the envelope gate and for every payload family, on both planes, under every future authority class: a consumer must never apply an artifact whose constructs — or whose signing authority's verification rules — it does not implement. Rejection is the designed failure mode: the agent retains last-known-good state and surfaces the error — the availability-bounded degradation the ctrl-plane model already prices in.
Producers stamp the lowest sufficient minor, per artifact
An artifact carries the lowest minor whose schema its content actually uses — never the producer's newest. Because artifacts are per-consumer projections, the stamp is per-artifact: a node whose projection uses no new construct keeps receiving artifacts at the old minor, indefinitely. Schema-version skew therefore bites only the nodes a new feature actually touches, not the fleet. (This is orthogonal to the envelope's version claim, which orders a stream's content, not its schema — see the routing core below.)
mgmt evolves producer-paced; ctrl emits consumer-paced
The one real asymmetry between the planes, resolved by who can move:
- mgmt has no negotiation channel. It is compiled offline, operator-signed, and relayed; nothing may adapt it per consumer after signing. Ordering is the only lever, and the operator holds it: upgrade the consumers (at least the touched nodes), then author content that needs the new minor. At mgmt churn this is a human-paced, operator-visible workflow; compile-time fleet-version warnings can soften it later.
- ctrl is emitted per consumer, within that consumer's ceiling. From B1+ the ctrl producer is upgraded independently of every fleet and must not strand lagging agents. The agent's fetch advertises the
major.minorit speaks; the Coordinator emits each per-node ctrl artifact at or below that ceiling. When a decision cannot be expressed within a node's ceiling, the Coordinator degrades to one that can be — or withholds the update and flags the node for upgrade — never emits above it. C1's degenerate producer (retectl compile, same machine as mgmt) satisfies this trivially; the ceiling advertisement rides the sync protocol, designed with B1.
The routing core: the defined, frozen relay contract
A relay stores and serves artifacts as opaque signed bytes; its entire read surface is the routing core, the relay-facing subset of envelope claims:
| Claim | What a relay does with it |
|---|---|
plane — the discriminator alone (mgmt / ctrl) | selects the storage and serving path; a relay never reads inside the plane object — obeys_mgmt_version is edge-consumer data |
version | answers conditional fetches (?have=<v>). Its semantics is a stream ordinal — a monotonic freshness position within the artifact's distribution stream, where stream granularity follows authority granularity: mgmt has one stream per node (the set version, stamped uniformly across the node's set — change detection is byte comparison, not version comparison), ctrl one stream per artifact |
node, name | the artifact's address |
schema_version | logging and metrics only — a relay never gates on it |
generated_at, payload, and signature are not in the core: a relay stores and serves them unread. The core is read via a lenient probe that ignores everything else, and a relay must accept and serve artifacts of any schema version, including majors it has never heard of. In exchange, the core is frozen: no future version may rename, retype, or remove these claims — a change there is a new distribution protocol, not a version bump. This keeps relay infrastructure — the least-trusted party — permanently off the critical path of contract evolution. The core, together with the fetch/publish endpoints on the Coordinator page, is the embryo of an artifact-distribution protocol; writing that protocol up as a document of its own becomes worthwhile when B1's push delivery is designed — what is committed now is the core itself, and the envelope spec (Validate & Compile § The envelope) and the Coordinator page both reference it.
Minors add; majors break; every schema-visible change bumps
Within a major, evolution is strictly additive and meaning-preserving: a new envelope claim, a new plane value, a new payload field, a new tagged variant (adapter, io, via, link-rule, bound types). Every schema-visible addition bumps the minor, however harmless it looks — the bump is precisely what turns a would-be parse failure on an older consumer into clear upgrade guidance. The semantics of existing fields never change within a major, and a change in what a field's absence means is a semantic change.
Everything else — renaming, removing, or retyping anything; changing the identity tuple; changing the signature canonicalization; changing the meaning of a join rule — is a major. Majors are extraordinary (the no-flag-day stance the C1 ctrl-plane split was made for) but not hypothetical, and conversion is not an option for mgmt: a relay cannot re-sign, so nothing downstream of the operator can translate an artifact between majors. A major therefore means dual-major operation, with the burden falling where signing authority already sits. retectl dual-publishes mgmt during a rete's transition window; the ctrl producer — already emitting per consumer — emits at each fleet's major; relays store and serve both (they never gated anyway); and a consumer may speak several majors during the transition, gating per-major (within each major it speaks, older-or-equal minors) and taking the highest it speaks. The B1+ managed Coordinator makes multi-major a standing capability, not a contingency: serving many retes on independent upgrade schedules while itself running the vendor's latest version, it must keep consuming every live tenant's bounds major and emitting ctrl accordingly — its own upgrade must never force a tenant's. Designing that is B1 scope (ticketed in the Coordinator sketch); C0/C1 commit only to the rules above, and major = 1 stays load-bearing until then.
Every change already visible on the roadmap lands as a minor: new adapter and link types (wg, B2's radio-medium link constructs), link ctrl (liveness), the Coordinator's own payload kinds, and delegated publication — whether that arrives as a new authority class on the plane axis or as new bound and directory constructs inside existing planes. In each case old consumers reject fail-closed with an upgrade error; for a new authority class that is not a compatibility bug but the security requirement.
What this does not version
- Spawn-injected runtime context — the
FLORWL_*environment contract a supervisor passes at spawn: intra-node, same-install conventions rather than a document. It needs no schema version; anything that ever crosses a machine or authority boundary must instead graduate to an enveloped artifact rather than grow a second versioning regime. - The source YAML facade — compiled away (ADR-0009); its evolution is
retectlUX, versioned with the tool. - The enrollment bundle and the sync/fetch protocol — separate contracts with their own lifecycles. One requirement is reserved here: the fetch protocol must carry the consumer's schema ceiling.
Rationale
Why one ladder per family, not per plane — and exactly one for the envelope. The envelope needs exactly one ladder because there is exactly one envelope schema; inventing per-plane envelope versions would version a difference that does not exist. Payload families need one ladder each because the join rules span the planes a family covers — split numbers would leave the security-critical zip unversioned, or demand a compatibility matrix between ladders. And the ladder count would only grow: every new producer class (Coordinator, delegated publishers, federated Coordinators) would either mint its own or argue over which it belongs to. One number per family keeps every present and future producer-consumer pair on one rule, and matches the single schema module every party compiles in.
Why major.minor, not full semver. Every component of schema_version has gate semantics: the major is matched exactly, the minor orders one-directionally. A third (patch) component would have neither — it could only mark changes with no schema-visible or semantic effect, and the gate would have to ignore it. A component consumers must ignore is not harmless: it invites smuggling a semantic fix past the gate as "just a fix bump," exactly the silent reinterpretation this policy forbids. Errata and clarifications that genuinely change nothing belong in the contract changelog, not on the wire. Two components mean every digit a consumer reads is load-bearing.
Why fail-closed on ctrl too. The tempting asymmetry — let old agents skim newer ctrl leniently, since ctrl is bounded by mgmt anyway — fails on inspection. A new ctrl construct can itself be a narrowing within the bound (a time-boxed activation, a rate cap, a drain flag); an agent that drops it still acts inside mgmt bounds but silently diverges from what the CP actually decided, and nobody learns the fleet diverged. Fail-closed keeps divergence loud, and its cost is exactly the degradation class the bounded-CP analysis already accepts (last-known-good; availability, not access). Lenient parsing on one plane would also mean two envelope parsers of different strictness in the same security-critical path — an invitation to drift.
Why negotiation for ctrl, rollout order for mgmt. Both are answers to "someone must move first," resolved by who can. mgmt's producer is offline and its output is signed and relayed — nothing can adapt it per consumer after signing, so ordering is the only lever, held by the party (the operator) who also controls fleet upgrades. ctrl's producer is online, per-node, and re-emits continuously — adapting emission to the consumer is nearly free, and it is the party that cannot assume fleet state. This is the Kubernetes API-server pattern — one internal model, served to each client at the version it asks for1 — applied to a signed stream, with the client side keeping its fail-closed gate: the stance JWS's crit header encodes for signed containers — an extension the recipient does not understand makes the token invalid, never partially processed2 — with the artifact contract treating every schema-visible addition as critical. The rejected alternative — permissive unknown-field parsing, protobuf-style3 — buys availability by silently degrading meaning, the one trade an authorization artifact cannot make.
Why the frozen routing core. Relays must serve artifacts they cannot parse; otherwise every contract evolution waits on infrastructure upgrades, inverting the trust design — the relay is the least-trusted party and must be the least load-bearing. Freezing the core claims is the minimal permanent promise that makes "relay anything, verify at the edge" true forever — and defining the core exhaustively (down to "the plane discriminator alone") is what keeps a relay implementation honest about how little it may look at.
Consequences
Benefits
- One rule for every pair: a new producer or consumer inherits the policy instead of negotiating a bilateral one.
- No consumer ever half-reads an artifact: everything applied was fully understood, on both planes, under every future authority class — and unknown authority classes are rejected by construction.
- Minimum-minor stamping confines forced upgrades to the nodes a new construct actually touches; the rest of the fleet keeps consuming old-minor artifacts indefinitely.
- Relays stay permanently off the schema-evolution critical path; the routing-core freeze makes them safely dumb, and its exhaustive definition makes "safely dumb" checkable.
- Every skew incident is an explicit, operator-visible "upgrade flor" — never a silent reinterpretation.
Trade-offs
- The operator must sequence mgmt evolution: upgrade the touched nodes before authoring content that needs a new minor. Compile-time fleet-version warnings can soften this; nothing removes it.
- The B1+ Coordinator carries an emission-side burden: it must be able to emit every minor — and, across a major transition, every major — that live fleets still speak, since a multi-tenant deployment cannot let its own upgrade force any rete's. It is the component best equipped to carry that.
- Consumers keep parse support for all older minors of their major; optional fields accumulate until a (rare) major clears them.
- A node too old for the current mgmt minor freezes on last-known-good — including ctrl, since fresh decisions may reference bounds the node cannot hold. That is the designed, surfaced failure mode; the remedy is upgrading flor, and the policy does not paper over it.
- Discipline cost: every schema-visible addition, however small, is a minor bump plus a changelog row — the per-minor changelog becomes part of the contract docs when 1.1 lands.
Evolution
- B1's sync protocol carries the consumer's schema ceiling; the Coordinator's emission honors it (this ADR reserves the requirement; the protocol design owns the wire shape).
retectl compilegains fleet-awareness warnings ("nodes X, Y run flor < 1.3; this compile emits 1.3 artifacts for them") once node telemetry exists.- An artifact-distribution-protocol document (the routing core + fetch/publish/push surfaces) is written with B1's push delivery; until then the core lives here and is referenced by the envelope spec and the Coordinator page.
- Multi-major operation is designed with B1 (ticketed in the Coordinator sketch): multi-major consume/emit in the managed Coordinator, dual-publish in
retectl, transitional multi-major flor builds. Until a major lands, major = 1 is load-bearing. - Delegated publication chooses its envelope form — a new authority class on the
planeaxis, or new constructs inside existing planes — in its own ADR; either way it lands as a minor under this policy.
Footnotes
-
Kubernetes API versioning — one internal object model, served to each client at the API version it requests via round-trippable conversion; the emission-side pattern the B1+ ctrl producer follows. ↩
-
RFC 7515 §4.1.11 — the JWS
crit(Critical) header — extensions a recipient MUST understand and process; a JWS using critical extensions the recipient does not understand is invalid. The fail-closed treatment of not-understood additions in a signed container, which the JWT/COSE-shaped envelope (ADR-0010) inherits wholesale. ↩ -
Protocol Buffers unknown fields — the permissive model (retain or ignore fields you don't know, for forward compatibility): appropriate for telemetry-class data, rejected here for authorization-class artifacts. ↩