Florete

Control Plane

Control plane artifact stream introduced in C1 Manual Mesh

C1 introduces the ctrl-plane artifact stream — a parallel, separately-signed delivery channel that carries the dynamic decisions an autonomous control plane will make in B1+. C1 itself has no autonomous CP yet: in C1 the ctrl artifact is produced by retectl compile (operator-side), every "decision" equals the corresponding mgmt bound, and the framework runs in its degenerate mode. The point of landing the framework in C1 is to lock in the shape — envelope, signature, distribution, agent-side verification — before the producer changes in B1+.

Read Mgmt Plane first. The bounds described there are what every ctrl decision is verified against; this page assumes you know what's signed by the operator and what's left for ctrl to fill in.

What lives in the ctrl artifact

In C1 only mesh-flor has ctrl-shaped state — it's where multi-hop forwarding lives. Link-flor and the agent itself have nothing dynamic in C1, so no ctrl artifacts are emitted for them. (Link-up/down liveness will likely appear here in B1+; the slot exists, just unused.)

The C1 ctrl artifact for mesh-flor:

{
  "schema_version": "1.0",
  "plane": { "ctrl": { "obeys_mgmt_version": 42 } },
  "kind": "vertex",
  "version": 17,
  "node": "alpha",
  "name": "rete",
  "generated_at": "2026-04-20T12:00:00Z",
  "payload": {
    "active_links": [ "beta", "gamma" ],
    "forwarding_table": [
      { "in": { "link": "beta",  "label": 17 }, "out": { "link": "gamma", "label": 42 } },
      { "in": { "link": "gamma", "label": 23 }, "out": { "link": "beta",  "label": 71 } }
    ],
    "label_bindings": [
      {
        "scope": "ingress",
        "target": "spiffe://rete-lovers/service/api",
        "initiator": "spiffe://rete-lovers/user/alice",
        "in": { "link": "beta", "label": 9 }
      },
      {
        "scope": "egress",
        "target": "spiffe://rete-lovers/service/mongodb",
        "initiator": "spiffe://rete-lovers/service/api",
        "out": { "link": "beta", "label": 11 }
      }
    ]
  },
  "signature": {
    "alg": "ed25519",
    "key_id": "spiffe://rete-lovers/control-plane/primary",
    "value": "<base64>"
  }
}

Field semantics:

  • Envelope — same shape as the mgmt envelope (see C0's spec), including the generic name claim (here the vertex name). The one difference is the plane: ctrl artifacts carry plane: { "ctrl": { "obeys_mgmt_version": … } } where mgmt artifacts carry the bare plane: "mgmt". obeys_mgmt_version rides on the ctrl plane itself (it's a ctrl-plane fact, common to every ctrl artifact) and pins which mgmt artifact this ctrl decision was made against; agents reject ctrl whose obeys_mgmt_version is older than the mgmt they currently hold (rollback-attack defense).
  • signature — produced by a control-plane/<name> key. The key_id is the CP's SPIFFE ID. Agents verify the signature against a pubkey in the agent's trust.authorized_ctrl_signers (carried in agent.json, not in this artifact — see ADR-0011); ctrl signed by an unauthorized key is rejected.
  • active_links — the subset of mgmt's links bound (the per-node projection of allowed_links) this mesh-flor should currently bring up. In C1 this equals the full set produced by links. In B1+ the CP picks based on health (drain, fail-over). This is all the rete-wide topology information a per-node agent ever sees — it knows its own links, nothing else.
  • forwarding_table — MPLS-style label-forwarding rows for each transit hop this node carries, each an in: {link, label}out: {link, label} swap. Labels are per-link (per-interface significance), so each is paired with its link name; the agent verifies that each in.link/out.link is in active_links and that every (in.link, in.label) is unique.
  • label_bindings — binds each accepted (initiator, target) flow from the mgmt allow lists to a per-link label: an ingress binding is in: {link, label} → local delivery; an egress binding is local origin → out: {link, label} (the first hop). Each binding's (scope, target, initiator) must appear in the corresponding ingress.allow or egress.allow flat list; this is the local verifier's check.

No active_paths field. Per-node agents have no use for the rete-wide path enumeration: a flor vertex can only act on packets it actually sees, which means its own forwarding rows and label bindings. Knowing that label 17 globally corresponds to "alice → alpha → beta → kafka" buys nothing operationally — the vertex doesn't process the path, it processes labels. Keeping path enumeration out of per-node ctrl artifacts also matches the practical limit of local verifiability (see "What hijacked CP can and cannot do" below).

Source layout (additions to mgmt-plane's)

my-rete/
├── …                            # mgmt YAMLs and compiled mgmt subtree (see mgmt-plane)
├── .gitignore                   # contains: .flor/compiled/*/ctrl/
└── .flor/
    └── compiled/
        └── alpha/
            ├── mgmt/            # signed by operator; committed
            │   └── …
            └── ctrl/            # signed by CP key; gitignored
                └── vertices/
                    └── rete.json

Ctrl artifacts are gitignored even in C1, where they're deterministic from operator YAMLs. The reasoning is lifecycle, not freshness: ctrl is runtime state, not authoring state. Committing it would conflate the audit log (mgmt) with the running configuration (ctrl) and create workflow drift at C1→B1+, where ctrl genuinely doesn't belong in git.

If an operator wants to record a snapshot of ctrl state for audit (e.g., "what forwarding decisions were live during the incident on May 3?"), it goes in a separate audit log, not in the rete repo.

Compile step (in C1)

The same retectl compile invocation that produces mgmt artifacts also produces ctrl artifacts in C1. There's no separate command — they're emitted from the same source-of-truth YAMLs in one pass:

retectl compile --node alpha --repo my-rete/
# emits:
#   .flor/compiled/alpha/mgmt/agent.json
#   .flor/compiled/alpha/mgmt/vertices/public.json
#   .flor/compiled/alpha/mgmt/vertices/rete.json
#   .flor/compiled/alpha/ctrl/vertices/rete.json

The mgmt artifacts are signed with the operator's mgmt signing key; the ctrl artifact is signed with the operator's CP signing key. In C0/C1 both keys live on the operator's workstation — same machine, separate files (~/.flor/operator-mgmt.key and ~/.flor/control-planes/primary.key). They are not the same key; conflating them would couple mgmt-signing-authority lifecycle with CP-signing-authority lifecycle, which becomes painful as soon as B1+ wants to put the CP key on a different host (managed cloud or BYO on-prem).

retectl publish pushes both subtrees:

  • Mgmt artifacts go to POST /publish/mgmt on the config-server.
  • Ctrl artifacts go to POST /publish/ctrl.

Authentication for the publish side is via the existing config-write group (operator role). Internally the config-server stores mgmt and ctrl on parallel paths; agents fetch via parallel endpoints (GET /artifact/mgmt/<node> and GET /artifact/ctrl/<node>), both gated by config-read. The CP authority being a different SPIFFE principal (control-plane/<name>) from the operator does not yet require a second write group in C1 — retectl compile runs on operator hardware and publishes both streams under the operator's user/<op> identity. The hypothetical split (CP becomes an external writer pushing ctrl independently of the operator) is a B1+ design call and would introduce a new group only then.

Label allocation

The label allocator is a ctrl-plane concern in B1+. In C1 it runs at compile time as a deterministic walk over operator-declared paths:

  • Per-link counter; for each path, walk hop-by-hop and assign the next unused labels for forward and backward directions.
  • The full label map for the rete is implicit — each node's ctrl artifact carries only its local slice (forwarding rows where this node is the transit, label bindings where this node hosts the initiator or target).
  • retectl compile --repo X produces identical output on any machine given identical inputs (same deterministic walk order as the rest of compile).

In B1+ the allocator runs continuously inside the CP and re-runs whenever active paths change. The wire format is the same; only the producer changes.

The validator surfaces label infeasibility (per-link label space exhausted) at retectl compile time in C1 — not during mgmt validation, since label feasibility is intrinsically a function of the chosen path set, not the bound.

Agent-side verification

Each agent fetches both streams and verifies them locally before applying. The flow on every sync:

  1. Fetch mgmt for this node from /artifact/mgmt/<node>.
  2. Verify mgmt signature against the agent's trusted mgmt-signer set — pinned at enrollment, carried in agent.json's trust block, and rotated by the vouched-successor rule (see ADR-0011).
  3. Fetch ctrl for this node from /artifact/ctrl/<node>.
  4. Verify ctrl signature against one of the pubkeys in agent.json's trust.authorized_ctrl_signers. Reject if signed by a key not in the authorized list, or if no authorized CP is listed at all.
  5. Verify obeys_mgmt_version ≥ the version of the mgmt artifact just verified. Reject if ctrl is older than mgmt (the CP hasn't caught up yet — wait for the next ctrl push).
  6. Verify ctrl-vs-mgmt structural rules:
    • Every entry in active_links is in the set produced by mgmt's links bound (dispatch on each rule's type; for enum that's literal membership).
    • Every forwarding_table in.link/out.link is in active_links.
    • Every label_bindings entry's (scope, target, initiator) appears in the corresponding flat ingress.allow or egress.allow list.
    • Every (in.link, in.label) is unique across the forwarding table and ingress bindings (per-link in-label uniqueness; out-labels live in the neighbour's space).
  7. Apply — combine mgmt's identities, allow lists, and link/adapter config with ctrl's active_links, forwarding_table, and label_bindings to derive the runtime state.

If any step fails, the agent retains its previous (last-known-good) state and surfaces the failure to the operator (status, logs).

What hijacked CP can and cannot do — the security tradeoff

This deserves to be named explicitly because it's a real and inherent tradeoff of the bounded-CP model.

Cannot:

  • Grant access — the workload-layer mTLS is end-to-end against signed mgmt; a hijacked CP can't make kafka accept a connection from someone whose SPIFFE ID isn't in kafka's mgmt-signed ingress.allow.
  • Add new principals or links — those require an operator signature; the CP can only act inside what mgmt declared.
  • Persist beyond a policy refresh — once the operator updates authorized_ctrl_signers to remove the breached CP key, agents stop accepting its ctrl artifacts.

Can:

  • Steer traffic through unintended hops within declared links. Each agent can verify that its local forwarding rows reference its declared links, and that label bindings reference its allowed (initiator, target) pairs. But labels are opaque at the local level — node alpha doesn't know whether label 17 came from a path that, end-to-end, traverses only operator-allowed nodes. A hijacked CP can rewire forwarding rows on intermediate nodes within their respective declared link sets, so traffic flows through different (still-allowed) hops than the operator intended.
  • Black-hole or drop traffic — a CP that omits forwarding rows or shrinks active_links causes connections to fail. DoS class.
  • Manipulate latency or do traffic analysis — by routing through observers along permissible paths.

Class-level summary: access (confidentiality, integrity, authentication) survives a hijacked CP. Availability and traffic-pattern privacy don't. For customers who can't accept that tradeoff, the BYO on-prem CP deployment tier (planned for B1+) keeps the CP signing key out of any cloud control. For customers who can, the managed CP tier offers the convenience of cloud-hosted bounded-CP and accepts the residual blast radius.

This tradeoff is intrinsic to "global path coherence is a non-local property and cannot be locally verified by per-node agents." Mitigations (redundant ctrl annotations the initiator's agent can fully verify; out-of-band rete-wide consistency gossip; transparency log) are deferred until concrete pressure justifies them.

Explicitly deferred

  • Autonomous CP service. C1's ctrl producer is retectl compile; B1+ replaces it with a CP service.
  • Multi-CP coexistence (multiple authorized_ctrl_signers entries, regional CP, A/B rollouts). Schema permits it from day one; orchestration is post-C1.
  • CP key rotation tooling and policy refresh on schedule.
  • Hot reload of ctrl-only changes (path failover with no mgmt change). Today every ctrl change still goes through flor agent sync's restart-with-commit-timeout path, like mgmt.
  • Path-coherence enforcement beyond local rules — see "What hijacked CP can do" above.
  • Bounds-language growth beyond allow-list. The typed-rule shell is in place; richer rules land when concrete features need them.

Reasoning

Captured rationale for the C1-specific ctrl-plane decisions. The broader "why bounded CP, why signed mgmt" framing lives in C0's reasoning.

On this page