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.
  4. Enrollment log consistency — every principal in users.yaml, services.yaml, and every entry in nodes.yaml has a matching sign-event in enrollment.log that hasn't been superseded by a revoke-event.
  5. Service placement — every service's at field names a known node in nodes.yaml.
  6. Management-node integrity — services named config-server and config-publisher must exist in groups config-read and config-write 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.
  7. Reserved-name protectionnode and operator (roles), config-read and config-write (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.
  8. Operator presence — at least one user in users.yaml must have role: operator. Without it, no one can call config-publisher, so the rete can't receive new state after the initial bootstrap — worth catching at validate time. 8a. 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>, be signed by the rete CA, and have a corresponding sign-event in enrollment.log. Without at least one signer, no key exists to sign compiled artifacts; without the SAN check, a misfiled cert could pass undetected.
  9. 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 config-read 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.
  10. 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.)
  11. 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.
  12. Principal role coherence — services that act as clients (outbound) must have a role declared; services without role may only appear as targets.
  13. 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 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 rootretes/<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" for agent.json and "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 payload kind of "link" or "mesh" that selects the engine. (C0 has a single link vertex per node; C1 adds mesh.)
  • versionmonotonic 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 trust block in agent.json's payload, where the agent — the sole verifier — reads them. 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.

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 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",
  "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 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 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 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.
  • Per-node filtering — each node's directory contains only the identity material and ACL rows relevant to its own local workloads.

On this page