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-0011);
  • supervises the platform workloads that make this node a rete member: the flor vertex on every node, plus the coordinator on the management node;
  • wields the node/<node> principal for sync with the coordinator (dialing through the vertex's SOCKS5 — the agent holds no keys itself);
  • answers local queries over its control socket (flor agent status, retectl'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": "flor", "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 (retectl'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 --rete <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-derivablecert/key locations, FlorIO 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; a per-scope identity store materializes it. C0's store: identity/ca.crt and identity/rete.json — the CA cert and the rete's trust-domain record, together the trust anchor (conceptually a SPIFFE trust bundle keyed by domain; interrete later adds further domains to the same anchor) — kept apart from principal material at identity/certs/<leaf>.crt / identity/certs/<leaf>.key for a principal whose SPIFFE path ends in <leaf> (collision-free within a node's store: principal names are cross-kind unique, and a store holds only its own node's principals). Consumers read the trust domain from the store; nobody infers it by sampling SPIFFE IDs out of configs — artifact IDs are checked against it, never its source. Like the rest of the store, it is enrollment-owned: delivered with the bundle, under the same out-of-band integrity assumption as ca.crt itself. Because names become file and socket names, the validator keeps them filesystem-safe: the SPIFFE charset (already spec-conservative), case-insensitive uniqueness (macOS/Windows filesystems), no OS-reserved basenames (CON, NUL, COM1, …) — covering principal leafs and vertex/workload names alike, with agent reserved for the supervisor. This validator rule is a documented C0 shortcut, not architecture: names touch the filesystem only because C0's store, artifact sets, and sockets are file-backed, so a storage-implementation constraint leaks up into the user-facing YAML. The designed fix is a store shim — consumers request objects by SPIFFE ID or name through a small API (get identity, get artifact, get channel), and the file backend maps names to storage with a deterministic, reversible encoding of any filesystem-hostile character — so no consumer ever derives a path from a name (paths that do reach a workload, like FLORWL_MGMT_ARTIFACT, are handed down already resolved, as opaque values), the backend can later be something other than files, and the validator relaxes to plain SPIFFE syntax. Until that shim exists, the strict rule is the cheapest correct behaviour. Enrollment populates the store; every consumer verifies at load that the certified SPIFFE ID matches the declared one. B1+ replaces the file convention with a SPIFFE Workload API served by the agent — the identity half of that shim; no artifact schema change, because the schema never knew about files. This keeps the config plane (compile-churn, signed JSON, no secrets — safe to relay and commit) cleanly separate from the identity plane (enrollment/rotation lifecycle, private keys that never transit the coordinator).

The envelope is minimal, and workloads receive it intact. An artifact's identity is (node, plane, name) — there is no envelope kind. Dispatch is by name: 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 retectl↔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 plane, enrollment-owned, 0700
│   ├── ca.crt, rete.json   # trust anchor: rete CA + trust-domain record
│   └── certs/              # <leaf>.crt / <leaf>.key per local principal
├── 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
├── agent/                  # the supervisor's own durable state: trusted signer sets +
│                           # version high-water-marks (mgmt: one for the set; ctrl: one per
│                           # stream — ADR-0011); C1 adds the pending-confirm record
└── workloads/              # one directory per supervised workload — its FLORWL_STATE_DIR
    └── <name>/

The identity plane is one subtree, and that is what makes rules about it hold. identity/ gathers the trust anchor and the per-principal material that otherwise would be spread across ca.crt, rete.json and certs/ at the scope root. Any rule about identity material is a quantifier — what may migrate it, what may be copied, what mode it carries — and an enumeration of three names silently stops covering whatever is added beside them, while a subtree cannot rot. It also makes the config-plane / identity-plane separation visible in the tree rather than only in prose, and it gives the mode statement one place to live: identity/ is 0700, and every *.key inside it 0600, created with those modes rather than chmod-ed afterwards.

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 — and separating them retires the reserved-name hack that previously kept a workload called agent from colliding with it. 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: the first release has nothing to migrate from, 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) and the trust block that vouches for their signers; 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-0011); 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 and its trust block vouches for the signers of those very siblings — 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 sync (before staging — the distribution-path defence signatures exist for) and fail-fast at startup (before spawning each workload — catching corruption and drift early). A workload whose artifact fails verification 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-0011'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-agent@<scope>) on Linux; on macOS a plist template rendered per scope, by whichever of enrollment, 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 enroll only activates the instance for its scope (systemctl enable --now flor-agent@<scope> or the launchd equivalent) — before enrollment there is no config, so nothing to run. Activation is separately reachable as flor agent enable, since a scope root can outlive an uninstall and resume without a bundle (Enrollment § Activation and resume). 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 and signature on every artifact against the trust block, the root agent.json's version against the persisted high-water-mark, 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. 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) installs the identity store and the initial artifact set (as mgmt/sets/<version>/ + mgmt/current) under the scope root, activates the installed service wrapper for this scope, and starts the agent; the agent brings up its workloads and runs one immediate sync. Scope-name collisions resolve with --as <scope>. Re-enrolling an existing scope is not hot: it stops the scope's workloads, replaces store and sets, and restarts — a rare, operator-driven operation with no atomicity promise. This page defines what the agent needs from the bundle — identity material, the trust snapshot, initial artifacts, coordinator bootstrap; the bundle format itself is #121 (Design C0 enrollment).

Local IPC

One agent per scope, and the agent is what enforces it. Nothing above the agent can: two flor binaries may exist on a host from different install prefixes (Installer § Directories), a developer may run one out of a build tree, and a service manager knows only about the instance it was given. So the agent takes a per-scope lock in the scope's runtime root before serving, and refuses to start when it is already held, naming the holder. The runtime root is 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.

  • agent.sock (per scope, under the runtime root) speaks a structured protocol — flor agent status renders it for humans; tools read the fields directly, never scraping text. 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. retectl uses it by resolving operator→principal from the repo and querying that principal's inbound, so the agent stays role-agnostic and needs no retectl-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