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 references
├── 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
├── bundles/                     # gitignored: issued enrollment bundles, transient
└── .flor/
    └── compiled/                # committed; operator runs `rete compile` then commits
        └── mgmt/                # one directory per plane
            ├── mgmt01/          # management node — bootstrap-applied manually (see Coordinator)
            │   ├── agent.json
            │   ├── hop.json
            │   └── coordinator.json
            ├── alpha/
            │   ├── agent.json
            │   └── hop.json
            ├── beta/
            │   ├── agent.json
            │   └── hop.json
            └── ...

What's not in the repo:

  • No identity material at all, public or private. Not the root CA's key and not its certificate; not a signer's key and not its certificate; not a principal's. All of it lives in rete's own tree (Enrollment § Genesis) or on the node that holds it.

The repo holds names + ACLs + topology, and the compiled tree. That is what is needed to compile per-node artifacts and to audit who has access; who is allowed to sign is audited from the signer certificate each compiled envelope carries in its signature, since certificates live in rete's tree and never here.

The *.yaml filenames above are a recommended default (scaffolded by rete 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. rete 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/, bundles/, and dotfiles. rete.yaml may carry a top-level source: block with include/exclude globs to override discovery precisely (useful in a shared repo). There is no per-invocation override: source is the only way to narrow discovery, and it is committed with the rete rather than typed per run.
  • 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/mgmt-signer, --kind management-plane, Kind::ManagementPlane
Filesystem directories holding collections of named instancesplural collection nameidentity/principals/user/alice.pem, artifacts/mgmt/sets/<v>/, <state-dir>/retes/<scope>/

The cross-mapping at compile time is a trivial s/-?s$//. Singletons (agent.json, anchors.pem) 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 one artifact per declared vertex, named after it — hop.json for a node's one link vertex, by convention, read by the flor-vertex instance the agent supervises — flat beside each other. C1 adds a second vertex artifact; the agent.json shape is stable across both.

The per-node mgmt/ subdirectory looks redundant in C0 (no sibling), but it keeps rete 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/, bundles/, and dotfiles
  include: ["**/*.yaml"]                                 # repo-relative globs; replaces the default set
  exclude: ["examples/**", "vendor/**"]                  # repo-relative globs; subtracted after include

rete:
  name: flor.demo                                        # also the SPIFFE trust domain

  root_ca:
    validity_days: 3650                                  # optional; compiler default: 3650

  signers:
    mgmt:
      validity_days: 365                                 # optional; default: 365
      principals:
        - { name: mgmt-signer }                          # resolved in `rete`'s own tree
    # ctrl (introduced in C1):
    #   principals:
    #     - { name: ctrl-signer }

  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://flor.demo/<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/, bundles/, 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 rete 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. Nothing bypasses source per invocation: a flag that chose a different file set per run would be a second discovery rule, and the one that is committed is the one a reviewer sees.

Per-category lifetimes. The root CA, the envelope signers, and the TLS principals have different lifecycles — the root 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, so a smaller rete can leave all three out.

The three must nest: no certificate may outlive the root that issued it. The slots are independent, so a leaf minted late in the root's life would carry a notAfter past its anchor's — and the chain then fails at the root's expiry while the leaf still looks valid, fleet-wide and after the material is couriered and installed. So validity_days is a ceiling, not a promise: every mint (rete init, id issue, issue-bundle) sets notAfter to the earlier of the configured lifetime and the root's own, and warns when it clamped. Late in a root's life the rete keeps issuing, with credentials that shorten. See Identity § Decision Log, ID-C0-22.

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 references name a principal, and carry no path and no key. The block is signers.mgmt.principals[] rather than keys[]: since ADR-0015 each entry holds neither a public key nor a certificate path, only a name, so what it lists is the principals authorised to sign — the same word the identity store and ADR-0005 use for them. signers.mgmt.principals[].name is the SPIFFE name segment (the cert's URI is spiffe://<rete>/management-plane/<name>), and the certificate itself is resolved in rete's own tree — no certificate lives in the repository. The compiler reads it there and signs with it, embedding that certificate into the signature of every envelope it emits, so a node validates each artifact's signer against its trust anchors without holding any signer list. Rotation is minting a fresh certificate from the root CA and replacing the operator's pair — no list edit, no overlap window, and 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 the coordinator
  mgmt01:
    vertices:
      - { name: hop, kind: link, type: quic, address: 9.10.11.12:4433 }

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

  # user nodes — dynamic IPs, behind NAT, initiate connections only
  alice-laptop:
    vertices:
      - { name: hop, kind: link, type: quic }   # no address — initiator-only
  bob-workstation:
    vertices:
      - { name: hop, 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), and the name of the vertex's workload and of its artifact, <name>.json (Identity, ID-C0-09). Required: nothing defaults it. A node's only link vertex is named hop by convention, in the examples here and in the rete rete init scaffolds. 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.
  • kind — link 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)
  coordinator:                     # read side — nodes poll for compiled artifacts
    at: mgmt01
    addr: 127.0.0.1:9000
    groups: [coordinator-sync]
  coordinator-publisher:           # write side — operators push new state
    at: mgmt01
    addr: 127.0.0.1:9001           # same process as coordinator, different endpoint
    groups: [coordinator-publish]

  # Workload services
  api:
    at: alpha
    # via: hop                        # omittable in C0: alpha 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]
  alpha/ssh:                          # node-scoped: service/alpha/ssh — node and scope read off the key
    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):
  coordinator-sync:  # read side — nodes poll for artifacts
  coordinator-publish: # 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: [coordinator-sync]  }   # auto-assigned by compiler to every node
  operator: { allow: [coordinator-publish] }   # 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 rete 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 coordinator-publisher. The CLI / CA does nothing special for operators at the TLS-principal level.

The mgmt-envelope signing principal is a separate identity, issued on operator hardware via rete id issue --kind management-plane --name mgmt-signer. Its name (mgmt-signer by convention) is independent of any user, and unique across every principal the rete declares — signers included, since an operator's tree keys them flat by name (Validate & Compile, rule 3) — the keypair belongs to the rete's signing authority, not to a specific person — and it names a slot, since a node accepts a signer on its certificate's chain to the anchors and never on its name. The cert is sign-only (keyUsage: digitalSignature, no extKeyUsage) so it cannot terminate TLS. Only the certificate is shipped to nodes, inside the signature of each envelope it signed, and is verified against the anchors delivered 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. Each device is a node, so each gets its own bundle from rete issue-bundle --node <device> and its own keypair for the same user/alice SPIFFE ID — bundles are per node and consumed by the enrollment that unpacks them, so nothing is copied between devices. Revoking alice removes the principal itself, so every device loses access 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