Validate & Compile
Validator rules and compile step for producing per-node artifacts in C0
Validator Rules
rete validate is the only safety net against hand-edit errors, so it must be strict.
rete validate/compile first discover and merge the source: they require rete.yaml at the repo root, and fail if it is missing or nested elsewhere. From that anchor, they recursively glob *.yaml/*.yml (skipping .flor/, bundles/, dotfiles; honoring any include/exclude in rete.yaml), then union same-kind collections into one logical view. File layout is operator-chosen (see Source Layout); the rules below run on the merged view. Discovery has no per-invocation override — source in rete.yaml is the only way to narrow it.
- Schema — every file matches its JSON Schema.
- Cross-references — every node, service, group or role a reference names exists.
- Name rules — a name is unique within its kind and its scope — per rete for a rete-scoped kind, per node for a node-scoped one — and names of different kinds may coincide (Identity, ID-C0-11). Every name is lowercase. The name of a targetable kind — service, vertex, node, workload — is one DNS label of letters, digits and hyphens, 1 to 63 octets; so is every label of the trust domain, and every rendered name fits 253 octets. User and signer names keep the SPIFFE charset. No segment that reaches a file name is an OS-reserved basename (
con,nul,com1, …) or ends in a dot or a space (Identity, ID-C0-12). Workload names are unique per node,agentnames the supervisor, andagentandcoordinatorcannot be vertex names (Identity, ID-C0-09). The file-name constraints are a documented C0 shortcut — a store with a reversible name encoding lifts them (Identity, ID-C0-28). - Service placement — every service's node is a known node in
nodes.yaml: theatfield of a rete-scoped service, and the key of a node-scoped one (alpha/ssh). - Management-node integrity — where a rete declares both reserved coordinator services,
coordinatorandcoordinator-publisher; a rete declaring exactly one fails, naming the other, and one declaring neither has no management node and skips rules 5 to 7. The two services must exist in groupscoordinator-syncandcoordinator-publishrespectively, both hosted on the same node (the management node) with a publicly-reachable link-vertex (akind: link, type: quicentry with anaddress). If missing or malformed, validator fails with a clear message rather than silently producing artifacts that can't sync. - Reserved-name protection —
nodeandoperator(roles),coordinator-syncandcoordinator-publish(groups) are reserved in every rete, and where a management node is declared they must be present in the YAML with their canonicalallow/ member definitions. Validator fails if they're missing or redefined with different structure. The compiler never writes YAML —rete initwrites these into a new rete commented out, and a rete declaring a management node uncomments them. - Operator presence — where a management node is declared, at least one user in
users.yamlmust haverole: operator. Without it, no one can callcoordinator-publisher, so the rete can't receive new state after the initial bootstrap — worth catching at validate time. 7a. Mgmt-plane signer presence — two checks with different preconditions. The structural check runs anywhere, including on a clone with no operator material:signers.mgmt.principalsmust be non-empty and every entry well-formed. The cryptographic check runs only where the material is present — each named signer's certificate resolves inrete's tree, parses as X.509, carries a SPIFFE URI SAN matching signing principal'sspiffe://<rete>/management-plane/<name>, and is signed by the rete's root CA. Splitting them is what letsrete validaterun in CI: the certificates are not in the repository (Source Layout), so a single combined rule would fail every clone. Without at least one signer no key exists to sign compiled artifacts; without the SAN check a misfiled certificate could pass undetected. - Vertex graph & reachability feasibility — every node declares at least one link-vertex (
kind: link); in C0, exactly one oftype: quic. Every node hosting a service (other thancoordinator-syncservices) must declare at least one link-vertexaddress. The validator checks that an address is declared, never that it is reachable, and admits loopback and private addresses — which a development rete of several nodes on one host declares — with at most a warning; an unreachable address is found at the first handshake. Initiator-only nodes (user laptops) may omitaddresson their link-vertex; referencing them as a service host is an error. - Workload vertex binding — every workload's host vertex must resolve. For services: the node its
at:or its key names is known;via:(when present) names a vertex declared on that node. For user device entries:at:names a known node;via:(when present) names a vertex on that node. For the agent:agent_via:(when present) on the node entry names a vertex on that node. Thevia:/agent_via:field may be omitted when the target node has exactly one vertex; the compiler picks that vertex automatically. When the node has multiple vertices and the field is absent, validation fails with "specifyvia:to disambiguate among<list of vertex names>." (In C0 every node has exactly one vertex, so these fields are always optional.) - Access consistency — every principal's role permits only groups defined in
groups.yaml; every service'sgroupis defined; every service with an egressrolehas that role defined. - Principal role coherence — services that act as clients (outbound) must have a
roledeclared; services withoutrolemay only appear as targets. - Source merge integrity — a duplicate
(kind, name)across the merged source files (twoservices:entries namedapi, twonodes:namedalpha, …) is a hard error, never last-wins; theretesingleton must appear exactly once; an unrecognized top-level collection key (e.g. a typo'dservies:) is an error, not a silent skip. This keeps a whole-view facade authored across many files as safe as one authored in a single file.
Cert chain verification happens at runtime during mTLS, not at validate time — the repo doesn't hold individual certs.
Compile Step
rete compile produces two artifacts per ordinary node in C0 — agent.json (consumed by flor agent run) and one vertex config per declared vertex, named after it — hop.json in the examples here, consumed by flor-vertex — placed flat under <repo>/.flor/compiled/mgmt/<node>/; the management node additionally gets coordinator.json, the coordinator's config, an opaque-payload artifact (see Agent). Every supervision entry's run argv is compiler built-in knowledge in C0, never an authored fact — the validator restricts it to that known set. Discovery differs by workload: one entry per declared vertex (rule 8 fixes the count at one per node in C0), plus one for the coordinator on whichever node carries the reserved-name services (rule 5). No YAML declares a program to run — the general mechanism, and why it isn't a marker on a service entry, is deferred (Open Follow-ups). agent.json doubles as the set's root — its inventory is the membership list and the uniform version stamp is the coherence check, so no separate set index exists (see Agent § Node layout). The mgmt/ tree is the only output tree in C0; C1+ adds a sibling ctrl/ (see C1's ctrl-plane doc). The compile is whole-rete, and the previous compiled tree is the compiler's ledger: for each node, rete reads the previous set through the artifact store and byte-compares every projected payload (and the set's membership) against it — bytes only, no payload parsing. A version numbers a set's content, never its envelopes. An untouched node's files are re-emitted byte-identical, its version unmoved, unless a member was signed by a certificate other than the current signer's: then every member is re-signed, the version still unmoved. A touched node gets version = previous + 1 stamped into every artifact of its set, all re-signed. A signer's rotation therefore reaches every document at the next compile, and no document in the tree carries a certificate older than the current signer's. A per-node compile scope (--node) is deliberately absent: mgmt state is cross-node coupled (adding a service on one node edits others' egress rows), so a partial compile could desynchronize the tree from the source; whole-rete compile is cheap.
Artifacts carry no filesystem references. Identity material is referenced by SPIFFE ID only and materialized on the node by the scope's identity store (Identity § The identity store); local sockets and paths are derived on the node from spawn-injected roots and names. This keeps artifacts fully portable: the same compiled JSON is valid whatever the operator named the rete scope locally, wherever the node's roots live, and on any OS. A signer's certificate reaches a node inside the signature of each envelope it signed, never as an on-disk file and never from the repository, which holds no certificate at all.
Node runtime layout
The node-side layout — deployment modes, install and runtime roots, the identity store, immutable artifact sets activated by the mgmt/current pointer, and the agent's durable state — is owned by Agent § Node layout. What the compiled artifacts rely on:
- One convention, located by name. Within a plane's directory, a node's configs sit flat:
<name>.jsonper workload — workload names are unique per node and filesystem-safe (Identity, ID-C0-09, ID-C0-12), so the flat layout is collision-free. From C1 a mesh vertex's ctrl config lives outside the set, in the node's per-artifact slots (ctrl/<name>@v<K>.json— see Agent § Node layout).agent.jsonlists only{ name, run }per workload; each workload receives its config's resolved path at spawn (FLORWL_MGMT_ARTIFACT), so consumers need no layout knowledge at all. - Identity resolves through the store. An entry names its principal by SPIFFE ID; the consumer asks the identity store for that principal's material, and the store checks the certified ID against the one requested. The rete's trust domain is read from the store's
scope.json— the record of what this scope is, carrying the node name beside it — never inferred from config contents and never taken off the scope directory's name (Identity § Decision Log, ID-C0-29). It sits besideidentity/anchors.pem, the two together being what a verifier needs.
The envelope
All artifacts — both planes, platform and opaque payloads alike — share one envelope. A frozen subset of its claims is the routing core (the plane discriminator, version, workload, plus schema_version informationally) — the only part a relay ever reads; everything else, a relay stores and serves as opaque signed bytes (ADR-0012).
{
"schema_version": "1.0",
"plane": "mgmt",
"version": 42,
"workload": "spiffe://flor.demo/workload/alpha/agent",
"generated_at": "2026-04-20T12:00:00Z",
"payload": { ... },
"signature": {
"cert": "<base64-der-x509 of the management-plane signer>",
"value": "<base64>"
}
}Field semantics:
schema_version—major.minorof the envelope contract only: the claims, the signature canonicalization, and the relays' frozen routing core within it. Deliberately near-frozen. Every consumer gates it fail-closed: it accepts its own major at any older-or-equal minor and rejects anything newer with an explicit upgrade error; the producer stamps the lowest minor the envelope actually uses. Payload schemas are not covered — each payload family versions itself insidepayloadwith its ownschema_version, an independent ladder gated by that payload's consumer. See ADR-0012 for the versioning policy.plane— the authority axis: it tells the payload-blind agent which signer set authenticates the artifact."mgmt"for everything in C0. Reserved:"ctrl"for the separately-signed control-plane decision stream that lands in C1 — ctrl decisions arrive as separate artifacts in this same envelope shape (withplane: { "ctrl": { … } }), never embedded inside a mgmt artifact. Discriminating now keeps the envelope schema stable across the split.workload— the consuming workload's SPIFFE ID, always a node-scopedworkload/<node>/<name>(Identity, ID-C0-09), so identity is(workload, plane), path-independent;workload/<node>/agentaddresses the supervisor's own config. It is validated by parsing it as a node-scoped workload ID, whose own rules fix its kind, its trust domain and its name's syntax (Artifact Store § Decision Log, ART-A0-06). There is no envelopekind: dispatch is by workload — each consumer parses only artifacts addressed to it and knows its own payload schema, so a new supervised workload is a new name, never an envelope change. A vertex payload internally carrieskind: "link" | "mesh"to select the engine (C0 has a singlelinkvertex per node; C1 addsmesh); other payloads' internal discriminators are their own business.version— on mgmt, the node's set version: a per-node monotonic counter stamped uniformly into every member of the node's set, advancing by one per publish that actually changes something on this node (the compiler derives it asprevious + 1from its ledger; no rete-global counter, and node sequences are fully independent — nothing ever compares versions across nodes). It gives the replay defense (a single mgmt high-water-mark covering the set) and the mixed-set defense (stamp equality across members); change detection is not its job — consumers compare payload bytes (see Agent § Node layout). On ctrl the same claim is a per-artifact stream version — each ctrl artifact is an independent stream (C1's ctrl-plane). One rule covers both: a monotonic freshness ordinal within the artifact's stream; mgmt simply has one stream per node, because its unit of coherence and authority is the whole set.- Trust roots — no artifact carries a signer set. Each envelope carries its own signer's certificate, inside its
signature, and the agent — the sole verifier — validates that certificate against the node's trust anchors, requires its SAN kind to match the envelope'splane(management-planefor mgmt,control-planefor ctrl — which also requires that signer to be inauthorized_ctrl_signerslist), and only then checks the signature. The kind step is what keeps an ordinary TLS leaf out of the signer role, since no extension distinguishes the two. The anchors are not referenced by any artifact: they sit atidentity/anchors.pemin the identity store, delivered at enrollment. An envelope is therefore verifiable alone, which is what ctrl's independent streams need and what lets a node absent across a signer rotation accept the successor. See ADR-0015. signature—{ cert, value }: the certificate of the signer that produced it, and the signature over the canonical-form envelope minus thesignaturefield itself, raw and base64-encoded. Two fields it deliberately lacks. There is nokey_id: the certificate carries the SPIFFE ID in its SAN, and a verifier reads the identity from there. And there is noalg: the certificate's key type fixes the verification scheme, which a verifier resolves through a compiled-in table — one row in C0, Ed25519 → PureEdDSA — after it has validated that certificate to the anchors. A signed document never selects the routine that checks its own signature; adding a key type means fixing its scheme alongside it, inrete.yaml's algorithm policy rather than per envelope. The signer is a management-plane principal — a sign-only identity issued independently from any user entry viarete id issue --kind management-plane, whose key never leaves operator hardware. See ADR-0005 for the kind separation and ADR-0015 for why the certificate travels with the signature.
Terminology
The payload uses these terms consistently, all from flor's point of view at the network boundary. An attachment is a TLS principal attached to the vertex — one the vertex terminates mTLS for; initiator/target below are the roles an attachment plays, not what makes it a principal — both are principals. A workload, elsewhere, is a supervised process, never an entry here.
- initiator / target — the two roles an attachment plays in a flow. An initiator originates a connection (flor dials on its behalf); a target serves one (flor delivers to it). An attachment can be both (e.g. a service that calls another service and is itself called). Determined by the attachment's
iochannel kinds — see inbound/outbound. - ingress / egress — peer-to-local and local-to-peer QUIC sessions respectively.
ingresslists which remote principals may initiate to a local target;egresslists which local principals may initiate to a given remote target. How to dial each target lives in the vertex'slinkstable, not inegress. - inbound / outbound — the two ways an attachment is wired into flor locally, determined by the
iochannel'skind, not a separate field. An inbound kind (socks5) means the attachment's owner connects into flor here (flor accepts traffic and originates outgoing connections on its behalf — the attachment acts as an initiator). An outbound kind (tcp) means flor connects out to it here (flor delivers incoming connections — the attachment acts as a target, i.e. a service). An attachment can have both kinds ofiochannels if it is both initiator and target. FlorIO (florio) is bidirectional — reserved for recursive flor layers (not used in C0).
Concretely in C0: the SOCKS5 listener flor exposes for a service's outbound calls is an inbound channel of kind: socks5; the loopback 127.0.0.1:port upstream a service binds to is an outbound channel of kind: tcp. (These replace C0's earlier socks5_proxy / upstream_addr pair.)
Example: agent.json
{
"schema_version": "1.0",
"plane": "mgmt",
"version": 42,
"workload": "spiffe://flor.demo/workload/alice-laptop/agent",
"generated_at": "2026-04-20T12:00:00Z",
"payload": {
"schema_version": "1.0",
"workloads": [
{ "name": "hop", "run": ["flor-vertex"] }
],
"inbounds": [
{ "principal": "spiffe://flor.demo/node/alice-laptop", "via": { "kind": "socks5", "addr": "127.0.0.1:1081" } },
{ "principal": "spiffe://flor.demo/user/alice", "via": { "kind": "socks5", "addr": "127.0.0.1:1080" } }
],
"sync": {
"principal": "spiffe://flor.demo/node/alice-laptop",
"coordinator": "spiffe://flor.demo/service/coordinator",
"poll_interval": "5m"
}
},
"signature": { "cert": "<base64-der-x509>", "value": "<base64>" }
}C0's agent.json carries no trust block at all. There is no mgmt-signer set to carry — each envelope brings its own signer's certificate inside its signature, and the agent, as the sole verifier, validates that certificate against the node's trust anchors before checking the signature, so a node absent across a signer rotation accepts the successor on the anchor's word rather than rejecting it against a list it never received. The block returns in C1 holding exactly one field, authorized_ctrl_signers, a list of SPIFFE identities narrowing who may sign ctrl: an authority mgmt delegates, and therefore one that never gates its own delivery. See ADR-0015 and ID-C0-20 in Identity.
workloads is the supervision inventory — each entry's run is a plain argv naming the executable, with all per-workload context injected via the uniform spawn environment (see Agent § Supervision contract); the link/mesh engine distinction lives in the vertex payload, which the agent never reads. inbounds is the compiler-projected principal → local inbound map feeding local endpoint discovery on agent.sock (how rete finds the operator's SOCKS5); it re-projects the vertex payload's io listen preferences, duplication compiler-guaranteed consistent; each inbound is typed ({ kind, addr }) so a new inbound protocol is a new kind, not a schema change. sync names the agent's principal, the coordinator to dial, and the poll cadence (poll_interval — operator-authored, so it rides normal config delivery) — the dial-out resolves through the agent's own inbounds entry (the compiler guarantees the node principal exactly one inbound), so there is no separate sync.via to keep consistent. Together these keep the agent inside its layering rule: it parses envelopes and its own artifacts, never another workload's payload.
The agent locates each workload's config by name (see Node runtime layout) — <name>.json in the active mgmt set, plus the node's newest verified ctrl/<name>@v<K>.json slot from C1 — verifies envelope and signature on each, then spawns each entry's run argv with the uniform environment (FLORWL_MGMT_ARTIFACT pointing at the verified file). agent.json therefore lists only { name, run } per workload, never a config path.
Example: vertex payload (user node)
A user device initiates connections only — no listen_udp-served services. The agent's node/alice-laptop principal appears here as an attachment so the agent can dial coordinator:
{
"schema_version": "1.0",
"plane": "mgmt",
"version": 42,
"workload": "spiffe://flor.demo/workload/alice-laptop/hop",
"generated_at": "2026-04-20T12:00:00Z",
"payload": {
"schema_version": "1.0",
"kind": "link",
"transport_endpoint": { "type": "quic" },
"connection_manager": { "adapters": [ { "name": "wire", "type": "udp" } ] },
"attachments": [
{
"spiffe_id": "spiffe://flor.demo/node/alice-laptop",
"io": [
{ "kind": "socks5", "listen": "127.0.0.1:1081" }
]
},
{
"spiffe_id": "spiffe://flor.demo/user/alice",
"io": [
{ "kind": "socks5", "listen": "127.0.0.1:1080" }
]
}
],
"links": [
{
"type": "list",
"members": [
{ "name": "coordinator", "peer": "spiffe://flor.demo/service/coordinator", "via": { "type": "udp", "adapter": "wire", "addr": "9.10.11.12:4433" } },
{ "name": "api", "peer": "spiffe://flor.demo/service/api", "via": { "type": "udp", "adapter": "wire", "addr": "1.2.3.4:4433" } },
{ "name": "kafka", "peer": "spiffe://flor.demo/service/kafka", "via": { "type": "udp", "adapter": "wire", "addr": "5.6.7.8:4433" } }
]
}
],
"egress": [
{ "target": "spiffe://flor.demo/service/coordinator", "allow": ["spiffe://flor.demo/node/alice-laptop"] },
{ "target": "spiffe://flor.demo/service/api", "allow": ["spiffe://flor.demo/user/alice"] },
{ "target": "spiffe://flor.demo/service/kafka", "allow": ["spiffe://flor.demo/user/alice"] }
]
},
"signature": { "cert": "<base64-der-x509>", "value": "<base64>" }
}Example: vertex payload (server node)
A server node hosts services and accepts inbound QUIC on a UDP listen address (set via the adapter's listen). Services with a role also appear as initiators (api calls mongodb, producing an egress row). ssh binds 0.0.0.0:22 because of the emergency-access exception — the outbound tcp upstream just mirrors whatever services.yaml declared.
{
"schema_version": "1.0",
"plane": "mgmt",
"version": 42,
"workload": "spiffe://flor.demo/workload/alpha/hop",
"generated_at": "2026-04-20T12:00:00Z",
"payload": {
"schema_version": "1.0",
"kind": "link",
"transport_endpoint": { "type": "quic" },
"connection_manager": { "adapters": [ { "name": "wire", "type": "udp", "listen": "0.0.0.0:4433" } ] },
"attachments": [
{
"spiffe_id": "spiffe://flor.demo/node/alpha",
"io": [
{ "kind": "socks5", "listen": "127.0.0.1:1080" }
]
},
{
"spiffe_id": "spiffe://flor.demo/service/api",
"io": [
{ "kind": "tcp", "upstream": "127.0.0.1:8000" },
{ "kind": "socks5", "listen": "127.0.0.1:18000" }
]
},
{
"spiffe_id": "spiffe://flor.demo/service/alpha/ssh",
"io": [
{ "kind": "tcp", "upstream": "0.0.0.0:22" }
]
}
],
"ingress": [
{
"target": "spiffe://flor.demo/service/api",
"allow": [
"spiffe://flor.demo/user/alice",
"spiffe://flor.demo/user/bob"
]
},
{
"target": "spiffe://flor.demo/service/alpha/ssh",
"allow": ["spiffe://flor.demo/user/bob"]
}
],
"links": [
{
"type": "list",
"members": [
{ "name": "coordinator", "peer": "spiffe://flor.demo/service/coordinator", "via": { "type": "udp", "adapter": "wire", "addr": "9.10.11.12:4433" } },
{ "name": "mongodb", "peer": "spiffe://flor.demo/service/mongodb", "via": { "type": "udp", "adapter": "wire", "addr": "5.6.7.8:4433" } }
]
}
],
"egress": [
{ "target": "spiffe://flor.demo/service/coordinator", "allow": ["spiffe://flor.demo/node/alpha"] },
{ "target": "spiffe://flor.demo/service/mongodb", "allow": ["spiffe://flor.demo/service/api"] }
]
},
"signature": { "cert": "<base64-der-x509>", "value": "<base64>" }
}Key points
- One attachments list per vertex. An attachment is an initiator if it has at least one
inboundio channel, a target if it has at least oneoutboundio channel, both if both. The schema doesn't privilege users vs services — they're all attachments with different SPIFFE-ID kinds and io shapes. - The agent's
node/<node>principal is also an attachment. It lives in the vertex payload like any other principal, with a SOCKS5 inbound that the agent dials when it needs to talk to coordinator. No special-casing in the runtime. - No forwarding table, no labels — every tunnel in C0 is a direct QUIC connection between the initiator's vertex and the target service's vertex. The
linkstable is the (degenerate, 1-1) routing: each entry maps a target service to its wireaddr(audpvia). The compiler pre-resolves (a) which remote peers may initiate to this node's services (ingress), (b) which local principals may initiate to which remote target (egress), and (c) how to dial each target (links). - A link vertex has exactly one
udpadapter — by design, not a current-milestone limitation. A link vertex is one transport endpoint over one medium: a single QUIC endpoint over a single UDP socket. It never aggregates multiple sockets under one endpoint (that would hide parallelism the mesh layer is built to see and traffic-engineer — interface/link aggregation is a mesh-layer concern, expressed as parallel link-vertices; see C1'suses:), and it never terminates a FlorIO adapter (that is mesh-flor reaching down to link-flor, never a link vertex's own connection manager). The compiler emits exactly oneudpadapter for every link vertex; a compiled link artifact with zero, multiple, or non-udpadapters is rejected as malformed (enforced in flor's artifact validation, sorete compilecatches it before signing). - Egress is advisory, not security-enforcing. The authoritative check happens at the target's
ingress.egressexists so the initiator's vertex can fail fast on disallowed SOCKS5 requests; the UDP address it dials lives inlinks. A compromised initiator could ignore its own egress filter; it still cannot pass the target's ingress gate. Do not treat egress as an access-control boundary — it's a local convenience filter over thelinksdialing table. - Roles expand at compile time.
roles.yamlis YAML-only; the compiler resolves roles to explicit SPIFFE ID lists inallow. Role changes take effect on the nextrete compile+ push — no cert reissuance needed, no role-resolution at runtime on the vertex hot path. - Deterministic — the same repository compiles to identical output on any machine given identical inputs; the committed previous compiled tree counts as an input, since it is the version ledger.
- Per-node filtering — each node's directory contains only the ACL rows and local-endpoint facts relevant to its own local workloads (identity material travels separately, via enrollment).