0010: Shape the Compiled Artifacts for flor Consumers
Status
Accepted
Realises, concretely, the "object model on the consumed side" that ADR-0009: Treat Source Config as a Compiled Facade placed but did not shape. Uses the rete/mesh vocabulary of ADR-0008 and the identity-as-key discipline of ADR-0007. Grounds and amends the artifact schemas in the C0 Validate & Compile and C1 Mgmt Plane / Ctrl Plane docs. Signer-set placement is decided here but its semantics live in ADR-0011: Bootstrap and Rotate Signer Trust.
Amendment note (2026-07). The C0 Agent design refined three decisions recorded here, and this record is amended in place accordingly. (1) The supervision inventory carries no
kindat all (a later refinement of the same pass — an earlier state of this amendment hadvertex/workload): the launch recipe waskind's only consumer, and it is now explicit as a per-entryrunargv, with all per-workload context injected through a uniform spawn environment; the payload's link/mesh engine kind was never in the inventory — the agent parses no payload but its own, so an engine tag there would be dead weight. (2) Artifacts carry no filesystem references —ca_cert_path/cert_path/priv_pathare gone; identity is keyed by SPIFFE ID and materialized by the node's identity store. (3) The mgmt↔ctrl join is verified by the vertex — the only party parsing both planes — not the agent, which verifies envelopes and signatures only; correspondingly, the versioning gate splits into an envelope contract and per-payload-family contracts, each family's version riding inside the payload as its ownschema_version(see the amended ADR-0012). (4) The envelopekindaxis is removed: identity is(node, plane, name),agentis a reserved name, and dispatch is by name to the consuming workload — consumer kinds never enter the envelope, so a new supervised workload never changes it. The text below is edited to the amended state.
Context
ADR-0009 settled that the operator authors a compose-style facade and that the object model lives on the consumed side — the compiled artifacts and the Coordinator's resource model. It did not pin the shape of those compiled artifacts. Implementing flor's JSON parser (greenfield: no serde yet, the first target is flor vertex) forced the question, and inspection of the C0/C1 examples surfaced four warts that would each bite a typed parser:
kind: vertexis not a real discriminator. It covers both link-flor and mesh-flor, which need different forwarding engines; the kind was reconstructed by sniffing optional-field presence (allowed_peers?). Fragile, and silently broken by the link↔mesh cycle the schema is told not to forbid.connection_managerhad three shapes (C0 singularadapter, C1 link singular, C1 meshadapters[]).viawas polymorphic across three contexts ({udp_peer},{adapter, udp_peer},{first_hop}), an untagged-union guessing game for a signed artifact.allowed_peerswas double-purposed (a CP-narrowable bound and dial config), and the mgmt↔ctrl relationship was an expand-and-match rather than a clean join.
A framing correction underlies the fix. ADR-0009's "self-describing, addressable objects" can be misread as "make the artifact a generic kind/name object store." It is not one. The per-node artifact is a typed projection delivered to a single consumer that reads it whole, once; the store is the Coordinator's resource model. The addressability the object model wants is already carried by the SPIFFE ID, used as the key for every entity (workloads[].spiffe_id, ingress/egress[].target, links[].peer) — with a local link name as the one additional, deliberately non-identity key the forwarding plane needs. Building a generic object decoder would re-import on the consumed side exactly the envelope ceremony ADR-0009 rejected on the facade side.
Decision
The artifact is a typed per-consumer projection, keyed by SPIFFE ID
flor's parser is plain typed deserialization (serde structs / internally-tagged enums), not a generic object engine. Every entity is addressed by its SPIFFE ID; that is the join key within and across artifacts. The container is typed; the entities are addressable — that is the object-model benefit without the ceremony.
Two axes; one vocabulary
The envelope carries one axis: plane — whose authority signed this. Which process consumes this is answered by name alone: dispatch is by name, each consumer parsing only artifacts addressed to it (agent reserved for the supervisor), so consumer kinds never enter the envelope and a new supervised workload never changes it. The link/mesh distinction is a payload axis:
| Axis | Question | Values | Location |
|---|---|---|---|
plane | Whose authority signed this? | mgmt / ctrl | envelope |
kind (vertex payload) | Which engine does the vertex run? | link / mesh | payload |
Naming is fixed by two words used identically in YAML and JSON: kind = the category of an object (vertex: link/mesh), type = the variant within a context (transport: quic; adapter: udp/florio/wg; bound rule: list). No YAML change: the YAML already uses kind: link|mesh and type: quic. Because the unified links / egress / ingress / connection_manager shapes (below) make the mgmt payload field-identical for both vertex kinds, the payload kind is a plain discriminator field, not a tagged union with per-variant data; it selects the forwarding engine, gates validation, and decides whether a ctrl artifact is consumed.
One connection-manager shape
connection_manager is always { "adapters": [ { "name", "type", … } ] }. The link/C0 case is a one-element list. An adapter is a local transport mechanism (a FlorIO socket, a UDP socket); a single adapter may carry several links, so a link references its adapter by name.
links — the in-layer adjacency bound, keyed by named link
A vertex reaches each peer (adjacent vertex) over one or more links, and two peers may be joined by several parallel links (dual-ethernet, or a QUIC link plus a private-LAN UDP link). The adjacency table is therefore keyed by a local link name, not by peer — a peer may appear in more than one entry. It is the connection manager's table for every vertex (replacing mesh's allowed_peers and link-flor's dial-bearing egress) and keeps the typed-rule bound shape (CP-narrowable; ctrl's active_links ⊆ links):
"links": [
{ "type": "list", "members": [
{ "name": "beta-pub", "peer": "spiffe://rete-lovers/vertex/beta/rete", "via": { "type": "florio", "adapter": "via-link" } },
{ "name": "beta-eth1", "peer": "spiffe://rete-lovers/vertex/beta/rete", "via": { "type": "udp", "adapter": "eth1", "addr": "10.0.1.6:5544" } },
{ "name": "gamma", "peer": "spiffe://rete-lovers/vertex/gamma/rete", "via": { "type": "udp", "adapter": "internal", "addr": "10.0.0.7:5544" } }
] }
]Each link carries a local name (the forwarding handle — beta-pub and beta-eth1 are two links to the same peer), its peer (the remote mesh-vertex identity, for link-layer mTLS), and exactly one via, internally tagged on type — { "type": <adapter-type>, "adapter": <name>, …type-specific dial }. A delegating adapter (FlorIO) carries no address (it hands dialing to the layer below); a UDP adapter carries addr (it terminates the wire), so the recursion boundary stays legible: the address surfaces at whichever layer owns the wire. The via type mirrors the referenced adapter's type (compiler-guaranteed consistent, validator-checked). via lives only here; the compiler assigns stable link names, and an operator's paths.yaml hop via: (the source-side link selector) compiles down to a link name.
ingress/egress are pure flat allow-lists
Both are [{ "target", "allow": [ <initiator-spiffe-id>… ] }] — operator authority, not bounds (the CP cannot narrow them, so no typed-rule shell). egress drops target_node (derivable from the peer's node-scoped SPIFFE ID), target_udp (moved to links), and via/first_hop (moved to ctrl). egress is now identical in shape to ingress.
Routing realisation is ctrl; the mgmt↔ctrl join is a zip
mgmt contributes stable ACL-and-dial facts; ctrl contributes the routing realisation. Labels are per-link, not per-peer or per-node (MPLS per-interface semantics): a label is only meaningful in the label space of the link it travels on, and parallel links to the same peer have distinct spaces — so every label is paired with its link name. Each entry is an in → out mapping where each side is either a link-label (a transit hop) or a local workload (origination/delivery, named by target+initiator):
{
"schema_version": "1.0", "plane": { "ctrl": { "obeys_mgmt_version": 42 } },
"version": 17, "node": "alpha", "name": "rete",
"payload": {
"active_links": [ "beta-pub", "gamma" ],
"label_bindings": [
{ "scope": "ingress", "target": "spiffe://rete-lovers/service/api",
"initiator": "spiffe://rete-lovers/user/alice",
"in": { "link": "beta-pub", "label": 9 } },
{ "scope": "egress", "target": "spiffe://rete-lovers/service/mongodb",
"initiator": "spiffe://rete-lovers/service/api",
"out": { "link": "beta-pub", "label": 11 } }
],
"forwarding_table": [
{ "in": { "link": "beta-pub", "label": 17 },
"out": { "link": "gamma", "label": 42 } }
]
},
"signature": { "alg": "ed25519", "key_id": "spiffe://rete-lovers/control-plane/primary", "value": "<base64>" }
}An ingress binding is link-label → local (the out is the local workload, given by scope); an egress binding is local → link-label (its out.link is the first hop — a specific link to the first-hop peer, which is what a paths.yaml hop's link selection resolves to); a forwarding_table row is link-label → link-label. The three differ only in whether each endpoint is a link or a local workload, leaving room to fold them into one table later — deferred, not done here.
The two join keys are the link name (ctrl active_links and every in.link/out.link ⊆ the mgmt links table) and the flow tuple (scope, target, initiator) (ctrl label_bindings ⊆ the mgmt ingress/egress allow-set, expanded once). The vertex verifies, before applying (it is the only party that parses both planes; the agent verifies envelopes and signatures only):
active_links⊆ ⋃linksrules (dispatch ontype); each link'speeris dialed via itsviaadapter.- every
in.link/out.link∈active_links. - every
label_bindings (scope, target, initiator)∈ the expanded mgmt allow-set. - each
(in.link, in.label)is unique acrossforwarding_table∪ ingress bindings (per-link in-label uniqueness; out-labels live in the neighbour's space and are not checked locally). obeys_mgmt_version≥ the held mgmtversion.
In C1 only the mesh vertex has a ctrl artifact; the link vertex's links table is its (degenerate, 1-1) routing. This is a C1 fact, not a permanent invariant — B1+ link liveness is slated to arrive as link ctrl.
Complete, worked per-artifact examples (agent.json, link and mesh vertex configs, and ctrl) live in the C0/C1 mgmt- and ctrl-plane docs this ADR amends; the snippets here are illustrative.
io direction is intrinsic to the channel kind
A workload's io channels carry no direction field: each kind fixes its direction — socks5 inbound, tcp outbound, florio bidirectional (reinforced by whether the entry carries listen, upstream, or socket). A new direction is a new kind (e.g. a future socks5_out), never a { kind, direction } combination — which keeps illegal pairings (an inbound tcp) unrepresentable. The parser derives direction from kind to populate its inbound/outbound/bidirectional registries.
The agent inventory is supervision-only, and paths use one root
agent.json lists workloads: [ { "name", "run" } ] — a supervision inventory the agent reads without parsing any other payload. run is a plain argv naming the executable; all per-workload context (scope, roots, the verified config's path) is injected through the uniform spawn environment, so the agent applies no per-entry special-casing — a vertex launches exactly like any other program. The link/mesh engine distinction lives solely in the vertex payload, where the engine reads it. There is no per-workload config path: configs are located by name under fixed per-plane conventions in the scope's artifact root (<name>.json in the active mgmt set; ctrl/<name>@v<K>.json slots) — never a config-supplied resolution root — and each workload receives its own config's resolved path at spawn. Supervision metadata — the inventory, how the agent reaches the Coordinator (sync: its principal and the coordinator identity; the dial-out resolves through the agent's own inbounds entry), and the compiler-projected inbounds endpoint map (each inbound a typed { kind, addr } so a new inbound protocol doesn't change the schema) — belongs in agent.json; engine internals stay in the vertex config.
agent.json carries the trust roots
The two artifact-signer sets live in a trust block in agent.json — authorized_mgmt_signers and authorized_ctrl_signers — and every other artifact's envelope carries only its own signature. (The CA cert is not referenced by any artifact: it sits at its conventional place in the node's identity store.) This removes the C0/C1 inconsistency (mgmt signers in every envelope, ctrl signers buried in the mesh vertex payload) and places the rete-wide trust roots once, where the sole verifier reads them. Their verification and rotation semantics are owned by ADR-0011; this record fixes only their placement.
Envelope naming
Keep payload (signed-container convention) — not spec, which carries the Kubernetes "user-authored desired state" meaning that belongs to the facade, not the compiled projection. The envelope stays flat (claims beside payload) and payload-agnostic: it carries a generic name — the consuming workload's name (agent reserved for the supervisor) — so identity is (node, plane, name), never path-derived and never consumer-kind-tagged. The envelope is a closed, schema_version-gated schema (unknown fields rejected): it evolves by adding an explicit field under a schema_version (major.minor) bump, never an open extension bag. A consumer gates each artifact fail-closed and one-directionally: it reads its own major at any older-or-equal minor (so a newer flor keeps consuming older artifacts — the rolling-upgrade direction), but rejects a newer minor (and any other major) rather than silently ignoring fields it doesn't understand — because deny_unknown_fields means a newer minor may have added a restriction, and dropping it would fail open. Compatibility across a minor bump is thus achieved by upgrading consumers before the producer emits the new minor, not by lenient parsing. Per-plane metadata rides on the plane discriminator itself — plane is a tagged value, "mgmt" or { "ctrl": { "obeys_mgmt_version": … } } — so a ctrl-only field needs no optional envelope claim, and a payload's per-kind data stays in payload. ADR-0012: Version the Compiled-Artifact Contract (as amended) owns the versioning policy: schema_version gates the envelope contract, while each payload family carries its own schema_version inside the payload, gated by that payload's consumer under the same fail-closed discipline — producer stamping, relay obligations, and the minor/major evolution rules apply within each contract.
Rationale
Why a typed projection, not a generic object store. The envelope ceremony pays rent only where state is stored, sliced, reconciled, and watched — the Coordinator's resource model. The per-node artifact is read whole by one process; a generic decoder there is the consumed-side mirror of the facade ceremony ADR-0009 rejected. SPIFFE-ID keying already gives clean projection from the Coordinator's store.
Why kind as a plain tag. Unifying links/egress/ingress/connection_manager across kinds leaves no structural mgmt difference between link and mesh — only behaviour (star vs label-switched forwarding) and whether ctrl applies. A single discriminator tagging the differing behaviour beats splitting the whole payload by kind, and it is the honest expression of "link is the degenerate mesh."
Why links is a bound but ingress/egress are not. links is precisely the surface a CP attenuates (active_links ⊆ links); the typed-rule shell is the extension point for the narrowing-closed algebra. ingress/egress are operator authority the CP may not narrow; a bound shell there would be cargo-cult. This is the load-bearing distinction between the two state categories the Coordinator consumes.
Why first_hop moves to ctrl. A first hop is a routing decision, bounded by allowed_paths. Putting it in ctrl alongside the label hands all routing to ctrl and makes mgmt egress the pure ACL it is described as — which is what turns the mgmt↔ctrl relationship into a zip. The direct/star case (link, C0) needs no first_hop; C0, which has no ctrl stream, is fine precisely because it is all-direct.
Why payload, not spec. The artifact is a JWT/COSE-shaped signed container; payload is the conventional term, and spec would conflate the compiled projection with the operator-authored facade.
Consequences
Benefits
- The parser is a total
matchonkind, with oneconnection_managershape and one internally-taggedvia; illegal states (a link carrying mesh routing) are unrepresentable. - The vertex's "combine mgmt + ctrl" step is a zip on link names and identities, not an expand-and-match.
- The agent gets a typed supervision inventory without cracking vertex payloads.
- Typed-rule
linksand the explicitkind/typevocabulary are additively forward-compatible (new adapters, parallel links, new bound types, link ctrl) without a schema migration.
Trade-offs
- For star vertices (link, C0) a target identity appears both as a link's
peer(dial) and inegress(ACL) — mild redundancy, the price of one uniform shape. first_hopmoves mgmt → ctrl, a real change from the current C1 docs (which must be amended), andauthorized_*_signersmove toagent.json(see ADR-0011).- Three nesting levels carry a
kind/typefield; consistent, but readers must hold the "category vs variant-in-context" rule.
Evolution
- B1+ swaps the ctrl producer (
retectl compile→ Coordinator) with no wire change; the join and verification rules are unchanged. - Link ctrl (liveness/up-down) slots into the existing ctrl envelope; the agent already dispatches "has ctrl" per vertex.
- New
linksrule types and first-class objective objects grow under the typed-rule shape, feature-by-feature.