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
Registrar endpoint: the socket and its wire protocol
Summary
The two registrar verbs exist as an in-process API. This group gives them the surface the co-located registrar actually calls. It is a grouping issue and carries no work of its own — both halves live in its children.
The cost was measured against the tree before the split, because the design's own phrasing invites an underestimate. The daemon (src/daemon.rs::run_daemon:68) binds nothing today — it is a pure outbound OpenBao + ACME client — and there is no UnixListener anywhere in src. So "expose two verbs on a daemon that already exists" means writing a small server, not adding a callback. Nothing here is a configuration change.
The seam between the two children is the external dependency, and that is the entire reason the split exists. The caller-facing shape of this protocol — every request and success-payload field name, every error identifier with its transient-versus-permanent class, and the full RegistrarUnavailable reason set — is owned by another repository. bootroot mirrors those names and never authors them. A wire-contract issue sitting beside this group — a sibling under the effort's top-level umbrella, not a child of this one — transcribes the whole contract into a checked-in reference under docs/. Only the second child below depends on it.
So the first child is everything that needs nothing from that reference: the socket, the activation contract, the root-owned directory rule, peer-credential authentication, the off-by-default gate, the bootroot-host unit pair, and the dispatcher that reaches exactly two operations and nothing else. It frames a request and returns a response using an internal test-only payload shape confined to its own tests. The second child is the wire half that implements the reference: the versioned request and response types with their exact field sets, the codec, the exhaustive mapping from the verb layer's typed refusals onto wire identifiers, the reserved health container, and golden fixtures generated from the reference so a rename surfaces as a diff rather than as a silent cross-repo break.
Splitting there is what keeps another repository's review latency off the critical path of the socket, mTLS termination, the internal credential, both certificate leaves and the entire audit trail — none of which need a settled wire identifier. They are strictly ordered: the protocol child depends on the listener child.
Children
Serve the registrar verbs on a root-owned, socket-activated unix socket — Surface
Implement the registrar endpoint's versioned wire protocol and its codec — Surface
Shared background
Why a unix socket and not a localhost port. A TCP endpoint on loopback is impersonable: an unprivileged local process can bind a free high port during any window in which the daemon is not listening — a restart, a failed start, or a boot-ordering race where the registrar comes up first — and that is not the root-compromise case the design puts out of scope. It matters because of what the mint verb returns: the response carries a CA anchor that becomes a freshly enrolled service's or a freshly joined host's trust root, so anything that can impersonate the endpoint injects a CA of its choosing. A root-owned 0700 unix-domain socket removes both the port and the bind race.
The directory is what prevents path occupation, not the socket mode.0700 on the socket says nothing about who may replace the path, and a daemon that unlink()s a stale socket before bind() opens the window itself. Hence two properties both children must respect: the socket lives in a root-owned directory that is neither group- nor world-writable, and the listener exists before any client can run — socket activation ordered ahead of the registrar, with the daemon inheriting a pre-opened listening descriptor rather than creating one. Unlink-and-rebind is prohibited outright rather than being one option among several, and the inherited descriptor is the only way the endpoint starts, in tests as well as in production.
The endpoint ships inside a fleet-wide binary, so it is off by default.bootroot-agent runs on every service host, not only where bootroot itself runs. The endpoint's code therefore ships everywhere and its absence rests entirely on the enablement gate being correct — which is why the gate is an acceptance criterion rather than a convenience, and why it matters most in the window before mTLS lands, when socket ownership plus the peer-credential check are the only access control. One further consequence is accepted rather than mitigated: one binary ends up running as root on the bootroot host and as a hardened non-root account everywhere else, from two different unit files.
Caller identity never comes from the wire, in any protocol version. It is the transport-authenticated identity: the listener derives it from what the transport itself can prove about the peer — the connected socket's peer credentials in this group, the client certificate's identity once the mTLS issue replaces the source — and hands it to the verbs as their opaque caller-identity value. The protocol child's request shapes deliberately have no field for it under any spelling. What is fixed here is the rule (the identity comes from the connection, never from the payload) rather than the source, so a statement anywhere in this group that names peer credentials describes today's implementation of that rule and not a property mTLS later violates. Accepting it from the caller would let that caller name itself in the audit record and in the rate limiter's bucket key — the same authority the verbs were narrowed to withhold. This is also why the protocol's unknown-field tolerance has an explicit carve-out: authority-bearing names are never read, so tolerance cannot become the channel through which a caller supplies its own identity.
Three identifiers travel with an invocation, and only one of them is the caller's. The request id is generated inside the daemon, by the verb layer at the first line of its pre-derivation arm; it appears on every response and in every audit record, and no request shape has a field for it under any spelling. The listener separately assigns a transport-level diagnostic id to each accepted connection — which, because the transport permits exactly one request per connection, is also one per request whenever a frame decodes — so that anything refused before the verb layer is entered still has something to log. Assigning it at accept rather than at decode is deliberate: a connection that sends nothing, or stalls part-way through a header, never produces a request at all and would otherwise be the one refusal with no key to log it under. It never reaches the verbs, never enters an audit record and never goes on the wire. The idempotency key is the caller's, mirrored into the request shape because the externally owned reference carries it — and bootroot's idempotence does not depend on it: a re-mint is idempotent because of the durable registration_id → host binding and the stored-spec comparison, never because of a key the caller chose. Keeping the three apart is what stops a caller correlating its own audit records or replaying under a key.
One reading follows from that and settles a wording collision with the issues that layer onto this group. A refusal detected before the verb layer is entered has no verb-layer request id, because none has been generated yet — so where a later issue describes such a refusal as logging "the request id", the identifier it means is the listener's transport diagnostic id, which is the only one that exists at that point. The listener child owns that helper and its parameters, and a sibling adding a caller to it inherits them rather than introducing a second id.
What is mirrored and what is bootroot's own. The reference owns the payload — request field names, success-payload field names, error identifiers, each identifier's transient-versus-permanent class, and the RegistrarUnavailable reason set. It does not own the envelope, the encoding, or anything this endpoint adds beyond the mirrored payload. This socket is not the calling repository's protocol wire; the co-located registrar is that protocol's peer and translates onto it. So four things are this repository's to define and the mirror rule does not reach any of them: the framing and the operation identifier (the listener child), and the serialization format the payload is encoded in and the endpoint's own health container with its member names (the protocol child). Getting the boundary wrong in either direction is a failure: inventing a payload name is a cross-repo break that surfaces at deploy time, and treating a bootroot-owned name as externally owned would gate the listener on exactly the review latency this split exists to avoid — or, for the health container, would fuse a local endpoint field with the ecosystem's audit_healthAgentInfo tail, which is a different field in a different repository that nothing here ever writes.
The envelope is not versioned; the message is. The two children draw that line rather than each drawing their own, because a version in the frame header and a version in the message are two mechanisms for one job and the second child would otherwise inherit a header field it cannot use. The listener child's framing carries a length and an operation identifier and nothing that describes the payload; the protocol version travels inside the message the protocol child defines, on every request and every response. That is also why an unrecognized operation is a transport refusal while an unrecognized field is tolerated on decode: the envelope's only vocabulary is the operation set, so it has nothing to be tolerant with.
Whatever guards the connection before the verbs are entered belongs to the listener child. The effort's rate limiter is keyed on the caller identity and charged at verb-arm entry, so it bounds invocations and starts counting only once a request has been read and dispatched. Everything a caller can spend before that point — connection count, bytes read, time held open — is bounded by the transport or by nothing at all, and "nothing at all" would put an unbounded allocation in front of the one control the audit design leans on. The listener child therefore carries the frame ceiling, the connection timeouts and the accepted-connection bound, and the protocol child assumes a frame it decodes is already length-bounded.
One request per connection, and two refusal shapes that must not be conflated. A connection carries exactly one request and one response and is then closed — which is what makes "the caller read zero application bytes" a meaningful assertion, and therefore what lets a refusal be expressed by writing nothing at all. A verb refusal is a response carrying a wire error identifier, its class and the request id. A transport refusal writes no response bytes, logs a typed reason, and closes the stream cleanly rather than dropping it. It deliberately carries no wire identifier, because the identifier set is externally owned and names no such refusal. The listener child establishes that shape and makes it the single pre-verb refusal path, so the mTLS issue's authorization refusal becomes an additional caller of it rather than a change to it.
Transport refusals are a family, not one case, and the difference is what the daemon knows when it refuses. An unrecognized operation is refused after a header was read, so the operation identifier as received is available to log. The rest of the family — a connection that sends nothing, one that stalls part-way through a header or body, and one whose declared frame length exceeds the ceiling — is refused with no operation identifier at all, or with one that was never fully read. So the shared helper's contract carries the operation identifier as optional, and carries a typed case naming which refusal fired; a helper that requires the identifier would force the timeout and over-length paths to invent or fabricate one, and the log line is the only diagnosis a caller-invisible refusal has. The listener child owns that contract, and the mTLS issue's authorization refusal joins the family as a case that does have an identifier.
The error mapping is exhaustive, and that is a mechanism rather than a style rule. The protocol child maps the verb layer's internal refusal taxonomy onto wire identifiers with a match that has no catch-all, so every later issue that adds a refusal variant to the verb layer — the audit integration's two, the limiter's one — fails to compile until it classifies its variant at this boundary. Because none of those issues depends on this group and this group depends on none of them, the protocol child pre-records the settled assignment for all three, so whichever lands second adds an arm rather than making a decision.
The health container is a bootroot-owned local name, reserved here, riding every response, and populated nowhere in this group. The protocol child reserves the container and its member names and fills in none of them. The container is not a mirrored field and has no counterpart in the external contract: it is this endpoint's own health surface, and the ecosystem's audit_healthAgentInfo tail — which some of these values eventually reach — is a different field, written by the co-located registrar in its own repository from what it reads here. bootroot's half of that path ends at this container, so its spelling is settled locally and is never adjusted to match the tail. Three later issues each add exactly one disjoint member — the certificates entry, the limiter entry and the audit-capacity entry — and none of them may reshape the container or touch a sibling's member. There is no ordering edge among those three, so whichever lands last is purely additive.
Health rides refusal responses as well as successes, and that placement is settled here rather than left to the members. The obvious shape — health on the two success responses only — fails in exactly the states the health signals exist to announce. The audit store is a fail-closed control: as it fills, invocations are refused, so a success-only container stops carrying the low-water alarm precisely when the reserve is running out. The same holds for the two other members: a registrar being throttled receives refusals by definition, and a lapsed certificate stops every enrollment in the deployment. A container reachable only while nothing is wrong is not a health surface. So the container is attached to all three wire response shapes — mint success, deregister success and refusal — and the three member-owning issues gain the refusal shape for free, because they populate a member while this group owns the container's placement.
What that costs, and why it is small. The container is serialized from a snapshot the daemon refreshes on its own tick; nothing in it is computed per request. Attaching it to a refusal therefore copies an already-computed value rather than doing work, which is what keeps it compatible with the limiter's argument that a throttled invocation must be cheap — and a throttled response carrying the limiter's own pressure reading beside its retry hint is the more useful answer, not a more expensive one. The container must stay bounded and fixed-shape for the same reason.
A transport refusal still carries nothing, because it writes no response bytes at all. The health container rides wire refusals — a verb refusal serialized with its error identifier — and not the listener's silent refusal of an unrecognized operation, which has no response to attach anything to.
Neither child owns the verbs. The two arms, their locks, the refusal taxonomy, the outcome classification, the request id and the opaque caller-identity parameter all belong to the verbs issue. This group serves them and maps what they produce; it changes none of it. A refusal that cannot be placed without reordering a verb-layer check is a finding to raise against the verb layer, not a change to make here.
Execution order
Issues in the same wave have no unmet dependencies among these children and can run in parallel.
graph TD
issue761["#761 Serve the registrar verbs on a root-owned, socket-activated unix socket [phase: Surface]"]
issue762["#762 Implement the registrar endpoint's versioned wire protocol and its codec [phase: Surface]"]
issue758["#758 Implement the restricted registrar mint and deregister verbs [phase: Verb building blocks]"]
issue758 -.-> issue761
issue761 --> issue762
issue759["#759 Settle and transcribe the registrar endpoint's caller-facing wire contract [phase: Verb building blocks]"]
issue759 -.-> issue762
Registrar endpoint: the socket and its wire protocol
Summary
The two registrar verbs exist as an in-process API. This group gives them the surface the co-located registrar actually calls. It is a grouping issue and carries no work of its own — both halves live in its children.
The cost was measured against the tree before the split, because the design's own phrasing invites an underestimate. The daemon (
src/daemon.rs::run_daemon:68) binds nothing today — it is a pure outbound OpenBao + ACME client — and there is noUnixListeneranywhere insrc. So "expose two verbs on a daemon that already exists" means writing a small server, not adding a callback. Nothing here is a configuration change.The seam between the two children is the external dependency, and that is the entire reason the split exists. The caller-facing shape of this protocol — every request and success-payload field name, every error identifier with its transient-versus-permanent class, and the full
RegistrarUnavailablereason set — is owned by another repository. bootroot mirrors those names and never authors them. A wire-contract issue sitting beside this group — a sibling under the effort's top-level umbrella, not a child of this one — transcribes the whole contract into a checked-in reference underdocs/. Only the second child below depends on it.So the first child is everything that needs nothing from that reference: the socket, the activation contract, the root-owned directory rule, peer-credential authentication, the off-by-default gate, the bootroot-host unit pair, and the dispatcher that reaches exactly two operations and nothing else. It frames a request and returns a response using an internal test-only payload shape confined to its own tests. The second child is the wire half that implements the reference: the versioned request and response types with their exact field sets, the codec, the exhaustive mapping from the verb layer's typed refusals onto wire identifiers, the reserved health container, and golden fixtures generated from the reference so a rename surfaces as a diff rather than as a silent cross-repo break.
Splitting there is what keeps another repository's review latency off the critical path of the socket, mTLS termination, the internal credential, both certificate leaves and the entire audit trail — none of which need a settled wire identifier. They are strictly ordered: the protocol child depends on the listener child.
Children
Shared background
Why a unix socket and not a localhost port. A TCP endpoint on loopback is impersonable: an unprivileged local process can bind a free high port during any window in which the daemon is not listening — a restart, a failed start, or a boot-ordering race where the registrar comes up first — and that is not the root-compromise case the design puts out of scope. It matters because of what the mint verb returns: the response carries a CA anchor that becomes a freshly enrolled service's or a freshly joined host's trust root, so anything that can impersonate the endpoint injects a CA of its choosing. A root-owned
0700unix-domain socket removes both the port and the bind race.The directory is what prevents path occupation, not the socket mode.
0700on the socket says nothing about who may replace the path, and a daemon thatunlink()s a stale socket beforebind()opens the window itself. Hence two properties both children must respect: the socket lives in a root-owned directory that is neither group- nor world-writable, and the listener exists before any client can run — socket activation ordered ahead of the registrar, with the daemon inheriting a pre-opened listening descriptor rather than creating one. Unlink-and-rebind is prohibited outright rather than being one option among several, and the inherited descriptor is the only way the endpoint starts, in tests as well as in production.The endpoint ships inside a fleet-wide binary, so it is off by default.
bootroot-agentruns on every service host, not only where bootroot itself runs. The endpoint's code therefore ships everywhere and its absence rests entirely on the enablement gate being correct — which is why the gate is an acceptance criterion rather than a convenience, and why it matters most in the window before mTLS lands, when socket ownership plus the peer-credential check are the only access control. One further consequence is accepted rather than mitigated: one binary ends up running as root on the bootroot host and as a hardened non-root account everywhere else, from two different unit files.Caller identity never comes from the wire, in any protocol version. It is the transport-authenticated identity: the listener derives it from what the transport itself can prove about the peer — the connected socket's peer credentials in this group, the client certificate's identity once the mTLS issue replaces the source — and hands it to the verbs as their opaque caller-identity value. The protocol child's request shapes deliberately have no field for it under any spelling. What is fixed here is the rule (the identity comes from the connection, never from the payload) rather than the source, so a statement anywhere in this group that names peer credentials describes today's implementation of that rule and not a property mTLS later violates. Accepting it from the caller would let that caller name itself in the audit record and in the rate limiter's bucket key — the same authority the verbs were narrowed to withhold. This is also why the protocol's unknown-field tolerance has an explicit carve-out: authority-bearing names are never read, so tolerance cannot become the channel through which a caller supplies its own identity.
Three identifiers travel with an invocation, and only one of them is the caller's. The request id is generated inside the daemon, by the verb layer at the first line of its pre-derivation arm; it appears on every response and in every audit record, and no request shape has a field for it under any spelling. The listener separately assigns a transport-level diagnostic id to each accepted connection — which, because the transport permits exactly one request per connection, is also one per request whenever a frame decodes — so that anything refused before the verb layer is entered still has something to log. Assigning it at accept rather than at decode is deliberate: a connection that sends nothing, or stalls part-way through a header, never produces a request at all and would otherwise be the one refusal with no key to log it under. It never reaches the verbs, never enters an audit record and never goes on the wire. The idempotency key is the caller's, mirrored into the request shape because the externally owned reference carries it — and bootroot's idempotence does not depend on it: a re-mint is idempotent because of the durable
registration_id → hostbinding and the stored-spec comparison, never because of a key the caller chose. Keeping the three apart is what stops a caller correlating its own audit records or replaying under a key.One reading follows from that and settles a wording collision with the issues that layer onto this group. A refusal detected before the verb layer is entered has no verb-layer request id, because none has been generated yet — so where a later issue describes such a refusal as logging "the request id", the identifier it means is the listener's transport diagnostic id, which is the only one that exists at that point. The listener child owns that helper and its parameters, and a sibling adding a caller to it inherits them rather than introducing a second id.
What is mirrored and what is bootroot's own. The reference owns the payload — request field names, success-payload field names, error identifiers, each identifier's transient-versus-permanent class, and the
RegistrarUnavailablereason set. It does not own the envelope, the encoding, or anything this endpoint adds beyond the mirrored payload. This socket is not the calling repository's protocol wire; the co-located registrar is that protocol's peer and translates onto it. So four things are this repository's to define and the mirror rule does not reach any of them: the framing and the operation identifier (the listener child), and the serialization format the payload is encoded in and the endpoint's own health container with its member names (the protocol child). Getting the boundary wrong in either direction is a failure: inventing a payload name is a cross-repo break that surfaces at deploy time, and treating a bootroot-owned name as externally owned would gate the listener on exactly the review latency this split exists to avoid — or, for the health container, would fuse a local endpoint field with the ecosystem'saudit_healthAgentInfotail, which is a different field in a different repository that nothing here ever writes.The envelope is not versioned; the message is. The two children draw that line rather than each drawing their own, because a version in the frame header and a version in the message are two mechanisms for one job and the second child would otherwise inherit a header field it cannot use. The listener child's framing carries a length and an operation identifier and nothing that describes the payload; the protocol version travels inside the message the protocol child defines, on every request and every response. That is also why an unrecognized operation is a transport refusal while an unrecognized field is tolerated on decode: the envelope's only vocabulary is the operation set, so it has nothing to be tolerant with.
Whatever guards the connection before the verbs are entered belongs to the listener child. The effort's rate limiter is keyed on the caller identity and charged at verb-arm entry, so it bounds invocations and starts counting only once a request has been read and dispatched. Everything a caller can spend before that point — connection count, bytes read, time held open — is bounded by the transport or by nothing at all, and "nothing at all" would put an unbounded allocation in front of the one control the audit design leans on. The listener child therefore carries the frame ceiling, the connection timeouts and the accepted-connection bound, and the protocol child assumes a frame it decodes is already length-bounded.
One request per connection, and two refusal shapes that must not be conflated. A connection carries exactly one request and one response and is then closed — which is what makes "the caller read zero application bytes" a meaningful assertion, and therefore what lets a refusal be expressed by writing nothing at all. A verb refusal is a response carrying a wire error identifier, its class and the request id. A transport refusal writes no response bytes, logs a typed reason, and closes the stream cleanly rather than dropping it. It deliberately carries no wire identifier, because the identifier set is externally owned and names no such refusal. The listener child establishes that shape and makes it the single pre-verb refusal path, so the mTLS issue's authorization refusal becomes an additional caller of it rather than a change to it.
Transport refusals are a family, not one case, and the difference is what the daemon knows when it refuses. An unrecognized operation is refused after a header was read, so the operation identifier as received is available to log. The rest of the family — a connection that sends nothing, one that stalls part-way through a header or body, and one whose declared frame length exceeds the ceiling — is refused with no operation identifier at all, or with one that was never fully read. So the shared helper's contract carries the operation identifier as optional, and carries a typed case naming which refusal fired; a helper that requires the identifier would force the timeout and over-length paths to invent or fabricate one, and the log line is the only diagnosis a caller-invisible refusal has. The listener child owns that contract, and the mTLS issue's authorization refusal joins the family as a case that does have an identifier.
The error mapping is exhaustive, and that is a mechanism rather than a style rule. The protocol child maps the verb layer's internal refusal taxonomy onto wire identifiers with a
matchthat has no catch-all, so every later issue that adds a refusal variant to the verb layer — the audit integration's two, the limiter's one — fails to compile until it classifies its variant at this boundary. Because none of those issues depends on this group and this group depends on none of them, the protocol child pre-records the settled assignment for all three, so whichever lands second adds an arm rather than making a decision.The health container is a bootroot-owned local name, reserved here, riding every response, and populated nowhere in this group. The protocol child reserves the container and its member names and fills in none of them. The container is not a mirrored field and has no counterpart in the external contract: it is this endpoint's own health surface, and the ecosystem's
audit_healthAgentInfotail — which some of these values eventually reach — is a different field, written by the co-located registrar in its own repository from what it reads here. bootroot's half of that path ends at this container, so its spelling is settled locally and is never adjusted to match the tail. Three later issues each add exactly one disjoint member — the certificates entry, the limiter entry and the audit-capacity entry — and none of them may reshape the container or touch a sibling's member. There is no ordering edge among those three, so whichever lands last is purely additive.Health rides refusal responses as well as successes, and that placement is settled here rather than left to the members. The obvious shape — health on the two success responses only — fails in exactly the states the health signals exist to announce. The audit store is a fail-closed control: as it fills, invocations are refused, so a success-only container stops carrying the low-water alarm precisely when the reserve is running out. The same holds for the two other members: a registrar being throttled receives refusals by definition, and a lapsed certificate stops every enrollment in the deployment. A container reachable only while nothing is wrong is not a health surface. So the container is attached to all three wire response shapes — mint success, deregister success and refusal — and the three member-owning issues gain the refusal shape for free, because they populate a member while this group owns the container's placement.
What that costs, and why it is small. The container is serialized from a snapshot the daemon refreshes on its own tick; nothing in it is computed per request. Attaching it to a refusal therefore copies an already-computed value rather than doing work, which is what keeps it compatible with the limiter's argument that a throttled invocation must be cheap — and a throttled response carrying the limiter's own pressure reading beside its retry hint is the more useful answer, not a more expensive one. The container must stay bounded and fixed-shape for the same reason.
A transport refusal still carries nothing, because it writes no response bytes at all. The health container rides wire refusals — a verb refusal serialized with its error identifier — and not the listener's silent refusal of an unrecognized operation, which has no response to attach anything to.
Neither child owns the verbs. The two arms, their locks, the refusal taxonomy, the outcome classification, the request id and the opaque caller-identity parameter all belong to the verbs issue. This group serves them and maps what they produce; it changes none of it. A refusal that cannot be placed without reordering a verb-layer check is a finding to raise against the verb layer, not a change to make here.
Execution order
Issues in the same wave have no unmet dependencies among these children and can run in parallel.
External dependencies: