Florete

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.

  1. Schema — every file matches its JSON Schema.
  2. Cross-references — every node/service/group/role name referenced exists and is unique across the whole rete.
  3. 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 .rete DNS namespace unambiguous (see ADR-0006 for the dns::format mapping): 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. Names must also be filesystem-safe, because principal leafs, vertex names, and workload names become file and socket names on nodes: the SPIFFE charset is enforced, uniqueness is case-insensitive (macOS/Windows filesystems), OS-reserved basenames (CON, NUL, COM1, …) are rejected, and agent is reserved for the supervisor (manifest is reserved as well, held for potential set metadata). The filesystem-safety constraints are a documented C0 shortcut — a file-backed-storage detail surfacing in YAML; a future store shim with reversible name encoding lifts them (see Agent § Artifact handling).
  4. Service placement — every service's at field names a known node in nodes.yaml.
  5. Management-node integrity — services named coordinator and coordinator-publisher must exist in groups coordinator-sync and coordinator-publish respectively, both hosted on the same node (the management node) with a publicly-reachable link-vertex (a kind: link, type: quic entry with an address). If missing or malformed, validator fails with a clear message rather than silently producing artifacts that can't sync.
  6. Reserved-name protectionnode and operator (roles), coordinator-sync and coordinator-publish (groups) must be present in the YAML with their canonical allow / 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.
  7. Operator presence — at least one user in users.yaml must have role: operator. Without it, no one can call coordinator-publisher, so the rete can't receive new state after the initial bootstrap — worth catching at validate time. 7a. Mgmt-plane signer presencerete.yaml's signers.mgmt.keys list must be non-empty and every referenced cert file must exist under certs/management-planes/, parse as X.509, carry a SPIFFE URI SAN matching spiffe://<rete>/management-plane/<key.name>, and be signed by the rete CA. Without at least one signer, no key exists to sign compiled artifacts; without the SAN check, a misfiled cert could pass undetected.
  8. Vertex graph & reachability feasibility — every node declares at least one link-vertex (kind: link); in C0, exactly one of type: quic. Every node hosting a workload service (other than coordinator-sync services) must have at least one link-vertex with an Internet-reachable address. Initiator-only nodes (user laptops) may omit address on their link-vertex; referencing them as a service host is an error.
  9. 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. The via: / 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 "specify via: to disambiguate among <list of vertex names>." (In C0 every node has exactly one vertex, so these fields are always optional.)
  10. Access consistency — every principal's role permits only groups defined in groups.yaml; every service's group is defined; every service with an egress role has that role defined.
  11. Principal role coherence — services that act as clients (outbound) must have a role declared; services without role may only appear as targets.
  12. Source merge integrity — a duplicate (kind, name) across the merged source files (two services: entries named api, two nodes: named alpha, …) is a hard error, never last-wins; the rete singleton must appear exactly once; an unrecognized top-level collection key (e.g. a typo'd servies:) 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 ordinary node in C0 — agent.json (consumed by flor agent run) and flor.json (the vertex config, consumed by flor-vertex) — placed flat under <repo>/.flor/compiled/<node>/mgmt/; 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 per-node mgmt/ subdirectory 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, retectl byte-compares every projected payload (and the set's membership) against the previous tree — bytes only, no payload parsing. An untouched node's files are re-emitted byte-identical, not re-signed, its version unmoved; a touched node gets version = previous + 1 stamped into every artifact of its set, all re-signed. 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 per-scope identity store (identity/ca.crt under the scope root, identity/certs/<leaf>.crt / .key per principal — see Agent § Artifact handling); 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. The repo's certs/management-planes/<name>.crt shape is for human/audit organisation; mgmt-signer pubkeys reach nodes inside agent.json's trust.authorized_mgmt_signers, never as on-disk files.

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 the active mgmt set, configs sit flat: <name>.json per workload — names are cross-kind unique and filesystem-safe, 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.json lists 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. A workload entry names its principal by SPIFFE ID; the consumer loads identity/certs/<leaf>.crt / .key by convention and verifies the certified ID matches the declared one. The rete's trust domain is read from the store's identity/rete.json (beside identity/ca.crt, together the trust anchor) — never inferred from config contents.

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, node, name, 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,
  "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_versionmajor.minor of 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 inside payload with its own schema_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 (with plane: { "ctrl": { … } }), never embedded inside a mgmt artifact. Discriminating now keeps the envelope schema stable across the split.
  • name — the consuming workload's name, so identity is (node, plane, name), path-independent; agent is the reserved name for the supervisor's own config. There is no envelope kind: dispatch is by name — 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 carries kind: "link" | "mesh" to select the engine (C0 has a single link vertex per node; C1 adds mesh); 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 as previous + 1 from 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 — the authorized mgmt-signer set is not repeated in every envelope; it lives in a trust block in agent.json's payload, where the agent — the sole signature verifier — reads it. The CA cert is not referenced by any artifact at all: it sits at its conventional place in the identity store. The agent accepts an artifact only if its signature.key_id is 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 the signature field itself. key_id is 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 via retectl ca sign --kind management-plane. See ADR-0005 for the kind separation. The signing key never leaves operator hardware.

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 io channel kinds — see inbound/outbound.
  • ingress / egress — peer-to-local and local-to-peer QUIC sessions respectively. ingress lists which remote principals may initiate to a local target; egress lists which local principals may initiate to a given remote target. How to dial each target lives in the vertex's links table, not in egress.
  • inbound / outbound — the two ways a workload is wired into flor locally, determined by the io channel's kind, 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 of io channels 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,
  "node": "alice-laptop",
  "name": "agent",
  "generated_at": "2026-04-20T12:00:00Z",
  "payload": {
    "schema_version": "1.0",
    "trust": {
      "authorized_mgmt_signers": [
        { "spiffe_id": "spiffe://rete-lovers/management-plane/primary", "pubkey": "<base64-ed25519-pubkey>" }
      ]
    },
    "workloads": [
      { "name": "flor", "run": ["flor-vertex"] }
    ],
    "inbounds": [
      { "principal": "spiffe://rete-lovers/node/alice-laptop", "via": { "kind": "socks5", "addr": "127.0.0.1:1081" } },
      { "principal": "spiffe://rete-lovers/user/alice",        "via": { "kind": "socks5", "addr": "127.0.0.1:1080" } }
    ],
    "sync": {
      "principal":     "spiffe://rete-lovers/node/alice-laptop",
      "coordinator":   "spiffe://rete-lovers/service/coordinator",
      "poll_interval": "5m"
    }
  },
  "signature": { "alg": "ed25519", "key_id": "spiffe://rete-lovers/management-plane/primary", "value": "<base64>" }
}

The trust block holds the authorized mgmt-signer set (C0 has no ctrl signers yet); the agent is the sole signature verifier and checks every artifact against it. 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 retectl 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 a workload so the agent can dial coordinator:

{
  "schema_version": "1.0",
  "plane": "mgmt",
  "version": 42,
  "node": "alice-laptop",
  "name": "flor",
  "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" } ] },
    "workloads": [
      {
        "spiffe_id": "spiffe://rete-lovers/node/alice-laptop",
        "io": [
          { "kind": "socks5", "listen": "127.0.0.1:1081" }
        ]
      },
      {
        "spiffe_id": "spiffe://rete-lovers/user/alice",
        "io": [
          { "kind": "socks5", "listen": "127.0.0.1:1080" }
        ]
      }
    ],
    "links": [
      {
        "type": "list",
        "members": [
          { "name": "coordinator", "peer": "spiffe://rete-lovers/service/coordinator", "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/coordinator", "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",
  "version": 42,
  "node": "alpha",
  "name": "flor",
  "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" } ] },
    "workloads": [
      {
        "spiffe_id": "spiffe://rete-lovers/node/alpha",
        "io": [
          { "kind": "socks5", "listen": "127.0.0.1:1080" }
        ]
      },
      {
        "spiffe_id": "spiffe://rete-lovers/service/api",
        "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",
        "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": "list",
        "members": [
          { "name": "coordinator", "peer": "spiffe://rete-lovers/service/coordinator", "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/coordinator", "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 inbound io channel, a target if it has at least one outbound io 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 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 links table is the (degenerate, 1-1) routing: each entry maps a target service to its wire addr (a udp via). 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 udp adapter — 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's uses:), 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 one udp adapter for every link vertex; a compiled link artifact with zero, multiple, or non-udp adapters is rejected as malformed (enforced in flor's artifact validation, so retectl compile catches it before signing).
  • Egress is advisory, not security-enforcing. The authoritative check happens at the target's ingress. egress exists so the initiator's vertex can fail fast on disallowed SOCKS5 requests; the UDP address it dials lives in links. 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 the links dialing table.
  • Roles expand at compile time. roles.yaml is YAML-only; the compiler resolves roles to explicit SPIFFE ID lists in allow. Role changes take effect on the next retectl compile + push — no cert reissuance needed, no role-resolution at runtime on the vertex hot path.
  • Deterministic — same retectl compile --repo X produces 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).

On this page