Florete

Source Layout

Git repo structure and YAML source files for C0

Git Repo Layout

my-rete/
├── rete.yaml             # name, per-category crypto policy, signer-cert references
├── certs/                # public certs that live in the repo
│   ├── ca.crt            # rete root CA cert (singleton)
│   └── management-planes/
│       └── primary.crt   # mgmt-envelope signer cert (one or more during rotation)
├── nodes.yaml            # nodes: UDP reachability
├── services.yaml         # published services + their principal roles (incl. infra)
├── groups.yaml           # service groups
├── roles.yaml            # role → allowed groups (applies to any principal)
├── users.yaml            # user identity names, home-node, role
├── enrollment.log        # append-only operator sign events (auditable)
└── .flor/
    └── compiled/                # committed; operator runs `retectl compile` then commits
        ├── mgmt01/              # management node — bootstrap-applied manually (see Config-server)
        │   └── mgmt/
        │       ├── agent.json
        │       └── vertices/flor.json
        ├── alpha/
        │   └── mgmt/
        │       ├── agent.json
        │       └── vertices/flor.json
        ├── beta/
        │   └── mgmt/
        │       ├── agent.json
        │       └── vertices/flor.json
        └── ...

What's not in the repo:

  • CA private key — lives on the operator's workstation (password-protected file).
  • Mgmt-signer private keys (certs/management-planes/primary.key, etc.) — also operator-only, never committed. Only the .crt siblings live in the repo.
  • TLS principal certs (alice.crt, api.crt, ...) — delivered to their holders via enrollment bundles; stored locally next to each holder's private key. They're public material, but there's no reason to duplicate them in the repo.
  • TLS principal private keys — generated on the holder's machine (security-purist flow) or on the operator's machine for bundle issuance (convenience flow). Never committed.

The repo holds names + ACLs + topology + CA cert + signer pubkeys. That's what's needed to compile per-node artifacts and to audit who has access (and who is allowed to sign).

The *.yaml filenames above are a recommended default (scaffolded by retectl init), not a fixed schema. The facade is a whole-view document — the operator authors the entire mgmt view and the compiler reads it as a whole — so file boundaries are cosmetic. Operators may keep everything in one file, or split by team/domain (services/payments.yaml, services/web.yaml), or group however suits the rete. See ADR-0009 for why the facade is compose-style rather than a collection of Kubernetes-style objects.

Each top-level collection is, at the data level, exactly kind/name/spec:

services:                 #   kind: Service
  api:                    #   name: api
    at: alpha             #   spec: { at: alpha, … }

so a single resource can live in its own file if desired — the equivalence is complete — without adopting any object envelope.

How the compiler reads source:

  • Discovery. retectl compile/validate require rete.yaml at the repo root (the fixed anchor — it names the rete and holds CA/signer policy); if it is missing or nested elsewhere, discovery cannot start and a command fails. From that root, they recursively glob *.yaml/*.yml under the repo, skipping .flor/, certs/, and dotfiles. rete.yaml may carry a top-level source: block with include/exclude globs to override discovery precisely (useful in a shared repo). retectl validate -f <file|glob>… and retectl compile -f <file|glob>… override discovery entirely for partial/CI runs; in that mode, at least one selected file must still contain the singleton rete block.
  • Merge = union. Collections of the same kind across files are unioned into one logical view before validation, so cross-references (a role naming a group, a service naming a node) resolve regardless of file layout.
  • Duplicates are errors. A duplicate (kind, name) across files is a hard validation error, never last-wins — silent override in a security source-of-truth is unacceptable, and erroring also makes the compile order-independent and deterministic.
  • Singletons are exactly-one. The rete block must appear exactly once (error on zero or two).
  • Unknown top-level keys error, not silently skip — so a typo'd servies: is caught rather than dropped.

Filesystem naming rule

Two parallel namespaces, two conventions:

NamespaceFormExamples
SPIFFE URI, CLI --kind, Rust Kind enumsingular type markerspiffe://<td>/management-plane/primary, --kind management-plane, Kind::ManagementPlane
Filesystem directories holding collections of named instancesplural collection namecerts/management-planes/primary.crt, <node>/mgmt/vertices/flor.json, ~/.flor/retes/<scope>/

The cross-mapping at compile time is a trivial s/-?s$//. Singletons (ca.crt, agent.json) sit at the root of their containing directory — no collection wrapper needed when there's exactly one and there will only ever be one.

Each node's compiled directory holds an agent.json (read by flor agent run) and a vertices/flor.json (read by the single flor vertex run instance the agent supervises in C0). C1 adds a second vertex artifact under vertices/; the agent.json shape is stable across both.

The per-node mgmt/ subdirectory looks redundant in C0 (no sibling), but it keeps retectl compile's output shape uniform with what flor agents see at runtime, and matches C1+ where a sibling ctrl/ subtree appears alongside (gitignored — ctrl artifacts have a different lifecycle, see C1's ctrl-plane doc for details). The path overhead is one segment; the consistency win is that no later milestone reshapes the layout.

rete.yaml

source:                                                  # optional; which files form the rete
  # default when omitted: recursively glob *.yaml/*.yml under the repo,
  # skipping .flor/, certs/, and dotfiles
  include: ["**/*.yaml"]                                 # repo-relative globs; replaces the default set
  exclude: ["examples/**", "vendor/**"]                  # repo-relative globs; subtracted after include

rete:
  name: rete-lovers                                      # also the SPIFFE trust domain

  ca:
    cert: certs/ca.crt
    validity_days: 3650                                  # optional; compiler default: 3650

  signers:
    mgmt:
      validity_days: 365                                 # optional; default: 365
      keys:
        - { name: primary, cert: certs/management-planes/primary.crt }
    # ctrl (introduced in C1):
    #   keys:
    #     - { name: primary, cert: certs/control-planes/primary.crt }

  tls_principals:
    validity_days: 90                                    # optional; default: 90
    # TLS principal certs (user/service/node/vertex) aren't listed here —
    # they aren't committed; this block only sets policy.

The rete name doubles as the SPIFFE trust domain: every principal in this rete has a SPIFFE ID of the form spiffe://rete-lovers/<kind>/<name> (see Identity & Naming).

Source discovery (source.include / source.exclude). Optional top-level block (not nested under rete), shapes which files the compiler treats as rete source. Omitted, the compiler uses its default: recursively glob *.yaml/*.yml under the repo, skipping .flor/, certs/, and dotfiles (see Source files are a recommended split above). include replaces that default glob set with an explicit, repo-relative list; exclude is applied after include to subtract paths — a vendor//examples/ subtree, or YAML that isn't rete source. rete.yaml must live at the repo root and is always read regardless of these globs (it is the anchor); without it, discovery cannot start and retectl validate/compile fail. Reach for this when the rete repo shares space with unrelated YAML, or when you want discovery to be explicit rather than implicit. retectl validate -f <file|glob>… and retectl compile -f <file|glob>… bypass source entirely and use exactly the given paths; at least one selected file must contain the singleton rete block.

Per-category lifetimes. The CA, the envelope signers, and the TLS principals have different lifecycles — the CA is long-lived (rete lifetime); mgmt signers rotate on operator policy (annual-ish); TLS principal certs rotate frequently (quarterly). Each category gets its own validity_days slot; the field is optional and falls back to the compiler default if omitted. Smaller retes can leave the policy fields out entirely and just declare cert paths.

Algorithm is not configurable in C0. All keys are Ed25519. A per-category algorithm slot will appear when a second algorithm is actually supported (B1+, e.g. for hardware tokens or post-quantum schemes); until then, exposing the field would be cosmetic.

Signer cert references. signers.mgmt.keys[].name is the SPIFFE name segment (the cert's URI is spiffe://<rete>/management-plane/<name>); signers.mgmt.keys[].cert is the repo-relative path to the public cert. The compiler reads these certs and embeds their pubkeys into every mgmt artifact's authorized_mgmt_signers field, so agents always know the currently-accepted signer set — rotation is a YAML list edit, not a re-enrollment event. C1 adds the symmetric signers.ctrl.keys[] block.

nodes.yaml

Every node declares its vertex graph — the flor vertices that run on that node and how they reference each other via uses:. C0 nodes have a single layer: one link-vertex (kind: link) per node, of type quic, terminating QUIC directly at the workload boundary. Principals' cert+keys live on this vertex; mTLS terminates here. C1 adds a mesh-vertex (kind: mesh, playing the rete role) on top of the link layer, at which point principals migrate up and link-vertices carry only hop-by-hop transit — but that's a C1 concern.

Two vertex kinds exist in the design (only link is used in C0):

  • link — terminates a transport on external medium (IP — QUIC or direct UDP today; in-kernel wg, third-party vless, … later). One per underlay surface. No hop-by-hop forwarding.
  • mesh — operates entirely inside Florete (its medium is other vertices). Does hop-by-hop label-forwarding. The mesh-vertex (C1) and the future interrete-vertex are both kind: mesh — what differs is the role configuration each is given, not the kind. Same code, same recursion property.
nodes:
  # management node — hosts config-server and metrics
  mgmt01:
    vertices:
      - { name: public, kind: link, type: quic, address: 9.10.11.12:4433 }

  # server nodes — Internet-facing, host services
  alpha:
    vertices:
      - { name: public, kind: link, type: quic, address: 1.2.3.4:4433 }
  beta:
    vertices:
      - { name: public, kind: link, type: quic, address: 5.6.7.8:4433 }

  # user nodes — dynamic IPs, behind NAT, initiate connections only
  alice-laptop:
    vertices:
      - { name: quic, kind: link, type: quic }   # no address — initiator-only
  bob-workstation:
    vertices:
      - { name: quic, kind: link, type: quic }

Each vertex entry has:

  • name — local handle for cross-references (other vertices' uses: in C1, links.yaml via: in C1, workload via: fields). Defaults to type when there's exactly one vertex of that type on the node. For mesh-vertices in C1 the name carries semantic meaning — rete is the conventional name for the rete-wide mesh-vertex; interrete by convention for the B1+ interrete-vertex.
  • kindlink or mesh (see above). C0 uses only link.
  • type — the interface this vertex exposes upwards (quic in C0; udp, in-kernel wg, third-party vless, … become available in C1+). The mesh-vertex can consume any supported link interface (via Connection Manager adapter), currently it exposes only quic interface (via FlorIO). The recursion property: an upper-layer mesh-vertex of type: quic consumes a lower mesh-vertex of type: quic indistinguishably from a wire-terminating link-vertex of type: quic.
  • address — listen host:port for link vertices that bind a socket. Omit for initiator-only nodes (user laptops, phones); they connect outbound from whatever ephemeral port they get.

In C0 every node that hosts a publicly-reachable service must be Internet-facing (see scope) — service/user endpoints establish QUIC directly to the service's host node, with no relaying.

Node-naming convention for pilots: short DNS-like names for servers (alpha, web01), and <user>-<device> for user devices (alice-laptop, bob-phone). Names must be unique within a rete.

services.yaml

services:
  # Rete infrastructure — on the management node (reserved; validator enforces)
  config-server:                      # read side — nodes poll for compiled artifacts
    at: mgmt01
    addr: 127.0.0.1:9000
    groups: [config-read]
  config-publisher:                   # write side — operators push new state
    at: mgmt01
    addr: 127.0.0.1:9001              # same process as config-server, different endpoint
    groups: [config-write]
  metrics:
    at: mgmt01
    addr: 127.0.0.1:9090
    groups: [config-read]

  # Workload services
  api:
    at: alpha
    # via: public                     # omittable in C0: mgmt01 has one vertex
    addr: 127.0.0.1:8000             # flor forwards here; service binds localhost
    socks5_proxy: 127.0.0.1:18000    # SOCKS5 port flor exposes to this service
    groups: [api]                        # what group api belongs to (ingress ACL side)
    roles: [api-backend]                 # api's role when acting as a client (egress ACL side)
  mongodb:
    at: beta
    addr: 127.0.0.1:27017
    groups: [db]
    # no roles — mongodb doesn't initiate Florete connections
  kafka:
    at: beta
    addr: 127.0.0.1:9092
    groups: [brokers]
  ssh:
    at: alpha                         # node-scoped service (published per-node)
    scope: node                       # vs. default `scope: rete`
    addr: 0.0.0.0:22                  # also reachable directly for emergency admin access
    groups: [admin]

Two conventions in this example:

  • addr binds 127.0.0.1 for all services reached exclusively through Florete. This is the whole point of publishing them via flor — the service itself must not be Internet-facing. SSH is the standard exception: it binds 0.0.0.0 so that the emergency access path (see Safety net) survives a bad Florete rollout.
  • socks5_proxy is intentionally direction-neutral. From the service's perspective it's an outbound proxy; from flor's perspective it's an inbound interface. Neutral naming sidesteps the PoV flip that would otherwise confuse operators reading this file.

There is no protocol field in C0/C1: flor is a pure L4 TCP forwarder, so whether the payload is gRPC, HTTP, or plain TCP is opaque to it. L7-aware features are a deliberate future addition; until then, operators can annotate with YAML comments if they want to remember what's running.

A service is simultaneously a target (its groups govern who may reach it) and, optionally, a principal (its roles govern what it may call). Services without roles only accept connections; they never initiate.

groups.yaml, roles.yaml

# groups.yaml — applies to any target (service)
groups:
  # reserved (must be present; validator enforces):
  config-read:  # read side — nodes poll for artifacts
  config-write: # write side — operators push new state

  # user-defined:
  api:
  db:
  brokers:
  admin:
# roles.yaml — applies to any principal (user OR service)
roles:
  # reserved (must be present, must have exactly these `allow` sets):
  node:     { allow: [config-read]  }   # auto-assigned by compiler to every node
  operator: { allow: [config-write] }   # manually assigned via `role: operator` in users.yaml

  # user-defined:
  devops:      { allow: [api, db, brokers, admin] }
  developer:   { allow: [api, brokers] }
  sales:       { allow: [api] }
  api-backend: { allow: [db, brokers] }    # api service calls db + brokers

users.yaml

users:
  fyodor:
    roles: [operator]
    nodes:
      - { at: fyodor-laptop }          # via: omittable in C0 — one vertex per node
  alice:
    roles: [developer]
    nodes:
      - { at: alice-laptop }           # nodes where alice's flor runs
  bob:
    roles: [devops]
    nodes:
      - { at: bob-workstation }

nodes is the list of user devices where this user's flor agent runs — the compiler embeds alice's identity material into each listed node's compiled artifact, so any of her devices can initiate Florete connections as alice. Each entry is {at: <node>, via: <vertex>} naming the host node and the vertex that holds the user's identity. In C0 every node has exactly one vertex, so via: may be omitted and the compiler picks that vertex automatically. In C1 via: is required (multiple vertices per node).

The operator is enrolled as a normal user. The user/fyodor TLS principal is issued through the same retectl issue-bundle --node fyodor-laptop flow as any other user. What makes fyodor an operator is the role: operator grant — that's the role that allows them to call config-publisher. The CLI / CA does nothing special for operators at the TLS-principal level.

The mgmt-envelope signing principal is a separate identity, issued out-of-band on operator hardware via retectl ca sign --kind management-plane --name primary. Its name (primary by convention, mirroring control-plane/primary in C1) is independent of any user — the keypair belongs to the rete's signing authority, not to a specific person. The cert is sign-only (keyUsage: digitalSignature, no extKeyUsage) so it cannot terminate TLS. Only the public cert is shipped to nodes, alongside ca.crt at enrollment. Issuing or rotating this key is an explicit operator step, never derived from users.yaml.

Multi-device users: list all the user's devices. The same key material is installed on each device (one bundle issued and applied to each, or one bundle copied). Revoking alice revokes all her devices at once.

If per-device revocation matters (lost phone without wanting to re-issue laptop), model each device as its own user with a shared role:

users:
  alice-laptop:
    roles: [developer]
    nodes:
      - { at: alice-laptop }
  alice-phone:
    roles: [developer]
    nodes:
      - { at: alice-phone }

No special mechanism is needed for this pattern — it falls out of the principal model.

On this page