You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Runtime registrar surface and its non-self-propagation guarantee
Summary
Today the only way to mint a bootroot-service-* identity is to hold raw OpenBao write on sys/policies/acl/* and auth/approle/role/*. The runtime registrar (the bootroot-co-located roxyd) needs to mint and tear down service identities long after the one-shot install CLI is gone, but handing it those raw paths lets it author an arbitrary policy body under a permitted name, or bind a new AppRole to the registrar's own policy — turning one leaked credential into fleet-wide identity minting.
This effort closes that by construction. bootroot keeps the broad role/policy-write authority in a bootroot-internal credential held by its own daemon, and exposes to the registrar only two narrow verbs — mint and deregister — that take an identity's parts (service_name, host, instance) and never a role definition or a policy body. The registrar ends up holding no OpenBao credential at all: it authenticates to a root-owned unix-domain socket with an identity-scoped client certificate, and every invocation of either verb (accepted and refused alike) leaves a bootroot-side audit record the registrar cannot forge or erase.
Independently of the confinement work, this effort also splits registration_id out of service_name. Today one string is both the SAN's service label and the sole key of every per-service namespace bootroot owns, which permits exactly one registration of a component deployment-wide. Splitting the key from the label is what admits per-host and multi-instance registrations, and it is the piece that would be expensive to retrofit later.
Design source: docs/rfcs/0001-registrar-role-and-non-self-propagation.md (accepted). Read §2 for the verified current state, §3–§4 for the threat model and the decided mechanism, §5 for what bootroot must provide, and §6 for the acceptance criteria. Children below carry their own self-contained specs; the RFC is background, not a substitute for reading the issue.
Children
Split the namespace key from the SAN label with a required registration_id — Foundation
Read the bootler-rendered registrar config and derive registration identities — Verb building blocks
Implement the restricted registrar mint and deregister verbs — Verb building blocks
Settle and transcribe the registrar endpoint's caller-facing wire contract — Verb building blocks
Issue a distinguishable registrar client identity and pin the endpoint's server identity — Surface
Registrar endpoint: the socket and its wire protocol — Surface (grouping issue)
Terminate mTLS on the registrar endpoint and scope it to the registrar identity — Surface
Add the in-repo client for the registrar endpoint — Surface
Provision the bootroot-internal privileged credential and run the verbs under it — Surface
Registrar certificates: self-issue and renew both leaves from the daemon — Surface (grouping issue)
Record and surface every registrar verb invocation — Detection (grouping issue)
Rate-limit both registrar verbs with two token buckets — Detection (grouping issue)
Bound the OpenBao audit device's growth — Detection
Registrar acceptance suite: the three test arms — Verification (grouping issue)
Five entries are grouping issues, not units of work. The endpoint, the certificates, the audit trail, the rate limit and the acceptance suite each carry the background their children share and close when those children close; they are not PRs and nobody is assigned one. Every other entry on this list is one PR. Each grouping issue lists its own children on its own issue — they are deliberately not repeated here, so this list stays a list of things this umbrella directly owns rather than a flattened tree. Counting through the groups, the effort is 26 pull requests, one of which — the limiter bucket-shape decision under the rate-limit group — is a human ruling on the RFC rather than implementation work.
Phase labels group the children; they are not barriers. The Depends on line in each child is what orders the work, and it does not follow the labels. Three children have no prerequisite at all and can start on day one — the registration_id split, the registrar client identity (labelled Surface) and the wire contract (labelled Verb building blocks). Once the verbs land, the audit-record writer (Detection), the socket transport (Surface) and the internal-credential provisioning (Surface, which also consumes the day-one client-identity child) all become available in parallel, so Detection work runs alongside Surface work rather than after it.
The longest chain is nine, and two chains run that long — neither of them through the wire contract. One goes through the audit store: namespace split → config and derivation → verbs → audit-record store → audit-store layout → enforced reserve → capacity probe → red-team scenario → endurance scenario. The other goes through the certificates: namespace split → config and derivation → verbs → socket listener → mTLS → certificate issuance → certificate renewal → lapse reporting → cargo acceptance arm. An eight-long chain runs concurrently with both — the same audit chain terminating at the red-team scenario. Worth reading off that: the critical path ends in the acceptance arms because they depend on nearly everything, and the audit branch is no shorter than the certificate branch, so the reserved-store work should start as early as the verbs allow rather than being treated as trailing polish.
The wire-contract child is not the longest chain, but it is the one whose latency this repository does not control, because one identifier in it is settled with another repository. It has no in-repo prerequisite, so start it on day one, in parallel with everything else — it is a conversation and a document, not a build, and every issue downstream of it is blocked only on its codec-facing half. Treat it as the schedule risk rather than the schedule length: the nine-long chains will finish on this repository's own effort, and this one will not. Those parallel children all touch the verb layer and will need rebasing against each other; that is a merge cost, not a dependency, and serializing them would buy nothing.
Shared background
Verified current state (origin/main @ 36d61e9, v0.2.0):
ensure_service_approle (src/commands/service/approle.rs:15) already derives a safe, fixed policy: it writes bootroot-service-<name> via write_policy and then create_approle(role_name, &[policy_name], ...), so a minted role's token_policies is always the derived policy and never caller-supplied. run_service_remove (src/commands/service/remove.rs:68) idempotently deletes the KV, the AppRole and the policy. The code is not the hole — the credential that runs it is.
SERVICE_ROLE_PREFIX = "bootroot-service-" lives at src/commands/service.rs:26; service_role_name / service_policy_name (src/commands/service/approle.rs:103/:107) both yield the same string. SERVICE_KV_BASE = "bootroot/services" lives at src/trust_bootstrap.rs:7.
The daemon (src/daemon.rs::run_daemon:68) binds nothing — it is a pure outbound client (OpenBao HTTPS + ACME). Every non-test TcpListener::bind in the crate belongs to bootroot-http01-responder or to commands/infra.rs port probes. Every rustls config in the repo, including the one ServerConfig, is with_no_client_auth(). A listener, a request/response protocol, and client-certificate verification are all net-new.
The daemon authenticates with the per-service AppRole (role_id_path / secret_id_path, src/config.rs), whose policy is the narrow bootroot-service-<name> body. The role/policy-write authority is exercised by the bootroot CLI process, not the agent.
bootroot-agent is a fleet-wide binary running on every service host. Anything added to it must be conditional, or it becomes attack surface on every host in the deployment.
A file-based OpenBao audit device is mandatory: verify_audit_file (src/openbao.rs:560) fails init when no file-type device is present, and commands/init/steps/openbao_setup.rs:162 propagates that failure.
Naming and derivation.registration_id derivation (per multiplicity class: <component> / <host>-<component> / <host>-<component>-<instance>, instance three digits zero-padded) is owned by the ecosystem RFC-A §4 and is implemented once in this repo, inside the registrar config-and-derivation library. The mint verb invokes that library; it does not re-derive, and neither does anything else. The rule is never restated elsewhere, and callers never supply the key.
Threat-model boundary, stated so no child overclaims. The guarantee bounds the credential, not a root-compromised host. The daemon runs as root on the bootroot host and holds the privileged internal credential; root there can read it. That is explicitly out of scope. What is in scope: a credential leaked or copied off the host (disk image, filesystem backup), independence from roxyd's code being correct, and the ability to relocate the registrar later.
The caller's identity is an opaque value threaded through the verbs. Both verbs take a caller-identity parameter they never interpret. The verb layer carries it into the outcome classification, the audit record writes it verbatim, and the rate limiter keys its buckets on it. It is coarse until mTLS lands (the socket's peer credentials) and becomes the client certificate's identity afterwards. This is why the detection children do not depend on the mTLS child: the field they need exists from the start and only gets sharper.
The health path crosses two repository boundaries, and bootroot owns only the first hop. The design says the low-water alarm and the intent-without-outcome count must actually reach an operator, and names three hops to get there: the daemon exposes the values on the mint/deregister endpoint, the co-located registrar relays them as an audit_healthAgentInfo tail field, and the control plane renders them. Only the first hop is in this repository, and it is the registrar_health container the endpoint protocol child reserves on all three response shapes, both successes and the refusal — a differently-named field at a different layer, not a rename of audit_health. It is also a container, carrying certificate lifetimes and the limiter's counters alongside the audit signals, which is why it cannot take the tail's name. No child here owns the relay or the rendering, and none should; both are other repositories' work, and the wire-contract child transcribes the tail's spelling only so this repository has it on record. Read any child's health criteria as "bootroot exposes this correctly", never as "an operator sees this" — the second is not something this plan can deliver alone. The container's placement is settled and is the protocol child's to fix: it rides refusals as well as successes, because every member in it reports a condition whose arrival is precisely what makes success responses stop — a filling audit store refuses mints, a shedding limiter refuses invocations, a lapsed certificate refuses everything. A success-only container would go silent in the three states it exists to report. Member-owning children populate their member wherever the container is carried and none of them re-decides this.
Five artifacts this repository never produces, and the children that depend on them. Each is owned by another repository — four by the provisioning tool, one by the calling repository's protocol specification — so no child here can be "finished" by writing it. A child defines or transcribes the format, ships a documented reference, and consumes it. Collected here because no single child can see that the set of cross-repo obligations is complete:
the rendered registrar config (deployment domain, per-component multiplicity class, per-component registration safe-set) — format owned by the config-and-derivation child; consumed at verb-invocation time, and consumed in sibling tests only through that child's shared fixture builder;
the ServiceRegistration spec's own identity fields, which must carry the component's plain keyword — roxyd, not the pre-split composed roxyd-<host>. This is a value obligation rather than a format one, which is why it needs stating separately: the spec type is the provisioning tool's, it carries component and service_name alongside the two fields the safe-set actually compares, and the control plane forwards it onto the wire. The config-and-derivation child refuses a spec.component or spec.service_name that disagrees with the wire service_name, because the alternative is minting under an identity the caller named rather than the one bootroot derived. The failure mode if the other side still renders composed names is total and silent until deploy: every enrollment of the affected component is refused with the spec-identity disagreement, no partial success masks it, and nothing in this repository can distinguish it from an attack. The composed name is derived on this side now, under the registration_id split; the spec's identity fields are plain keywords like everything else. Confirm this against the provisioning repository's rendering early — alongside the wire-contract child's identifier, and for the same reason — and treat a composed name found there as a finding to raise against the ecosystem RFC's rendering, never as something to accommodate by relaxing the equality check;
the endpoint pin file (registrar-endpoint-anchors.sha256, SHA-256 of trust-anchor DER, one per line) — the only entry on this list whose contract is split across two children, which is why it is spelled out rather than left to be assembled from both. The format and the basename are the client-identity child's; the placement is the certificate-issuance child's, because bootroot fixes no certificate directory — every path is configured — so the pin file's directory is the one the client-certificate path setting that child adds resolves to, and the client-identity child deliberately fixes no absolute path. Nothing in this repository ever writes the file: the provisioning tool writes it once at install, and it must write it at that directory, under that basename, in that format — three obligations from two children, all of which have to hold together. A file that is correct in format but written somewhere else fails exactly as a misformatted one does, and later: the endpoint client is handed an explicit path, finds nothing there, and refuses every dial with a pin-file-missing error that names a path the operator never chose;
the initial registrar client certificate — the daemon self-issues the endpoint's server leaf and renews both leaves thereafter (the certificate-renewal child), but the first client leaf arrives at install.
the caller-facing wire protocol specification — the BootstrapMaterial success-payload field spellings and the RegistrarUnavailable reason set, owned by the calling repository rather than the provisioning tool. Neither exists anywhere in this repository: both appear in the RFC only as prose, with no field list behind them. This is the one entry that blocks work rather than merely constraining it, and it has a child of its own so that it blocks exactly one issue instead of leaking into several. The wire-contract child obtains these spellings, settles the one identifier this effort needs added — a seventh, for a service_name or host that is not a DNS label, proposed as ServiceLabelInvalid — and transcribes the result into a checked-in docs/ reference. A component absent from the multiplicity table keeps the ServiceInstanceMismatch that three merged documents already assign it; that is an intentional second meaning, recorded as such, not a collision to be fixed later. The plan deliberately hard-gates on that identifier landing upstream rather than closing the wire-contract child on everything else obtainable and tracking the last spelling in a follow-up. The cost is real and is accepted: this repository's protocol codec, and the acceptance arms behind it, wait on another repository's review latency. The alternative was rejected because it buys parallelism with a window in which one refusal path has no wire identifier at all — and a refusal that cannot be named on the wire is one the caller cannot classify, which is the failure the typed error set exists to prevent. Everything that does not need a settled identifier is already routed around this child, which is why the gate costs one branch rather than the effort. Every other child then reads that reference: the transport child writes its codec and generates its golden fixtures from it, the certificate-renewal and rate-limit children mirror its spelling for the health field, and the acceptance suite asserts identifiers against it. No child other than the wire-contract child proposes, renames or negotiates anything on this contract — that separation is deliberate, because an issue that both consumes a contract and argues about it cannot be finished.
If the other repository renders any of these differently from the format its owning child documents, the affected children ship code that cannot run in production and the failure appears only at deploy time. Treat the documented format in each owning child as the cross-repo contract.
Non-goals for the whole effort: the mTLS PKI itself, and ACME/EAB issuance and certificate rotation for ordinary services, are unchanged; bootroot's own update is out of scope. service add is deliberately not on this list. The confinement work (the verbs, the endpoint, the credential, the audit record) leaves install-time service add untouched, and that is the claim worth making — but two children do change it, by design: the registration_id split adds --registration-id and renames the lookup-key flags on six commands, and the client-identity child adds a reserved-prefix guard refusing any service_name beginning with bootroot-. Read "install-time behaviour is unchanged" as scoped to the confinement work only; a child that needs to change service add for those two reasons is following the plan, not violating it.
Decisions — all settled
No human decision remains open in this tree. Every question raised during decomposition has been answered, and a child that reads as contingent on an unmade choice is a defect, not a deferral. The answers, and where each is carried:
The limiter's bucket shape departs from the accepted RFC — recorded as an intentional RFC correction. The RFC specifies per-(verb, outcome-class) buckets "so refusals throttle without starving accepted mints". The rate-limit children instead specify per-(client identity, verb, bucket) buckets over a two-value enum — predecision_refusal and admission. The two-value enum is kept, because it is the maximal refinement knowable at the point the limiter is charged: a pre-derivation refusal is known there, since that arm fires only on refusals and label validation plus multiplicity-class resolution precede it, while the outcome within an admitted invocation is determined by OpenBao work that follows the intent write. Keying on the outcome class would require either charging after the durable write the limiter exists to prevent, or predicting the outcome. What the shape delivers, stated positively: the cheapest flood path — malformed input, no OpenBao work — cannot starve accepted mints, which is the path the RFC's own rate-limit rationale names. What it does not isolate is expensive refusals, which consume admission budget and are bounded by attacker cost rather than by the limiter. The RFC is amended rather than left to contradict the tree; the decision child owns that amendment.
A throttled invocation answers RegistrarBusy from the admission check point only. The RFC's rate-limit sentence and its acceptance criterion are unqualified, and that is the second correction the decision child carries. A pre-derivation invocation that is limited has its two audit records suppressed but still receives its real, permanent refusal, because RegistrarBusy is the family's only retryable error while the pre-derivation refusals are classified as deterministic and permanent elsewhere in the set — answering it there would hand a caller the one value whose contract says "this clears on its own" for a request that can never succeed, and would have the limiter feed the traffic it was added to damp. The suppression keeps the coalesced counter increment and the anomaly definition still counts it; otherwise a flood becomes least visible exactly while it is worst.
The audit store's privileged provisioning phase is the operator's. bootroot renders the exact commands and artifacts, the operator performs the privileged host surgery, and bootroot verifies and fails closed until verification passes. bootroot infra install is not a root-on-host command today and the one-shot root container cannot stand in; the choices this phase makes are site-specific and destructive to guess on the one host that must not be restarted; and every other systemd artifact in this repository already reaches a host by being operator-installed. The reserve child carries it.
The endurance scenario runs both negative-control methods. The committed harness-level positive control is shipped and the throwaway renewal mutation's diff and failing output are carried in the pull request. They cover different failure modes — a mis-aimed watcher and a mis-timed one — and this arm sits outside the per-PR gate, so running both costs little. The endurance child carries it.
The decision child still has its dependency edge, and it still blocks the limiter mechanism child, which blocks the counted-record child. That edge now encodes ordering — the RFC amendment lands before the mechanism is built against it — not an unanswered question.
Execution order
Issues in the same wave have no unmet dependencies among these children and can run in parallel.
graph TD
issue756["#756 Split the namespace key from the SAN label with a required `registration_id` [phase: Foundation]"]
issue757["#757 Read the bootler-rendered registrar config and derive registration identities [phase: Verb building blocks]"]
issue758["#758 Implement the restricted registrar mint and deregister verbs [phase: Verb building blocks]"]
issue759["#759 Settle and transcribe the registrar endpoint's caller-facing wire contract [phase: Verb building blocks]"]
issue760["#760 Issue a distinguishable registrar client identity and pin the endpoint's server identity [phase: Surface]"]
issue763["#763 Registrar endpoint: the socket and its wire protocol [phase: Surface]"]
issue764["#764 Terminate mTLS on the registrar endpoint and scope it to the registrar identity [phase: Surface]"]
issue765["#765 Add the in-repo client for the registrar endpoint [phase: Surface]"]
issue766["#766 Provision the bootroot-internal privileged credential and run the verbs under it [phase: Surface]"]
issue771["#771 Registrar certificates: self-issue and renew both leaves from the daemon [phase: Surface]"]
issue780["#780 Record and surface every registrar verb invocation [phase: Detection]"]
issue784["#784 Registrar acceptance suite: the three test arms [phase: Verification]"]
issue788["#788 Rate-limit both registrar verbs with two token buckets [phase: Detection]"]
issue789["#789 Bound the OpenBao audit device's growth [phase: Detection]"]
issue756 --> issue757
issue757 --> issue758
issue758 --> issue763
issue759 --> issue763
issue761["#761 Serve the registrar verbs on a root-owned, socket-activated unix socket [phase: Surface]"]
issue761 -.-> issue764
issue760 --> issue764
issue762["#762 Implement the registrar endpoint's versioned wire protocol and its codec [phase: Surface]"]
issue762 -.-> issue765
issue764 --> issue765
issue760 --> issue765
issue758 --> issue766
issue760 --> issue766
issue760 --> issue771
issue762 -.-> issue771
issue764 --> issue771
issue766 --> issue771
issue758 --> issue780
issue761 -.-> issue780
issue762 -.-> issue780
issue757 --> issue784
issue758 --> issue784
issue759 --> issue784
issue762 -.-> issue784
issue764 --> issue784
issue765 --> issue784
issue766 --> issue784
issue767["#767 Self-issue the registrar surface's two certificates from the daemon [phase: Surface]"]
issue767 -.-> issue784
issue768["#768 Renew both registrar leaves on the daemon's existing loop and honour the reload contract [phase: Surface]"]
issue768 -.-> issue784
issue769["#769 Report the registrar certificates' lifetime and turn a lapse into a typed non-retryable error [phase: Surface]"]
issue769 -.-> issue784
issue786["#786 Bound both registrar verbs with two token buckets [phase: Detection]"]
issue786 -.-> issue784
issue787["#787 Record limited registrar invocations and report the throttle [phase: Detection]"]
issue787 -.-> issue784
issue773["#773 Make the audit store's reserve a kernel-enforced ceiling [phase: Detection]"]
issue773 -.-> issue784
issue774["#774 Probe the reserved audit store's capacity and report it on the endpoint [phase: Detection]"]
issue774 -.-> issue784
issue777["#777 Write intent and outcome records around both registrar verb arms [phase: Detection]"]
issue777 -.-> issue784
issue779["#779 Scan the audit store for anomalies and report them on `bootroot status` [phase: Detection]"]
issue779 -.-> issue784
issue789 --> issue784
issue776["#776 Add the append-only record store and its on-disk format for registrar audit records [phase: Detection]"]
issue776 -.-> issue788
issue777 -.-> issue788
issue779 -.-> issue788
issue762 -.-> issue788
issue772["#772 Lay out the reserved audit store and move both audit writers onto it [phase: Detection]"]
issue772 -.-> issue789
Runtime registrar surface and its non-self-propagation guarantee
Summary
Today the only way to mint a
bootroot-service-*identity is to hold raw OpenBao write onsys/policies/acl/*andauth/approle/role/*. The runtime registrar (the bootroot-co-located roxyd) needs to mint and tear down service identities long after the one-shot install CLI is gone, but handing it those raw paths lets it author an arbitrary policy body under a permitted name, or bind a new AppRole to the registrar's own policy — turning one leaked credential into fleet-wide identity minting.This effort closes that by construction. bootroot keeps the broad role/policy-write authority in a bootroot-internal credential held by its own daemon, and exposes to the registrar only two narrow verbs — mint and deregister — that take an identity's parts (
service_name,host,instance) and never a role definition or a policy body. The registrar ends up holding no OpenBao credential at all: it authenticates to a root-owned unix-domain socket with an identity-scoped client certificate, and every invocation of either verb (accepted and refused alike) leaves a bootroot-side audit record the registrar cannot forge or erase.Independently of the confinement work, this effort also splits
registration_idout ofservice_name. Today one string is both the SAN's service label and the sole key of every per-service namespace bootroot owns, which permits exactly one registration of a component deployment-wide. Splitting the key from the label is what admits per-host and multi-instance registrations, and it is the piece that would be expensive to retrofit later.Design source:
docs/rfcs/0001-registrar-role-and-non-self-propagation.md(accepted). Read §2 for the verified current state, §3–§4 for the threat model and the decided mechanism, §5 for what bootroot must provide, and §6 for the acceptance criteria. Children below carry their own self-contained specs; the RFC is background, not a substitute for reading the issue.Children
registration_id— FoundationFive entries are grouping issues, not units of work. The endpoint, the certificates, the audit trail, the rate limit and the acceptance suite each carry the background their children share and close when those children close; they are not PRs and nobody is assigned one. Every other entry on this list is one PR. Each grouping issue lists its own children on its own issue — they are deliberately not repeated here, so this list stays a list of things this umbrella directly owns rather than a flattened tree. Counting through the groups, the effort is 26 pull requests, one of which — the limiter bucket-shape decision under the rate-limit group — is a human ruling on the RFC rather than implementation work.
Phase labels group the children; they are not barriers. The
Depends online in each child is what orders the work, and it does not follow the labels. Three children have no prerequisite at all and can start on day one — theregistration_idsplit, the registrar client identity (labelled Surface) and the wire contract (labelled Verb building blocks). Once the verbs land, the audit-record writer (Detection), the socket transport (Surface) and the internal-credential provisioning (Surface, which also consumes the day-one client-identity child) all become available in parallel, so Detection work runs alongside Surface work rather than after it.The longest chain is nine, and two chains run that long — neither of them through the wire contract. One goes through the audit store: namespace split → config and derivation → verbs → audit-record store → audit-store layout → enforced reserve → capacity probe → red-team scenario → endurance scenario. The other goes through the certificates: namespace split → config and derivation → verbs → socket listener → mTLS → certificate issuance → certificate renewal → lapse reporting → cargo acceptance arm. An eight-long chain runs concurrently with both — the same audit chain terminating at the red-team scenario. Worth reading off that: the critical path ends in the acceptance arms because they depend on nearly everything, and the audit branch is no shorter than the certificate branch, so the reserved-store work should start as early as the verbs allow rather than being treated as trailing polish.
The wire-contract child is not the longest chain, but it is the one whose latency this repository does not control, because one identifier in it is settled with another repository. It has no in-repo prerequisite, so start it on day one, in parallel with everything else — it is a conversation and a document, not a build, and every issue downstream of it is blocked only on its codec-facing half. Treat it as the schedule risk rather than the schedule length: the nine-long chains will finish on this repository's own effort, and this one will not. Those parallel children all touch the verb layer and will need rebasing against each other; that is a merge cost, not a dependency, and serializing them would buy nothing.
Shared background
Verified current state (
origin/main@36d61e9, v0.2.0):ensure_service_approle(src/commands/service/approle.rs:15) already derives a safe, fixed policy: it writesbootroot-service-<name>viawrite_policyand thencreate_approle(role_name, &[policy_name], ...), so a minted role'stoken_policiesis always the derived policy and never caller-supplied.run_service_remove(src/commands/service/remove.rs:68) idempotently deletes the KV, the AppRole and the policy. The code is not the hole — the credential that runs it is.SERVICE_ROLE_PREFIX = "bootroot-service-"lives atsrc/commands/service.rs:26;service_role_name/service_policy_name(src/commands/service/approle.rs:103/:107) both yield the same string.SERVICE_KV_BASE = "bootroot/services"lives atsrc/trust_bootstrap.rs:7.src/daemon.rs::run_daemon:68) binds nothing — it is a pure outbound client (OpenBao HTTPS + ACME). Every non-testTcpListener::bindin the crate belongs tobootroot-http01-responderor tocommands/infra.rsport probes. Every rustls config in the repo, including the oneServerConfig, iswith_no_client_auth(). A listener, a request/response protocol, and client-certificate verification are all net-new.role_id_path/secret_id_path,src/config.rs), whose policy is the narrowbootroot-service-<name>body. The role/policy-write authority is exercised by thebootrootCLI process, not the agent.bootroot-agentis a fleet-wide binary running on every service host. Anything added to it must be conditional, or it becomes attack surface on every host in the deployment.verify_audit_file(src/openbao.rs:560) fails init when nofile-type device is present, andcommands/init/steps/openbao_setup.rs:162propagates that failure.Naming and derivation.
registration_idderivation (per multiplicity class:<component>/<host>-<component>/<host>-<component>-<instance>, instance three digits zero-padded) is owned by the ecosystem RFC-A §4 and is implemented once in this repo, inside the registrar config-and-derivation library. The mint verb invokes that library; it does not re-derive, and neither does anything else. The rule is never restated elsewhere, and callers never supply the key.Threat-model boundary, stated so no child overclaims. The guarantee bounds the credential, not a root-compromised host. The daemon runs as root on the bootroot host and holds the privileged internal credential; root there can read it. That is explicitly out of scope. What is in scope: a credential leaked or copied off the host (disk image, filesystem backup), independence from roxyd's code being correct, and the ability to relocate the registrar later.
The caller's identity is an opaque value threaded through the verbs. Both verbs take a caller-identity parameter they never interpret. The verb layer carries it into the outcome classification, the audit record writes it verbatim, and the rate limiter keys its buckets on it. It is coarse until mTLS lands (the socket's peer credentials) and becomes the client certificate's identity afterwards. This is why the detection children do not depend on the mTLS child: the field they need exists from the start and only gets sharper.
The health path crosses two repository boundaries, and bootroot owns only the first hop. The design says the low-water alarm and the intent-without-outcome count must actually reach an operator, and names three hops to get there: the daemon exposes the values on the mint/deregister endpoint, the co-located registrar relays them as an
audit_healthAgentInfotail field, and the control plane renders them. Only the first hop is in this repository, and it is theregistrar_healthcontainer the endpoint protocol child reserves on all three response shapes, both successes and the refusal — a differently-named field at a different layer, not a rename ofaudit_health. It is also a container, carrying certificate lifetimes and the limiter's counters alongside the audit signals, which is why it cannot take the tail's name. No child here owns the relay or the rendering, and none should; both are other repositories' work, and the wire-contract child transcribes the tail's spelling only so this repository has it on record. Read any child's health criteria as "bootroot exposes this correctly", never as "an operator sees this" — the second is not something this plan can deliver alone. The container's placement is settled and is the protocol child's to fix: it rides refusals as well as successes, because every member in it reports a condition whose arrival is precisely what makes success responses stop — a filling audit store refuses mints, a shedding limiter refuses invocations, a lapsed certificate refuses everything. A success-only container would go silent in the three states it exists to report. Member-owning children populate their member wherever the container is carried and none of them re-decides this.Five artifacts this repository never produces, and the children that depend on them. Each is owned by another repository — four by the provisioning tool, one by the calling repository's protocol specification — so no child here can be "finished" by writing it. A child defines or transcribes the format, ships a documented reference, and consumes it. Collected here because no single child can see that the set of cross-repo obligations is complete:
ServiceRegistrationspec's own identity fields, which must carry the component's plain keyword —roxyd, not the pre-split composedroxyd-<host>. This is a value obligation rather than a format one, which is why it needs stating separately: the spec type is the provisioning tool's, it carriescomponentandservice_namealongside the two fields the safe-set actually compares, and the control plane forwards it onto the wire. The config-and-derivation child refuses aspec.componentorspec.service_namethat disagrees with the wireservice_name, because the alternative is minting under an identity the caller named rather than the one bootroot derived. The failure mode if the other side still renders composed names is total and silent until deploy: every enrollment of the affected component is refused with the spec-identity disagreement, no partial success masks it, and nothing in this repository can distinguish it from an attack. The composed name is derived on this side now, under theregistration_idsplit; the spec's identity fields are plain keywords like everything else. Confirm this against the provisioning repository's rendering early — alongside the wire-contract child's identifier, and for the same reason — and treat a composed name found there as a finding to raise against the ecosystem RFC's rendering, never as something to accommodate by relaxing the equality check;registrar-endpoint-anchors.sha256, SHA-256 of trust-anchor DER, one per line) — the only entry on this list whose contract is split across two children, which is why it is spelled out rather than left to be assembled from both. The format and the basename are the client-identity child's; the placement is the certificate-issuance child's, because bootroot fixes no certificate directory — every path is configured — so the pin file's directory is the one the client-certificate path setting that child adds resolves to, and the client-identity child deliberately fixes no absolute path. Nothing in this repository ever writes the file: the provisioning tool writes it once at install, and it must write it at that directory, under that basename, in that format — three obligations from two children, all of which have to hold together. A file that is correct in format but written somewhere else fails exactly as a misformatted one does, and later: the endpoint client is handed an explicit path, finds nothing there, and refuses every dial with a pin-file-missing error that names a path the operator never chose;BootstrapMaterialsuccess-payload field spellings and theRegistrarUnavailablereason set, owned by the calling repository rather than the provisioning tool. Neither exists anywhere in this repository: both appear in the RFC only as prose, with no field list behind them. This is the one entry that blocks work rather than merely constraining it, and it has a child of its own so that it blocks exactly one issue instead of leaking into several. The wire-contract child obtains these spellings, settles the one identifier this effort needs added — a seventh, for aservice_nameorhostthat is not a DNS label, proposed asServiceLabelInvalid— and transcribes the result into a checked-indocs/reference. A component absent from the multiplicity table keeps theServiceInstanceMismatchthat three merged documents already assign it; that is an intentional second meaning, recorded as such, not a collision to be fixed later. The plan deliberately hard-gates on that identifier landing upstream rather than closing the wire-contract child on everything else obtainable and tracking the last spelling in a follow-up. The cost is real and is accepted: this repository's protocol codec, and the acceptance arms behind it, wait on another repository's review latency. The alternative was rejected because it buys parallelism with a window in which one refusal path has no wire identifier at all — and a refusal that cannot be named on the wire is one the caller cannot classify, which is the failure the typed error set exists to prevent. Everything that does not need a settled identifier is already routed around this child, which is why the gate costs one branch rather than the effort. Every other child then reads that reference: the transport child writes its codec and generates its golden fixtures from it, the certificate-renewal and rate-limit children mirror its spelling for the health field, and the acceptance suite asserts identifiers against it. No child other than the wire-contract child proposes, renames or negotiates anything on this contract — that separation is deliberate, because an issue that both consumes a contract and argues about it cannot be finished.If the other repository renders any of these differently from the format its owning child documents, the affected children ship code that cannot run in production and the failure appears only at deploy time. Treat the documented format in each owning child as the cross-repo contract.
Non-goals for the whole effort: the mTLS PKI itself, and ACME/EAB issuance and certificate rotation for ordinary services, are unchanged; bootroot's own update is out of scope.
service addis deliberately not on this list. The confinement work (the verbs, the endpoint, the credential, the audit record) leaves install-timeservice adduntouched, and that is the claim worth making — but two children do change it, by design: theregistration_idsplit adds--registration-idand renames the lookup-key flags on six commands, and the client-identity child adds a reserved-prefix guard refusing anyservice_namebeginning withbootroot-. Read "install-time behaviour is unchanged" as scoped to the confinement work only; a child that needs to changeservice addfor those two reasons is following the plan, not violating it.Decisions — all settled
No human decision remains open in this tree. Every question raised during decomposition has been answered, and a child that reads as contingent on an unmade choice is a defect, not a deferral. The answers, and where each is carried:
The limiter's bucket shape departs from the accepted RFC — recorded as an intentional RFC correction. The RFC specifies per-
(verb, outcome-class)buckets "so refusals throttle without starving accepted mints". The rate-limit children instead specify per-(client identity, verb, bucket)buckets over a two-value enum —predecision_refusalandadmission. The two-value enum is kept, because it is the maximal refinement knowable at the point the limiter is charged: a pre-derivation refusal is known there, since that arm fires only on refusals and label validation plus multiplicity-class resolution precede it, while the outcome within an admitted invocation is determined by OpenBao work that follows the intent write. Keying on the outcome class would require either charging after the durable write the limiter exists to prevent, or predicting the outcome. What the shape delivers, stated positively: the cheapest flood path — malformed input, no OpenBao work — cannot starve accepted mints, which is the path the RFC's own rate-limit rationale names. What it does not isolate is expensive refusals, which consume admission budget and are bounded by attacker cost rather than by the limiter. The RFC is amended rather than left to contradict the tree; the decision child owns that amendment.A throttled invocation answers
RegistrarBusyfrom the admission check point only. The RFC's rate-limit sentence and its acceptance criterion are unqualified, and that is the second correction the decision child carries. A pre-derivation invocation that is limited has its two audit records suppressed but still receives its real, permanent refusal, becauseRegistrarBusyis the family's only retryable error while the pre-derivation refusals are classified as deterministic and permanent elsewhere in the set — answering it there would hand a caller the one value whose contract says "this clears on its own" for a request that can never succeed, and would have the limiter feed the traffic it was added to damp. The suppression keeps the coalesced counter increment and the anomaly definition still counts it; otherwise a flood becomes least visible exactly while it is worst.The audit store's privileged provisioning phase is the operator's. bootroot renders the exact commands and artifacts, the operator performs the privileged host surgery, and bootroot verifies and fails closed until verification passes.
bootroot infra installis not a root-on-host command today and the one-shot root container cannot stand in; the choices this phase makes are site-specific and destructive to guess on the one host that must not be restarted; and every other systemd artifact in this repository already reaches a host by being operator-installed. The reserve child carries it.The endurance scenario runs both negative-control methods. The committed harness-level positive control is shipped and the throwaway renewal mutation's diff and failing output are carried in the pull request. They cover different failure modes — a mis-aimed watcher and a mis-timed one — and this arm sits outside the per-PR gate, so running both costs little. The endurance child carries it.
The decision child still has its dependency edge, and it still blocks the limiter mechanism child, which blocks the counted-record child. That edge now encodes ordering — the RFC amendment lands before the mechanism is built against it — not an unanswered question.
Execution order
Issues in the same wave have no unmet dependencies among these children and can run in parallel.
registration_id#756, Settle and transcribe the registrar endpoint's caller-facing wire contract #759, Issue a distinguishable registrar client identity and pin the endpoint's server identity #760, Rate-limit both registrar verbs with two token buckets #788, Bound the OpenBao audit device's growth #789External dependencies:
bootroot status#779bootroot status#779, Implement the registrar endpoint's versioned wire protocol and its codec #762