Management Plane
Management plane additions for C1 Manual Mesh
This milestone inherits the management plane design from C0. Tended Tunnels wholesale: same git repo as source of truth, same CA and enrollment bundles, same flor agent sync mechanics, same naming scheme, same atomic-artifact distribution. (C1 additionally introduces the commit-timeout / auto-rollback safety net on sync — deferred from C0; see Scope.)
C1 is also where the ctrl-plane artifact stream appears for the first time, even though no autonomous control-plane service exists yet. In C1 the ctrl artifact is operator-produced via rete compile (degenerate case — every "decision" equals the corresponding bound), but it travels in its own envelope, has its own signature, and is verified separately by each agent against bounds expressed here in mgmt. This page covers the bounds; ctrl mechanics and how decisions are derived from them live in Ctrl Plane.
All source YAMLs in the rete repo author mgmt-plane bounds. Ctrl-plane decisions are derived: in C1 by rete compile, in B1+ by an autonomous CP service. The authoring surface stays mgmt-shaped throughout — operators never edit ctrl artifacts directly.
What C1 adds is the mesh layer: mesh-vertex as a new principal kind (with SPIFFE IDs of the form spiffe://<rete>/vertex/<node>/<name>), declared inter-node links, explicit multi-hop paths, and a second flor vertex per node so the recursive stack from Data Plane can run.
Two framing notes worth getting right up front, because the obvious mental model is wrong in opposite directions for the two new pieces:
Mesh-vertex identity belongs to mesh-flor; its TLS is terminated by link-flor. Mesh-flor publishes itself as a service in the link layer — that's the recursive principle, exactly as the HLD describes. Like any principal, mesh-flor delegates the mTLS handshake to a vertex (link-flor in this case), which holds the cert+key on its behalf. The shape is identical to C0 flor holding alice's cert+key: alice owns the identity, flor acts on her behalf. Link-flor isn't the mesh-vertex any more than C0 flor is alice.
Link-flor is a full vertex, not a UDP forwarder. It has a QUIC transport endpoint that terminates mesh-vertex ↔ mesh-vertex mTLS, a UDP connection manager for the wire, and a FlorIO interface where it serves mesh-flor (its only local principal in the standard topology). It just doesn't act on its own behalf — like every other vertex, it acts on behalf of the workloads it carries.
Two link types from day one: QUIC and bare UDP. Mesh-flor's connection manager runs multiple adapters simultaneously, picking per peer:
- QUIC links — full mTLS link-layer crypto, terminated by a separate link-flor vertex that mesh-flor reaches over FlorIO. Right answer for Internet-facing peerings (NAT traversal, metadata-resistance, authenticated transport).
- Bare UDP links — no link-layer crypto, implemented directly inside mesh-flor as a plain UDP socket. No link-flor process for these peerings. Right answer for private LAN/datacenter peerings where the underlay is already trusted and the mesh-vertex ↔ mesh-vertex QUIC overhead is wasted.
Both can coexist in the same mesh-flor. From the user's PoV the mesh looks the same — services are published once (in mesh-flor) and reachable through any combination of QUIC and UDP links along the path. The link-type choice is per-link, declared in links.yaml, transparent to workloads, and the multi-hop forwarding works uniformly across mixed link types.
This is also why publication should always go through mesh-flor in the standard topology: it's what gives operators a single mesh view regardless of which underlay each hop uses. Publishing in link-flor remains schema-allowed (see "mixed-vertex publication" in deferred work) but is not the default story for private-network optimisation; using a UDP link is.
What C1 adds
- Two link types — QUIC (via link-flor) and bare UDP (direct in mesh-flor). Mesh-flor's connection manager runs both adapters simultaneously and picks per peer; the multi-hop forwarder works uniformly across mixed link types.
- One or two flor vertices per node instead of C0's one. Same
florbinary, no--roleflag — what makes a vertex link-shaped or mesh-shaped is its compiled config (transport_endpoint + connection_manager + which workloads it carries). A node with only UDP-link peerings runs just mesh-flor; nodes with any QUIC peerings additionally run link-flor. - Mesh-vertex as a new principal kind — one per node per Florete rete, distinct from C0's node principal. SPIFFE ID
spiffe://<rete>/vertex/<node>/<name>(e.g.vertex/alpha/reteby convention). Used for hop-by-hop link-layer mTLS. flor-agentas a distinct first-class workload, not a vertex. The node has its own rete-member identity (node/<node>); the agent is the workload that acts on the node's behalf — fetching configs, supervising vertices, reaching coordinator. Like every other principal, the node delegates its mTLS to a vertex (mesh-flor).links.yaml— declares allowed UDP peerings between mesh-vertices (a topology bound).paths.yaml— declares allowed multi-hop routes at the mesh layer (a path bound). Hop sequences are operator-authored; labels and forwarding tables are produced as ctrl-plane decisions, not mgmt.- Three mgmt artifacts per node — one
agent.jsonplus<name>.jsonper vertex, flat undermgmt/<node>/. Standard topology produceshop-public.json(link-vertex) andrete.json(mesh-vertex). Forwarding state (labels, forwarding table, label-bindings) is not here — it's in the ctrl artifact atctrl/<node>/rete.json(see Ctrl Plane). authorized_ctrl_signersinagent.json'strustblock — a list of SPIFFE IDs the agent will accept ctrl artifacts from. It narrows what the trust anchors already permit: a ctrl envelope carries its signer's certificate, that certificate must validate against the node's anchors, and the identity it certifies must appear in this list. In C1 there is a single CP identity, operator-held. Unlike mgmt, this list is delegated by mgmt rather than self-anchored, which is why it survives (see ADR-0015).- Per-vertex FlorIO sockets — each vertex that exposes FlorIO serves a socket at
<runtime-root>/vertices/<vertex-name>.sock. The path is derived at runtime from the spawn-injected runtime root plus the vertex name — never compiled into artifacts, which carry no filesystem references (see C0's Agent page); a FlorIO adapter references the serving link-vertex by name. - Additional validator rules — link validity, address feasibility, multi-hop path validity, principal-role coherence for services that appear as path initiators. (Label feasibility moves to ctrl-plane.)
Everything else (CA, per-node bundles, revocation, flor agent sync, coordinator-fronted distribution, naming, version-stamped envelope, SOCKS5 proxy, emergency-access safety net) is identical to C0 — except that C1's sync gains the commit-timeout / auto-rollback guard noted above.
Source layout additions
my-rete/
├── … # everything from C0 mgmt-plane
├── links.yaml # NEW: declared allowed peerings (topology bound)
├── paths.yaml # NEW: declared allowed paths (path bound — hop sequences only, no labels)
├── .gitignore # contains `.flor/compiled/ctrl/`
└── .flor/
└── compiled/ # committed (operator runs `rete compile` then commits)
├── mgmt/ # signed by operator; committed
│ ├── alpha/
│ │ ├── agent.json
│ │ ├── hop-public.json # link-vertex named "hop-public"
│ │ └── rete.json # mesh-vertex named "rete"
│ └── …
└── ctrl/ # signed by CP key; gitignored
├── alpha/
│ └── rete.json
└── …YAMLs in the repo are mgmt-source — they author bounds. The mgmt subtree is the compiled, signed projection of those bounds. The ctrl subtree alongside it is produced by the same rete compile step in C1 (the ctrl producer becomes a CP service in B1+); it's gitignored because its lifecycle is "runtime state, not authoring artifact." See Ctrl Plane for what's inside ctrl/.
rete.yaml (additions over C0)
C1 adds a signers.ctrl block listing the rete's authorized ctrl-envelope signers, symmetric to signers.mgmt from C0:
rete:
name: flor.demo # same as C0
root_ca:
validity_days: 3650 # same as C0
signers:
mgmt:
principals:
- { name: mgmt-signer } # resolved in `rete`'s tree; same as C0
ctrl: # NEW in C1
validity_days: 365 # optional
principals:
- { name: ctrl-signer } # likewise — no certificate in the repo, and it
# may not reuse the mgmt signer's name
tls_principals:
# validity_days — default appliesThe compiler embeds the listed ctrl-signer identities into agent.json's trust.authorized_ctrl_signers (see ADR-0010 for placement). An agent accepts a ctrl artifact when its carried certificate validates against the node's anchors and the identity that certificate names is on this list. Rotating a ctrl key touches nothing: the operator mints a fresh control-plane/ctrl-signer certificate and the CP signs with it, exactly as mgmt rotation works. Revoking a ctrl signer is the list edit — the operator publishes an updated agent.json, signed with the mgmt key, without that identity. There is no vouched-successor chain and no self-reference: ctrl authority is delegated by mgmt, so a compromised CP can neither rotate nor retain its own authorisation (ADR-0015).
nodes.yaml (additions over C0)
C1 extends each node's vertex graph with a mesh-vertex (kind: mesh, named rete by convention) on top of the link layer, and broadens the link-vertex type enum from C0's QUIC-only to include udp (and, after C1, wg, vless, …). The mesh-vertex's uses: field names which link-vertices it relies upon for transit.
nodes:
alpha:
vertices:
- { name: hop-public, kind: link, type: quic, address: 1.2.3.4:4433 }
- { name: hop-internal, kind: link, type: udp, address: 10.0.0.5:4433 }
- { name: rete, kind: mesh, type: quic, uses: [hop-public, hop-internal] }
agent_via: rete # which vertex hosts the agent's node/alpha principal
beta:
vertices:
- { name: hop-public, kind: link, type: quic, address: 5.6.7.8:4433 }
- { name: hop-internal, kind: link, type: udp, address: 10.0.0.6:4433 }
- { name: rete, kind: mesh, type: quic, uses: [hop-public, hop-internal] }
agent_via: rete
gamma:
vertices:
- { name: hop, kind: link, type: udp, address: 10.0.0.7:4433 }
- { name: rete, kind: mesh, type: quic, uses: [hop] }
agent_via: rete
internal_only: true # not reachable from Internet; only via peersThe kind: mesh value is structural — it identifies the vertex code that operates over Florete-internal media (other vertices) and does hop-by-hop label-forwarding. The same kind underlies both the C1 mesh-vertex (named rete by convention) and the future interrete-vertex (named interrete); what distinguishes them is the vertex name the operator assigns and the configuration that follows from it. rete is a name convention, not a YAML kind — the C1 mesh-vertex is "the kind: mesh vertex named rete on this node."
C1 conventions and constraints:
- Exactly one mesh-vertex per node, named
reteby convention. Its SPIFFE ID isspiffe://<rete>/vertex/<node>/<vertex-name>— e.g. alpha's rete mesh-vertex isspiffe://flor.demo/vertex/alpha/rete. The name is operator-chosen;reteis the community convention, not a reserved or validated label. A future interrete-vertex on the same node would bevertex/alpha/interreteby convention. agent_via:on the node entry names which vertex hosts the agent'snode/<node>principal. Required when the node has multiple vertices (always in C1). The agent connects to coordinator through the named vertex's SOCKS5 listener.- Mesh-vertex
typeis the upward interface contract.type: quicis what mesh vertices in C1 use; this is what makes recursion work — an upper-layer mesh-vertex consuming a lower mesh-vertex sees the same QUIC interface a wire-terminating QUIC link-vertex would expose. - Mesh-vertex
uses:lists the link-vertex names this mesh-vertex relies upon. Multiple link-vertices supports the canonical "border" case (one Internet-facing QUIC link + one private-net UDP link) and the parallel-underlay case (e.g., dual-ethernet UDP between two nodes — twotype: udplink-vertices on each side, both inuses:). - Principals (users, services) attach via
via:— each service and user device entry names its host vertex with avia:field. C1 convention isvia: retefor all of them (migrating up from C0's single link-vertex). Link-vertices in C1 carry only mesh-vertex ↔ mesh-vertex transit. - No-process link-vertices are an implementation optimization, not a separate config category. The runtime may collapse a
type: udplink-vertex into mesh-flor's process (a UDP socket inline), or implement atype: wglink-vertex as an in-kernel WireGuard interface with no userspace process at all. The YAML lists every link-vertex uniformly; the agent decides what to spawn.
A node joining multiple Florete retes appears in each rete's repo under whatever name that rete uses — cross-rete multi-tenancy is handled at the repo level, not in nodes.yaml. The address ports on link-vertices must not overlap with another rete's entries on the same external IP; the node operator is responsible for this when enrolling into multiple retes (see Multi-rete on a single node below).
services.yaml (additions over C0)
C1 adds a required via: field to every service entry. Because C1 nodes have multiple vertices (link + mesh), the compiler must know which vertex hosts each service. Workloads in C1 belong on the rete mesh-vertex by convention:
services:
api:
at: alpha
via: rete # REQUIRED in C1: name of vertex on alpha that hosts api
addr: 127.0.0.1:8000
socks5_proxy: 127.0.0.1:18000
groups: [api]
roles: [api-backend]
mongodb:
at: beta
via: rete
addr: 127.0.0.1:27017
groups: [db]
alpha/ssh:
via: rete
addr: 0.0.0.0:22
groups: [admin]via: names a vertex declared on the at: node in nodes.yaml. Mixed-topology opt-in (publishing a service in link-flor for private-network access) is via: <link-vertex-name> — no schema change.
users.yaml (additions over C0)
C1 adds via: to each user device entry:
users:
alice:
roles: [developer]
nodes:
- { at: alice-laptop, via: rete }
bob:
roles: [devops]
nodes:
- { at: bob-workstation, via: rete }Each nodes: entry is an {at, via} object in C1. at: names the host node; via: names the vertex on that node that holds alice's identity material and SOCKS5 listener. In C0 via: was omittable (one vertex per node); in C1 it is required.
links.yaml (new) — topology bound
# Bound: the set of mesh-vertex peerings the CP is allowed to bring up.
# Each entry under `allowed_links` is a typed rule. Agents verify ctrl's
# active peers against the union of all rules' results.
# In C1 the only rule type is `list`. New rule types can be added as
# additional entries in this list without reshaping the schema.
allowed_links:
- type: list
links:
- { a: alpha, b: beta, via: hop-public }
# asymmetric form, where the link-vertex names differ between endpoints:
- { a: { node: alpha, vertex: hop-internal }, b: { node: gamma, vertex: hop } }
- { a: { node: beta, vertex: hop-internal }, b: { node: gamma, vertex: hop } }
# Future rule types — illustrative, not implemented in C1:
# - { type: any-pair-via, nodes: [alpha, beta], via: hop-internal }via names a link-vertex declared on each endpoint node in nodes.yaml. Symmetric shorthand (via: <name>) requires both sides to have a link-vertex with that name; the asymmetric form {a: {node, vertex}, b: {node, vertex}} covers cases where the names diverge.
The validator checks that both sides' link-vertices have the same type (a quic link-vertex can only peer with another quic link-vertex) and that the two addresses can plausibly reach each other (public↔public, or shared internal network). The link's transport is fully determined by the link-vertices it references — there is no separate link_type field on the rule.
In C1 these declared peerings are the bound — the operator signs that only these peerings are allowed. The ctrl artifact's per-node active_peers is a subset (in C1, the full set — degenerate case). In B1+ a CP service might activate a subset based on health, with the bound still capping what's possible.
paths.yaml (new) — path bound
# Bound: the set of multi-hop routes the CP is allowed to provision.
# Each path entry has four fields:
# principal — user or service name (the initiator)
# from — the principal's home node; the starting mesh-vertex is inferred
# from the principal's via: binding in services.yaml / users.yaml
# hops — ordered list of transit mesh-vertices. Each hop:
# node: (required) the node hosting the target mesh-vertex
# vertex: (optional) which mesh-vertex on that node; defaults
# when exactly one mesh-vertex on the node is reachable
# from the previous mesh-vertex via a declared link
# via: (optional) source-side link-vertex name; defaults when
# exactly one link connects the previous mesh-vertex to
# this one; required only when multiple links connect
# the same mesh-vertex pair (e.g. two ethernet links)
# to — target service name; its host mesh-vertex (from its via: binding)
# must equal the last hop's mesh-vertex
#
# In C1 every node has exactly one mesh-vertex, so vertex: always derives.
# via: derives unless two or more links connect the same adjacent mesh-vertex pair.
allowed_paths:
- type: list
paths:
# users
- principal: alice
from: alice-laptop
hops: [{ node: alpha }]
to: api
- principal: alice
from: alice-laptop
hops: [{ node: beta }, { node: gamma }]
to: kafka
- principal: bob
from: bob-workstation
hops: [{ node: alpha }]
to: ssh
# services
- principal: api
from: alpha
hops: [{ node: beta }]
to: mongodb
- principal: api
from: alpha
hops: [{ node: beta }, { node: gamma }]
to: kafka
# Future rule types — illustrative, not implemented in C1:
# - { type: any-path-respecting, must_avoid: [delta], max_hops: 4 }Paths declared here are the operator-signed bound on which routes are allowed. The ctrl artifact selects which paths are currently provisioned and assigns labels for them; agents only ever see local slices of those decisions (their own forwarding rows + label bindings) and never the rete-wide path enumeration — see Ctrl Plane for why.
Why typed-rule lists in the YAMLs (and where they don't apply)
The allowed_links/allowed_paths shells exist in the bounds YAMLs because those bounds are exactly the surface CP is allowed to pick within. Adding a new rule type later (say, "any peering between nodes in this VPC") is then a strictly additive change — agents implement the bound-checker as dispatch on the rule's type, and unknown rule types fail-closed.
Direct ACLs (ingress, egress) deliberately don't use this shell. They're pure operator authority — CP doesn't narrow ingress/egress allow lists, so there's no bound to express. Richer authoring grammars (roles, groups, future attribute-based selectors) resolve at compile time to flat allow lists in the JSON artifact. Same pattern as C0: rich YAML, flat compiled output.
CLI additions
# operator (rete) — authoring
rete compile
# whole-rete; emits <out>/mgmt/<node>/{agent.json,
# <vertex-name>.json…} and <out>/ctrl/<node>/
# per node
# node (flor) — runtime
flor-vertex # vertex daemon; spawned by the agent with full
# FLORWL_* context (CLI flags are explicit debug
# overrides of the same variables); the agent
# supervises one or more of these per nodeflor agent run keeps its C0 behaviour and CLI shape ([--scope <scope>], reading agent.json from the active artifact set); it just supervises N vertex instances rather than one, spawning each entry's run argv with the uniform FLORWL_* environment (see C0's Agent page) in inventory list order. Launch mechanics are as pinned in C0's Agent page: the agent forks and supervises directly; per-workload launch runtimes are a reserved evolution.
Vertex certificates are issued as part of rete issue-bundle, which packs every TLS principal a node hosts; the vertex kind was already foreshadowed in C0. The vertex's SPIFFE name is derived from <node>/<vertex-name>.
Additional validator rules
On top of C0's:
- Vertex graph validity — every node declares at least one mesh-vertex (
kind: mesh, namedreteby convention in C1) and at least one link-vertex; everyuses:reference resolves to a vertex name declared on the same node; vertex names are unique within a node. C1 graphs are trees (one mesh root above link-vertex leaves); the schema does not require acyclicity in general — see Reasoning > Why the vertex graph is a directed graph, not a DAG. - Workload vertex binding — every service's
via:and every user device entry'svia:must name a vertex declared on the respectiveat:node. Theagent_via:field on a node entry must name a vertex declared on that node. In C1 these fields are required (multiple vertices per node); omitting them is an error ("specifyvia:to disambiguate among<list of vertex names>"). Validator also warns (not errors) whenvia:references akind: linkvertex — that is a valid mixed-topology opt-in but unusual enough to surface. - Link validity — every link is between two distinct, known nodes; the
viareference resolves to a link-vertex on each side (symmetric shorthand or asymmetric form); both link-vertices have the sametype. - Address feasibility — link pairings use addresses that can actually reach each other (public↔public, or shared internal network). Internal-only nodes can only pair with peers on the same internal network.
- Link-type coherence —
type: udplink-vertices may only peer over shared internal networks (not over Internet-facing addresses), since they carry no link-layer crypto.type: quiclink-vertices are allowed everywhere. - Multi-hop path validity —
principalis a known user or service name.fromis a known node; for users it must appear inusers.yaml::nodes; the starting mesh-vertex is the one declared in the principal'svia:binding.- Each
hops[].nodeis a known node.hops[].vertex(when present) names a mesh-vertex declared on that node; when absent, the compiler resolves it: it must be uniquely determined by the links inallowed_linksthat connect the previous mesh-vertex to any mesh-vertex on the hop node. If the lookup is ambiguous, validation fails with "specifyvertex:to disambiguate." hops[].via(when present) names a link-vertex on the previous node (the source side of this hop). When absent, there must be exactly one link inallowed_linksconnecting the previous mesh-vertex to this hop's mesh-vertex; if multiple links connect the same pair, validation fails with "specifyvia:to disambiguate."- Every adjacent mesh-vertex pair in the resolved path (origin → hop₁ → … → last hop) has a declared link in
allowed_links. tois a published service. Its host mesh-vertex (from itsvia:binding in services.yaml) must equal the last hop's resolved mesh-vertex; for on-origin services, equal the origin mesh-vertex.
- Principal role coherence — services that appear as path initiators must have a
roledeclared.
(Label feasibility is now a ctrl-plane concern — the label allocator runs against the operator-declared paths and surfaces any infeasibility there. See Ctrl Plane.)
Compile step additions
rete compile produces two artifact subtrees in C1, each holding a directory per node: a signed mgmt subtree (mgmt/<node>/) and a CP-signed ctrl subtree (ctrl/<node>/). A node's mgmt directory contains one agent.json plus one <name>.json per vertex (standard topology: hop-public.json for the link-vertex and rete.json for the mesh-vertex), all sharing the common envelope inherited from C0 (including plane: "mgmt" and the operator signature — see C0's envelope spec). The ctrl subtree contains only what's CP-shaped (forwarding tables, label bindings) and is documented separately in Ctrl Plane.
This page covers the mgmt envelope and the per-vertex mgmt payloads. Mgmt envelope:
{
"schema_version": "1.0",
"plane": "mgmt",
"version": 42,
"workload": "spiffe://flor.demo/workload/alpha/agent", // the consuming workload; workload/<node>/agent is the supervisor
"generated_at": "2026-04-20T12:00:00Z",
"payload": { ... },
"signature": { "cert": "<base64-der-x509>", "value": "<base64>" }
}Artifact identity is (workload, plane); there is no envelope kind — dispatch is by workload, each consumer parsing only artifacts addressed to it (see C0's envelope spec). plane is the decision-authority axis: "mgmt" is signed by the operator; "ctrl" is the separately-signed control-plane stream this milestone introduces. Everything on this page is plane: "mgmt"; the ctrl stream is covered in Ctrl Plane.
Attachments & IO
Both vertex configs use a unified attachments shape — one entry per principal attached to the vertex (a TLS principal; initiator, target, or both). Each attachment has zero or more io channels, and each channel's direction is intrinsic to its kind (matching C0 scope's "Inbound/Outbound" usage):
- inbound kind (
socks5) — the principal's process connects into flor here; flor originates outgoing connections on its behalf. The principal acts as an initiator. - outbound kind (
tcp) — flor connects out to the principal's process here; flor delivers incoming connections. The principal acts as a target (service). - bidirectional kind (
florio) — one socket does both, carrying Connect requests downward and inbound deliveries upward between recursive flor layers.
An attachment entry:
{
"spiffe_id": "spiffe://flor.demo/service/api",
"io": [
{ "kind": "tcp", "upstream": "127.0.0.1:8000" },
{ "kind": "socks5", "listen": "127.0.0.1:18000" }
]
}(Identity material is not referenced here — the serving vertex loads it from the scope's identity store by SPIFFE ID.)
io.kind is open-ended; today's inventory is socks5 (inbound), tcp (outbound), florio (bidirectional; new in C1). A new northbound interface — or a new direction for an existing protocol, like a future socks5_out — is a new kind, with no separate direction field to keep in sync and no illegal combinations to reject.
hop-public.json payload
The link-vertex config file is named after the vertex's name: field in nodes.yaml — hop-public in the standard topology. The compiler derives the filename from the vertex name; operators never pick it manually.
{
"schema_version": "1.0",
"kind": "link",
"transport_endpoint": { "type": "quic" },
"connection_manager": { "adapters": [ { "name": "wire", "type": "udp", "listen": "0.0.0.0:4433" } ] },
"attachments": [
{
"spiffe_id": "spiffe://flor.demo/vertex/alpha/rete",
"io": [
{ "kind": "florio" }
]
}
],
"links": [
{
"type": "list",
"members": [
{
"name": "beta",
"peer": "spiffe://flor.demo/vertex/beta/rete",
"via": { "type": "udp", "adapter": "wire", "addr": "5.6.7.8:4433" }
}
]
}
],
"ingress": [
{
"target": "spiffe://flor.demo/vertex/alpha/rete",
"allow": [ "spiffe://flor.demo/vertex/beta/rete" ]
}
],
"egress": [
{
"target": "spiffe://flor.demo/vertex/beta/rete",
"allow": ["spiffe://flor.demo/vertex/alpha/rete"]
}
]
}Things worth pointing out about this shape:
- The cert+key are link-flor's loaded material, not its identity.
vertex/alpha/reteis an attachment that link-flor serves and acts on behalf of — link-flor loads its material from the identity store by SPIFFE ID, as for any attachment. From link-flor's perspective it's just a service name with a bidirectional FlorIO channel — link-flor has no idea what a mesh-vertex semantically is. The structural mirror to C0 flor holding alice's cert+key is exact. - No top-level
florioblock, no socket paths. FlorIO channels are properties of individual attachments'ioentries; the socket path (<runtime-root>/vertices/<vertex-name>.sock, from the serving vertex's own name) is a runtime convention over the spawn-injected runtime root — never a config field. ingress/egressare pure ACL, just like C0's. The link layer has the same access-control shape as the mesh layer; flor doesn't distinguish which mTLS layer it's enforcing. Dial info lives inlinks, never inegress.linksis the star. The link-vertex'slinkstable maps each peer mesh-vertex to its wireaddr(audpvia) — its entire (degenerate, 1-1) routing. Noforwarding_table, no labels, no paths: multi-hop forwarding lives entirely in mesh-flor. (Herehop-publiccarries only the QUIC peering tobeta; alpha↔gamma is a direct-UDP mesh link with no link-flor — seerete.jsonbelow.)
Mixed-topology case (publishing a regular service in link-flor for private-network use): the service's attachment entry simply moves from rete.json into the link-vertex's config file (hop-public.json in the standard topology), with no schema change. The compiler decides placement based on the service's via: field.
rete.json payload
{
"schema_version": "1.0",
"kind": "mesh",
"transport_endpoint": { "type": "quic" },
"connection_manager": {
"adapters": [
{ "name": "via-link", "type": "florio", "vertex": "hop-public" },
{ "name": "hop-internal", "type": "udp", "listen": "10.0.0.5:5544" }
]
},
"links": [
{
"type": "list",
"members": [
{
"name": "beta",
"peer": "spiffe://flor.demo/vertex/beta/rete",
"via": { "type": "florio", "adapter": "via-link" }
},
{
"name": "gamma",
"peer": "spiffe://flor.demo/vertex/gamma/rete",
"via": { "type": "udp", "adapter": "hop-internal", "addr": "10.0.0.7:5544" }
}
]
}
],
"attachments": [
{
"spiffe_id": "spiffe://flor.demo/node/alpha",
"io": [
{ "kind": "socks5", "listen": "127.0.0.1:1080" }
]
},
{
"spiffe_id": "spiffe://flor.demo/service/api",
"io": [
{ "kind": "tcp", "upstream": "127.0.0.1:8000" },
{ "kind": "socks5", "listen": "127.0.0.1:18000" }
]
}
],
"ingress": [
{
"target": "spiffe://flor.demo/service/api",
"allow": ["spiffe://flor.demo/user/alice"]
}
],
"egress": [
{
"target": "spiffe://flor.demo/service/mongodb",
"allow": ["spiffe://flor.demo/service/api"]
}
]
}The agent's principal — node/alpha — appears as an attachment here so the agent can dial coordinator through mesh-flor like any other principal. This is the mechanism that makes the agent's bootstrap chain work: agent → mesh-flor's SOCKS5 → mTLS to coordinator. It's also why the agent doesn't need to hold any cert+key itself.
connection_manager.adapters[] is a list, one entry per active link type, each with a stable name that each link's via.adapter field references. The example above shows alpha running both adapters: via-link (FlorIO down to link-flor for the QUIC peering with beta) and hop-internal (a bare UDP socket for the trusted-LAN link to gamma). A node with only QUIC peerings would have just the florio adapter; a node with only UDP peerings would have just the udp adapter and no link-flor at all. Other adapter values (wg and similar for third-party links) are foreshadowed in Scope > Link Protocols and Data Plane, not implemented in C1.
The multi-adapter connection manager is a mesh-vertex trait. A link-vertex keeps exactly one udp adapter, as in C0 — by design, not a milestone limitation: a link vertex is one QUIC endpoint over one UDP socket, and it never terminates a FlorIO adapter (FlorIO is mesh-flor reaching down to link-flor). The parallel-underlay cases C1 supports — dual-ethernet, or QUIC + private-UDP to the same peer — are realized as multiple link-vertices the mesh-vertex uses:, each a separate single-adapter link, so the mesh forwarder sees and traffic-engineers across them. A compiled link artifact with zero, multiple, or non-udp adapters is rejected as malformed.
links is a typed-rule bound list, mirroring allowed_links/allowed_paths in the YAMLs (it is the per-node projection of allowed_links). Each rule produces a set of links — each a local name, its peer identity, and the via adapter selection; the agent's effective link set is the union across rules. The compiler assigns the stable link names, and a paths.yaml hop's via: (link selection) resolves to one. A peer joined by parallel links (dual-ethernet, or QUIC + private-UDP) appears in more than one entry — the link, not the peer, is the forwarding handle. CP-shaped decisions (which subset is currently active, which forwarding rows reference which link) are verified against this bound at the agent — see Ctrl Plane.
In C1 the only rule type is list; the rule body lists links explicitly under members. The via per link (adapter + dial info) is fixed by the operator — CP doesn't switch a link between QUIC and bare UDP at runtime. CP can only narrow which allowed links it actually brings up.
ingress.allow and egress.allow are direct flat allow lists of authorized SPIFFE IDs. They are not bounds and not typed rules — they're pure operator authority. Compile time resolves richer YAML grammar (roles, groups, future selectors) into these flat lists; the agent's check is a simple principal ∈ allow. CP cannot narrow ingress/egress, so there's no bounds shell to provide.
Forwarding labels and the forwarding_table itself are not in this artifact — they're CP-shaped state and live in the ctrl artifact (see Ctrl Plane). The mgmt artifact carries only what the operator authored: who may reach what (ingress/egress) and which links are allowed (links). All routing — which links are active, the first hop, the labels, the forwarding table — is ctrl.
The approved CP signing identities (authorized_ctrl_signers) are not in this vertex artifact — they live in agent.json's trust block, where the agent (the sole verifier) reads them. There is no mgmt-signer list beside them: a mgmt signer is trusted on the anchors alone, and only the delegated ctrl authority is narrowed by a list. See agent.json below, with ADR-0010 for the placement and ADR-0015 for verification and rotation.
agent.json payload
{
"schema_version": "1.0",
"trust": {
"authorized_ctrl_signers": [ "spiffe://flor.demo/control-plane/ctrl-signer" ]
},
"workloads": [
{ "name": "hop-public", "run": ["flor-vertex"] },
{ "name": "rete", "run": ["flor-vertex"] }
],
"inbounds": [
{ "principal": "spiffe://flor.demo/node/alpha", "via": { "kind": "socks5", "addr": "127.0.0.1:1080" } }
],
"sync": {
"principal": "spiffe://flor.demo/node/alpha",
"coordinator": "spiffe://flor.demo/service/coordinator",
"poll_interval": "5m"
}
}The agent supervises every entry under workloads, spawning each entry's run argv with the uniform FLORWL_* environment — it applies no per-entry special-casing (the link/mesh engine distinction lives in the payloads, which the agent never reads). The agent is the sole signature verifier: it checks every mgmt and ctrl artifact's envelope, validates the signer certificate the envelope carries against the node's trust anchors, requires its SAN kind to match the artifact's plane — management-plane for mgmt, control-plane for ctrl, narrowed further by the authorized_ctrl_signers list here — and stages verified configs for the vertices it spawns; see ADR-0015; the semantic mgmt↔ctrl join is verified by the mesh vertex itself, the only party parsing both planes (see Ctrl Plane). To reach coordinator, the agent acts as the principal node/alpha, resolving its dial-out from its own entry in inbounds — the compiler-projected endpoint-discovery map served on agent.sock (sync names the principal, the coordinator, and the poll cadence; the compiler guarantees the node principal exactly one inbound, hosted on the vertex agent_via: selects). It locates each workload's config by name (see C0's node runtime layout): <name>.json in the active mgmt set, plus the node's newest verified ctrl/<name>@v<K>.json slot, all verified before spawning. Artifacts carry no filesystem references — identity material loads from the scope's identity store, socket paths derive from the runtime root (see C0's Agent page).
The number and shape of vertices is fully driven by workloads. The standard mixed-topology has two (link + rete); a node with only udp-type links runs mesh-flor alone (one vertex, no link-flor); an enrollment-bootstrap config (single-vertex, direct UDP to mgmt01, just enough for the first sync) is also one. The agent doesn't care — it enumerates whatever it's told to.
Label allocation, forwarding tables, and the per-allow label bindings are ctrl-plane state, not mgmt — see Ctrl Plane for that machinery.
Identity model additions
C1 issues two kinds that C0 names and issues nothing under:
| Kind | SPIFFE ID | Used for |
|---|---|---|
| Mesh vertex | spiffe://<rete>/vertex/<node>/rete | Hop-by-hop link-layer mTLS (one per node per rete, named rete by convention) |
| Control plane | spiffe://<rete>/control-plane/<name> | Signing authority for ctrl artifacts (one or more per rete; in C1 a single operator-held key) |
Link vertices stay principals issued no SVID, named in the same vertex/<node>/<name> namespace (vertex/alpha/hop-public). Their canonical names become load-bearing in B2, as the derivation source for medium attachment-point labels; a link vertex would gain an SVID only if a vertex below it had to terminate TLS for it (e.g. a non-DAG vertex graph where a link vertex uses the mesh for its return channel).
Every principal's material rests in the scope's identity store, and a vertex terminates TLS with it on the principal's behalf (C0 Identity). Across the four issued kinds:
| Principal | Owner (the process acting as it) | Terminator (the vertex using its material) |
|---|---|---|
node/<node> | the agent | mesh-flor, via the node/<node> attachment there |
vertex/<node>/rete | mesh-flor | link-flor |
user/<name> | the applications the person runs | mesh-flor |
service/<name> | the processes serving it | mesh-flor (or link-flor in mixed-topology setups) |
That is the recursive pattern from the HLD: mesh-flor terminates mesh-layer mTLS for its attachments, and link-flor terminates link-layer mTLS for mesh-flor, its sole standard-topology attachment. A vertex's own TLS is terminated by the vertex below it, never by itself.
The node/<name> principal is the node's identity within a specific rete's trust domain — the rete's logical view of this computing host. The agent is not the principal; it is the process that acts as it, just as alice's applications act as alice. Mesh-flor terminates mTLS for node/<name>, exactly as it does for user and service principals. The agent's job is to act as that identity: fetch configs, supervise vertices, and reach coordinator through mesh-flor's SOCKS5 listener.
Node identity and mesh-vertex identity aren't merged because they serve different layers and are terminated by different vertices — node/<name> is for mgmt traffic (agent ↔ coordinator), vertex/<node>/rete for hop-by-hop mesh transit mTLS. A node joining multiple Florete retes has one node/<name> principal per rete (in each rete's own trust domain) and one agent per rete owning it.
Enrollment flow is unchanged: rete issue-bundle --node alpha packages alpha's node identity and alpha's mesh-vertex identity in the same per-node bundle, alongside every user/service principal hosted at alpha. The compiler knows to add a vertex/<node>/rete principal for every node in C1 (derived from the rete mesh-vertex's name and its host node); operators don't declare it anywhere. SPIFFE IDs are derived from the vertex name, the host node, and the rete trust domain.
CP signing identities are issued on the operator's own machine (rete id issue --kind control-plane --name ctrl-signer mints the keypair and signs its cert). The CP key is not shipped in per-node bundles, and neither is its certificate: authorized_ctrl_signers in agent.json's trust block lists the CP's SPIFFE identity, and the certificate itself rides in the signature of each ctrl artifact, verified against the node's trust anchors like any signer's. This keeps the CP private key purely on the operator's workstation in C0/C1 (and on whichever host runs the CP service in B1+); no node ever sees it.
The bundle always carries the node's current agent.json + per-vertex configs, cut from the committed compiled tree — sufficient for the agent's first run with no prior sync. That is not an option a C1 bundle may drop: enrollment needing no coordinator reachability is an invariant rather than a convenience (C0 · Enrollment, ENRL-C0-15), and the set travelling in the bundle is what makes it one. C1 adds the mesh vertex's artifact to that set and changes nothing else about it. Subsequent flor agent sync calls pull whatever the rete currently runs (which may be richer or simpler than what was bundled).
Multi-rete on a single node
The foundation — rete scope, per-scope install root, one agent per scope, filesystem-reference-free artifacts, local listen-address ownership — is established in C0's multi-rete section. C1 introduces two additional concerns specific to the mesh layer.
FlorIO socket path. The socket between mesh-flor and a link-flor vertex is at <runtime-root>/vertices/<link-vertex-name>.sock — e.g. /run/flor/<scope>/vertices/… in system mode. Both segments are scope-distinct by construction: the per-scope runtime root separates scopes, and <vertex-name> separates vertices within one.
UDP connection-manager listeners and external addresses. Mesh-flor's bare-UDP adapter (connection_manager.adapters[].listen) and link-flor's QUIC-link UDP listener only appear on server nodes — nodes with a declared external IP:port in nodes.yaml. Server nodes are by definition admin-managed. Two retes binding the same external IP:port on the same host must be coordinated by the IT admin responsible for that host; this is the same constraint as any two services sharing a server. User devices have no fixed UDP listen address and are unaffected. B1+ direction: node-side port-availability advertisement, so the rete's mgmt-plane knows which external ports the node has available rather than relying on operator coordination (see C0 multi-rete section).
Explicitly deferred (in addition to C0's list)
rete compile --derive-paths— declarative path derivation from topology + access. Ships post-C1 once the topology model is stable.- Priority / fallback paths.
- 3rd-party connection-manager adapters (WireGuard, VLESS, …) and the identity-projection rules they need. Architectural placeholder in Scope > Link Protocols and Data Plane; no implementation in C1, but the multi-adapter
connection_manager.adapters[]shape is exercised by the QUIC + UDP pair so adding a third adapter type is purely additive. - Mixed-vertex publication (services in link-flor instead of mesh-flor). Now a corner case rather than a planned optimisation, since UDP links cover the private-network performance story without splitting publication. Schema still allows it; YAML surface and validator rules deferred indefinitely.
Evolution
Unchanged from the C0 evolution table. The "Binaries" row updates from "Single flor (link role)" to "flor agent + N flor vertex per node, same binary, different subcommands." C1 is the first milestone where every row reads as written.
Scope checklist (delta over C0)
- YAML schemas for
links.yaml(incl.type: quic | udp) andpaths.yaml; per-vertex FlorIO socket convention (<runtime-root>/vertices/<vertex-name>.sock) derived at runtime from the spawn-injected runtime root — never compiled into artifacts - Compiled-artifact schemas:
agent.jsoncarrying atrustblock; one vertex payload shape, itskind: link | meshselecting the engine;ingress.allow/egress.alloware flat SPIFFE-ID lists; the per-nodelinkstable (andallowed_links/allowed_pathsin the source YAMLs) uses the typed-rule list shape - Per-node compiled output layout:
mgmt/<node>/{agent.json, <name>.json…}, flat (rete.jsonalways; a link-vertex file — named after itsname:field innodes.yaml— for eachquic-type link-vertex on the node) - Two new principal kinds wired through issuance, compile, and runtime:
vertex/<node>/<name>(rete mesh-vertex, namedreteby convention) andcontrol-plane/<name> -
via:andagent_via:validation: required in C1 (multiple vertices per node), compiler resolves to target vertex for workload placement - every envelope carrying its signer's certificate in
signature, validated againstidentity/anchors.pem(the agent is the sole signature verifier; no artifact carries a signer set — see ADR-0015) - Connection-manager
adapters[]list with bothflorio(for QUIC links via link-flor) andudp(direct in mesh-flor) implementations, plus thelinkstyped-rule bound (named links; each apeer+viaadapter) declaring which links reach which peer mesh-vertices - Multi-hop forwarder operates uniformly across mixed adapter types
-
rete compileemitting the per-node mgmt layout (omitting the link-vertex file when no QUIC-type link-vertices are declared). The ctrl subtree is produced by the samerete compileinvocation but documented in Ctrl Plane. - Additional validator rules (link validity, address feasibility, link-type coherence, multi-hop path validity, principal role coherence)
-
rete issue-bundle --node <n>includes the node's mesh-vertex identity alongside workload principals; optionally bundles a currentagent.json+ vertex configs -
rete id issue --kind control-plane --name <name>mints a CP signing keypair on operator hardware -
flor-vertex --scope <scope> --name <name> [--set <version>]daemon entry point (reads mgmt + mesh ctrl configs by name from the artifact set; loads identities from the scope's identity store; verifies the mgmt↔ctrl join) -
flor agent runsupervises its workloads (direct fork under the OS-managed agent service — see C0's Agent page; per-workload launch runtimes reserved); fetches mgmt and ctrl artifacts as separate streams from the coordinator - Example
my-rete/repo with a 3-node mesh (alpha/beta/gamma) showing multi-hop paths through an internal-only node
Reasoning
Captured justifications for the C1-specific additions. See the C0 reasoning for everything else.