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.
Source files are a recommended split, not a requirement
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/validaterequirerete.yamlat 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/*.ymlunder the repo, skipping.flor/,bundles/, and dotfiles.rete.yamlmay carry a top-levelsource:block withinclude/excludeglobs to override discovery precisely (useful in a shared repo). There is no per-invocation override:sourceis 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
reteblock 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:
| Namespace | Form | Examples |
|---|---|---|
SPIFFE URI, CLI --kind, Rust Kind enum | singular type marker | spiffe://<td>/management-plane/mgmt-signer, --kind management-plane, Kind::ManagementPlane |
| Filesystem directories holding collections of named instances | plural collection name | identity/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-kernelwg, third-partyvless, … 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 bothkind: 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.yamlvia:in C1, workloadvia: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 namedhopby convention, in the examples here and in the reterete initscaffolds. For mesh-vertices in C1 the name carries semantic meaning —reteis the conventional name for the rete-wide mesh-vertex;interreteby convention for the B1+ interrete-vertex.kind—linkormesh(see above). C0 uses onlylink.type— the interface this vertex exposes upwards (quicin C0;udp, in-kernelwg, third-partyvless, … become available in C1+). The mesh-vertex can consume any supported link interface (via Connection Manager adapter), currently it exposes onlyquicinterface (via FlorIO). The recursion property: an upper-layer mesh-vertex oftype: quicconsumes a lower mesh-vertex oftype: quicindistinguishably from a wire-terminating link-vertex oftype: quic.address— listenhost:portfor 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:
addrbinds 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 binds0.0.0.0so that the emergency access path (see Safety net) survives a bad Florete rollout.socks5_proxyis 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 + brokersusers.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.