Florete

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 with commit-timeout, same naming scheme, same atomic-artifact distribution.

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 retectl 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 retectl 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; the cert+key are held 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 flor binary, no --role flag — 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/rete by convention). Used for hop-by-hop link-layer mTLS.
  • flor-agent as 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 config-server. 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.json plus vertices/<name>.json per vertex under <node>/mgmt/. Standard topology produces vertices/public.json (link-vertex) and vertices/rete.json (mesh-vertex). Forwarding state (labels, forwarding table, label-bindings) is not here — it's in the ctrl artifact at <node>/ctrl/vertices/rete.json (see Ctrl Plane).
  • authorized_ctrl_signers in agent.json's trust block — the agent carries one or more authorized CP signing pubkeys (alongside the mgmt signers) and accepts ctrl artifacts only if signed by one of these. In C1 there's a single CP key, operator-held.
  • Per-vertex FlorIO sockets — each vertex that exposes FlorIO gets a socket at /run/flor/<rete>/vertices/<vertex-name>.sock. The compiler derives the path from the rete name and vertex name declared in nodes.yaml; no separate rete.yaml config is needed.
  • 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 --commit-timeout, config-server-fronted distribution, naming, version-stamped envelope, SOCKS5 proxy, emergency-access safety net) is identical to C0.

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 `retectl compile` then commits)
        ├── alpha/
        │   ├── mgmt/            # signed by operator; committed
        │   │   ├── agent.json
        │   │   └── vertices/
        │   │       ├── public.json   # link-vertex named "public"
        │   │       └── rete.json     # mesh-vertex named "rete"
        │   └── ctrl/            # signed by CP key; gitignored
        │       └── vertices/
        │           └── 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 retectl 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: rete-lovers                 # same as C0

  ca:
    cert: certs/ca.crt              # same as C0
    # validity_days — default applies

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

  tls_principals:
    # validity_days — default applies

The compiler embeds the listed ctrl-signer pubkeys into agent.json's trust.authorized_ctrl_signers, alongside the mgmt signers (see ADR-0010 for placement). Agents accept ctrl artifacts only if signed by one of these keys. Rotating a ctrl signer is simpler than mgmt rotation: the operator publishes an updated agent.json — signed with the mgmt key — that lists the new ctrl pubkey. There's no vouched-successor chain, because ctrl authority is delegated by mgmt, not self-anchored; a compromised CP cannot rotate its own signers (see ADR-0011).

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: public,   kind: link, type: quic, address: 1.2.3.4:4433 }
      - { name: internal, kind: link, type: udp,  address: 10.0.0.5:4433 }
      - { name: rete,  kind: mesh, type: quic, uses: [public, internal] }
    agent_via: rete              # which vertex hosts the agent's node/alpha principal
  beta:
    vertices:
      - { name: public,   kind: link, type: quic, address: 5.6.7.8:4433 }
      - { name: internal, kind: link, type: udp,  address: 10.0.0.6:4433 }
      - { name: rete,  kind: mesh, type: quic, uses: [public, internal] }
    agent_via: rete
  gamma:
    vertices:
      - { name: internal, kind: link, type: udp, address: 10.0.0.7:4433 }
      - { name: rete,  kind: mesh, type: quic, uses: [internal] }
    agent_via: rete
    internal_only: true             # not reachable from Internet; only via peers

The 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 rete by convention. Its SPIFFE ID is spiffe://<rete>/vertex/<node>/<vertex-name> — e.g. alpha's rete mesh-vertex is spiffe://rete-lovers/vertex/alpha/rete. The name is operator-chosen; rete is the community convention, not a reserved or validated label. A future interrete-vertex on the same node would be vertex/alpha/interrete by convention.
  • agent_via: on the node entry names which vertex hosts the agent's node/<node> principal. Required when the node has multiple vertices (always in C1). The agent connects to config-server through the named vertex's SOCKS5 listener.
  • Mesh-vertex type is the upward interface contract. type: quic is 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 — two type: udp link-vertices on each side, both in uses:).
  • Workloads (users, services) attach via via: — each service and user device entry names its host vertex with a via: field. C1 convention is via: rete for all workloads (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: udp link-vertex into mesh-flor's process (a UDP socket inline), or implement a type: wg link-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]
  ssh:
    at: alpha
    via: rete
    scope: node
    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 `enum`. New rule types can be added as
# additional entries in this list without reshaping the schema.
allowed_links:
  - type: enum
    links:
      - { a: alpha, b: beta,  via: public   }
      - { a: alpha, b: gamma, via: internal }
      - { a: beta,  b: gamma, via: internal }
  # Asymmetric form when the link-vertex names differ between endpoints:
  # - { a: { node: delta, vertex: ext }, b: { node: epsilon, vertex: eth0 } }
  # Future rule types — illustrative, not implemented in C1:
  # - { type: any-pair-via, nodes: [alpha, beta, gamma], via: 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: enum
    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 (retectl) — authoring
retectl compile --node <name> [--repo <dir>] [--out <dir>]
                                            # emits <out>/<node>/{agent.json, vertices/*.json}

# node (flor) — runtime
flor vertex run --rete <scope> --name <name>
                                            # vertex daemon; reads mgmt + (mesh) ctrl
                                            # configs by name under the rete root; the agent
                                            # supervises one or more of these per node

flor agent run keeps its C0 behaviour and CLI shape ([--rete <scope>], reading <root>/mgmt/agent.json); it just supervises N vertex instances rather than one, spawning each as flor vertex run --rete <scope> --name <name>. Exactly how the agent launches vertex processes — direct fork, systemd unit, future local workload-runtime interface — is intentionally not pinned in C1; the runtime API stays simple (a vertex starts when given its scope and name, and the agent makes that happen by whatever means the platform offers).

retectl ca sign adds vertices to its --kind enumeration (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, named rete by convention in C1) and at least one link-vertex; every uses: 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's via: must name a vertex declared on the respective at: node. The agent_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 ("specify via: to disambiguate among <list of vertex names>"). Validator also warns (not errors) when via: references a kind: link vertex — that is a valid mixed-topology opt-in but unusual enough to surface.
  • Link validity — every link is between two distinct, known nodes; the via reference resolves to a link-vertex on each side (symmetric shorthand or asymmetric form); both link-vertices have the same type.
  • 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 coherencetype: udp link-vertices may only peer over shared internal networks (not over Internet-facing addresses), since they carry no link-layer crypto. type: quic link-vertices are allowed everywhere.
  • Multi-hop path validity
    • principal is a known user or service name. from is a known node; for users it must appear in users.yaml::nodes; the starting mesh-vertex is the one declared in the principal's via: binding.
    • Each hops[].node is 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 in allowed_links that connect the previous mesh-vertex to any mesh-vertex on the hop node. If the lookup is ambiguous, validation fails with "specify vertex: 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 in allowed_links connecting the previous mesh-vertex to this hop's mesh-vertex; if multiple links connect the same pair, validation fails with "specify via: to disambiguate."
    • Every adjacent mesh-vertex pair in the resolved path (origin → hop₁ → … → last hop) has a declared link in allowed_links.
    • to is a published service. Its host mesh-vertex (from its via: 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 role declared.

(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

retectl compile produces two artifact subtrees per node in C1: a signed mgmt subtree (<node>/mgmt/) and a CP-signed ctrl subtree (<node>/ctrl/). The mgmt subtree contains one agent.json plus one vertices/<name>.json per vertex (standard topology: vertices/public.json for the link-vertex and vertices/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",
  "kind": "agent",          // or "vertex"
  "version": 42,
  "node": "alpha",
  "name": "agent",          // vertex name for vertex artifacts
  "generated_at": "2026-04-20T12:00:00Z",
  "payload": { ... },
  "signature": { "alg": "ed25519", "key_id": "spiffe://rete-lovers/management-plane/primary", "value": "<base64>" }
}

plane and kind are orthogonal: plane is the decision-authority axis ("mgmt" signed by the operator today; "ctrl" reserved for the unsigned control-plane stream introduced in B1+), kind distinguishes the supervisor artifact from the data-plane vertex artifacts. C1 stays at plane: "mgmt" for everything; the CP stream lands later.

Workloads & IO

Both vertex configs use a unified workloads shape — one entry per local workload (a TLS principal; initiator, target, or both). Each workload 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 workload connects into flor here; flor originates outgoing connections on its behalf. Workload acts as an initiator.
  • outbound kind (tcp) — flor connects out to the workload here; flor delivers incoming connections. Workload acts as a target (service).
  • bidirectional kind (florio) — one socket does both, carrying Connect requests downward and inbound deliveries upward between recursive flor layers.

A workload entry:

{
  "spiffe_id": "spiffe://rete-lovers/service/api",
  "identity": { "cert_path": "...", "priv_path": "..." },
  "io": [
    { "kind": "tcp",    "upstream": "127.0.0.1:8000" },
    { "kind": "socks5", "listen":   "127.0.0.1:18000" }
  ]
}

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.

vertices/public.json payload

The link-vertex config file is named after the vertex's name: field in nodes.yamlpublic in the standard topology. The compiler derives the filename from the vertex name; operators never pick it manually.

{
  "kind": "link",
  "ca_cert_path": "ca.crt",
  "transport_endpoint": { "type": "quic" },
  "connection_manager": { "adapters": [ { "name": "wire", "type": "udp", "listen": "0.0.0.0:4433" } ] },
  "workloads": [
    {
      "spiffe_id": "spiffe://rete-lovers/vertex/alpha/rete",
      "identity":  { "cert_path": "cv-alpha.crt", "priv_path": "cv-alpha.key" },
      "io": [
        { "kind": "florio", "socket": "/run/flor/rete-lovers/vertices/public.sock" }
      ]
    }
  ],
  "links": [
    {
      "type": "enum",
      "members": [
        {
          "name": "beta",
          "peer": "spiffe://rete-lovers/vertex/beta/rete",
          "via": { "type": "udp", "adapter": "wire", "addr": "5.6.7.8:4433" }
        }
      ]
    }
  ],
  "ingress": [
    {
      "target": "spiffe://rete-lovers/vertex/alpha/rete",
      "allow": [ "spiffe://rete-lovers/vertex/beta/rete" ]
    }
  ],
  "egress": [
    {
      "target": "spiffe://rete-lovers/vertex/beta/rete",
      "allow": ["spiffe://rete-lovers/vertex/alpha/rete"]
    }
  ]
}

Things worth pointing out about this shape:

  • The cert+key are link-flor's loaded material, not its identity. vertex/alpha/rete is a workload entry that link-flor serves and acts on behalf of. 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 florio block. FlorIO sockets are properties of individual workloads' io entries, derived from the vertex name. The socket path /run/flor/<rete>/vertices/<vertex-name>.sock is a compiler convention, not a config field.
  • ingress/egress are 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 in links, never in egress.
  • links is the star. The link-vertex's links table maps each peer mesh-vertex to its wire addr (a udp via) — its entire (degenerate, 1-1) routing. No forwarding_table, no labels, no paths: multi-hop forwarding lives entirely in mesh-flor. (Here public carries only the QUIC peering to beta; alpha↔gamma is a direct-UDP mesh link with no link-flor — see rete.json below.)

Mixed-topology case (publishing a regular service in link-flor for private-network use): the service's workload entry simply moves from rete.json into the link-vertex's config file (public.json in the standard topology), with no schema change. The compiler decides placement based on the service's via: field.

rete.json payload

{
  "kind": "mesh",
  "ca_cert_path": "ca.crt",
  "transport_endpoint": { "type": "quic" },
  "connection_manager": {
    "adapters": [
      { "name": "via-link", "type": "florio", "socket": "/run/flor/rete-lovers/vertices/public.sock" },
      { "name": "internal", "type": "udp",    "listen": "10.0.0.5:5544"     }
    ]
  },
  "links": [
    {
      "type": "enum",
      "members": [
        {
          "name": "beta",
          "peer": "spiffe://rete-lovers/vertex/beta/rete",
          "via": { "type": "florio", "adapter": "via-link" }
        },
        {
          "name": "gamma",
          "peer": "spiffe://rete-lovers/vertex/gamma/rete",
          "via": { "type": "udp", "adapter": "internal", "addr": "10.0.0.7:5544" }
        }
      ]
    }
  ],
  "workloads": [
    {
      "spiffe_id": "spiffe://rete-lovers/node/alpha",
      "identity":  { "cert_path": "alpha.crt", "priv_path": "alpha.key" },
      "io": [
        { "kind": "socks5", "listen": "127.0.0.1:1080" }
      ]
    },
    {
      "spiffe_id": "spiffe://rete-lovers/service/api",
      "identity":  { "cert_path": "api.crt", "priv_path": "api.key" },
      "io": [
        { "kind": "tcp",    "upstream": "127.0.0.1:8000" },
        { "kind": "socks5", "listen":   "127.0.0.1:18000" }
      ]
    }
  ],
  "ingress": [
    {
      "target": "spiffe://rete-lovers/service/api",
      "allow": ["spiffe://rete-lovers/user/alice"]
    }
  ],
  "egress": [
    {
      "target": "spiffe://rete-lovers/service/mongodb",
      "allow": ["spiffe://rete-lovers/service/api"]
    }
  ]
}

The agent's principal — node/alpha — appears as a workload here so the agent can dial config-server 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 config-server. 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 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 enum; 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, alongside the mgmt signers. See agent.json below, with ADR-0010 for the placement and ADR-0011 for verification and rotation.

agent.json payload

{
  "trust": {
    "ca_cert_path": "ca.crt",
    "authorized_mgmt_signers": [
      { "spiffe_id": "spiffe://rete-lovers/management-plane/primary", "pubkey": "<base64-ed25519>" }
    ],
    "authorized_ctrl_signers": [
      { "spiffe_id": "spiffe://rete-lovers/control-plane/primary", "pubkey": "<base64-ed25519>" }
    ]
  },
  "vertices": [
    { "name": "public", "kind": "link" },
    { "name": "rete",   "kind": "mesh" }
  ],
  "control_plane": {
    "principal":     "spiffe://rete-lovers/node/alpha",
    "config_server": "spiffe://rete-lovers/service/config-server",
    "via":           { "kind": "socks5", "addr": "127.0.0.1:1080" }
  }
}

The agent supervises every vertex listed under vertices; each entry's kind (link/mesh) tells the agent which engine it's running without parsing the vertex payload. The agent is also the sole signature verifier: it checks every mgmt and ctrl artifact against the trust block here (the CA plus the mgmt and ctrl signer sets) and hands already-verified configs to the vertices it spawns — see ADR-0011. To reach config-server, the agent acts as the principal node/alpha, dialing the local flor inbound named in control_plane.via ({ kind, addr }, typed so a new inbound protocol is a new kind) — no need to parse any vertex payload. It locates each vertex's configs by name under the single rete root (see C0's node runtime layout): mgmt/vertices/<name>.json for every vertex and ctrl/vertices/<name>.json for mesh vertices, both verified before it supervises with flor vertex run. Paths in the artifact (ca_cert_path, cert_path, priv_path) are scope-relative and resolved by the agent against the rete install root (~/.flor/retes/<scope>/) at startup — see C0's compile step for the convention.

The number and shape of vertices is fully driven by vertices. 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 adds two new principal kinds on top of C0's users, services, and nodes:

KindIn C0In C1SPIFFE IDUsed for
Mesh-vertexNoYesspiffe://<rete>/vertex/<node>/reteHop-by-hop link-layer mTLS (one per node per rete, named rete by convention)
Control planeReservedYesspiffe://<rete>/control-plane/<name>Signing authority for ctrl artifacts (one or more per rete; in C1 a single operator-held key)

A useful "owned-by / held-by" view across all four principal kinds:

KindPrincipal (identity owner)Cert+key held by (the vertex on its behalf)
nodethe node (the host as rete member)mesh-flor (via the node/<node> workload entry there)
mesh-vertexmesh-flor (qua principal in the link layer)link-flor
userthe human (their workload)mesh-flor
servicethe service workloadmesh-flor (or link-flor in mixed-topology setups)

A vertex always holds identity material on someone else's behalf — that's the recursive pattern from the HLD. Mesh-flor does the mesh-layer mTLS for its workloads; link-flor does the link-layer mTLS for mesh-flor (its sole standard-topology workload). No vertex is a principal at its own layer; every vertex is a principal at the layer below.

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. flor-agent is not the principal; it is the workload that acts on the node's behalf, just as alice's process acts on alice's behalf. Mesh-flor holds the cert+key and runs mTLS for node/<name>, exactly as it does for user and service principals. The agent's job is to wield that identity: fetch configs, supervise vertices, and reach config-server through mesh-flor's SOCKS5 listener.

Node-identity vs. mesh-vertex-identity aren't merged because they serve different layers and are held by different vertices — node/<name> is for control-plane traffic (agent ↔ config-server), vertex/<node>/rete is 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 flor-agent process per rete acting on its behalf.

Enrollment flow is unchanged: retectl issue-bundle --node alpha --rete <config-server-url> 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 separately on the operator's own machine (retectl ca sign --kind control-plane --name primary produces the cert+keypair that signs ctrl artifacts). The CP key is not shipped in per-node bundles — only its public counterpart, embedded in authorized_ctrl_signers of agent.json's trust block. 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 may also include a current agent.json + per-vertex configs from the latest published state — sufficient for the agent's first run with no prior sync. 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 rete, scope-relative artifact paths, 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 /run/flor/<rete-name>/vertices/<link-vertex-name>.sock. Both segments are scope-distinct by construction: <rete-name> separates retes, and <vertex-name> separates vertices within a rete. Two retes' stacks on the same host use different socket paths as long as their rete names differ — which they will unless two independent operators happen to share a name, in which case the node operator overrides the local scope name via CLI (flor enroll <bundle> --as <scope>).

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)

  • retectl 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) and paths.yaml; per-vertex FlorIO socket convention (/run/flor/<rete>/vertices/<vertex-name>.sock) baked into compiler rather than declared in rete.yaml
  • Compiled-artifact schemas: agent.json (envelope kind: agent) carrying a trust block; a single vertex.json envelope shape (kind: vertex) carrying a payload kind: link | mesh; ingress.allow/egress.allow are flat SPIFFE-ID lists; the per-node links table (and allowed_links/allowed_paths in the source YAMLs) uses the typed-rule list shape
  • Per-node compiled output layout: <node>/mgmt/{agent.json, vertices/*.json} (vertices/ contains rete.json always; a link-vertex file — named after its name: field in nodes.yaml — for each quic-type link-vertex on the node)
  • Two new principal kinds wired through issuance, compile, and runtime: vertex/<node>/<name> (rete mesh-vertex, named rete by convention) and control-plane/<name>
  • via: and agent_via: validation: required in C1 (multiple vertices per node), compiler resolves to target vertex for workload placement
  • trust block in agent.json carrying ca_cert_path, authorized_mgmt_signers, and authorized_ctrl_signers (the agent is the sole verifier)
  • Connection-manager adapters[] list with both florio (for QUIC links via link-flor) and udp (direct in mesh-flor) implementations, plus the links typed-rule bound (named links; each a peer + via adapter) declaring which links reach which peer mesh-vertices
  • Multi-hop forwarder operates uniformly across mixed adapter types
  • retectl compile emitting 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 same retectl compile invocation but documented in Ctrl Plane.
  • Additional validator rules (link validity, address feasibility, link-type coherence, multi-hop path validity, principal role coherence)
  • retectl issue-bundle --node <n> includes the node's mesh-vertex identity alongside workload principals; optionally bundles a current agent.json + vertex configs
  • retectl ca sign --kind control-plane --name <name> issues a CP signing keypair on operator hardware
  • flor vertex run --rete <scope> --name <name> daemon entry point (reads mgmt + mesh ctrl configs by name; resolves identities against the rete root)
  • flor agent run supervises vertex instances (mechanism — direct fork, systemd, or future local runtime — left open in C1); fetches mgmt and ctrl artifacts as separate streams from the config-server
  • 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.

On this page