Florete

Enrollment

How a rete comes into existence, and how a host joins one

Enrollment is what makes a host a member of a rete — and, once per rete, what makes the rete exist at all. Two acts carry the name: genesis, which an operator performs once on their own hardware, and node enrollment, repeated for every machine that joins and again whenever one is re-keyed.

This page is the design, written for whoever implements or changes enrollment. Shape is the map — structure, flow, and the rules that hold across both acts. The Decision Log at the foot records what was settled and what was rejected, and is where to start before changing any of it. Evolution sketches names what this milestone deliberately does not build and what each of those futures needs from the design. Everything between is the reasoning that connects them.

What a person needs in order to enroll a node is not here. The commands in order, the delivery advice, the per-platform notes: that is the operator guide, #14 (User guide), and this page deliberately does not duplicate it.

Shape

STRUCTURE

  subject     enrollment — what makes a host a member of one rete, and the
              once-per-rete sequence that makes the rete exist at all

  boundary    install   host machinery, below every rete
              enroll    per-rete: identity material and initial artifacts
              agent     everything after — activation, sync, supervision

  bundle      one per node, carrying every principal that runs on it
              form       tar container; entries are
                         logical names, never destinations; a repeated name, a
                         non-regular entry and a missing named entry all refuse
              placement  no filename reaches a path — the identity store
                         places a credential by its certificate, and the
                         artifact intake places an artifact by its envelope
              agreement  an entry's name must equal what its content says it
                         is, checked by whoever reads that content
              entries    manifest     schema_version, trust_domain, node, and
                                      every entry by name and SHA-256 digest
                         anchors      anchors.pem
                         credentials  one .pem per principal on the node,
                                      named by its SPIFFE path: the chain,
                                      and the key unless keyless
                         artifacts    the node's initial mgmt set, byte-exact
              version    schema_version, same-major minor-older-or-equal
                         (per ADR-0012); issue-bundle stamps the lowest
                         minor its content uses
              binding    the manifest names the trust domain and the node;
                         enroll hands both, with the anchors, to the identity
                         store, which pins them on a first enrollment and
                         refuses a later bundle differing in any of the three
              integrity  SHA-256 digests over every entry; no algorithm field
              signature  a block inside the manifest, in an envelope's shape and
                         canonicalisation, carrying the signer's certificate
              signer     the manifest's certificate must be management-plane,
                         as every mgmt artifact's must; no list narrows it
              delivery   any out-of-band channel the recipient trusts; bearer
                         material from issue until enroll consumes it

  identity    enrollment delivers the TLS principals' material to nodes

  operator    `rete`'s own tree, ~/.rete, RETE_HOME overrides it whole
              retes/<scope>/  identity/    the identity store, holding
                                           the root CA and the signers
                              config.toml  the operator's config
              the repo holds compiled artifacts, no secret and no certificate
              one operator machine per rete

  material    what enroll lands, under <state-dir>/retes/<scope>/
              receipt.json  the scope root's schema_version and the enrollment
                            phase, enrolling then enrolled; written before
                            anything else, and enrolled is the commit point
              scope.lock    the scope's one lock, held by whoever owns the scope
                            — the agent for its life, enroll for its run
              identity/   the identity store: the anchors, the scope record
                          and the node's principals
              incoming/   artifact files deposited by enroll, awaiting the
                          agent's intake; removed admitted or not

  commands    rete init <name>        writes repo scaffold, identity store with root CA and
                                      mgmt signer, and config template
              rete id issue           key born on operator hardware, stays there
              rete compile            writes the tree that is the version ledger
              rete publish            uploads the tree to coordinator,
                                      target derived from the reserved service
                                      name and rete.name
              rete issue-bundle       packs every TLS principal and artifacts
                                      for one node from the committed tree;
                                      refuses an uncommitted one, and needs no
                                      coordinator at all
              flor enroll <bundle>    unpack under the scope lock; refuses while
                                      the scope is live and names what clears it;
                                      --multi-node admits a second node of one
                                      rete on the host
              flor agent enable       activation, and the only thing that performs
                                      it; disable is its inverse and releases the
                                      scope for an enroll

FLOW

  genesis     once per rete; every step before the publish happens outside
              Florete, and genesis needs no special case — it is an ordinary
              sequence whose publish happens to fall last, nothing being able
              to receive one until mgmt01 runs
                1  create the repo, then rete init <name> inside it
                2  seed the collections, the operator's own entry among them
                3  declare the management node, uncommenting the reserved
                   coordinator entries rete init scaffolded
                4  rete compile, then commit
                5  issue-bundle mgmt01, enroll it over SSH — its bundle carries
                   coordinator.json — then flor agent enable there to start it
                6  issue-bundle the operator's own machine, enroll it and
                   flor agent enable it; the operator's TLS keypair is born here
                7  fill config.toml with the operator's own principal name —
                   rete init left it a commented template
                8  rete publish — the first Florete-over-Florete call

  enroll      per node, fresh and repeat; * marks a re-enrollment-only step
                 1  declare the node and its principals in YAML
                 2  rete compile, read the diff, then commit
                 3  rete publish — promptly, so the cache does not lag the
                    source; a running node may hold the set before the
                    credentials it names
                 4  rete issue-bundle --node <n> — packs the committed tree
                 5  deliver the bundle out of band
                 6  flor enroll <bundle> [--multi-node]
                    derive the scope's handle from the manifest's node and
                      trust-domain claims, and read nothing else of the bundle
                    create retes/ and the scope root if either is absent
                    take the scope lock, or refuse naming the holder
                    read the receipt: gate the scope root's schema_version
                    ask the identity store whether it holds anchors: if it
                      does, this run is a re-enrollment, whatever the phase
                    on a fresh enrollment, refuse a sibling scope holding
                      another node of the same rete, unless --multi-node
                    process the bundle, and nothing is written before
              *       the signature is verified
                      the container and its digests are checked
                      the identity store has accepted the credentials
                    replace the receipt, phase enrolling
                    the identity store writes the credentials, replacing
                      each principal the bundle names, leaving others untouched
                    replace incoming/ wholesale with the bundle's artifact files
              *       agent/ and workloads/ survive untouched
                    replace the receipt, phase enrolled — the commit point
                    remove the bundle
                    print enabling hint & exit, releasing the lock
                 7  flor agent enable, by whoever ran the enroll
                      the agent takes the scope lock and refuses any scope whose
                      receipt is absent, mid-run or unreadable; otherwise it admits
                      incoming/ before its first dial

RULES

  cli split     a command belongs to the tool that owns the state it authors —
                `rete` authors rete state, `flor` authors node state
                a private key is material, not state: it has a holder, no author
                `rete` reaches no node state

  minting       a private key is born in exactly one place and the name of the
                command that births it says where
                a key that must never leave operator hardware is issued; a key
                that must reach a node is bundled

  shipment      git is the source of truth and the coordinator is a cache of it;
                a bundle is cut from the source, never from the cache
                a version handed to any node is spent — the ledger must never
                reissue it, which rewinding the compiled tree is how you do
                the gate is the commit, never the publish: issue-bundle refuses
                an uncommitted tree and asks no coordinator anything
                the order stays compile, commit, publish, issue-bundle — the
                publish is prompt rather than required

  channels      the two mgmt channels are independent and the bundle is the more
                capable one, carrying identity where the coordinator carries none
                nothing on the bundle path may require the coordinator to have
                worked, or the repair for a dead coordinator needs one

  partiality    material without an artifact is inert; an artifact without
                material is degraded; both are normal
                a local principal with no material gets no listener at all, and
                the vertex serves every principal it does have

  trust         a breach of rete infrastructure costs availability, never
                authority — the coordinator holds no key at all

  separability  the anchors stay an entry of their own; a credential entry
                may carry no key

  bearer        the bundle carries private keys for its whole life, and nothing
                at the bundle level bounds that window

  ownership     install owns the host, the agent owns the rete, and enroll is the
                one act that crosses between them
                retes/ is created by whichever of the two runs first, identically

  idempotence   a re-enroll cannot be atomic; what enroll promises is convergence
                — any bundle for this scope's rete and node completes an
                interrupted run
                the phase says only whether the last run finished: enrolling
                is an interrupted run, enrolled a finished one
                a run is a re-enrollment when the identity store holds
                anchors, and fresh when it holds none

  scope         a node's scope is <node>.<rete>, derived from the manifest's
                node and trust-domain claims
                a fresh enrollment beside another node of the same rete is
                refused unless --multi-node says it is meant
                `rete`'s scope is <rete> alone
                the identity store holds the pin; enroll never compares it

  offline       enrollment needs no coordinator reachability: the bundle carries
                the set, incoming/ is read before the first dial, the first sync
                is opportunistic

  unattended    a check whose interactive form is a person is owed a machine
                form; automation never skips a check to run unattended
                C0 has no such check

What enrollment is

Two acts share the name, and they differ in almost everything but vocabulary. Genesis happens once per rete, entirely on operator hardware, and creates the authority every later act depends on. Node enrollment happens for every machine that joins, is repeated whenever a node is re-keyed, and is a two-party operation across a channel Florete does not control. They belong on one page because they share a command surface and because genesis is enrollment — the operator's own machine joins by exactly the flow any other node uses.

Install leaves a host ready to enroll; enroll never installs. Everything below every rete — executables, the per-OS service wrapper — is the installer's. Everything per-rete arrives here: identity material and an initial artifact set, and nothing more — enroll starts nothing. Everything after that is the agent's: activation, syncing, supervising, and owning the scope root's contents. Enroll is the single act that crosses between them, which is why it is the only thing that creates a scope root and the only thing that hands one over.

Enrollment needs no network, and that is an invariant rather than a convenience (ENRL-C0-15). A node that cannot reach anything comes up on what it was given and syncs when a path exists. This is the property that makes provisioning a vehicle at a depot the same operation as provisioning a server, and it must survive every later change to how bundles are delivered.

The invariant has an operator-side half, and it is the half easiest to lose (ENRL-C0-37). Management state reaches a node by two channels — the coordinator, online and incremental and carrying artifacts alone, and the bundle, couriered and manual and carrying identity material as well — and neither may become a precondition of the other. The bundle is the more capable of the two and it is the repair path when the other fails, so a rule requiring the coordinator anywhere on the bundle's path would mean the remedy for a dead coordinator needed a working one. The independence costs something and the price is stated where it is paid: two channels converge at different speeds, so a revocation takes effect at each node's next sync and a courier-fed node is the slowest (ENRL-C0-38).

Genesis

Genesis brings a rete into existence, and it runs before Florete does — the last step is the first Florete call the rete ever makes. The sequence is the Shape's genesis block; what follows is why it is ordered that way.

The repository comes first, because the rete must be named before anything is minted in its name. rete.name is the SPIFFE trust domain, so it is not a label an operator revises casually; every principal's identity is scoped by it. rete init <name> creates the scaffold and then the root CA and the management-plane/mgmt-signer signer, and it is the only way a root CA comes into existence (ENRL-C0-03). It also requires a git repository, and refuses without one: ENRL-C0-35 makes the commit the gate that issue-bundle checks, so git stops being convention here and becomes load-bearing. Checking at init moves that to the one command where the answer is free and the fix is one git init. It creates the operator's identity store by importing the root and the signer it has just minted, which writes the store's scope.json naming the trust domain and no node — an operator's tree belongs to a rete rather than to a machine in it — so rete reads the domain out of a record like every other reader of a store, instead of off the directory it found the tree in ID-C0-29. Everything it makes afterwards is scoped by a name that already exists.

The management node is bootstrapped before the operator's own machine, because the coordinator has to exist before anything can publish to it. Its bundle carries coordinator.json alongside the rest of its artifact set, so the coordinator comes up under the agent like any other supervised workload — see Coordinator § Run story. Both of genesis's enrollments are two commands, exactly as every later one is (ENRL-C0-20): enroll writes the material and flor agent enable starts the agent that runs the coordinator, and neither node does anything until the second command has been run. Genesis earns no exemption here either. The operator's machine follows as an ordinary participant, and rete publish is the first Florete-over-Florete call the rete makes. Every step before that publish happens outside Florete, because the publish is itself a Florete call. After it, the coordinator serves the rete's state to every node that can reach it — authoritative for distribution, never for authorship, which stays with the repository.

One thing does not arrive with a bundle, and genesis is where the operator writes it by hand. rete publish reaches coordinator-publisher as a principal, and which human is at the keyboard is the one fact the repository cannot state (ENRL-C0-09), so rete init leaves ~/.rete/retes/<scope>/config.toml a commented template and the operator fills in their own principal name before the first publish. A publish that finds the key unset fails naming the file, the key and the principals holding the operator role, which is the same teaching this step would otherwise owe a guide.

Genesis needs no special case, and that is a property rather than a coincidence. Its two bundles are cut before any coordinator exists to publish to, which under a publish-gated issuance would be the one situation needing an exemption — and an exemption written for genesis would be silently absent the day a management node has to be rebuilt, which is genesis happening again in worse circumstances. Because the gate is the commit (ENRL-C0-35), genesis is an ordinary sequence whose publish falls last: compile and commit at step 4, issue and enroll, publish when there is finally something to publish to.

Three identities exist by the end and are never merged — the root CA, the mgmt signer and the operator's own principal (ID-C0-19). The operator's principal is the one genesis delivers like anyone's, in the bundle for the operator's own machine.

Operator material lives in rete's own tree and never in the repository (ENRL-C0-06, ENRL-C0-07). The certificates are absent for a reason worth stating on this page: the compiler embeds every signer's certificate into the signature of each envelope it emits, and the compiled tree is committed, so a separate copy in the repository adds no fact a reviewer did not already have. See Installer § Directories for how the tree sits beside flor's own layout, and CLI Surface for what rete reads from it.

One machine holds all of it, and C0 has no way to move it (ENRL-C0-34). A git clone carries the repository and nothing else — not the root key, not the signer, not rete's configuration — so the machine that ran rete init is the rete's only operator machine for the life of the milestone.

issue-bundle assembles each node's anchors.pem from the operator's root certificates — one of them, in this milestone.

The bundle

Two flows are named throughout this design, and only the first is built. In Flow A the operator generates every principal's keypair on their own machine and delivers both halves in a bundle; the private key travels. In Flow B the principal generates its own keypair and sends only a certificate signing request, so no private key ever leaves the machine that will hold it. C0 is Flow A only — during pilots the operator is the Florete team, so the trust Flow A asks for already exists in fact — and Flow B is an open question for C1, where its commands would be rete id keygen and rete id sign.

A bundle is one file per node, carrying every principal that runs on that machine — user, service, and the node itself. It is per node rather than per principal because a server hosting five services would otherwise mean five deliveries, five bearer files and five windows to close, for one machine that is trusted or not as a unit.

It is a tar container, and its entry names are identifiers rather than destinations.

<node>.bundle
├── manifest.json             schema_version, trust_domain and node, every entry below
│                             by name and SHA-256 digest, and a signature block over
│                             the canonical manifest
├── anchors.pem               the rete's trust anchors
├── credentials/<path>.pem    one per principal on the node, named by its SPIFFE
│                             path below the trust domain — a label checked
│                             against the certificate, never read as a path;
│                             the chain, and the key unless the entry is keyless
└── artifacts/<plane>/<name>.json   each envelope byte-exact as the compiler emitted it

flor enroll derives every path it writes, and derives all of them from content rather than from names. Artifacts it does not place at all: they are deposited in incoming/ and placed by the agent's intake, which reads each destination out of the envelope (Agent § Sync) — one rule for a set arriving from the coordinator and a set arriving from a bundle. Credentials it does not place either: it hands them, with the anchors and the manifest's claims, to the identity store's import, which places each at the SPIFFE path its certificate carries and pairs a key with its certificate by public-key correspondence rather than by filename (ID-C0-31). Reading entry names as paths is what makes archive extraction dangerous, and this format has no path a filename contributed to.

Entry names still have to be right, as a check rather than a source, and each is checked by whoever reads the content: the store refuses a credential entry whose label, trust domain or key disagrees with its certificate, and the intake an artifact entry whose <plane> disagrees with its envelope or whose envelope names another node. Each disagreement is refused as the producer bug it is. The node agreement is the one with an everyday failure behind it — destinations come from plane and name alone, so a set cut for another node would stage and activate with nothing noticing, leaving that node running another machine's agent.json, degraded rather than broken by ENRL-C0-36's rule and therefore silent. There is no entry for the scope's own record: the store's scope.json is written from the manifest's trust_domain and node claims, so each fact has one source rather than two that could disagree. The container's shape, its refusals and its three separable parts are ENRL-C0-16's.

The bundle carries no fact an artifact already carries. There is no coordinator URL and no expected coordinator identity: agent.json's sync block names the coordinator by principal, and the dial resolves through the agent's own compiler-projected inbounds map. And there is no signer list to carry: each envelope in the set brings its own signer's certificate, which a node validates against the anchors it already holds — the anchors a bundle delivers are what future artifacts will be checked against, never what checks the bundle itself.

The first enrollment establishes the identity store, which pins three things, and no later bundle may move any of them (ID-C0-18). The trust domain, the anchors and the node are what the scope is; each is a manifest claim under the signature rather than a fact read back out of an entry, and enroll hands all three to the store, which refuses a bundle differing in any of them. Re-enrollment therefore reaches principal material and the artifact set alone. The node binding is the one with an everyday failure behind it — a bundle carrying node alpha's identity applied to beta would silently give beta alpha's name — while the other two are what stop an anchor change, the one delivery that would hand a node a new root of trust and the one C0 cannot authorise more strongly than it authorises an ordinary artifact.

What a node can check

A fresh enrollment verifies no signature, and that is deliberate. A run is fresh when the identity store holds no anchors, so there is nothing on the node to verify against: a signature checked against a key the bundle itself supplies proves nothing, since an attacker who alters a bundle re-signs it and updates the trust material it delivers. What enroll does instead is check every entry against the manifest's digests, hand the credentials to the store — whose import validates every certificate against the anchor set it is about to pin, a coherence test on the delivery rather than a trust decision, and the one that catches a bundle cut against the wrong operator tree before the material reaches a handshake (ID-C0-31) — then print the trust domain, the anchors' fingerprints and the signer's name, marked plainly as unverified. It is a display and not a check: C0 offers no flag turning it into a refusal, because the operator who could supply the expected fingerprints is the operator who cut the bundle, and a second channel in every pilot's critical path buys nothing against the threat it names.

The gain from declining the check is structural: there is then exactly one signature-verification path in the whole flow, and it can only be pointed at keys the node already trusts. A path that cannot be handed an attacker-supplied key is worth more than one guarded by care.

A re-enrollment verifies the manifest signature, and a run is a re-enrollment whenever the node holds anchors — after any finished enrollment, and after any interrupted one whose import had begun: it reads them through the store, validates the certificate the manifest carries against them and refuses a signer that does not chain. This is not new machinery — it is the check every signed document goes through (ID-C0-20).

And the store compares before it writes. The bundle re-delivers the anchors and names the trust domain and the node, so the store has both halves of the comparison in hand: each must equal what it pinned, as set or string equality, and a difference is a refusal naming which. The comparison is what makes the pin real — without it the anchors would be silently replaceable by anyone who could sign a manifest, which in C0 is anyone holding the mgmt signer key. It also means a bundle cut for the right node against a rete whose anchors have moved fails loudly at the node rather than being discovered later at the first artifact verification.

Bearer material, honestly. A bundle carries private keys for its whole life, and nothing at the bundle level bounds that window — a control checked by our own tooling binds only the honest party, since an attacker holding a bundle does not run flor enroll, they copy PEM files into place. The credential's own validity is the only bound that binds. What remains is guidance — deliver over a channel the recipient trusts, enroll promptly — and one mechanical aid: flor enroll deletes the bundle on success, or prints a loud instruction where it cannot. That is hygiene rather than a control, and the honest path's leftover copy is the only one this milestone can act on.

Enrolling a node

One flow serves a first enrollment and every later one — the Shape's enroll block is the sequence, and this section is why each step is where it is rather than a second copy of it. The decision is ENRL-C0-21.

A bundle is cut from the source of truth, not from the coordinator (ENRL-C0-35). rete issue-bundle compiles nothing and packs the committed tree, refusing an uncommitted one; that refusal is its whole precondition, so issuing needs no network and asks no coordinator anything. The order is compile, read the diff, commit, publish, issue — and the publish is prompt rather than required, because git is where the rete's state actually lives and the coordinator is a cache of it. A node handed a bundle is not running ahead of the rete. It is current, and the cache is behind.

What the commit is guarding is a fork, not a delay. A node's set version is the previous one plus one, counted against the compiled tree, so a tree that rewinds — a checkout, a revert, an abandoned branch, a restore from backup — will reissue a number that a bundle has already given to some node. That node then polls carrying a version the coordinator also holds, receives the empty answer that means nothing new, and never converges: it runs a set nobody else has, and both ends read the situation as settled. The coordinator refuses two contents at one version when they arrive through it; a bundle is a second issuance out of the same ledger and never passes that check. Hence the rule worth remembering — a version handed to any node is spent — and hence the gate, which cannot make reissuing one impossible but makes it a deliberate, recorded act instead of an accident.

rete publish stays separate and stays the review gate, so an operator still reads the diff before nodes see it. What it no longer is is a precondition of anything: gating issuance on it would mean the repair for a dead management node — re-enroll it, which needs a bundle — required the publish that the dead coordinator cannot accept (ENRL-C0-37).

The identity store is replaced only where the bundle speaks (ID-C0-33): material for a principal removed from YAML since the last bundle stays on disk, inert, because no peer's access check names it any more.

The two planes arrive by different channels and at different times, and the gap between them is ordinary (ENRL-C0-36). Publishing precedes issuing, so a running node fetches a set naming a new principal one sync after the change and receives that principal's key and certificate whenever the courier arrives — hours later, or days. That is not a window to be closed but the normal shape of adding a service to a node already in service. What it costs is a principal that is declared, routable and simply not answering: the store reports that principal absent when the vertex asks for it, and the vertex opens no listener for it, warns once, and serves every other principal it terminates for. A caller sees an absent service rather than a handshake that fails, which is the truer report. So the pair of rules is symmetric — material without an artifact is inert; an artifact without material is degraded — and neither is an error. What nobody can see is the operator's side: issuance is unrecorded (ID-C0-25), so rete cannot compare what a published set declares against what has ever been cut, and the gap is visible only in that node's log until an issuance ledger closes it (ID-C0-25).

The artifact set enters through the agent's ordinary intake, not through enroll. Enroll deposits it in incoming/; staging is a step inside the agent's intake, and nothing outside the agent performs it. The cost of the split is that enroll succeeds before the set it deposited has been admitted: its success means the identity store is written and the files deposited, never that the node is running them, which is why a rejected intake is loud rather than a swept slot.

Reading incoming/ before the first dial is what makes one repair possible at all. A node that has been offline long enough for the coordinator to have moved cannot reach the coordinator to learn where it went — the only thing that could tell it is a set it can no longer fetch. Re-enrollment fixes exactly that, and only because the bundle's set is genuinely admitted rather than assumed stale.

Enroll reads its scope only under that scope's lock (ENRL-C0-39). The bundle says which scope — the manifest's node and trust-domain claims give its handle — and enroll takes that scope's lock before it reads anything the scope holds. Every check it performs — which path this run is on, whether the anchors verify the manifest, whether the bundle's claims match what the scope is pinned to — is a fact about the scope, not about the bundle, so reading any of them outside the lock is deciding on a value something else may change before the write lands. Other scopes on the host are read without their locks, and only for their records, which never change (ENRL-C0-41).

So enroll refuses a live scope rather than quieting it. It creates retes/ and the scope root if either is missing, takes the scope's lock, and stops there if someone already holds it — naming the holder, and naming the flor agent disable that clears the ordinary one. It does not stop the agent itself: stopping and then acquiring leaves a gap a service manager can restart the agent into, where a refusal leaves none. What that costs is a command, and the person running enroll was expecting the scope down for a material change in any case.

The receipt is what a partial scope root says about itself. receipt.json carries two things, the scope root's schema_version and a phase, and enroll writes the phase enrolling before any material and replaces it with enrolled as its last act. That replacement is the commit point of the run. The phase says one thing: whether the last run finished. It does not decide whether a run is fresh; the identity store does, by holding anchors or not, so no interruption — of a first enrollment or of a re-enrollment — reopens the unverified path on a node that already holds trust.

A failed run therefore converges rather than resuming. Renaming over a populated directory is atomic on no platform and part of that directory must survive, so atomicity was never available; what stands in for it is that any bundle for this scope's rete and node completes an interrupted run. A bundle for another node never reaches this scope, its claims naming another handle. One for an independent rete that shares the name, and the node's name, is refused, even when the run it would complete was a first one: the identity store may already hold the first bundle's rete (ID-C0-18), and removing the scope root by hand is the remedy, as for any wrong first enrollment. Nothing on disk records which bundle started it, incoming/ is replaced wholesale rather than merged, and material left by the interrupted run is inert by the same rule that leaves a removed principal's material in place — each principal being one file written whole, none is torn. The operator who re-cut the bundle after a crash is the ordinary case, and it works.

A node's scope is named by its node and its rete: <node>.<rete> (ENRL-C0-41) — alpha.flor.demo for node alpha of flor.demo, as a directory under retes/ and as a service instance. Enroll derives it from the manifest's node and trust-domain claims, each parsed as a name before the two are joined, so the handle is a path component by construction; nothing chooses it. A node name is one DNS label (ID-C0-12), so a handle always splits back at its first dot. The handle finds a scope; what the scope is lives in identity/scope.json.

The same node enrolled twice is a re-enrollment, and a second node of the same rete is a decision. A host may hold several nodes of one rete, each its own scope under its own service instance. Since a renamed node or a bundle delivered to the wrong host would land as exactly that, a fresh enrollment refuses when a sibling scope holds another node of the same rete, unless --multi-node says the second node is meant. The refusal comes before any material is written, with the bundle still on disk, and names the sibling, the flag, and removing that scope root by hand where the new bundle replaces it. Two independent retes that chose the same name meet only when a node name coincides as well, and then the identity store's pin refuses the second one.

rete's tree belongs to a rete rather than to a node, and is keyed by the rete's name alone (ENRL-C0-08).

retes/ is created by whichever of the installer and enroll runs first, identically, through one library function (ENRL-C0-22) — creating the directory is not writing into it, and a distribution package places binaries without ever invoking flor install, so enroll cannot assume it exists. Modes and ownership below it are Agent § Node layout's.

Without an operator present

A server enrolled over SSH, a provisioning script, and a machine enrolling at first boot are the same path: nothing about writing an identity store changes when nobody is watching, and every check enroll performs is mechanical and runs identically either way (ENRL-C0-23).

No flag makes an image carry an identity. Enrolling during an image build gives every instance from that image one node identity, and baking a bundle into the image does not fix it — a bundle is per node too, so an image carrying one enrolls every instance as the same node. An image therefore carries no identity at all, and each instance receives its own bundle at first boot through some channel outside the image: a provisioning system, an operator over SSH, or eventually a credential fetched by the node itself. Building one image per node is the other answer, and it is a fleet answer only where the fleet is small.

Activation

Enroll activates nothing (ENRL-C0-20). flor agent enable is the only thing that does, and enrolling a node is therefore two commands rather than one command with two steps: enroll writes the material, prints the enable that finishes the job, and exits — releasing the scope lock as it goes, which is the point. Enroll cannot both hold that lock and start the party that needs it, and releasing early would put its last act outside the lock it took to make the act safe; handing activation to enable removes the choice instead of picking the less bad half.

What that costs is a node that stays down until someone enables it, where enroll used to bring it back by itself. The mitigation is a printed command rather than a mechanism, which is the same answer flor install gives when it reports each surviving scope's enable line and starts nothing. A node that is enrolled-but-inactive is a resting state this design already relies on: it is exactly what survives an uninstall, resumable with no bundle at all — the alternative charges a re-issued bundle for a reinstall, and a bundle is bearer material that must be couriered. It also lets a multi-rete node leave one rete without touching the others. disable is neither revocation nor purge, and it is what an operator runs to hand a live scope over to an enroll.

"Activate" names an act, never a service manager. Today it instantiates the wrapper the installer placed — see Installer § Service registration — but a nested rete, where one rete's agent runs as a supervised workload of another's, would make activation adding a supervision entry instead. The installer owns the wrapper and this page owns the act, so nothing forecloses that; the contract simply does not name systemd.

Revocation and rotation

Revocation, rotation, lifetimes and signer trust are the life of a credential, and Identity decides them: a principal is revoked by removing it and the name is spent (ID-C0-25), rotation is per node (ID-C0-22), expiry is warned about where a certificate is read (ID-C0-23), and a signer is trusted on the anchors' word (ID-C0-20). What this page owns is how those changes travel.

"At the next sync" is the whole of the guarantee, and it is per node rather than rete-wide. A revocation takes effect at each peer when that peer converges, so between the publish and the last node's sync the principal is refused by some and admitted by others — effective access during a transition being the union across nodes that have not caught up (ENRL-C0-38). For a coordinator-fed node that is a poll interval. For a node fed mainly by courier it is however long until it next syncs, which nothing in C0 shortens and no mechanism enforces. Revocation is therefore eventual, and its deadline is the slowest channel that reaches the node you care about.

Rotation has one path and it is re-enrollment: a fresh bundle, couriered, replacing every principal on the node at once. It reaches principal material alone, never the anchors or the trust domain, which the store has pinned — so re-enrollment cannot displace a leaked signer either, the same anchors going on vouching for it until its certificate expires.

Evolution sketches

Recorded so nothing above forecloses them. None is designed and none is a commitment; each names the property this milestone owes it, and each cites the decision that defers it.

  • The life of a credential. Replacing a credential over the mgmt channel, bounding a hot issuer by chaining credentials, rotating the anchors C0 pins, and an intermediate that lets the root stay offline are Identity's to design (ID-C0-26, ID-C0-27, ID-C0-18, ID-C0-21).
  • A set identified by its content rather than its position. (ENRL-C0-35) The commit gate makes reissuing a spent version deliberate rather than accidental; it cannot make it impossible, because a version is an ordinal and two different contents can wear the same one. Carrying the set's digest alongside its version in the node's high-water-mark would close that: a node holding v9 of one content, meeting a coordinator serving v9 of another, would detect the fork where today it reads an empty answer as nothing new. The mark is small and additive, and agent.json already has a door left open for a per-member digest map. What makes it a design rather than a field is everything it opens behind it — a set identity that is a content hash, a predecessor it can name, a chain of them that is a lineage — which is git's construction arriving by a second route, and not by coincidence: both are versioning a tree of files that must be recognised across parties who cannot talk. Whether to lean on git for it, to mirror its mechanisms, or to build something of our own is the question, and it deserves its own pass rather than a field added here.
  • A sealed bundle. (ENRL-C0-29) Sealing the whole bundle to its recipient keeps its topology and access state as private in transit as its keys, where encrypting only the secret entries would leave them readable to every carrier; a re-enrollment then needs no secret at all, the node's existing public key being the recipient key.
  • Delivery that is not a bundle. (ENRL-C0-29) Nothing may assume the bundle format is the only way a credential reaches a node; a courier file a human handles once and a machine-to-machine payload over mTLS need not share a format.
  • A separable anchor and a replaceable issuer. (ENRL-C0-30) Exactly one thing is common to a fleet — the anchors — so a two-phase provisioning split moves one field. And the rete CA is one implementation of a boundary rather than a fixture: the identity store is a sink, and nothing may assume what filled it. An identity may one day be created by a component this project does not ship.
  • Naming the coordinator rather than its address. (ENRL-C0-31) From the milestone where a node joins at a link to some node, its position is not known when its material is issued. sync names a principal, and nothing may carry the coordinator's address as the primary fact.
  • Foreign and temporary principals. (ENRL-C0-32) An ingress.allow entry names a full SPIFFE ID and therefore already expresses a foreign principal; an entry must never be shortened to a bare local name. A temporary grant is a short-lived identity rather than a time-boxed permission, because the latter needs validity windows in the artifact and clock agreement between every enforcing pair.

Decision Log

Entries are grouped by what a future change would be touching, not by the order they were settled.

Tooling and command surface

ENRL-C0-01. Split the CLI by the state a command authors. rete authors rete state; flor authors node state. A private key is material rather than state — it has a holder and no author, and where it sits decides nothing. Minting therefore belongs to rete, because a SPIFFE name enters the rete's YAML and the certificate is CA-vouched, both facts the rete holds. rete reaching no node state is an invariant it keeps, not a limitation it has. Because the criterion has to classify a command nobody has written yet, and only this one does. Audience partitions people, and people overlap — the operator's workstation is itself an enrolled node. Scope-of-effect cannot classify minting, its own motivating case. A closed list of flor's commands fails on the first unanticipated one: a node-local override is not install, uninstall, enroll, agent or version, so a list would route it to rete, the one tool that must never touch it. Accepting that the boundary is argued per command rather than read off a list, and that the state-versus-material distinction has to be taught before it can be applied. Rejected audience, on the overlap above; scope of effect, superseded by this reading of it; node-maintenance-versus-everything, on the override case; authoring-versus-running, which has no cell for flor install.

ENRL-C0-02. Name a rete command group for its subject, never its actor. A bare command is a verb whose implicit subject is the rete — compile, publish, issue-bundle. Where a group exists, the group's name is the subject its verbs act on: ca init creates the authority, id issue produces an identity. A command that emits a principal's certificate belongs to id, whatever instrument signs it. Because the scar exists: ca sign was named for the actor, read as the authority mints, and written into this page as a mint — contradicting the CLI reference and the implementation for long enough to need a ticket. Accepting that the rule bites only when a group is introduced, which is rare — and that its value is concentrated exactly there, since a group name is what a reader uses to guess what a command does before running it.

ENRL-C0-03. Give the identity plane four commands and one front door. rete init <name> creates the repo scaffold, the root CA and the management-plane/mgmt-signer signer, and is the only way a root CA comes into existence. rete id issue produces a key that stays in the operator's custody — that is its subject, and accepting signing kinds only is the consequence, those being the principals whose keys must never leave. C0 has no ctrl plane, so it accepts management-plane alone. rete issue-bundle mints and packs every TLS principal on one node. id keygen and id sign are named for Flow B and are not part of C0. Because a key that must never leave operator hardware is issued, and a key that must reach a node is bundled — which is this design's existing split between signing-principal and TLS-principal material, expressed as a command surface rather than invented. A TLS key issued directly would have nowhere to go — bundles are per node, so no per-principal delivery exists to reach for — and the definition survives rotation, which issues TLS credentials from machinery rather than from this command. Genesis earns a front door because it happens once per rete at the worst moment for a new operator, while rotation forces the primitives to exist anyway. Accepting that C0 has no ca group at all; it returns when CA rotation lands, and ENRL-C0-02 already says where its commands go. Revisit the Flow B carve-out when Flow B is designed: the grouping is settled, the two commands' argument shapes are not. Revisit the kinds id issue accepts with the milestone that brings a ctrl plane, and its custody clause once a ctrl signer stops being operator-held: ADR-0015 makes the ctrl signer a running service, hot by design and hosted rather than held. That key then needs delivery, which is a bundle's job and not this command's, so what is owed is a decision about which command mints a hosted signer — not a wider id issue. Rejected folding the root CA into id, since a CA is not a principal; a mint-or-sign command whose behaviour depends on whether an argument was supplied, which is the contradiction that prompted this; composing genesis by hand, which writes a freshly-minted private key to an intermediate file.

ENRL-C0-04. Put a node-local override in flor, outside the signed set. Overriding an inbound or outbound locally authors node state, so it is flor's. The command and the merge mechanics are flor#44's; two properties are owed to it. An override must live outside artifacts/, because sets are immutable, staged by version and swept by the cleanup pass, so anything inside is either destroyed at the next sync or indistinguishable from tampering. And an override may change only what this node does — anything that changes what another node does is a rete fact, since reachability needs other nodes' ingress and route entries, which only the compiler produces and only the coordinator distributes. Because a legitimate override has to be distinguishable from tampering, and under the documented hand-edit posture the two are the same act. Accepting that "add a new inbound or outbound locally" collapses in both directions: an inbound nobody can route to is not a published service, and an outbound needs peer routing the rete had to supply anyway. Override yes; add no.

ENRL-C0-05. Superseded by ENRL-C0-35.

ENRL-C0-35. Pack only what is committed, and let the coordinator lag. The operator sequence is compile → commit → publish → issue-bundle → enroll, and rete issue-bundle compiles nothing: it packs the node's set out of the compiled tree and refuses when that tree is not committed, naming the commit. That is its whole precondition — it consults no coordinator, needs no network, and has no special case for a rete that has never published. rete publish stays a separate command and stays the review gate, the diff being read between compile and commit; it is prompt rather than required, since publishing is what stops the cache lagging the source. Because git is the rete's source of truth and the coordinator is a cache of it. A node fed from a bundle is therefore not ahead of anything — it is current, and the coordinator is stale. Read the other way round the gate lands on the cache, which is both the wrong authority and, per ENRL-C0-37, a dependency the bundle path must not have. Because the hazard the ordering exists for is a fork, not a delay. A version is previous + 1 against the compiled tree, so a tree that rewinds — a checkout, a revert, an abandoned branch, a restore — reissues a number some bundle already handed to a node. That node polls with a version the coordinator also holds, gets the empty answer that means nothing new, and never converges: it runs a set no one else has, and both ends read it as steady state. The coordinator's 409 refuses equal-version-different-bytes on the path through it; a bundle is a second issuance out of the same ledger and routes around that check. So the invariant is a version handed to any node is spent, and the commit is what makes reissuing one a deliberate, recorded act rather than an accident. Because the check must be offline to be worth anything: it guards the channel whose reason for existing is that the other one is unavailable. The compiled tree's own commit state answers it with no receipt to maintain, no ref to move and nothing to ask the network. Because it makes true a claim the design already makes. The git log is described as the rete's shipment history, and nothing currently requires that what shipped was ever committed. Because the repair path of ENRL-C0-21 survives and is sharper: a node offline long enough for the coordinator to have moved is repaired from the source of truth directly, which is the one place that cannot itself be unreachable. Accepting that issue-bundle acquires a hard dependency on git, where ENRL-C0-08 deliberately anchored discovery on rete.yaml rather than on .git. Git stops being convention here and becomes load-bearing. It is payable because the repository is a git repository in every other part of this design, and it is the pick's real cost rather than a detail. Accepting that the commit gate narrows the fork without closing it: an operator who rewinds the committed tree and compiles forward can still reissue a spent version. Closing it needs a set's identity to be its content rather than its position, which is a design of its own and is deferred — see Evolution sketches. Accepting that the artifact set now reaches a running node before the credentials it names, for the courier's whole duration and on every change that adds a principal. Under the superseded entry the two travelled together. This is the standard sequence rather than an anomaly, and ENRL-C0-36 is what pays for it. Accepting that genesis gains an explicit compile and commit, once per rete. Revisit at the publish design, which owes what publish requires of the repository, whether the review gate acquires a mechanism, how publish serialises once a rete has more than one operator machine, and whether the coordinator should report a node polling above what it holds — worth more than it looked, since what that detects is a fork rather than a lag. Rejected compiling inside issue-bundle, the superseded ENRL-C0-05: it kept the packed set equal to what would be published and said nothing about what was, so it minted a spent version while appearing to prevent one, and charged a whole-rete compile per bundle for the appearance. Its own rejection of "refusing on a stale tree" does not carry over — that was a staleness test against YAML, which the compiler cannot perform; this is a test of whether the tree is committed. Rejected gating on a successful publish, which reads well and is a cycle: a dead management node is repaired by re-enrolling it, re-enrolling needs a bundle, and that bundle would need the publish the dead coordinator cannot accept. It also needs a record of published state that a bundle-cutting operator may be offline to consult, and it invents a genesis carve-out to excuse the one case where the cycle is unavoidable. Rejected folding publish into issue-bundle, which cannot run at genesis and would make the one offline command networked; publish also packing pending bundles, which emits bearer material as a side effect for nodes nobody meant to re-key; packing the last published set, which fails on the main case, a newly declared node being absent from it; compiling only the packed node, which produces a set differing from what will be published; and a warning rather than a refusal, which loses on timing — the output is couriered for days, so the moment of the warning and the moment of the harm are unrelated.

Operator-held material

ENRL-C0-06. Keep the operator's identity store in rete's own tree. ~/.rete, overridden whole by RETE_HOME, holds one identity store per rete at retes/<scope>/identity/, plus everything else rete owns, its configuration included. The store's shape and the custody of the root key in it are Identity's (ID-C0-28, ID-C0-21). Because one tree is what this material needs: its defining operation is being moved as a unit, backed up, encrypted, or carried to another host, and the XDG bases exist to scatter an application across four of them. Every comparable credential-holding tool — ssh, gpg, step, pass, kubectl, docker — keeps one tree for the same reason. Rejected the node's scope root, which rete may not touch and which re-enrollment replaces wholesale; the rete repo, on ENRL-C0-07; no default at all, which forces the operator to decide where a root of trust lives before they have a rete; XDG_STATE_HOME, which the specification defines as what is not important or portable enough for XDG_DATA_HOME — the exact inverse of this material.

ENRL-C0-07. Keep every secret and every certificate out of the rete repo. The repo carries YAML source and the compiled tree, both committed deliberately so the git log is a shipment history. It carries no private key, and no public certificate either: rete.yaml names a signer, and the certificate resolves in rete's tree. Bundles go to a gitignored <repo>/bundles/<node>.bundle, always and with no override, and that directory is transient by design. Because a repo's defining operation is being copied — cloned to a second workstation, checked out in CI, archived by a backup — so a private key inside one is wrong whatever git tracks. And the audit case for committing certificates does not survive checking: every compiled envelope carries the certificate of the signer that signed it, and the compiled tree is already committed, so a separate copy adds no fact a reviewer did not have. Bundles are the exception because they are not durable: the repo is where the operator already stands to compile and publish, and a default of "wherever you were standing" scatters bearer files nobody can later find. Accepting that the repo no longer shows the trust anchor in a human-readable place, and that a reviewer reads it out of a compiled artifact; and that a spent bundle survives in the workspace until deleted, since nothing can know it was consumed. Rejected a durable bundle directory in rete's tree, which would accumulate spent bundles still carrying live keys; a temporary directory removed on exit, which forces delivery to happen inside the issuing command.

ENRL-C0-08. Require a repo root, take the scope from it, and let no command name a path. rete walks up from the working directory to find rete.yaml, as git finds .git, and refuses to run outside one; rete init inverts the same test, refusing inside one. rete's scope is the rete name, keying the operator's tree, which belongs to a rete rather than to a node; a node's scope is ENRL-C0-41's. No command takes a path: no --repo, no --out, no -f. compile always writes <repo>/.flor/compiled/ and issue-bundle always <repo>/bundles/<node>.bundle; narrowing the source set is source.include/source.exclude in rete.yaml. Because the structure is the point: paths become repo-relative, bundles land in one place per rete, and the scope comes for free, which removes a --rete flag from every command that would have needed one. rete.yaml is already the anchor, so a marker directory would be a second one for the same job — and a renamed rete.name is a different trust domain rather than an edited convenience, so the tree moving with it is correct. Because each rejected flag reintroduces the ambiguity the repo root removes. A named root and a discovered root are two discovery rules, and the flag version is the one that lets a command run against a repository the operator is not standing in. -f is worse: it selects files with no root to anchor them, so requiring that some selected file carry the rete block is the rule admitting it has lost the anchor. And an output override is wrong on a stronger ground than consistency — the compiled tree is an input to the next compile, being the version ledger ENRL-C0-35 relies on, so a tree written elsewhere compiles from the wrong history and produces versions the coordinator rejects or, worse, accepts. Accepting that CI runs the same command in the same layout as an operator, which is the point rather than a limitation; that a partial or scratch compile has no expression in C0 and gets one by copying the repo; and that a rete repo becomes required rather than recommended.

ENRL-C0-09. Share flor's host configuration, none of its state, and exactly one of its locations. rete reads no flor configuration; its log stream defaults to stderr and to off, and RUST_LOG turns it on. It resolves one flor path — agent.sock — because publish reaches coordinator-publisher through the local agent's SOCKS5 listener. Per rete it is told exactly one thing, the operator's own principal name, and derives the rest: the SOCKS5 address through agent.sock, the publish target from the reserved service name plus rete.name. rete init writes ~/.rete/retes/<scope>/config.toml as a commented template; publish with the key unset fails naming the file, the key and the principals holding the operator role. CLI § rete local wiring has the mechanics. Because the log stream exists for processes nobody is watching, and rete changes rete state — whose record is the git commit — with the person who ran it watching the terminal. Because configure-the-stable, derive-the-volatile: a principal name changes only by a deliberate rete-level act, while a SOCKS5 address is compiler-projected and goes stale silently. Resolving the operator from the repo cannot work at all — the repo describes the rete, and the missing fact is which human is at the keyboard. Because the publish target is the opposite case and is therefore derived: coordinator-publisher and its group are reserved names the validator enforces (Coordinator), so the target is fixed by the rete rather than chosen per operator — and rete is the compiler, which already resolves that principal to fill every node's agent.json sync block. Because the template is written rather than prompted for: the value names a human, so it travels by no channel the rete has. It cannot be committed, does not follow a git clone — ~/.rete being outside the repository — and must not be copied even alongside trust material, the receiving operator being a different principal. There is no delivery to design, only a place to write it and a failure that teaches. Accepting that flor's commands log to syslog and rete's do not, so the uniform output rule covers stdout-for-data and stderr-for-people but not the log stream. Revisit when agent.sock ships: the configured address becomes the fallback and the principal the normal path. Rejected a coordinator URL in rete.yaml, which duplicates a reserved name into authored source; configuring the target beside the principal, which fails this entry's own criterion — the target is determined by context where only the typist is not; --target per invocation, an argument that never varies; and an interactive prompt at init, against ENRL-C0-23's unattended posture.

ENRL-C0-10. Keep agent.sock internal to flor's own tools. It serves flor agent status, flor agent sync, and rete's operator lookup, and nothing else in C0. It carries no version. Because rete and flor ship as one release and are upgraded together, so on a single host the two ends of this socket cannot skew — the property one release version over four executables was bought for. A third party has no such guarantee, so inviting one means versioning the protocol, which should wait for a consumer to design it for. Rejected public and versioned, as premature; public and unversioned, which is a dependency people would take and nobody priced.

Trust model

ENRL-C0-34. Support exactly one operator machine, and say so. The rete's root key, its mgmt signer key and rete's per-rete config.toml all live in ~/.rete and travel by no channel C0 builds: a git clone brings the repository and none of them. C0 therefore has one operator machine — the one that ran rete init — and no command moves, copies or re-creates what it holds. Because the three pieces do not decompose the way "support a second operator" suggests. The root key and a signer key are separable and would not travel together: a root can stay with one operator while a signer is copied to a second, which is a real and probably desirable arrangement, and designing the copy before anyone needs it would settle that split by guesswork. config.toml must never be copied at all, naming as it does a particular human. A single "move my rete" operation would be wrong in one of its three parts and premature in the other two. Accepting that losing the operator's machine loses the rete — the same exposure ENRL-C0-06 already takes by keeping the root key at a predictable path with no passphrase. The remedy is a backup of ~/.rete, and it is C0's only remedy for a lost root key, the spare anchor having been rejected in ID-C0-18 partly on this ground. The mechanism is the operator's own — any file backup restores the tree, since it is one directory by construction — but the instruction is ours to write, and it belongs in the operator guide, #14. Revisit with multi-operator, which owes four separate answers rather than one: how a signer is copied without the root, how a root is moved rather than copied, where per-operator configuration comes from on a machine that never ran init, and how publish serialises once two machines can run it. The likely shape of the third is splitting rete init, which today conflates make a repository with mint a rete in a way git init does not. The fourth is the one with a mechanism already in sight and it is not the coordinator's: the source of truth is git, the coordinator holds output and no ancestry, and its monotonic 409 rejects a stale publish without being able to tell a stale base from a legitimate one. It belongs to the publish design of ENRL-C0-35, where "nobody published behind your back" can reduce to "nobody pushed behind your back". Rejected an export/import pair now, on the guesswork above; splitting init now, which buys nothing until a second machine exists to run the second half.

ENRL-C0-11. Moved to ID-C0-16.

Bundle shape and verification

ENRL-C0-12. Moved to ID-C0-20.

ENRL-C0-13. Moved to ID-C0-18.

ENRL-C0-14. Moved to ID-C0-17.

ENRL-C0-40. Moved to ID-C0-29.

ENRL-C0-15. Join by a per-node bundle carrying the node's initial artifact set. One bundle per node enrolls that machine and every principal on it. The operator generates the keypairs (Flow A); the principal-generated CSR path (Flow B) is not part of C0. The bundle carries the node's initial mgmt set. Enrollment therefore works offline, and that is an invariant rather than a happy accident: no coordinator reachability is required at enroll time, the bundle carrying the set, incoming/ being consumed before the first dial and the first sync being opportunistic. Because a server hosting five services would otherwise need five deliveries, five bearer files and five windows to close, for one machine that is trusted or not as a unit; because during pilots the operator is the Florete team, so the trust Flow A asks for already exists in fact; and because the management node cannot fetch artifacts from a coordinator that is not yet running — its own coordinator.json is in that set — while no node has Florete connectivity before it enrolls. Because the offline property is what makes provisioning a node at a depot identical to provisioning a server. Accepting that this is C0's join model and explicitly not the design's final form: a token-and-fetch join, a principal-generated key, and a two-phase split with fleet-common material are successors, not rejected alternatives. Revisit the artifact-set clause under a two-phase installer, where it does not change — the set is node-specific by construction, so what a split moves is the anchor.

ENRL-C0-37. Keep the two mgmt channels independent, and never let the bundle depend on the coordinator. mgmt state reaches a node two ways: the coordinator, online and incremental and carrying artifacts alone; and the bundle, out of band and manual and carrying identity material as well. Neither may be a precondition of the other. Concretely: nothing on the bundle path — issuing, delivering or enrolling — may require the coordinator to be reachable, to be current, or to have worked at all. The bundle is the more capable of the two and it is the repair path for the other's failure. Because the failure that most needs the bundle is the coordinator's own. A dead management node is repaired by re-enrolling it or its replacement, which needs a bundle; if cutting one needed a publish, the repair for a dead coordinator would need a working coordinator. That is a cycle, and it is genesis recurring after a failure — except genesis can be excused as a first act and a rebuild cannot. Because the asymmetry is already load-bearing elsewhere: ENRL-C0-15 makes needing no coordinator reachability an invariant of enrollment rather than a convenience, and ENRL-C0-21 reads incoming/ before the first dial precisely so a node that cannot reach the coordinator can still be repaired. A dependency at the issuing end would undo both from the other side. Accepting that the two channels converge at different speeds, and revocation's effective time is the slowest node's. C0 revocation is a name removal that every node applies at its next sync (ID-C0-25); a node fed mainly by courier keeps admitting a revoked principal until it syncs, and nothing shortens that. Independence buys the repair path and pays for it in convergence. Accepting that transition windows widen with it — a change is minutes wide for coordinator-fed nodes and courier-wide for the others (ENRL-C0-38). Rejected a single channel, which is the same as deleting the repair path; and any rule making one channel's use conditional on the other's health, of which gating issuance on publish was the instance this entry was written against (ENRL-C0-35).

ENRL-C0-16. Ship the bundle as a versioned tar container of three separable parts. <node>.bundle is a tar archive holding manifest.json, anchors.pem, credentials/<path>.pem — one per principal, named by its SPIFFE path below the trust domain, holding the certificate chain and, unless the entry is keyless, the key — and artifacts/<plane>/<name>.json byte-exact as the compiler emitted them. The manifest carries its own signature block, in the shape and canonicalisation an envelope uses. Entry names are logical identifiers, never destinations, and no filename is an input to any path: enroll hands the credentials to the identity store's import, which places each by the SPIFFE path its certificate carries and checks the entry's label against it (ID-C0-31), and deposits the artifacts for the agent's intake, which places each by its envelope and checks the entry's <plane> and <name> and the envelope's workload against it. Digests are SHA-256 with no algorithm field. Three refusals keep the archive unambiguous: a repeated entry name, an entry that is not a regular file, and a manifest-named entry the archive does not carry; an entry the manifest does not name is not extracted. The manifest's trust_domain and node are claims under its signature rather than facts read back out of entries, because they are two of the three bindings the store pins (ID-C0-18). schema_version makes the manifest a fourth versioned surface under ADR-0012, gated same-major and minor-older-or-equal, and stamped by issue-bundle at the lowest minor its content uses. Because the three parts must stay structurally distinct even though C0 always ships all three: a keyless credential set, an anchor delivered by another channel, and a fleet-common part each become possible without a format change. A credential is one entry, as the store keeps it, and a keyless one is an entry with no key block. Because entry names are logical where the producer and consumer can skew across releases, so a bundle written against one scope-root layout must not break against another. And a container beats a document because a signed envelope embedded in JSON is either a nested object whose bytes change on re-serialisation or an escaped string, while a tar entry is the bytes. Because minimum-minor stamping is what makes that skew survivable: without it a newer rete would stamp its newest minor on every bundle and strand every older flor, which is the ordinary direction here — an operator upgrades on their schedule and a node on its own. Because tar lets an archive say a thing twice, and a fresh enrollment authenticates none of it. Duplicate names are legal on tape and readers disagree about which wins, so a digest checked against one occurrence and a file written from another is a check that passes over bytes nobody verified; a repeated name is therefore a refusal, not a resolution. Non-regular entries are refused for the same reason destinations are derived — a symlink or a device node is an entry type whose effect is not the file it names. And a manifest-named entry the archive lacks would otherwise leave a principal silently unmaterialised on an enrollment that reported success. Because a destination derived from a filename is not derived at all, and a pairing made by filename is a binding nobody checked — which is why placement, pairing and the check of a name against its content belong to whoever reads that content: the identity store for credentials, the intake for artifacts. Accepting a tar dependency and a manifest to keep consistent with the archive. Path traversal is closed by extracting only manifest-named entries to destinations no filename contributed to: a structural closure rather than a filter, and not a sentence to weaken into "the names are validated". Accepting that a second digest algorithm is a schema_version-gated schema change when it arrives — the property is reserved, not a field. Rejected validating each entry name as a single path component and keeping it as the source, a shape test in place of a fact; naming credential entries by full SPIFFE ID, which puts a trust domain in a filename where the manifest and the certificate already carry it; separate certificate and key entries, which served an encryption plan sealing the secret entries alone, where sealing the whole bundle (ENRL-C0-29) makes the inner split irrelevant.

ENRL-C0-17. Verify a bundle only against trust the node already holds. A fresh enrollment verifies no signature. It checks every entry against the manifest's digests and hands the credentials to the identity store, whose import checks each against the anchor set it is about to pin (ID-C0-31); then it displays the trust domain, the anchors' fingerprints and the signer's name, marked unverified — a display, and no flag turns it into a check. A re-enrollment — any run on a node whose store already holds anchors, whatever the receipt's phase — reads those anchors through the store, validates the certificate the manifest carries against them, and checks the signature, refusing a signer that does not chain; the store then refuses a delivered anchor set, trust domain or node that differs from its pin (ID-C0-18). Because a signature checked against a key the bundle itself supplies proves nothing: an attacker who alters a bundle re-signs it and updates the trust material it delivers. Removing that check leaves exactly one signature-verification path, which can only be pointed at keys the node already trusts — and a path that cannot be handed an attacker-supplied key is worth more than one guarded by care. A manifest carrying the key alongside its name would put a verifier one convenience away from exactly that bug. Because the store's chain check on a first import is not that circularity wearing a different hat: it decides whether the files will work, never whether to accept the bundle — availability, never authority (ID-C0-31). The test for any future check on a first enrollment is that one. Accepting that unverified fresh enrollment is the default and remains possible; that a bundle with a malformed signature enrolls and fails only at the first re-enrollment; and that the operator's reading of the displayed fingerprints is advisory, catching an honest mix-up rather than an attacker. Accepting that a leaked signer key is unrecoverable in C0 (ID-C0-20), and that a lost one is not a scope-root problem at all: a fresh certificate minted from the root restores re-enrollment without touching any node. Rejected an --anchor-fingerprint flag comparing the displayed set against operator-supplied values. It looked like the non-interactive half of a human check, but the operator who could supply the expected fingerprints is the operator who cut the bundle, so it puts a second channel in every pilot's critical path for a confirmation that party cannot independently make; and the threat it was reaching for — a substituted anchor set — is closed structurally by the pin at every enrollment after the first, where it is a refusal rather than a flag anyone must remember. Also rejected: treating the manifest's signer certificate the way the delivered credentials are treated — requiring it to chain to the bundle's own anchors on a first enrollment. It is one line and it is the circularity, because unlike a credential check its outcome would be accept or refuse this bundle, decided against material the bundle itself chose; an attacker who alters a bundle supplies both halves and passes. The credentials are checked because the answer changes nothing about trust, and the signer is not because it would change everything.

ENRL-C0-18. Bound the bearer window nowhere at the bundle level. C0 enforces nothing on the interval between issue and enroll. Guidance covers it: deliver over a channel the recipient trusts, enroll promptly, delete the file. The credential's own validity is the only bound that exists. flor enroll deletes the bundle on success, or prints a loud instruction where it cannot — hygiene rather than a control, but the honest path's leftover copy is the only one C0 can act on at all. Because a control checked by our own consumer binds only the honest party. An attacker holding a bundle does not run flor enroll; they hold PEM keys and certificates and copy them into place. Anything that binds an attacker must be checked by someone they must convince — a peer at the handshake, or an issuer at fetch. Accepting that C0 ships with no enforceable bearer-window control, stated plainly rather than papered over. Revisit when a credential can be spent at first use, which replaces expiry with consumption. Rejected a bundle expiry, on the argument above; single-use redemption, which needs an issuer that remembers a principal has enrolled — and the coordinator deliberately keeps no per-node state.

Genesis and the node-side act

ENRL-C0-19. Moved to ID-C0-19.

ENRL-C0-20. Keep activation out of enroll. flor enroll unpacks and stops there; flor agent enable is the only thing that activates, with disable as its inverse. Enrolling a node is therefore two commands rather than one command with two steps, and enroll prints the enable that finishes the job. Uninstall reverses activation and leaves the scope root. "Activate" names an act, never a service manager. Today it instantiates the installer-placed wrapper; a nested rete would make it a supervision entry instead, and the contract prose must not foreclose that by naming systemd. Because the alternative charges a re-issued bundle for a reinstall — bearer material couriered out of band for a node that already holds valid identity — and because a multi-rete node must be able to leave one rete without touching the others. disable is neither revocation nor purge. Because enroll cannot both hold the scope lock and start the party that needs it (ENRL-C0-39). Activating while holding it deadlocks; releasing first would put enroll's last act outside the lock it took to make the act safe. Handing activation to enable removes the choice rather than picking the less bad half. Because the operator is already there. enroll is a couriered-bundle act with a person or a script at the keyboard, and the same caller runs enable on the next line; the cost is one command in a sequence that already has five. Accepting that a re-keyed node stays down until someone enables it, where enroll used to bring it back by itself. The mitigation is the installer's: enroll's last line prints the exact flor agent enable for the scope (Installer § Install reports the same way), and a node that is enrolled-but-inactive is a documented resting state rather than a failure. Accepting that "enroll in two steps" stops describing enroll and starts describing the operator's sequence. What the split was actually protecting is untouched: uninstall still reverses activation alone, and a scope root still resumes with no bundle. Revisit an activating flag, or a stop/start pair beside enable/disable, when something wants a takedown that survives a reboot. C0 needs neither: the receipt makes a reboot mid-enroll safe, since the agent refuses a scope reading enrolling. Rejected enroll activating and enroll stopping, both for the lock reason above; and a --no-start escape, which is this decision as a flag and therefore two paths where one will do. Rejected a per-scope removal command. Several remedies on this page end in removing a scope root by hand — moving a scope to a different node, replacing a rete whose root must change, and a partial root whose bundle is gone — and C0 still ships nothing for it: flor uninstall --purge removes everything and disable removes nothing. A pilot is not expected to need it, and a command nobody exercises is a command nobody has tested. The pages say "by hand" and mean it.

ENRL-C0-21. Enroll by depositing, and re-enroll by replacing only what the bundle names. Enroll verifies before it writes anything, refuses while the scope is live rather than stopping it (ENRL-C0-39), then hands the credentials to the identity store, which replaces each principal the bundle names and leaves every other principal untouched (ID-C0-33), and deposits the artifact files in incoming/ rather than staging them. agent/ and workloads/ survive. A failed run converges rather than resuming a particular bundle, and the receipt and lock that make that work are ENRL-C0-39's. The sequence is the Shape's enroll block. Because a bundle-delivered set is another intake path, and treating it as one keeps monotonicity a rule rather than an exception: writing it straight into artifacts/ would install a stale set unchecked and reopen the replay window the version mark exists to close. Staging is the agent's — it is part of its intake and belongs in one place — and enroll runs before the agent does, so a handover is needed either way. Making it a drop of files rather than a call into set machinery keeps the boundary narrow. It is consumed before the first dial because that is the whole of the repair path: a node offline long enough that the coordinator moved cannot reach it to learn where it went, so a set arriving only after a successful sync arrives never. Because an agent reading the identity store while enroll imports into it is a race — closed by the scope lock rather than by a stop, so correctness rests on the lock and not on the operator having read the step (ENRL-C0-39). Accepting that re-enrollment is not atomic and cannot be — renaming over a populated directory is atomic nowhere, and part of that directory must survive; what stands in for atomicity is ENRL-C0-39's convergence, one mechanism for both cases, since a node is enrolled once and re-enrolled whenever it is re-keyed. Accepting that flor enroll succeeds before the set it delivered has been admitted, and cannot report what the agent will make of it. The gap is real: on a fresh enrollment no artifact signature has been checked at all, so a set signed by a key that does not chain to the anchors the same bundle delivered enrolls cleanly and is rejected only at intake — and the bundle is already deleted, though it could not have helped, its content being malformed in this case. What C0 owes this is noise rather than a mechanism. Accepting that the two non-admissions are different events: a set ignored as older is expected, since a bundle's set legitimately goes stale between issue and enroll, and logs at INFO; a set rejected as invalid logs at WARN once, at the admission attempt. The slot is removed either way, and nothing on the node records that it was rejected. Revisit enroll performing the intake itself when incoming/ proves a source of silent failures in practice rather than in principle; reading flor agent's status before completing would be the better shape, and C0 defers it. Rejected starting the agent and handing the set over its control socket, which inverts the enroll-then-activate order and needs a socket that may not ship; enroll performing the intake through a shared library function, which would make its success truthful and puts set construction and the version mark in a second caller to buy a better error message; and keeping a rejected slot as evidence, which preserves bytes nobody can re-admit in a directory scanned at every start.

ENRL-C0-39. Make a partial enrollment converge, with a receipt for the phase and one lock for the scope. retes/<scope>/receipt.json carries exactly two things: the scope root's schema_version and the enrollment phase, enrolling or enrolled. It is written before anything else lands in the scope root and replaced with enrolled as the last act of enroll, which makes that replacement the commit point of the run. The phase says only whether the last run finished: enrolling is an interrupted run and enrolled a finished one. It does not choose the path: a run is a re-enrollment when the identity store holds anchors, verified against them whatever the phase, and fresh when it holds none. An interrupted first run may have left the store holding its rete, and then the retry is verified against those anchors — a re-cut bundle passes, while one from an independent rete of the same name is refused, its remedy being removing the scope root by hand (ID-C0-18). retes/<scope>/scope.lock is the scope's single advisory lock, held by the agent for as long as it serves and by flor enroll for its whole run; a holder is named rather than queued for, and the name enroll prints is the flor agent disable that clears the ordinary one. Enroll reads the scope only under it: the manifest's node and trust-domain claims name the scope, and are all enroll reads first; creating retes/ and the scope root comes next, because the lock lives inside the scope root; the receipt, the anchors, the pinned claims and the rest of the bundle are then read under the lock and acted on under the same one. Enroll releases it upon exit and activates nothing (ENRL-C0-20). The agent reads the receipt before serving and refuses a scope whose receipt is absent, reads enrolling, or carries a schema_version this release cannot understand, naming the way out in each case. There is no bundle identity on disk: any bundle for this scope's rete and node completes an interrupted run, and incoming/ is replaced wholesale rather than merged. Because every check enroll performs on its scope reads state that can change, so a read outside the lock is a decision something else may already have invalidated. The anchors the store holds choose the path and verify the manifest, the pinned claims decide whether the bundle applies at all, and the receipt says whether the last run finished — none is a property of the bundle alone, and a concurrent agent or a second enroll can move any of them between the read and the write. Taking the lock first collapses that window instead of narrowing it, which is why the ordering is scope-then-bundle and not the reverse the flow reads more naturally as. Because verification needs only trust the node already holds (ENRL-C0-17), and the store is where that trust is held; a phase records a run, not what the node trusts. Because written first with a phase, a receipt states a fact rather than an intention — the installer's rule (Installer § Install), and it is what makes a crashed run recognisable as ours instead of as a mystery tree. Because the lock has to be the scope's rather than enroll's. A lock belonging to enroll would not exclude the agent, which a service manager can restart mid-write, and that is the collision that corrupts an identity plane. One lock every owner of the scope takes closes it, and it is the same guarantee the agent already wanted for itself. Because a held flock dies with its holder, so a killed enroll or a power loss strands nothing and leaves no file for anyone to clear by hand. The file's existence means nothing; the held lock is the lock. Because the version gates the scope root, not the receipt. What a downgrade risks is an older flor agent run reading identity/, artifacts/ and agent/, and Installer § Downgrade already reserved this refusal for the agent on exactly that ground — the installer cannot judge whether an older binary can serve a node's state, and the agent can. The number gates by ADR-0012's one rule, own major and minor-older-or-equal, so a plain downgrade reaches releases sharing it and no further. Because a persisted artifact outlives the process that wrote it, which is the whole reason it needs a version at all. Producer and consumer are one release within a single run and not across a crash and a retry: an operator whose enroll died and who reaches for the previous release is the ordinary way an older binary meets a newer scope root. Accepting the lock has to sit in the scope root, not the runtime root. The agent's per-scope lock lived in the runtime root and the agent removes that root on a clean stop (Agent § Local IPC), so stopping the agent before enrolling would delete the directory holding the lock it is about to take. The scope lock in the scope root exists whenever the scope does. Accepting that enroll no longer promises to finish the run it started, only to converge. Leftover material from an interrupted run is inert by ENRL-C0-21's rule, and incoming/ is replaced rather than merged, so what survives a crash is stale material and never a mixture. Accepting that a crash leaves some principals replaced and others not; each principal is one file written by one rename (ID-C0-32), so none is left torn, and the next run completes the rest. Accepting that the agent gains a runtime dependency on a file flor enroll writes, and that the scope root gains a version that never fires in C0, nothing having migrated yet. Its value is that the first migration finds the refusal already in place instead of inventing it. Accepting that enroll refuses a live scope rather than quieting it, which is one more command for the operator and no window at all for anyone: a stop-then-acquire sequence has a gap a service manager can restart the agent into, where a refusal has none. The person who ran enroll expects the scope down for a material change in any case. Accepting that a refused bundle can leave an empty scope root behind on a first enrollment, the container having been created before the bundle was read. It carries no receipt, so it is exactly a scope that has not been enrolled — inert to the agent, and completed by the next run rather than needing a cleanup. Revisit whether enable/disable should be joined by a stop/start pair, once something wants an agent down without deregistering it. C0 does not: disable is the only takedown, and a reboot inside an enroll is safe because the agent refuses a scope whose receipt reads enrolling. Revisit the bundle's identity on disk when something must decide on content rather than report it. The digest is cheap to add and earns nothing today; the caller that would want it is the set-identity question of ENRL-C0-35, which is a design rather than a field. Rejected keying the partial scope root to the bundle that made it — recording the manifest digest and refusing a different bundle, which is what this entry replaces. It blocks no attacker, since a fresh enrollment verifies nothing and anyone who can drop a bundle can remove the partial root first, while on a re-enrollment anything reaching the write stage has already passed the signature and the three pinned claims. What it does block is an operator who re-cut the bundle, and their way out is a hand-removal C0 ships no command for (ENRL-C0-20). Rejected deciding freshness from the phase, which lets any bundle through unverified after a re-enrollment interrupted in enrolling; recording that a run was fresh and resetting the identity store on its retry, which gives a writer back the pin the store keeps from it — a crash is not an authorisation to replace a root of trust; recording the digest for a warning alone, which buys a sentence nobody can act on differently — that the run is completing an interrupted one is the whole of the actionable fact; a separate enroll.lock, on the agent-exclusion argument above; leaving the lock in the runtime root; and versioning the receipt rather than the scope root, which would let a receipt field block a downgrade the trees were perfectly happy with.

ENRL-C0-41. Name a node's scope <node>.<rete>, and require --multi-node for a second node of one rete. Every scope root on a node is retes/<node>.<rete>/, and every service instance is named by the same handle. Enroll derives it from the manifest's node and trust-domain claims, each parsed as a name before the two are joined; nothing chooses it. On a fresh enrollment enroll lists the other scope roots under retes/, reads each one's scope record through the identity store's reading part without its lock, and refuses when one holds another node of the same trust domain, unless --multi-node is passed, naming the sibling, the flag, and removing that scope root by hand where the new bundle replaces it. Re-enrolling an existing scope is never guarded. Because identity/scope.json already records a scope as its node and trust domain (ID-C0-29), so a handle rendering both is a lookup key equal to the identity: the same node enrolled twice lands on the same handle and is an ordinary re-enrollment, and two nodes of one rete cannot collide. Because the rendering is unambiguous only where a node name holds no dot, which ID-C0-12 guarantees, and a dotted handle passes through systemd's instance escaping unchanged where a nested <rete>/<node> does not; : is not a file-name character on Windows. Because the refusal comes before any material is written, with the bundle still on disk, where a warning after a completed enroll finds the bundle already deleted; and the flag is the shape ENRL-C0-23 prescribes for a decision a person makes. Because the guard catches a mistake rather than holding an invariant — two nodes of one rete on one host are legal — and a scope's record never changes (ID-C0-29), so reading it without the lock a running scope holds exclusively is safe, and a directory name only says where to look. Accepting that a renamed node or a misdelivered bundle lands beside the existing scope rather than in it, where the node pin cannot see it, which is what --multi-node guards; and that a sibling whose first enrollment runs at the same instant, before its record exists, is missed. Rejected the rete name verbatim with a reserved --as alias, whose default and alias are asymmetric; the node name alone, which collides across retes on one host more easily; a nested <rete>/<node>, whose separator systemd escapes; a state-directory override per node, which cannot separate two service instances of one handle; a warning in place of the refusal; forbidding a second node of one rete, which pushes development back onto state-directory overrides; reading siblings under their locks, which a running scope holds exclusively, or under a lock per rete, which needs a rete-level directory to live in and makes an invariant of a mistake-catcher; refusing beside any other scope, which on a host in several retes asks for the flag and catches nothing; a sibling's node or rete read off its directory name; deferring the guard, since a refusal added after 1.0.0 breaks automation that enrolls a second node without the flag.

ENRL-C0-22. Create retes/ from one library function, whichever component runs first. retes/ is 0755, owned by root in system mode and by the invoking user in user mode; retes/<scope>/ is 0700, owned by the service user. Both the installer and flor enroll create it through the same function. Because creating the directory is not writing into it — the installer's prohibition is about contents, which are the agent's, and its permissions rule is about the container. Neither component is guaranteed to run: a distribution package places binaries without invoking flor install. A rule stated in two places is a rule that eventually becomes two rules.

ENRL-C0-23. Owe every human-facing check a non-interactive equivalent, and note that C0 has one. Enrollment over SSH, into a provisioning script, or at a machine's first boot is the same path with the same checks. Where a check's interactive form is a person reading something off a screen, its machine form is a flag, and automation is never made to skip a check in order to run unattended. C0 ships one decision of that kind, and no check. Everything flor enroll verifies is mechanical and identical either way — the digests, the signature on a re-enrollment, and the comparisons against the pinned trust domain, anchors and node. What an operator sees before a first enrollment is a display, not a decision. The one decision is --multi-node (ENRL-C0-41), that a second node of one rete is meant on this host. Because two flows would diverge and the unattended one would be the less-exercised and less-safe of the pair, which is how unattended paths quietly become the insecure ones. The rule is a constraint on the next check anyone proposes, and the one place it would have bitten in C0 was resolved by removing the human from the loop rather than by adding a flag. Rejected dropping the rule along with --anchor-fingerprint, its former instance (ENRL-C0-17): the flag was rejected on its own merits, not because the principle failed.

ENRL-C0-36. Treat missing material for a declared principal as degraded, never as an error. An artifact set naming a local principal whose material is absent from the identity store is a normal steady state. The vertex starts, serves every principal it has material for, and does not serve that one — no listener at all, rather than a listener that cannot complete a handshake — warning once per principal at load. Only local principals can be in this state: an ingress.allow entry naming a remote one needs no local material. The mirror already holds, ENRL-C0-21 leaving a removed principal's material in place and inert. So: material without an artifact is inert; an artifact without material is degraded; both are normal. Because ENRL-C0-35 makes the window standard rather than rare — the set ships at publish and the credentials arrive by courier, so every added principal spends the courier's duration in exactly this state. A condition that occurs on every change of this kind cannot be modelled as a fault. Because refusing the set would let the identity store veto the artifact set, inverting ENRL-C0-21's rule that the set is what is authoritative about which principals live on a node — and it would block unrelated changes, one missing credential stopping four working services. Because the store already reports a principal it does not have as absent when asked for it (ID-C0-23), and the vertex is what asks — so absent is that check's other outcome, and the vertex decides what it means. The agent stays payload-blind and the boundary between them gains nothing. Accepting that a service can be published, routable and simply not answering, with the reason visible only in that node's log — and that rete cannot warn about it, since issuance is unrecorded (ID-C0-25), so nothing operator-side can compare what is declared against what has been issued. Revisit with the issuance ledger of ID-C0-25, which would make that comparison possible — "this node's published set names a principal whose material has never been issued" — and which is a stronger motivation for the ledger than the expiry answer recorded there. Rejected refusing the whole set until every named principal has material, on the veto and blast-radius arguments above; refusing to start the vertex, which is the same objection at a coarser grain; and a listener that accepts and then fails the handshake, which presents an absent service to its caller as a trust failure.

Revoking and rotating

ENRL-C0-24. Moved to ID-C0-25.

ENRL-C0-25. Moved to ID-C0-22.

ENRL-C0-26. Moved to ID-C0-24.

ENRL-C0-27. Moved to ID-C0-26.

ENRL-C0-28. Moved to ID-C0-27.

ENRL-C0-33. Moved to ID-C0-23.

What must not be foreclosed

ENRL-C0-29. Let a credential arrive by means other than a bundle. (deferred) Nothing may assume the bundle format is the only way a credential reaches a node, and the courier and machine-to-machine forms need not be the same one. Because a bundle is a file a human handles once and a rotation credential is a payload over mTLS; sharing a format buys code and pays in a dependency between contexts that will diverge. Revisit with encryption, which is the hardening that works where an expiry did not. The likely shape seals the whole bundle to its recipient, leaving clear only what a carrier needs — so its topology and access state are no more readable in transit than its keys. A re-enrollment then needs no secret at all, the node's existing public key being the recipient key; a first enrollment needs a secret, or a key the node generates first. Deliberately not settled here.

ENRL-C0-30. Keep the anchor separable and the issuer replaceable. (deferred) Exactly one thing is common to a fleet — the trust anchor — while credentials are per principal and artifact sets per node. So a two-phase provisioning split moves one field, and flor install gaining an optional anchor argument is the whole of its eventual shape. Separately, the rete CA is one implementation of a boundary rather than a permanent fixture: the identity store is a sink, and nothing may assume what filled it. No code may require that a certificate came from rete, only that it validates against the anchor and matches its principal. Because a cloud deployment, a federated anchor, a fleet image and an external issuer are four callers for the same two properties, and both cost nothing today. The structural fit for the last is already present: this design's agent occupies the position SPIRE's agent does — supervising workloads, holding per-scope durable state, delivering their material — so consuming an external issuer means the agent fetching from one instead of from the coordinator, not a new component. Accepting that a credential may one day be created by a component this project does not ship, which is where an open-core boundary would fall — and that it needs no plugin mechanism, since the extension shape here is a supervised workload: a separate executable, a run name, an opaque payload.

ENRL-C0-31. Name the coordinator, never its address. (deferred) agent.json's sync block names the coordinator by principal, and the dial resolves through the compiler-projected inbounds map. Nothing in a bundle or an artifact may carry the coordinator's address as the primary fact. Because from the milestone where a node joins at a link to some node, its position is not known when its material is issued — so the address cannot be written into a credential, and the name can. The eventual answer has two complementary halves: any node relaying, so a joiner needs a path rather than the address; and delegated introduction, since whoever admits a node is the one party guaranteed to have a working path and can hand over a verified one rather than a claimed one.

ENRL-C0-32. Admit foreign and temporary principals without new artifact semantics. (deferred) An ingress.allow entry names a full SPIFFE ID, which carries its trust domain, so a foreign principal is already expressible — and an entry must never be shortened to a bare local name. A temporary grant is a short-lived identity rather than a time-boxed permission. Because a guest is a member with a short lease while two operators' devices meeting are two trust domains interoperating without either joining, and collapsing them is the mistake to avoid. And a time-boxed ACL entry would need validity windows in the compiled artifact and clock agreement between every enforcing pair — a distributed-time problem introduced to express what a credential lifetime already expresses. Accepting that making an ACL entry time-valued must remain an additive payload change, which is the same room a per-principal revocation floor needs, so one field's worth of space serves both. Rejected nothing here for the service-mesh deployment, which for enrollment is a strict subset of the workspace case — always-connected, unattended, operator-owned nodes — and stresses cadence and issuer choice rather than any part of this page.

ENRL-C0-38. Treat a transition between two sound states as unsound, and rely on topology rather than on a mechanism. (deferred) Nodes converge independently and at different speeds, so a rete passes through mixed states that neither endpoint state describes. C0 builds nothing for this and does not have to, for two structural reasons rather than by luck. Application is atomic within a node: a set arrives in one response, coherent by construction, staged and activated by a pointer flip, so no node ever runs half a change. And C0 has no relaying: every connection is initiator-to-target, so no node's reachability depends on a third node's configuration, and a mixed state can only misalign a pair — a caller that updated before its target sees an outage that heals when the target converges. The exception is self-referential change: anything altering the path to the coordinator can strand a node that has not yet applied it, since the thing that would tell it has become unreachable. That is the one C0 transition with no safe order, it is already the case ENRL-C0-21's repair path exists for, and the operator's rule is not to move the coordinator in one step. Because both containments are properties of this milestone and neither survives contact with the next. C1 makes reachability transitive, at which point a node applying a change first can break the path of a node that has not — and the nodes that lose their path are exactly the ones the fix cannot reach. That is a ctrl-plane concern rather than a mgmt one, and it is a real coordinator's to solve: sequenced delivery, per-node confirmation, and phased application — build the new, confirm it, only then retire the old. Accepting that C0's transitions are as wide as its slowest channel, which for a courier-fed node is days (ENRL-C0-37) — so a mixed state is a condition rather than a moment. Accepting that during one, effective access is the union across un-converged nodes, which can be more permissive than either endpoint state. For a removal that is ID-C0-25's non-atomic revocation seen from the other side. Two properties are owed to the successor. A node's reachability must be expressible as a set during a transition, not a single value — which makes make-before-break an operator procedure needing no coordination machinery: publish a set naming both the old and the new address, let it converge, then publish the retirement. inbounds projects one address per vertex today, so the procedure is not expressible, and reserving the plurality without building the rotation is exactly what anchors.pem does for trust. And nothing may assume the per-node version sequences are the only ordering fact: they are deliberately independent and nothing compares them across nodes, so a phased rollout needs a rete-wide epoch above them, arriving additively and shaped as obeys_mgmt_version is shaped. C1's commit-timeout and pending-confirm record are the seed, per-node "did it apply" being the precondition for any phasing. Rejected building ordering or phasing into C0, whose topology makes it unnecessary and whose only self-referential change has an out-of-band repair; and treating the coordinator-move case as a staleness problem, which is how it currently reads and which hides that it is the one transition the design cannot make safely.

On this page