Florete

Agent

flor agent — the node's workload supervisor; contract, artifact handling, and OS integration for C0

Role

flor agent is the node-side workload supervisor — the first, deliberately degenerate incarnation of the Execution Plane Agent. In C0 it:

  • verifies every artifact the node receives, as the rete's sole verifier (ADR-0015);
  • supervises the platform workloads that make this node a rete member: the flor vertex on every node, plus the coordinator on the management node;
  • owns the node/<node> principal for sync with the coordinator — dialing through the vertex's SOCKS5, the vertex terminating TLS for it and its material resting in the identity store (Identity, ID-C0-03);
  • answers local queries over its control socket (flor agent status, rete's operator-SOCKS5 lookup).

One agent runs per enrolled rete scope. Two invariants keep the multi-rete and hosting futures open:

  • No node-global state. Everything the agent reads or writes lives under its scope root and its scope's runtime directory; it claims no host resource beyond what its artifacts declare. Host-level concerns spanning retes (well-known ports, future resource quotas) belong to the host's own authority, below every rete — never to a merged multi-tenant agent.
  • Retes federate only through interrete. Two retes on one host relate exactly as two retes on different hosts do; there is no local agent-to-agent channel.

Supervision contract

The agent uses the three-layer split mature supervisors converge on (kubelet's PodSpec / ConfigMap / downward API; systemd's unit / app config / LISTEN_FDS):

  1. Supervision entries — the agent's own vocabulary, fully parsed by it. agent.json's payload carries a workloads inventory of { name, run } entries. run is a plain argv naming the executable to spawn — with no per-workload context in it: everything variable is injected through the uniform spawn environment (layer 3 below), so every entry is just its executable — { "name": "hop", "run": ["flor-vertex"] }, { "name": "coordinator", "run": ["flor-coordinator"] }. The launch recipe is the only per-entry fact the agent needs, and run states it explicitly — the agent applies zero per-entry special-casing, vertices included; the inventory deliberately carries no workload taxonomy. run names executables resolved by node-side convention against the installation — the lib directory first, never bare PATH (the same class of thing as workload names becoming socket names, not a filesystem path), and the C0 validator restricts it to the known argvs — it is compiler-emitted, never operator-authored, until tenant workloads open the set post-C0. Each workload's config sits flat in the active mgmt set at <name>.json, plus — from C1 on — the node's newest verified ctrl/<name>@v<K>.json slot. The link/mesh distinction is not here — it selects the forwarding engine and lives where the engine reads it, in the vertex payload. C0 is the degenerate case: one vertex entry per node, plus the coordinator on the management node. Evolution fields are reserved, added when a milestone pulls on them: launch runtime (fork today; per-workload system units, containers, remote WRI later — run is that runtime's parameter), restart policy, declared dependencies, consumed-artifact references, workload attributes (system, provides-networking) once a policy actually consumes them.
  2. Payload artifacts — delivered, not owned. The agent verifies envelope and signature, stages the file, and hands it over; only the workload parses its payload.
  3. Runtime context and status — injected downward at spawn as the uniform environment contract (table below), identical for every child, vertices included — consumers need no layout knowledge; run by hand, flags override env. Reported upward: exit status in C0; readiness, bound ports, and health once the per-workload control channel lands. Together with the exit-code vocabulary and the SIGTERM convention below, this is the complete supervised-program contract — the surface a tenant workload implements from B2+, dogfooded by flor's own components from C0.

The supervised-program contract — every child gets these, flor's own components and, from B2+, tenant workloads alike. Each names something that belongs to the workload:

VariableValue
FLORWL_SCOPEName of the enrolled rete scope the workload runs under
FLORWL_NAMEThe workload's own name from the inventory
FLORWL_MGMT_ARTIFACTAbsolute path of the workload's verified mgmt artifact in the active set
FLORWL_CTRL_ARTIFACTAbsolute path of the newest verified ctrl artifact for this name (C1+); unset when none exists
FLORWL_STATE_DIRAbsolute path of the workload's own durable-state directory (workloads/<name>/ under the scope root), created by the agent before spawn
FLORWL_LOG_SINKWhich log-sink class the agent installed on fd 2 (Observability)

Platform additions — for flor's own binaries only: flor-vertex, flor-coordinator, and whatever later joins that set. These expose flor's own layout (Installer § Directories) rather than anything the workload owns, and a tenant workload must never read them — the scope root in particular is where the identity store lives:

VariableValue
FLORWL_SCOPE_ROOTAbsolute path of the scope root — flor's tree for this rete
FLORWL_RUNTIME_ROOTAbsolute path of the scope's runtime root, shared across its workloads — where FlorIO sockets meet (C1+)

The split is the contract boundary, and it is what keeps the vocabulary honest: flor's state directory (the host-level one holding every scope) and a workload's state directory (FLORWL_STATE_DIR) are different things with different owners, and no variable now names both.

Every path is injected already resolved, so no workload derives one from a name — the store-shim direction, and the reason this table can change shape without touching any workload that only reads it.

The layering rule

The agent parses envelopes and its own artifact (the agent config) — no workload's payload, the vertex's included. Every other payload is verified, staged, and delivered opaque. (A rule like "the agent never parses vertex artifacts" would be both too strong — about envelopes — and too inviting of exceptions — about payloads.) The needs that seem to demand payload knowledge resolve without it: post-C0 hot reload is the agent delivering the new verified config over the control channel — the vertex, which owns its own semantics, answers "swapped" or "restart me"; local facts the agent must answer (rete's operator SOCKS5) come from the compiler-projected inbounds map (principal → local inbound) in the agent's own payload — the agent's coordinator dial-out resolves through its own entry there, so there is no separate sync.via; the map re-projects the vertex payload's io listen addresses, compiler-guaranteed consistent, the ADR-0010 pattern, so the agent answers without cracking a vertex payload; local discovery (which port serves which principal) is runtime state reported upward by workloads, not config — static artifacts hold only preferences, actuals exist at runtime. Schema ownership stays two-tier — platform payload schemas (agent, vertex) are part of the flor contract, tenant payloads are third-party — but agent behavior is uniform: all payloads opaque.

The agent's own state is headed for the same mgmt/ctrl split as the vertex's. The workloads inventory is operator authority today and the bound tomorrow: from B1+ a Coordinator may decide, within mgmt bounds, which allowed workloads actually run where (placement) — arriving as a (plane: ctrl, name: agent) artifact selecting within the mgmt inventory. The envelope already represents this; C0/C1 are simply the degenerate case where mgmt determines everything. Nothing in this page's shape may assume the inventory is always exhaustive-and-active.

Two vocabulary notes. agent.json's workloads inventory and a vertex payload's workloads list are two projections of the HLD's single workload concept — what this node runs vs. what a vertex serves network for; alice's browser is served but not run, the coordinator is both. And the vertex payload's workloads[].io surface is conceptually the vertex's interface section — the part of local interest; it stays in the vertex artifact (a split-out interface artifact would still carry only preferences), with local needs served by projection (static) and reporting (runtime).

Artifact handling

A supervised workload consumes the verified original artifact plus injected context; the agent never transforms payloads. A vertex reads the config FLORWL_MGMT_ARTIFACT points at — <name>.json in the active mgmt set; if run by hand for debugging, flor-vertex --scope <scope> --name <name> --set <version> resolves the same file from explicit flags — parses and schema-gates it, and resolves everything local by convention. Production and debug differ only in where the context comes from; the resolution path is one and uniform. There is no derived intermediate config: what a workload ran is exactly what the operator signed.

Every value in the system belongs to one of three classes:

ClassExamplesLives
Operator intentACLs, links and wire addresses, io upstream addresses, listen preferencesIn the signed artifact
Node-derivableFlorIO socket paths, agent.sock pathDerived from injected roots + names, by convention — never in artifacts
Runtime-assignedactually-bound ports when a preference losesNever in artifacts; reported upward, queryable via agent.sock

Artifacts carry no filesystem references. Identity is referenced by SPIFFE ID only, and the scope's identity store materializes it. The store is Identity's: its layout under identity/, the scope.json record every consumer takes its trust domain and node from, how material is keyed, imported and read, and the name encoding that would one day lift the strict name rules. The agent reads it through the store's reading part like every consumer — the anchors to verify what it admits, scope.json for which scope and node it serves — and writes nothing to it; enrollment does. This keeps the config plane (compile churn, signed JSON, no secrets — safe to relay and commit) separate from the identity plane (enrollment and rotation lifecycle, keys that never transit the coordinator).

The two planes travel by different channels, so a set routinely names a principal whose material has not arrived yet. The config plane syncs from the coordinator in minutes; the identity plane arrives in a couriered bundle, hours or days later (Enrollment § Decision Log, ENRL-C0-36). This is the ordinary shape of adding a service to a node already in service, not a fault to be caught, and the rule is symmetric: material without an artifact is inert; an artifact without material is degraded; both are normal. The vertex is where it surfaces: it asks the store for each principal its artifact attaches to it, and the store reports one it does not have as absent (Identity, ID-C0-23). It starts partially: it serves every local principal it holds material for, opens no listener at all for one it does not, and warns once per principal. No listener rather than a listener that cannot complete a handshake, because an absent service should read to its caller as absent rather than as a trust failure. Refusing the whole set instead would let the identity store veto the artifact set — inverting the rule that the set is what is authoritative about which principals live here — and would stop four working services for one missing credential. Only local principals can be in this state; an ingress.allow entry naming a remote one needs no local material.

The envelope is minimal, and workloads receive it intact. An artifact's identity is (workload, plane), the workload named by its SPIFFE ID — there is no envelope kind. Dispatch is by workload: each consumer parses only artifacts addressed to it and knows its own payload schema, so a new supervised workload is a new name, never an envelope change (the vertex payload's internal kind: link | mesh selects the engine and is nobody else's business). plane is the authority axis and stays in the envelope for exactly one reason: the payload-blind agent must pick the signer set that authenticates an artifact (mgmt — operator-anchored; ctrl — delegated, bounded). It imposes nothing per workload — the split is opt-in by existence: a workload with no dynamic state simply has no ctrl artifact (in C1, only the mesh vertex has one). And the agent hands over the verified original file, envelope included: stripping it would be a transform, and the envelope carries facts consumers need — the mgmt version and the ctrl plane's obeys_mgmt_version feeding the vertex's join check — plus the self-description that keeps a file meaningful outside its tree. Platform workloads share the binary's envelope code; tenant workloads get a small, documented, near-frozen format whose signatures and version monotonicity are precisely the value of riding config delivery.

Versioning splits along the same boundary as parsing. The envelope is the distribution contract — its claims, the signature canonicalization, and (as a frozen subset of those claims) the relays' routing core. It is gated by the agent, and it is deliberately near-frozen. Each payload family carries its own contract inside the payload — as the payload's own schema_version, a ladder independent of the envelope's — gated by its consumer, evolving at its own pace: the vertex payload contract — its mgmt payload, its C1 ctrl payload, and the join rules between them, verified by the vertex, the only party that parses both — moves fastest; the agent payload has its own; an opaque workload config's schema and versioning belong entirely to its producing/consuming pair, outside the flor contract. The coordinator's config is the first exerciser of that path — and what marks it out is not the agent's opacity (that covers the vertex equally) but that its schema sits outside the flor contract, owned by the rete↔coordinator pair even though both ends are flor's own, dogfooding the tenant contract from C0. (ADR-0012 owns this policy; the single-number rule survives at the right scope — within each payload family.)

Node layout

Two deployment modes, chosen by the installer (which owns everything that isn't per-rete state — see Supervision mechanics):

  • User mode — personal devices, running as the user. State directory $XDG_STATE_HOME/flor (macOS included — it is the only macOS mode in C0); user-level service.
  • System mode — servers. A dedicated flor service user; state directory /var/lib/flor on Linux, %ProgramData%\flor on Windows (C1); system-level service.

flor's state directory is the one tree spanning every scope on this host; executables, config, and runtime files have directories of their own, and the full table belongs to Installer § Directories — including why node material is state rather than data or config, so nothing lands in /etc. FLOR_STATE_HOME overrides it for development and CI, one of a per-directory set; absent an override, every directory follows the install mode the running binary detects from its own location, so a command acts on the install it was invoked from and never searches across the other. In system mode agent.sock carries group permissions so operator CLIs can query without root.

The scope root, at <state-dir>/retes/<scope>/:

retes/<scope>/
├── identity/               # the identity store, Identity's; written by enrollment
├── artifacts/
│   ├── mgmt/
│   │   ├── current         # one line: the active set's directory name, e.g. "v42"
│   │   └── sets/
│   │       └── v42/…       # immutable set, flat: agent.json + <name>.json per workload
│   └── ctrl/               # C1+: per-artifact last-known-good slots, e.g. rete@v17.json
├── incoming/               # an enrollment-delivered artifact set awaiting intake,
│                           # consumed at startup before the first dial, then removed
│                           # — always removed, whether admitted or not
├── agent/                  # the supervisor's own durable state: version high-water-marks
│                           # (mgmt: one for the set; ctrl: one per stream — ADR-0015);
│                           # C1 adds the pending-confirm record
└── workloads/              # one directory per supervised workload — its FLORWL_STATE_DIR
    └── <name>/

One mode rule covers everything under the scope root: directories 0700, files 0600, each created with its final mode. The scope root is itself 0700 and owned by the service user (Installer § Directories); the agent and its workloads run as that user, and flor enroll — which runs as root in system mode, since a server is enrolled over SSH by an admin and not by the service account — creates every directory and file already owned by it and already at its final mode, never chmod-ing or chown-ing afterwards. So nothing outside the scope root has any business below it, and no subtree needs a looser mode than another. The looser modes in this tree are all above the scope root or beside it — retes/ at 0755, the runtime root at 0750 — where a different owner genuinely needs access.

The identity plane is one subtree, and that is what makes rules about it hold. identity/ is the identity store, whose shape is Identity's (ID-C0-28).

agent/ and workloads/ are siblings because their owners differ. The agent is the supervisor, not one of the supervised, so its durable state is not an entry in a collection of workload states. The layout records ownership, and the supervisor's workload name, agent, is reserved all the same (Identity). Every supervised workload gets workloads/<name>/, created by the agent before spawn and injected resolved as FLORWL_STATE_DIR. Most leave theirs empty (the vertex is config-driven, with no durable state); in C0 only the coordinator uses one, for its published-tree store. Workload state dirs are outside the sync cleanup pass: removing a workload from the inventory does not destroy its durable state — that is an operator act (re-enrollment replaces everything; anything finer is deferred).

Migrating this tree between layouts is the agent's, not the installer's. A release that changes the shape of a scope root migrates it here, because the agent is the only component present on every install channel — a package manager will replace binaries and restart a service, but will never run installer logic of ours (Installer § Migrations). None of it is C0 code: a node arriving from A0 is re-enrolled rather than migrated (DSGN-A0-21), and the rules — forward-only, chaining, snapshot-and-restore, and a separate treatment for the identity plane — are designed with the mechanism rather than ahead of it.

mgmt is versioned as one set per node. Each node's mgmt state carries a single version — a per-node monotonic counter, the node's mgmt sequence, advancing by one each time a publish actually changes anything on this node — stamped uniformly into every member artifact's envelope. (ctrl versions work differently — per artifact, not per node; see below.) The sequence is node-local, not rete-wide, because every mechanism that reads a version is node-scoped (replay defence, set-coherence checks, obeys_mgmt_version) — nothing ever compares versions across nodes. A stable node's number therefore never moves, each node's set history is dense (v7, v8, v9), and the compiler needs no rete-global counter: each bump is previous + 1 against its ledger, the previous compiled tree. Correlating a version to its publish event is tooling's job, not the number's — the envelope's generated_at and the git history of the node's compiled directory give the mapping. Change detection is not the version's job either, and needs no producer discipline: payload-byte equality is the change signal — the agent compares each staged artifact's payload bytes against the previous set's (envelope excluded: every member's envelope changes each bump) to decide restarts. Comparing bytes is mechanical and payload-blind; semantic diffing stays with payload owners — post-C0 hot reload already places it in the vertex.

agent.json is the set's root document. It carries the membership (the workloads inventory names every sibling); each member vouches for its own signer, carrying that certificate in its signature, and the uniform stamp supplies coherence — no separate set index is needed. A sync verifies a set with three checks: the root's version against the persisted mgmt high-water-mark (a single mark covering the whole set — replaying any old set is rejected wholesale; ctrl keeps one mark per stream), every member's stamp equal to the root's (a relay cannot mix sets), and every inventory-named file present (a relay cannot withhold one). ctrl gets no set semantics at all: its artifacts are independent streams, each carrying its own per-artifact monotonic version and its own mgmt pin (obeys_mgmt_version, naming the node's mgmt set version); in B1 they arrive hot over the control channel at Coordinator pace and are persisted only as last-known-good for restart recovery — which is exactly what the ctrl/ slots below are, so C1→B1 changes the delivery path, not the layout. The granularity rule behind the asymmetry: the artifact is the atomicity unit, the stream is the independent-evolution unit, the plane is the authority unit. mgmt collapses all three into one per-node set — a single authoring act, a single trust root, cross-referencing members. ctrl keeps them apart — per-decision cadences; no trust roots of its own (ctrl artifacts are signed, but their signers are authorized from mgmt — a ctrl artifact never carries signer-set material, per ADR-0015); coherence packed inside each artifact (C1's active_links + forwarding_table + label_bindings travel together precisely so the join checks are atomic). Should two ctrl streams ever need atomic co-application, the answer is merging them into one artifact — or an obeys-like cross-pin — never set machinery. A door left open, not used: if a mgmt payload ever grows large enough that whole-set fetch hurts, a per-member digest map in agent.json (digests — computed, so no producer discipline is needed) restores selective fetch as a purely additive payload change.

Both planes persist through the same atomic primitive — a single-file rename(2) replace — and differ only in how much must change as one. A mgmt set is genuinely multi-file: agent.json's inventory names its sibling artifacts — a crash halfway through an in-place update of k files would leave exactly the mixed set the coherence checks exist to forbid. The coherence unit (a tree) exceeds the atomic primitive (one file), so mgmt adds one level of indirection: stage the complete new set at mgmt/sets/v<N>.partial (N = the new set's version), rename it to mgmt/sets/v<N> when complete and verified (a crash leaves partials clearly marked), then collapse the multi-file decision into a single-file flip of mgmt/current — a one-line file holding the active set's directory name, replaced by the standard idiom: write current.tmp, fsync, rename over current (rename(2) atomically replaces the destination on POSIX; MoveFileEx(MOVEFILE_REPLACE_EXISTING) on Windows) — a pointer file rather than a symlink (Windows symlinks need privileges) or a directory rename (not atomic-replace on any OS). Why this instead of stop-everything, overwrite in place, restart: a crash or power loss mid-overwrite leaves no valid config, and fetching a fresh one requires a working vertex — a corrupt tree can strand the node behind its own dead network layer. The pointer flip guarantees old-or-new, never neither, and the retained previous set it implies is exactly what C1's rollback needs anyway. A set is immutable once staged. A ctrl artifact (C1+) has no cross-references — its only coherence requirement points at mgmt, is carried inside the artifact (obeys_mgmt_version), and is enforced at the vertex's join check — so its coherence unit is one file and the primitive applies directly, no pointer, no sets: each verified artifact lands as ctrl/<name>@v<K>.json beside its predecessor, and the active slot is the highest verified version present, unless C1's pending-confirm record in agent/ vetoes it; a failed confirm deletes the rejected file (it is re-fetchable) and records the rejection so sync does not loop on it. Otherwise the two planes are managed identically: the version sits in the directory name there and in the file suffix here; both retain the previous version (at least one back) for rollback; and cleanup is part of normal operation, not a background collector — at startup and at each sync, the agent deletes any .partial directory and, on either plane, anything that is neither active nor in the retained-previous list.

Verification and the local trust boundary

The agent verifies at admission — at sync before staging, and at the incoming/ intake — and the two are the same check: envelope gate, the signer certificate validated against the anchors as of now, its kind checked against the artifact's plane — plus, on ctrl, the authorized_ctrl_signers narrowing — signature, version mark. The kind check is not a formality; ADR-0015 has why. At startup it checks the already-active set for integrity, not authority: signature and set coherence, with the signer certificate's validity window deliberately not evaluated, because that set crossed the trust boundary when it was admitted and re-authorising it would brick every node that restarted after the mgmt signer's certificate expired without having synced a re-signed set. See ADR-0015. A workload whose artifact fails that startup check is not started with it; the agent keeps the last-known-good set running and surfaces the error — availability degradation, never fail-open.

The vertex does not re-verify: it trusts agent-staged files, per ADR-0015's threat model — a signature protects the distribution path, not local-filesystem integrity, which is the node's own concern. Consequently hand-editing staged artifacts takes effect, and that is C0's documented posture: a user with filesystem access can rewire their own node, but cannot forge CA-signed certs, so access to real peers stays gated by mTLS at those peers. (Local tampering avenues are not all equal — a hardened system-mode install protects the binary better than mutable state — so this posture is a pragmatic C0 simplification, not a theorem. The definitive local-integrity stance, and any designed local-publishing/override flow, is flor#44 (Determine flor's local trust boundary), deferred to C1+.)

Supervision mechanics

Two layers with different owners, by design:

  • The OS keeps the agent alive; the installer ships the machinery. The install package delivers the binary and the per-OS service definition — a systemd template unit (flor@<scope>) on Linux; on macOS a plist template rendered per scope, by whichever of flor agent enable or an upgrade acts first — the copy is derived state, reproducible from template plus scope name, so rendering it is idempotent wherever it happens (launchd has no template units); an init hook on embedded Linux without systemd; an SCM service on Windows (C1). flor agent enable is the only thing that activates an instance (systemctl enable --now flor@<scope> or the launchd equivalent), and flor enroll does not — it writes material and prints the enable that follows it (Enrollment § Activation). The same command resumes a scope root that outlived an uninstall, with no bundle involved, and it is how an operator hands a live scope back after a re-key. These wrappers are the only OS-specific artifacts in the system, and the boundary is worth stating precisely, because it runs through the flor binary rather than around it: the supervisor daemon is host-agnostic — it forks children, verifies and stages artifacts, and answers its socket, knowing nothing of systemd or launchd — while the host-facing commands in the same binary are exactly where the OS-specific code lives. flor agent enable and disable wrap systemctl and launchctl; flor install and uninstall place and remove the wrappers. That is host business, and host business is the installing channel's (Installer § What installing is) — which is also why rendering a wrapper is never the daemon's job.
  • The agent keeps its workloads alive. It spawns children directly (fork), applies capped exponential backoff, marks a repeatedly-crashing workload as crash-looping in status instead of retrying forever, and treats artifact-verification failure as "don't swap" — not as a crash. Owning this loop keeps supervision semantics identical on every platform (including ones with no capable init) and keeps C1's confirm-or-rollback state machine inside one process. Delegating child supervision to systemd/launchd was considered and rejected for C0 — it multiplies platform backends with materially different semantics (launchd's dynamic-job surface in particular) and splits sync/rollback state across two systems. It remains available later as a per-entry launch-runtime backend.

Lifecycle and ordering. The agent starts first — it is the supervisor — then forks its workloads in agent.json list order, tearing them down in reverse. Order is best-effort, never a correctness mechanism. Any local dependency must already survive its provider restarting at runtime (a crash, or a C1 per-workload config swap), so it is reconnect-tolerant — which means it equally tolerates the provider not being up yet at cold start. Workloads are therefore forked nearly simultaneously and converge by retry: in C1 mesh-flor retries link-flor's FlorIO socket until it binds, and the agent's own first sync retries the vertex's SOCKS5 the same way (vertex failure blocks sync only by making the agent wait, not fail). A small initial retry backoff keeps cold-start convergence sub-second, so no readiness pipe is needed — readiness gating would be additive machinery atop the reconnect-tolerance the crash path requires regardless. The compiler still emits a sensible order (vertices before dependents, link before mesh) to shave the common-case retry; the agent never infers order from anything — it cannot even tell a vertex from any other program, let alone link from mesh. A workload patiently retrying a dependency counts as up (it hasn't exited), so failures attribute to the actually-broken component — a crash-looping link-flor is flagged as such while mesh-flor waits. True dependency gating (a declared depends_on, readiness over the control channel) is a reserved field for the milestone that needs it. Stop is SIGTERM, a grace timeout, then SIGKILL; workloads treat SIGTERM as drain-and-exit-0. Restart is stop-then-start with a fresh process and the new set as context. (Windows, in C1, maps stop onto the per-workload control channel when it lands.)

Health and exit codes. C0 has no readiness channel: a workload is considered up when it has run for a liveness window (a few seconds) without exiting. Failure modes are separated by a minimal exit-code vocabulary shared by everything the agent runs: 0 — clean stop (after SIGTERM); 78 (EX_CONFIG) — artifact rejected, or otherwise unservable as configured: the workload cannot serve this config and waiting will not change that, so the agent does not backoff-loop it, it surfaces the error immediately; 69 (EX_UNAVAILABLE) — a host service the workload depends on is not answering, the log destination being C0's one case: a real refusal to start, but of something that may well be there on the next attempt, so it is retried like any crash; anything else — crash, retried under backoff. The split that matters is not between severities but between permanent and may resolve, because that is the only distinction a restart policy can act on. Readiness (and richer health) arrives with the per-workload control channel post-C0.

The agent provisions each workload's log sink; it never relays log bytes. Before fork it opens a sink for the child and installs it as fd 2: a datagram socket connected to the configured syslog destination in normal operation, or — when the agent itself was run with --log-stderr — its own fd 2 passed straight down. Which of the two is a configured choice, never a detection: the agent does not probe for a socket and does not sniff its own terminal, and an absent destination stops it rather than downgrading it (69, above). The child holds a descriptor it did not open, so it cannot redirect its own output, and it keeps logging across an agent crash; what a child writes into that sink, its own name included, is its own content. The same property has a cost, recorded with the log stream: a child's descriptor cannot be replaced from the outside either, so a syslog daemon that recreates its socket blinds the tree until the agent restarts. FLORWL_LOG_SINK tells it which class it got, because neither the sink's framing nor "is this fd shared with my siblings" is observable from the fd. Interposing a filtering logger process per workload — the confinement a third-party workload will eventually need, and what would make the name enforced rather than cooperative — changes nothing on the child's side of this contract. This is the seam Observability builds on; sink classes, line format, and level conventions live there, and the decision itself is ADR-0014.

Sync

Sync is executed by the agent daemon — the one process owning all supervision state. It runs once at startup (this is enrollment's first sync), on a poll timer (poll_interval in agent.json's sync block, defaulting to a few minutes — operator-authored, so it rides normal config delivery), and on demand: the flor agent sync CLI is a thin client that triggers a sync over agent.sock and reports the result, exactly as flor agent status reads state there. There is no separate syncer process — and when hot reload lands, delivery simply replaces restart inside the same daemon. The sequence is fetch → verify → stage → restart:

  1. Poll the coordinator (acting as the sync principal, dialing through its own inbounds entry) with the held set version: if the node's published set is still at it, the answer is empty and the sync is a no-op — this is the cheap poll. Otherwise the response is the complete packaged set — one response, internally coherent by construction.
  2. Verify the fetched set: envelope gate on every artifact, each one's signer certificate validated against the node's trust anchors, its kind checked against the artifact's plane, and then its signature checked, the root agent.json's version against the persisted high-water-mark, every artifact's workload is this node's in this rete, every member's stamp equal to the root's, and every inventory-named file present — a relay serving a stale, mixed, or incomplete set is caught here.
  3. Stage the new set at mgmt/sets/v<N>.partial (N = the set's version); when complete and verified, rename to mgmt/sets/v<N> and activate by atomically replacing mgmt/current. Where each artifact lands is read out of its own envelope — plane selects the shape (a mgmt set here, a ctrl/<name>@v<K>.json slot from C1) and the workload's name the file within it — never out of a path, a filename, or anything the transport said. The workload is parsed as a node-scoped workload ID before its name becomes a path component, which holds that name to one DNS label (Identity § Decision Log, ID-C0-12), re-checked here rather than trusted from the validator, which runs on the operator's machine. The rule is the agent's and covers every admission path: an artifact arriving from the coordinator and one arriving in incoming/ are placed by the same reading, which is what lets the enrollment hand-off below be a drop of files rather than a second staging implementation. A crash at any point leaves either the old or the new set active — never a mix; leftovers are removed by the cleanup pass.
  4. Restart the workloads whose artifact payload bytes changed against the previous set (envelope excluded — every envelope carries the new stamp), so a coordinator-only edit restarts only the coordinator. Each workload is spawned with the new set as explicit context — a running workload never re-reads current mid-life. If the agent's own payload changed, the agent restarts itself through the service wrapper, and its children restart with it. Success = every restarted workload survives its liveness window (no EX_CONFIG, no crash); then persist the new high-water-mark.

--dry-run performs fetch + verify and reports would-be versions against active ones — no stage, no restart. Previous sets are retained (at least one) — C0's manual rollback is reverting the YAML and re-compiling: the ledger stamps fresh, higher versions carrying the prior content, and nodes sync forward onto it. (Re-publishing old compiled files verbatim cannot work — any node that already synced the bad set has advanced its high-water-marks and would reject the old versions as replay.) C0 has no confirm/rollback window, and blast radius is bounded by the out-of-band emergency path (Distribution & Reload). C1's guard becomes: restore the retained previous state — flip mgmt/current back and/or drop the rejected ctrl slot — and restart, driven by a pending-confirm record in agent/ that survives an agent crash mid-window.

Enrollment hand-off

flor enroll <bundle> (Enrollment) hands the bundle's credentials to the identity store and deposits the bundle's artifact files in incoming/. It activates nothing and starts nothing: the agent arrives when someone runs flor agent enable, which is the only thing that activates.

The agent admits incoming/ at startup, before its first dial, through exactly the path a synced set takes — verify against the anchors, check the persisted high-water-mark, check every artifact's workload is this node's in this rete and that the name it was delivered under — its <plane> and <name> — agrees with the envelope, stage, activate, then remove the slot. The slot is removed whether it was admitted or not. Both non-admissions log at WARN, once, at the admission attempt, and are never re-raised — a set ignored as not newer than the high-water mark, and a set rejected as invalid. They are distinct messages and neither is routine: an enrollment-delivered set is a deliberate operator act that already cost a courier, so it not landing is news, where a synced poll returning nothing new is the steady state and stays at INFO. An ignored one means either that the bundle went stale between issue and enroll, or that the compiled tree was rewound and reissued a spent version (Enrollment § Decision Log, ENRL-C0-35) — and the second is a fork the operator has to hear about, because the node then goes on running a set nobody else has while both ends read the situation as settled. Silence would be worst exactly here: this is the path ENRL-C0-21 leans on to repair a node whose coordinator has moved, and a repair that quietly does nothing is the failure that costs a second visit. Nobody can re-admit a rejected set — it is not fetchable forward, not fixable in place, and its bundle is gone — so keeping the files answers no question while guaranteeing the same WARN at every startup for ever. What the operator needs is the reason, which the files never carried. That matters because flor enroll has already exited successfully by then, and nothing else would mention it. An older set is ignored and a newer one activated, so enrollment is not an exception to monotonicity. Reading it before dialling is what makes one repair possible: a node offline long enough for the coordinator to have moved cannot reach the coordinator to learn where it went, and re-enrollment fixes that only because the bundle's set is genuinely admitted. Enrollment therefore needs no network at all; the first sync is opportunistic.

Re-enrolling an existing scope is not hot, and flor enroll refuses to run while the scope is live rather than stopping it — an agent reading the identity plane while enroll rewrites it is a race, and the scope lock below is what closes it. The operator clears the way with flor agent disable and hands the scope back with enable. Enroll replaces the material of each principal the bundle names and leaves every other principal's alone, because what is authoritative about which principals live here is the artifact set and not the bundle. agent/ and workloads/ survive untouched: the first because discarding the version high-water-mark would let a stale bundle reopen the replay window, the second because on the management node it holds the coordinator's published tree. There is no atomicity promise — renaming over a populated directory is atomic nowhere — but a failed run converges: any bundle the scope would accept completes it, and the receipt's phase says which run is being completed. The bundle format, the receipt and the lock are Enrollment's.

Local IPC

One process per scope, and the scope lock is what enforces it. The agent takes the scope lock before serving — retes/<scope>/scope.lock, the one lock every consumer of a scope takes (Enrollment § Decision Log, ENRL-C0-39) — and refuses to start when it is already held, naming the holder. It lives in the scope root rather than the runtime root, because flor enroll takes the same lock and the runtime root is a directory the agent removes on a clean stop: a lock inside it would be deleted by the very stop that hands the scope over. It is also durable, where the runtime root is not, so the lock exists whenever the scope does.

The agent refuses a scope it should not serve, and reads one file to decide. After acquiring the scope lock, it reads retes/<scope>/receipt.json and stops when the receipt is absent (this scope was never enrolled), reads enrolling (an enrollment was interrupted), or carries a schema_version this release cannot read (the scope root was written by a newer flor) — naming the way out in each case: flor enroll <bundle>, the same, or the release that can read it. The last is the refusal Installer § Downgrade reserves for the agent rather than the installer, on the ground that only the agent knows what shape the trees are in; in C0 nothing migrates, so it never fires. The refusal is per scope, so a half-enrolled rete does not take down the others on a multi-rete host.

The runtime root remains the agent's own, on every platform — it creates it at start with mode 0750, removes it on a clean stop, and clears what an unclean one left behind. No service wrapper provisions it: launchd and the SCM cannot, and letting systemd do it on Linux alone would resolve flor's layout against a second authority (Installer § Service registration). The lock is what makes the clearing safe, which is why it exists rather than relying on agent.sock: a second agent that unlinks a stale socket before binding — the ordinary way to recover from an unclean shutdown — would otherwise take the name from a live one.

  • flor agent status reports the agent's own state and the active artifact set version. It reports no certificate expiry: the obligation in ID-C0-23 follows what a component already reads, and status reads none of this scope's certificates — the agent's own warnings fire where it does read them, at admission. A certificate inventory arrives with the validity controls of C1+, not as a readout bolted to a status command.
  • agent.sock (per scope, under the runtime root) speaks a structured protocol — flor agent status renders it for humans, and flor's own tools read the fields directly rather than scraping text. It is not a public interface in C0 and carries no version: rete and flor ship as one release and cannot skew on a host, which is the guarantee a third party would not have, so inviting one means versioning the protocol and that waits for a consumer to design it for. It accepts the sync trigger (flor agent sync is a client of this socket) and serves the active mgmt set version (plus each ctrl slot's version, from C1), per-workload state, and local endpoint discovery: a principal → local inbound {kind, addr} map. rete uses it by querying the inbound of the operator principal it has been told it is — a fact no repository can supply, since a rete may declare several operators and the missing question is which human is at the keyboard — so the agent stays role-agnostic and needs no rete-specific field. The map's source follows the parse boundary: in C0 the compiler projects it into the agent payload (the agent reads its own payload; C0 binds preferred ports, so projected = actual); post-C0 vertices report their actually-bound listeners upward and the map turns runtime-authoritative (covering pick-if-busy / port-offset). The agent's own coordinator dial-out resolves through the same map — sync names only the principal and the coordinator, and the compiler guarantees the node principal exactly one inbound. C1's sync --confirm reaches the pending-rollback holder over the same socket.
  • Per-workload control sockets are reserved, not built. Post-C0 they carry hot-reload config delivery (agent delivers, workload answers "swapped" or "restart me"), readiness and serving-table reports (feeding discovery with actuals), richer health, and the cross-platform stop convention. C0's restart-only reload needs none of it; exit codes and the liveness window cover C0's health story.

Evolution sketches

Recorded so nothing above forecloses them; none is designed, none is a commitment:

  • Host broker. A host-authority component owning cross-rete scarce resources (well-known-port muxing, quotas), leasing them to per-rete agents — systemd-socket-activation-shaped. Enabled by the no-node-global-state invariant.
  • Cloud multiplexing (B1). Per-customer container stacks behind a shared UDP 443 QUIC demux (SNI-routed first flight, connection-ID-routed steady state); the mux terminates nothing and is untrusted underlay, invisible to every schema. Requires only that flor's QUIC dialing keeps a routable SNI and non-zero, pluggable connection IDs.
  • Local inbound hardening. Local ports are not a security boundary through B1: any local process may use a bound port and act as its principal. Later: containerized execution isolates server workloads (per-workload network namespaces, as Kubernetes does), richer user-node inbounds (split-DNS + tun/loopback alongside SOCKS5), and authenticated SOCKS5 where local security matters.
  • Nested retes / supernodes (B2+). An on-board rete's agent running a fleet rete's agent and vertex as ordinary tenant workloads — the recursive case of this page's own contract. Kept open by tenant-payload opacity and the launch-runtime seam (a future remote/WRI backend with placement hints).
  • Domain config delivery (B2+). Third-party apps shipping fleet configs through the same envelope/relay/verify machinery under scoped signers that can never touch rete mgmt. Needs only the opaque workload-config artifact class (exists), consumed-artifact references on supervision entries (reserved), and a new authority class on the plane axis (future ADR).
  • Operator-authored managed workloads (B1+). The distinct axis from the above: not a third-party producer, but the operator declaring a workload flor runs and authoring its config, which the compiler projects into that workload's opaque payload — the workload parses it, the agent stays payload-blind. C0 has neither half: supervision entries and the coordinator's config payload alike come from the compiler's built-in knowledge of flor's own platform workloads (one entry per declared vertex; the coordinator found by reserved name where its services are placed), with run validator-restricted to those argvs. Generalizing is one option among several (a workload could instead read a consumed-artifact reference, or fetch its own domain config per the bullet above), it arrives with the launch-runtime seam above, and it is a threat-model change as much as a schema one — an operator-authored run is arbitrary code execution as the agent's user. The facade half — where such a declaration lives, and why a marker on a services.yaml entry is the wrong shape — is milestone-level: Open Follow-ups, with the eventual decision touching ADR-0009 and Validate & Compile.

On this page