Validate & Compile
Validator rules and compile step for producing per-node artifacts in C0
Validator Rules
retectl validate is the only safety net against hand-edit errors, so it must be strict.
retectl 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/, certs/, 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. When -f <file|glob>… is used, discovery is bypassed and the merged view is built from exactly those paths, but at least one selected file must contain the singleton rete block.
- Schema — every file matches its JSON Schema.
- Cross-references — every node/service/group/role name referenced exists and is unique across the whole rete.
- Principal registry — every principal name (users, services, nodes; vertices in C1+) is unique across all principal kinds; no name collision across YAML files. Cross-kind disjointness also keeps the
.reteDNS namespace unambiguous (see ADR-0006 for thedns::formatmapping): without it, e.g. a service and a vertex sharing a short name on the same node would both render to<name>.<node>.<rete>.rete. - Enrollment log consistency — every principal in
users.yaml,services.yaml, and every entry innodes.yamlhas a matching sign-event inenrollment.logthat hasn't been superseded by a revoke-event. - Service placement — every service's
atfield names a known node innodes.yaml. - Management-node integrity — services named
config-serverandconfig-publishermust exist in groupsconfig-readandconfig-writerespectively, 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),config-readandconfig-write(groups) 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 — these live in the repo as part of the initial rete template. - Operator presence — at least one user in
users.yamlmust haverole: operator. Without it, no one can callconfig-publisher, so the rete can't receive new state after the initial bootstrap — worth catching at validate time. 8a. Mgmt-plane signer presence —rete.yaml'ssigners.mgmt.keyslist must be non-empty and every referenced cert file must exist undercerts/management-planes/, parse as X.509, carry a SPIFFE URI SAN matchingspiffe://<rete>/management-plane/<key.name>, be signed by the rete CA, and have a corresponding sign-event inenrollment.log. Without at least one signer, no key exists to sign compiled artifacts; without the SAN check, a misfiled cert 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 workload service (other thanconfig-readservices) must have at least one link-vertex with an Internet-reachableaddress. 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:
at:names a known node;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
retectl compile produces two artifacts per node in C0 — agent.json (consumed by flor agent run) and vertices/flor.json (consumed by flor vertex run) — placed under <repo>/.flor/compiled/<node>/mgmt/. The per-node mgmt/ subdirectory is the only output tree in C0; C1+ adds a sibling ctrl/ (see C1's ctrl-plane doc). Both artifacts share a common envelope:
Paths in compiled artifacts are scope-relative. ca_cert_path, cert_path, and priv_path values are bare filenames (ca.crt, alpha.crt, …). The agent resolves them against the rete install root (retes/<scope>/) at startup. This keeps artifacts portable: the same compiled JSON is valid regardless of what the operator named the rete scope locally, and a node joining multiple retes can hold each rete's artifacts in separate roots without any path collisions.
The install root is flat: the bundle delivers ca.crt (and the per-principal cert+key files) directly into retes/<scope>/, regardless of how the repo organises them under certs/. The repo's certs/management-planes/<name>.crt shape is for human/audit organisation; the runtime sees only the flat install root, with mgmt-signer pubkeys delivered via agent.json's trust.authorized_mgmt_signers rather than as on-disk files.
Node runtime layout
Everything flor reads at a node resolves against a single rete root — retes/<scope>/ — so there is one base path, not several:
retes/<scope>/
├── ca.crt # plus per-principal <name>.crt / <name>.key (flat)
├── alpha.crt
├── alpha.key
├── mgmt/
│ ├── agent.json
│ └── vertices/
│ └── <name>.json # one per vertex (e.g. flor.json)
└── ctrl/ # added in C1; absent in C0
└── vertices/
└── <name>.json # only vertices with ctrl state (mesh)Identity files (ca_cert_path, cert_path, priv_path) are bare filenames at the root. Per-vertex configs are not referenced by path — the agent locates them by the vertex name from agent.json, at the fixed convention mgmt/vertices/<name>.json (signed) and, from C1, ctrl/vertices/<name>.json (CP-signed). So agent.json lists only { name, kind } per vertex: one root, one convention, nothing resolved against a second base.
{
"schema_version": "1.0",
"plane": "mgmt",
"kind": "agent",
"version": 42,
"node": "alpha",
"name": "agent",
"generated_at": "2026-04-20T12:00:00Z",
"payload": { ... },
"signature": {
"alg": "ed25519",
"key_id": "spiffe://rete-lovers/management-plane/primary",
"value": "<base64>"
}
}Field semantics:
schema_version— semver of the compiled-artifact public contract.plane—"mgmt"for everything in C0/C1. Reserved:"ctrl"for the unsigned control-plane decision stream that lands in B1+ (carried in a separate envelope, not inside this one). Discriminating now keeps the envelope schema stable across the split.kind—"agent"foragent.jsonand"vertex"for vertex configs.name— the artifact's name within its(node, kind)space, so identity is(node, plane, kind, name), path-independent: the vertex name for a vertex artifact,"agent"for the single agent. Vertex artifacts additionally carry a payloadkindof"link"or"mesh"that selects the engine. (C0 has a singlelinkvertex per node; C1 addsmesh.)version— monotonic per-compilation number. Lets future delta-based distribution ask "what version do you have?" without re-sending the full tree, and lets agents reject older-than-current artifacts (rollback-attack defense).- Trust roots — the CA path and the authorized mgmt-signer set are not repeated in every envelope; they live in a
trustblock inagent.json's payload, where the agent — the sole verifier — reads them. The agent accepts an artifact only if itssignature.key_idis in that set and the signature verifies. The bootstrap snapshot ships in the enrollment bundle; rotation follows the vouched-successor rule (add a new signer with an artifact signed by a current one, switch to it, then retire the old). See ADR-0010 for placement and ADR-0011 for the full bootstrap and rotation semantics. signature— operator signature over the canonical-form envelope minus thesignaturefield itself.key_idis the SPIFFE URI of the management-plane signing principal (e.g.spiffe://<rete>/management-plane/primary) — a sign-only identity, issued independently from any user entry viaretectl ca sign --kind management-plane. See ADR-0005 for the kind separation. The signing key never leaves operator hardware.
In B1+, a parallel plane: "ctrl" envelope (unsigned) will carry CP decisions and reference the mgmt version it obeys; agents will refuse a ctrl artifact whose claimed mgmt version is older than what the agent already holds. C0/C1 do not produce ctrl artifacts.
Terminology
The payload uses these terms consistently, all from flor's point of view at the network boundary. A workload is a TLS principal wired into the vertex (it holds an SVID and speaks mTLS); initiator/target below are the roles a workload plays, not what makes it a principal — both are principals.
- initiator / target — the two roles a workload plays in a flow. An initiator originates a connection (flor dials on its behalf); a target serves one (flor delivers to it). A workload can be both (e.g. a service that calls another service and is itself called). Determined by the workload'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 a workload is wired into flor locally, determined by the
iochannel'skind, not a separate field. An inbound kind (socks5) means the workload connects into flor here (flor accepts traffic and originates outgoing connections on its behalf — the workload acts as an initiator). An outbound kind (tcp) means flor connects out to the workload here (flor delivers incoming connections — the workload acts as a target, i.e. a service). A workload 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",
"kind": "agent",
"version": 42,
"node": "alice-laptop",
"name": "agent",
"generated_at": "2026-04-20T12:00:00Z",
"payload": {
"trust": {
"ca_cert_path": "ca.crt",
"authorized_mgmt_signers": [
{ "spiffe_id": "spiffe://rete-lovers/management-plane/primary", "pubkey": "<base64-ed25519-pubkey>" }
]
},
"vertices": [
{ "name": "flor", "kind": "link" }
],
"control_plane": {
"principal": "spiffe://rete-lovers/node/alice-laptop",
"config_server": "spiffe://rete-lovers/service/config-server",
"via": { "kind": "socks5", "addr": "127.0.0.1:1081" }
}
},
"signature": { "alg": "ed25519", "key_id": "spiffe://rete-lovers/management-plane/primary", "value": "<base64>" }
}The trust block holds the rete's CA and authorized mgmt-signer set (C0 has no ctrl signers yet); the agent is the sole verifier and checks every artifact against it. control_plane.via is the local flor inbound the agent dials — acting as principal — to reach config_server (the same "conduit to the target" sense as a link's via); it is typed ({ kind, addr }) so a new inbound protocol is a new kind, not a schema change. Carrying it here keeps flor agent independent of vertex payloads: the agent verifies a vertex artifact's signature and hands the file to flor vertex run, but never parses its contents.
The agent locates each vertex's configs by its name (see Node runtime layout) — mgmt/vertices/<name>.json always, plus ctrl/vertices/<name>.json for vertices that carry ctrl state — verifies both, then supervises with flor vertex run. agent.json therefore lists only { name, kind } per vertex, never a 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 a workload so the agent can dial config-server:
{
"schema_version": "1.0",
"plane": "mgmt",
"kind": "vertex",
"version": 42,
"node": "alice-laptop",
"name": "flor",
"generated_at": "2026-04-20T12:00:00Z",
"payload": {
"kind": "link",
"ca_cert_path": "ca.crt",
"transport_endpoint": { "type": "quic" },
"connection_manager": { "adapters": [ { "name": "wire", "type": "udp" } ] },
"workloads": [
{
"spiffe_id": "spiffe://rete-lovers/node/alice-laptop",
"identity": { "cert_path": "alice-laptop.crt", "priv_path": "alice-laptop.key" },
"io": [
{ "kind": "socks5", "listen": "127.0.0.1:1081" }
]
},
{
"spiffe_id": "spiffe://rete-lovers/user/alice",
"identity": { "cert_path": "alice.crt", "priv_path": "alice.key" },
"io": [
{ "kind": "socks5", "listen": "127.0.0.1:1080" }
]
}
],
"links": [
{
"type": "enum",
"members": [
{ "name": "config-server", "peer": "spiffe://rete-lovers/service/config-server", "via": { "type": "udp", "adapter": "wire", "addr": "9.10.11.12:4433" } },
{ "name": "api", "peer": "spiffe://rete-lovers/service/api", "via": { "type": "udp", "adapter": "wire", "addr": "1.2.3.4:4433" } },
{ "name": "kafka", "peer": "spiffe://rete-lovers/service/kafka", "via": { "type": "udp", "adapter": "wire", "addr": "5.6.7.8:4433" } }
]
}
],
"egress": [
{ "target": "spiffe://rete-lovers/service/config-server", "allow": ["spiffe://rete-lovers/node/alice-laptop"] },
{ "target": "spiffe://rete-lovers/service/api", "allow": ["spiffe://rete-lovers/user/alice"] },
{ "target": "spiffe://rete-lovers/service/kafka", "allow": ["spiffe://rete-lovers/user/alice"] }
]
},
"signature": { "alg": "ed25519", "key_id": "spiffe://rete-lovers/management-plane/primary", "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",
"kind": "vertex",
"version": 42,
"node": "alpha",
"name": "flor",
"generated_at": "2026-04-20T12:00:00Z",
"payload": {
"kind": "link",
"ca_cert_path": "ca.crt",
"transport_endpoint": { "type": "quic" },
"connection_manager": { "adapters": [ { "name": "wire", "type": "udp", "listen": "0.0.0.0:4433" } ] },
"workloads": [
{
"spiffe_id": "spiffe://rete-lovers/node/alpha",
"identity": { "cert_path": "alpha.crt", "priv_path": "alpha.key" },
"io": [
{ "kind": "socks5", "listen": "127.0.0.1:1080" }
]
},
{
"spiffe_id": "spiffe://rete-lovers/service/api",
"identity": { "cert_path": "api.crt", "priv_path": "api.key" },
"io": [
{ "kind": "tcp", "upstream": "127.0.0.1:8000" },
{ "kind": "socks5", "listen": "127.0.0.1:18000" }
]
},
{
"spiffe_id": "spiffe://rete-lovers/service/alpha/ssh",
"identity": { "cert_path": "ssh.crt", "priv_path": "ssh.key" },
"io": [
{ "kind": "tcp", "upstream": "0.0.0.0:22" }
]
}
],
"ingress": [
{
"target": "spiffe://rete-lovers/service/api",
"allow": [
"spiffe://rete-lovers/user/alice",
"spiffe://rete-lovers/user/bob"
]
},
{
"target": "spiffe://rete-lovers/service/alpha/ssh",
"allow": ["spiffe://rete-lovers/user/bob"]
}
],
"links": [
{
"type": "enum",
"members": [
{ "name": "config-server", "peer": "spiffe://rete-lovers/service/config-server", "via": { "type": "udp", "adapter": "wire", "addr": "9.10.11.12:4433" } },
{ "name": "mongodb", "peer": "spiffe://rete-lovers/service/mongodb", "via": { "type": "udp", "adapter": "wire", "addr": "5.6.7.8:4433" } }
]
}
],
"egress": [
{ "target": "spiffe://rete-lovers/service/config-server", "allow": ["spiffe://rete-lovers/node/alpha"] },
{ "target": "spiffe://rete-lovers/service/mongodb", "allow": ["spiffe://rete-lovers/service/api"] }
]
},
"signature": { "alg": "ed25519", "key_id": "spiffe://rete-lovers/management-plane/primary", "value": "<base64>" }
}Key points
- One workloads list per vertex. A workload is a principal 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 just workloads with different SPIFFE-ID kinds and io shapes. - The agent's
node/<node>principal is also a workload. It lives in the vertex payload like any other principal, with a SOCKS5 inbound that the agent dials when it needs to talk to config-server. 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, soretectl 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 nextretectl compile+ push — no cert reissuance needed, no role-resolution at runtime on the vertex hot path. - Deterministic — same
retectl compile --repo Xproduces identical output on any machine given identical inputs. - Per-node filtering — each node's directory contains only the identity material and ACL rows relevant to its own local workloads.