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
Refuse the registrar verbs while the audit store is not mounted
Context
The registrar's two audit artifacts — the daemon's verb records under <audit_store_dir>/records and the OpenBao file audit device under <audit_store_dir>/openbao — share one directory on the bootroot host, sized by [registrar] audit_store_reserve_bytes and placed by audit_store_dir, with audit_store_enforcement selecting filesystem (the default) or directory. The registrar endpoint that serves the verbs is not a process of its own: it is a config-gated feature inside bootroot-agent, brought up by resolve_registrar_service when [registrar_endpoint] enabled is on, listening on the systemd-owned socket /run/bootroot/registrar.sock that systemd/bootroot-registrar.service supervises.
By the time this issue starts, the provisioning half of the reserve exists and is the issue this one depends on. In filesystem mode the installer derives a loopback image at <audit_store_dir>.img, sized to exactly audit_store_reserve_bytes, fully allocated, uid 0, mode 0600, carrying an ext4 filesystem made with mkfs.ext4 -m 0 -E nodiscard; it renders a generated <escaped audit_store_dir>.mount unit plus ordering drop-ins for docker.service and bootroot-registrar.service, and it verifies — with metadata reads only, and only on the installer's own surfaces — that the image meets its whole contract, that all three artifacts are installed under /etc/systemd/system, and that audit_store_dir is mounted ext4 from a loop device whose backing file is the derived image. The operator performs every step that changes the host; bootroot renders, reads and reports. In filesystem mode bootroot's install path creates audit_store_dir alone and neither subdirectory beneath it, since a subdirectory made before the mount is one the mount hides.
Nothing refuses at run time, and two things follow from that. A filesystem-mode host whose mount unit was never enabled, or whose mount job failed, still serves the registrar verbs. Boot ordering does not cover it — the drop-ins are Wants= plus After=, which order the boot and guarantee nothing, because a hard relation on either target would take a whole stack down over a fault local to one directory. So the configured reserve is a reservation nobody is standing on, and the verb records go back to filling the root filesystem.
Worse, the daemon manufactures the very store it should be refusing over.AuditRecordStore::open calls prepare(), which creates the record directory's ancestors — audit_store_dir itself, at ANCESTOR_DIR_MODE — and then records/ at STORE_DIR_MODE, unconditionally and with no notion of a mount. Opened while the mount is absent it therefore creates records/ beneath the empty mount point and writes verb records to the root filesystem. The provisioning work records that as a residual and leaves prepare() alone deliberately: its behaviour over a mounted store is correct, and the fix is a refusal that runs before the open rather than a change inside it. Closing that residual is this issue's.
This issue makes the daemon refuse the registrar verbs while filesystem mode is configured and audit_store_dir is not a mount point — without stopping the daemon, without leaving callers hanging on an adopted socket, and without opening the store it is refusing over.
Scope
The check, and where it goes
One cheap predicate answers "is audit_store_dir a mount point": its st_dev differs from its parent's. It runs in resolve_registrar_service, inside the arm that has an activated endpoint — which is what gates it on [registrar_endpoint] enabled — and ahead of the build_registrar_handler call, so it precedes every dependency that function resolves, the AuditRecordStore::open inside it included, and the refusal costs no writes and no credential load.
That ordering is load-bearing for more than cost: it is what stops the daemon manufacturing the store it is refusing over. Refusing before the open closes the residual above, and closes it without touching prepare(), which stays byte-unchanged.
This is deliberately not the installer's deep identity proof. The installer's phase 3 reads /proc/self/mounts and resolves a loop device's backing file through sysfs to prove that the mount is the derived image; that is the installer's job. Putting that parse and that sysfs read on the daemon's startup path buys nothing the cheap comparison does not at the moment it matters, and it is not added here.
The choice is made once, at start, matching the check's position: a mount established afterwards takes effect on the next daemon start, and both manuals say so.
The daemon names the condition once at startup, through tracing, with the store path and the expected mount unit, so an operator reading the journal sees the cause without a caller having to provoke it.
directory mode reaches none of this, and neither does a host whose endpoint is not enabled: nothing is checked, nothing is refused, and no diagnostic is added.
What fails closed is the endpoint, never the daemon's renewal duties
Neither of the two obvious mechanisms delivers that, and the reasons are the design.
The verdict is not returned as an error.run_daemon calls resolve_registrar_service with ? at src/daemon.rs:186, ahead of the profile and fast-poll loops, so anything that function returns as an error stops the whole daemon. Taking certificate renewal down over a misconfigured audit mount is a second and larger outage than the one being prevented: the fast-poll loop and per-service ACME renewal must keep running exactly as they do today.
Resolving to no endpoint is equally wrong, and the socket makes the reason concrete.bootroot-registrar.socket is Accept=no: systemd creates, owns and holds /run/bootroot/registrar.sock and the service inherits the one listening descriptor, so a daemon that adopts that socket and never accepts leaves every caller connecting successfully and then waiting — a hang rather than a named refusal, which resolve_registrar_service's own doc comment already identifies as strictly worse than a failed start. RegistrarService being an Option is therefore not the mechanism.
The mechanism is a deliberately limited handler, and the endpoint's own seam is built for one. In the arm that has an activated endpoint, resolve_registrar_service runs the mount-point check ahead of the build_registrar_handler call; where the store is not a mount point in filesystem mode it pairs that endpoint with a refusing handler instead of the production one. Everything downstream is unchanged: the accept task is spawned exactly as it is today, and the endpoint accepts, completes its handshake and its peer check, and answers.
run_daemon returns no new error on this path. Comment the departure where it lands, because it deliberately differs from its neighbours: every other endpoint dependency failure keeps stopping the daemon, and nothing here changes that.
The refusing handler
It resolves none of the endpoint's dependencies. It opens no AuditRecordStore, loads no internal credential, reads no state file, and builds no verbs and no rate limiter — that whole set is what not calling build_registrar_handler avoids, and it is why the refusal costs no writes. Its construction must therefore be reachable without any of them.
What a caller gets is a typed refusal in the vocabulary that already exists, not a closed connection and not a new wire shape: a RefusalResponse carrying class: permanent and error: RegistrarUnavailable { reason: AuditUnwritable }. RegistrarUnavailableReason is a closed, externally owned set that already carries that reason, and validate_refusal_class already requires Permanent for every RegistrarUnavailable: add no variant, no field and no refusal class. A mount that is not there is the audit trail being unwritable, which is exactly what the caller needs to know and act on. registration_id is absent, nothing having been registered, and registrar_health is the default empty snapshot — populating any member of it belongs to the issues that own them.
The response's request_id is generated here, because there is nothing to echo. Neither wire request carries one: a RegisterRequest has service_name, delivery_mode, host, instance, spec, wrap_ttl and idempotency_key, and a DeregisterRequest the same without the mint-only parts. request_id exists only on the three response shapes, and today it is a correlation handle a verb mints at entry when it opens its audit context. So the handler calls RequestId::generate() itself — a counter bump and a read of the random source, reaching no verb, no audit context and no store — and puts a fresh handle on each refusal. That is no new coupling: the handler seam already imports CallerIdentity from the same module. Do not substitute the caller's idempotency_key: it is caller-chosen, so two callers can collide on it, and the protocol defines this field as the registrar's own handle rather than a value echoed back.
A generated handle that appears nowhere on the host is decoration, so the handler logs one line per refusal carrying it. That line is what the id correlates with, and it is the only thing: there is no audit record under it, the store being unwritable is why the refusal exists at all. It carries the generated request_id, the operation, the caller identity, the store path and the expected mount unit — enough that an operator handed a request_id by a failing caller can find, in the journal, which path was wrong. It deliberately does not carry the connection diagnostic id: that handle belongs to the transport, never appears on the wire, and does not cross the handler seam, which passes only the operation, the payload and the caller. Do not widen that seam to reach it.
The per-refusal line sits at the level the endpoint's other per-connection diagnostics use, not at warning level. The condition is already announced loudly once at start; a caller that retries must not be able to fill the journal with warnings, and a refusal is Permanent precisely so a well-behaved one does not.
A payload the handler cannot decode takes the existing HandlerRefusal path, exactly as the production handler's does, so the transport's refusal taxonomy is untouched. That is the one reason the handler decodes at all — it binds nothing from the decoded request — and it is deliberate: reporting a malformed payload as AuditUnwritable would blame the deployment for a client's bug and hide it behind an operator-facing condition.
No verb runs, so nothing is audited, and that is the point — the reason for refusing is that the audit trail cannot be written. Do not record the refusal to the store, do not count it as an invocation in any limiter, and do not reach for the store to log that the store is missing.
The two documented invariants this reuse falsifies
Generating a RequestId outside a verb makes two doc comments false, so this change broadens them rather than leaving them wrong.
RequestId's own. It is documented today as "a correlation handle for one verb invocation, generated at entry" which "correlates an audit record with a response" — the second half being the sharper falsehood here, since the point is that no record was written. Its doc comment is rewritten to define the type as the registrar's correlation handle for one endpoint response: minted at verb entry where a verb runs, and by the refusing handler where none does; correlating a response with its audit record where there is one, and with the daemon's log line for that refusal where there is not. What stays is what is still true and worth keeping — not a secret, not an idempotency key, never caller-supplied, nothing cached under it.
endpoint/refusal.rs's module header. It distinguishes the connection diagnostic id from "the verb layer's request id, which is a correlation handle for a verb invocation that did happen". That contrast is worth keeping and is restated on its real axis rather than deleted — a connection id never appears on the wire and correlates the lines of one connection, while a request id always appears on the wire and correlates one response with whatever was recorded for it. "A verb invocation that did happen" is a proxy for that distinction, and it stops being accurate once a refusal that ran no verb carries one.
Do not introduce a second handle type for unaudited refusals. It would need its own generator and monotonic sequence, put two types behind one wire field that callers cannot tell apart, and buy nothing the broadened definition does not.
Encoding, and where the handler lives
One narrow protocol-owned encoder, and no widening of the general one. The by-fields helper that builds a RefusalResponse, encode_refusal, is private to protocol.rs, and the pub(crate)encode_refusal_response takes a VerbRefusal this handler must not manufacture — the VerbError variants that map to AuditUnwritable describe an audit write that failed inside a verb, and constructing one for a verb that never ran puts a false provenance into the mapping table. So protocol.rs gains one entry point of its own — encode_registrar_unavailable(request_id, reason, health) or a name of the module's choosing — which delegates to the existing private encode_refusal with RefusalClass::Permanent, EnrollError::RegistrarUnavailable { reason } and no registration_id. It is an internal helper, not a wire change: same response struct, same closed reason set, same validate_refusal_class, same serialization. Do not make encode_refusal itself pub(crate) — that hands every caller an arbitrary class-and-error assembler, when the pairing is exactly what protocol.rs exists to keep decided.
That helper is pub(super), and the handler's placement is what allows it. The refusing handler is a module under registrar::endpoint, beside production.rs — which is where the production handler already lives and what build_registrar_handler already constructs — so pub(super) on the helper reaches it, and reaches the endpoint's tests, which are a descendant of the same parent. Only the handler type and its constructor are pub(crate), because resolve_registrar_service in src/daemon.rs builds it, exactly as it builds ProductionHandler. Put the handler anywhere else and the helper has to widen; that is the wrong fix, and the placement is pinned here so nobody reaches for it. encode_refusal_response keeps its existing pub(crate), which is load-bearing — registrar::verbs's tests call it.
Documentation
Document, in bothdocs/en/ and docs/ko/, on the existing audit-store and configuration pages with no mkdocs.yml nav change. The provisioning work already wrote those sections — the two modes, the phases, the four outcomes, the fresh-host sequence, the removal sequence and the residuals. Extend those passages; do not open a second audit-store section:
The runtime refusal: that a filesystem-mode deployment whose store is not a mount point has its registrar endpoint refused at daemon start, with the store path and the expected mount unit named in the journal; that the check is made at daemon start, so a mount established afterwards takes effect on the next start; and that it does not run at all on a host whose registrar endpoint is not enabled or whose mode is directory.
What the refusal looks like from both sides, so neither is mistaken for a fault of its own: the daemon keeps running and keeps renewing certificates; a caller reaching the endpoint gets a permanent refusal reading registrar_unavailable with reason audit_unwritable rather than a hang or a closed connection, and retrying will not clear it. Its request_id is freshly generated and has no audit record behind it — the store being unwritable is the reason for the refusal. What it does resolve to is one line the daemon logs for that refusal, naming the store path and the expected mount unit, so a caller's report can be traced without searching a store that holds no such record.
Replace the residual the provisioning work recorded about a started daemon still creating records/ beneath an unmounted store, rather than leaving both statements on the page: refusing before the record store is opened is what closes it. Leave the other residuals it recorded alone — including the boot-path one about the OpenBao container's bind source, which is separate work and whose passage this issue does not touch.
Acceptance criteria
The daemon verifies that audit_store_dir is a mount point whenever the endpoint is enabled and filesystem mode is configured, refuses to serve the verbs when it is not, names the store path and the expected mount unit in its log once at start, and runs that check before the record store is opened. Because it precedes AuditRecordStore::open, a filesystem-mode host whose mount is absent has neither records/ nor audit_store_dir created by the daemon, and prepare() itself is byte-unchanged.
That refusal takes down the endpoint and nothing else: the daemon starts, its fast-poll loop and per-service ACME renewal run exactly as they do today, and run_daemon returns no new error. A test asserts both halves — the verbs refused and the renewal duties running — on one fixture. Every other endpoint dependency failure still stops the daemon; a change that generalises the new behaviour to them fails this criterion.
The inherited socket is still accepted from, and the caller gets an answer rather than a wait. The accept task is spawned, the endpoint completes its handshake and peer check, and a request is answered with a RefusalResponse carrying class: permanent, error: RegistrarUnavailable { reason: AuditUnwritable }, no registration_id and the default empty registrar_health. An implementation that resolves to no endpoint and leaves /run/bootroot/registrar.sock unaccepted fails this criterion, whatever it logs.
The refusing handler resolves none of the endpoint's dependencies: no AuditRecordStore opened, no internal credential loaded, no state file read, no verbs and no rate limiter built, nothing written to the store and no invocation counted. No new RegistrarUnavailableReason, EnrollError, RefusalClass or response member is added for it.
Each refusal carries a freshly generatedrequest_id from RequestId::generate(), not a constant, not empty and not any value taken from the request — the idempotency_key included. No second handle type is introduced, and the generator is not duplicated.
Both doc comments that the reuse falsifies are corrected in the same change: RequestId's, which defines it as a handle for one verb invocation correlating an audit record with a response, and endpoint/refusal.rs's module header, which distinguishes the connection diagnostic id from a request id "for a verb invocation that did happen". The first becomes the registrar's correlation handle for one endpoint response, minted at verb entry or by the refusing handler and resolving to a record or to a log line; the second keeps the contrast on the axis that survives — never on the wire versus always on the wire. No comment in the tree still says a request id implies a verb ran.
Each refusal emits one log event carrying that request_id, together with the operation, the caller identity, the store path and the expected mount unit, so the handle a caller reports resolves to something on the host. It carries no connection diagnostic id and the handler seam is not widened to obtain one, and it is not emitted at warning level. No audit record exists under the id, and both manuals say so.
A payload the handler cannot decode takes the existing HandlerRefusal path rather than being reported as AuditUnwritable.
The encoding goes through one new pub(super) helper in protocol.rs that delegates to the existing private encode_refusal. encode_refusal itself stays private, encode_refusal_response keeps its pub(crate), no VerbRefusal or VerbError is constructed to reach it, and validate_refusal_class still gates the class-and-error pairing. The refusing handler is a module under registrar::endpoint so that pub(super) suffices; only its type and constructor are pub(crate), for resolve_registrar_service to build.
The daemon's gate is the cheap st_dev comparison and nothing more: it reads neither /proc/self/mounts nor sysfs, and it does not compare the image's size, owner or backing file.
With [registrar_endpoint] enabled false, or with audit_store_enforcement set to directory, the gate does not run: the daemon refuses nothing, adds no diagnostic, and its fast-poll and ACME duties run as before.
The installer surfaces are untouched: bootroot init, bootroot reinit, bootroot infra up and bootroot infra install produce the same outcomes, rendered commands and exit statuses as before this issue, and the rendered Compose override and its reader are byte-unchanged.
Every operator-facing string this issue adds or changes exists in bothen and ko.
Everything listed under Documentation is documented in bothdocs/en/ and docs/ko/ with no mkdocs.yml nav change, ./scripts/check-docs.sh passes, and the daemon-path residual the provisioning work recorded is replaced rather than left standing beside its fix.
cargo clippy --all-targets is warning-free under -D warnings and cargo fmt -- --config group_imports=StdExternalCrate --check passes.
Constraints
Everything this issue adds is gated on [registrar_endpoint] enabled. Do not change behaviour, output or exit status for a deployment that has not enabled the endpoint, and do not add a warning for one whose configuration selects filesystem mode.
audit_store_enforcement is the only input that selects a mode. Never infer one, never fall back from filesystem to directory, never write the key back, and never apply a filesystem-mode refusal to a directory deployment.
Do not let the mount-point refusal stop the daemon, and do not generalise the endpoint-only treatment to the endpoint's other dependency failures. One check is answered with a refusing handler; the rest keep propagating.
Do not leave the inherited listening descriptor unaccepted. Resolving to no endpoint while systemd holds /run/bootroot/registrar.sock open turns every caller into a wait, which is not a refusal.
Do not put the installer's deep mount-identity proof in the daemon: no /proc/self/mounts parse, no sysfs read, no image comparison on the startup path.
Do not change AuditRecordStore::open or prepare(). The fix is a refusal ahead of the open, not an edit inside it.
Do not extend the wire vocabulary for this refusal, and do not populate any registrar_health member.
Do not echo a caller-supplied value into request_id, and do not leave it empty or constant. Do not add a second correlation-handle type, and do not leave RequestId's or endpoint/refusal.rs's doc comments asserting an invariant this change breaks.
Do not widen encode_refusal's visibility, and do not construct a VerbRefusal or a VerbError to reach an encoder that takes one.
Do not write to the audit store on the refusal path, do not count the refusal in any limiter, and do not emit the per-refusal line at warning level.
Do not change the store's layout contract or ownership rules, the derivation of audit_record_dir, the derived image path or unit name, or the rendered systemd artifacts' content. Do not add an audit_store_* key.
Do not touch bootroot init, bootroot reinit, bootroot infra up, bootroot infra install, the rendered Compose override or its reader.
Do not have the daemon perform any step that changes the host: no mount, no mkdir beneath a filesystem-mode store, no chown/chmod.
No unwrap() in production code; no [] indexing.
Out of scope
The provisioning half itself. The image contract, the free-space preflight, the artifact renderers, the phase-3 checks and the four outcomes belong to the issue this one depends on, and none of them is called or re-derived here — the daemon's evidence is the st_dev comparison alone.
The installer's fail-closed half: bootroot infra up gaining --agent-config, running phases 1 and 3 and refusing the bring-up before the OpenBao container starts, and the audit bind declared create_host_path: false so Docker refuses to manufacture the bind source on the boot path. That is separate work with its own issue; this one changes no installer surface and no Compose rendering, and leaves that residual's manual passage as the provisioning work wrote it.
Moving records onto the reserve — the holding directory, the migration outcome, the capacity check, the copy, the verification comparisons, the closing rename, resume and rollback, and the operator procedures in both manuals. Separate work; nothing here renders or documents any of it.
Measuring the store. The statvfs-backed capacity probe, the usage measurement, the headroom arithmetic, the low-water alarm and any registrar_health member for it are separate work; this issue computes none of it and leaves the health snapshot on the refusal empty.
Detecting a mount lost while the daemon runs. The check is made once at start, matching where it sits; a mount established or lost afterwards takes effect on the next start, and both manuals say so.
Bounding the OpenBao audit device's own growth — rotating, pruning or capping it. Implementing a project quota or repartitioning a host for a dedicated audit partition. The registrar's rate limiter, in every part.
Test plan
Gate test: with the endpoint enabled, filesystem mode configured and audit_store_dir not a mount point, resolve_registrar_service selects the refusing handler without calling build_registrar_handler and names the store path and the expected mount unit in its log; correctly mounted, it selects the production handler. Drive the st_dev comparison through injected metadata so the test needs no root.
Caller-visible refusal test driving the refusing handler over the endpoint the way the existing handler-substitution tests do: a well-formed mint request and a well-formed deregister request each come back as a RefusalResponse whose class is permanent, whose error is RegistrarUnavailable { reason: AuditUnwritable }, whose registration_id is absent and whose registrar_health is {}; an undecodable payload takes the HandlerRefusal path; and the connection is answered rather than left open — the regression an implementation that spawns no accept task fails by timing out instead.
Request-id test, asserting shape and uniqueness rather than equality with anything in the request: the field is non-empty; two refusals answered by one process carry different ids; and the id equals neither the request's idempotency_key nor any other request field — the regression an implementation echoing a caller-supplied value fails. A refusal for a request whose idempotency_key repeats an earlier one still gets its own id.
Log-correlation test over the refusing handler with a tracing capture: one event per refusal, carrying the same request_id the caller received plus the operation, the caller identity, the store path and the expected mount unit, and emitted below warning level. Two refusals produce two events with the two different ids.
Daemon-ordering test: the mount-point check runs beforeAuditRecordStore::open, so a refusal opens no record store, writes nothing, and leaves records/ and audit_store_dir uncreated on a host whose mount is absent.
Daemon-degradation test asserting both halves on one fixture — an endpoint-enabled filesystem-mode daemon whose store is not a mount point starts, serves the verbs only as refusals, and runs its fast-poll and ACME renewal duties with run_daemon returning no error — and, on a second fixture, that an unopenable audit record store still stops the daemon, so the exception has not leaked to its neighbours. Assert no audit record is written on the refusal path and that the store is never opened.
Daemon-gate containment test: the daemon's check reads neither /proc/self/mounts nor /sys/block, and does not compare the image's size, owner or backing file.
Encoder-containment test: encode_refusal is still private, encode_refusal_response still pub(crate), the new entry point is pub(super) and reachable from the handler module and the endpoint tests, and the response it produces still passes validate_refusal_class. No VerbRefusal or VerbError is constructed anywhere on this path.
Doc-comment test or review assertion: no doc comment in the tree still defines a request id as implying a verb invocation, and both rewritten comments state the broadened contract.
Endpoint-disabled and directory-mode test: with [registrar_endpoint] enabled false and audit_store_enforcement set to each of filesystem and directory, and with the endpoint enabled under directory mode, the daemon starts, serves its fast-poll and ACME duties, refuses nothing and emits no new diagnostic.
Unchanged-surface test: bootroot init, bootroot reinit, bootroot infra up and bootroot infra install produce the same outcomes, rendered commands and exit statuses as before this issue, and the rendered Compose override and read_audit_override_store_dir are byte-unchanged.
Message-catalogue test asserting every string this issue adds or changes resolves in both en and ko.
Documentation check: ./scripts/check-docs.sh passes, and a review checklist confirms the en/ko pair covers the runtime refusal, what a caller sees, the start-time nature of the check, and that the daemon-path residual is replaced rather than left standing beside its fix while the other residuals the provisioning work recorded are untouched.
Tests use tempfile::tempdir() and never a fixed path; no test binds a fixed port or reaches the network.
Dependencies
Depends on #923, the issue that provisions and verifies the reserve — the loopback image, the generated mount unit and its two ordering drop-ins, and the store-creation rule that leaves audit_store_dir alone in filesystem mode. The expected mount unit this refusal names is the one that work derives, and the residual this issue closes is the one it records.
The installer's fail-closed half, #924 — bootroot infra up with --agent-config running the phases and refusing the bring-up, and the create_host_path: false bind guard on the boot path — is a sibling of this issue rather than a predecessor or a successor: it depends on the same provisioning work, shares no code with the daemon, and may land before or after. Both write into the same audit-store passage of the two manuals and both add message-catalogue entries, so whichever lands second extends what the first wrote rather than opening a second section. Part of #773, which is part of #775.
Pointers
src/daemon.rs:607 (resolve_registrar_service) — where the mount-point check goes, in the arm that has an activated endpoint and ahead of the build_registrar_handler call at :472, so it precedes the AuditRecordStore::open at :533 and everything else that function resolves; its doc comment already states why an adopted socket with no accept task is worse than a failed start. src/daemon.rs:186 — the ? that makes anything returned as an error there stop the daemon, and why this verdict is not one; src/daemon.rs:634 (spawn_registrar_endpoint) — the accept task that must still be spawned
src/registrar/endpoint/handler.rs — the RegistrarRequestHandler seam the refusing handler implements, and HandlerRefusal for an undecodable payload; src/registrar/endpoint/tests.rs:763, :780 — existing substituted handlers to model it on and to drive it through the endpoint
src/registrar/endpoint/production.rs:61 (ProductionHandler) — the sibling module the refusing handler is placed beside, and the pub(crate) shape its type and constructor follow
src/registrar/endpoint/protocol.rs:226 (RefusalClass), :235 (RegistrarUnavailableReason, already carrying AuditUnwritable), :247 (EnrollError), :350 (RefusalResponse) and :476 (validate_refusal_class, which pins Permanent for RegistrarUnavailable) — the vocabulary this refusal reuses and does not extend; :443 (encode_refusal_response, which takes a VerbRefusal and is therefore not the entry point here) and the private encode_refusal at :457 — the helper the new pub(super) entry point delegates to
src/registrar/endpoint/protocol.rs:132 (RegisterRequest) and :150 (DeregisterRequest) — the two wire requests, neither of which carries a request_id; src/registrar/verbs/outcome.rs:29–:32 — RequestId's doc comment, both sentences of which this change rewrites; :35 (RequestId) and :39 (RequestId::generate) — the correlation handle and the generator this handler calls directly; src/registrar/verbs.rs:305 — where a verb mints one when it opens its audit context, which is what does not happen here
src/registrar/endpoint/refusal.rs:35 (ConnectionId) — the transport's own correlation handle, which never appears on the wire and does not cross the handler seam; its module header at :13–:19 carries the request-id contrast this change restates. src/registrar/endpoint/serve.rs:320, :351 — the per-connection diagnostic events whose level the per-refusal line follows
systemd/bootroot-registrar.socket — Accept=no, systemd-owned pathname, one inherited descriptor: why an unaccepted socket hangs callers
src/registrar/audit.rs — AuditRecordStore::open and StoreInner::prepare, which create audit_store_dir and records/ and are not changed here; src/registrar/audit_store.rs — layout_of and the RECORDS_SUBDIR / STORE_DIR_MODE / ANCESTOR_DIR_MODE constants
src/config.rs:110–118 — the four audit_store_* fields the daemon reads; AuditStoreEnforcement at :357
src/i18n/audit_store.rs, src/i18n/en.rs, src/i18n/ko.rs — every new refusal and diagnostic needs both locales
Refuse the registrar verbs while the audit store is not mounted
Context
The registrar's two audit artifacts — the daemon's verb records under
<audit_store_dir>/recordsand the OpenBao file audit device under<audit_store_dir>/openbao— share one directory on the bootroot host, sized by[registrar] audit_store_reserve_bytesand placed byaudit_store_dir, withaudit_store_enforcementselectingfilesystem(the default) ordirectory. The registrar endpoint that serves the verbs is not a process of its own: it is a config-gated feature insidebootroot-agent, brought up byresolve_registrar_servicewhen[registrar_endpoint] enabledis on, listening on the systemd-owned socket/run/bootroot/registrar.sockthatsystemd/bootroot-registrar.servicesupervises.By the time this issue starts, the provisioning half of the reserve exists and is the issue this one depends on. In
filesystemmode the installer derives a loopback image at<audit_store_dir>.img, sized to exactlyaudit_store_reserve_bytes, fully allocated, uid 0, mode0600, carrying an ext4 filesystem made withmkfs.ext4 -m 0 -E nodiscard; it renders a generated<escaped audit_store_dir>.mountunit plus ordering drop-ins fordocker.serviceandbootroot-registrar.service, and it verifies — with metadata reads only, and only on the installer's own surfaces — that the image meets its whole contract, that all three artifacts are installed under/etc/systemd/system, and thataudit_store_diris mountedext4from a loop device whose backing file is the derived image. The operator performs every step that changes the host; bootroot renders, reads and reports. Infilesystemmode bootroot's install path createsaudit_store_diralone and neither subdirectory beneath it, since a subdirectory made before the mount is one the mount hides.Nothing refuses at run time, and two things follow from that. A
filesystem-mode host whose mount unit was never enabled, or whose mount job failed, still serves the registrar verbs. Boot ordering does not cover it — the drop-ins areWants=plusAfter=, which order the boot and guarantee nothing, because a hard relation on either target would take a whole stack down over a fault local to one directory. So the configured reserve is a reservation nobody is standing on, and the verb records go back to filling the root filesystem.Worse, the daemon manufactures the very store it should be refusing over.
AuditRecordStore::opencallsprepare(), which creates the record directory's ancestors —audit_store_diritself, atANCESTOR_DIR_MODE— and thenrecords/atSTORE_DIR_MODE, unconditionally and with no notion of a mount. Opened while the mount is absent it therefore createsrecords/beneath the empty mount point and writes verb records to the root filesystem. The provisioning work records that as a residual and leavesprepare()alone deliberately: its behaviour over a mounted store is correct, and the fix is a refusal that runs before the open rather than a change inside it. Closing that residual is this issue's.This issue makes the daemon refuse the registrar verbs while
filesystemmode is configured andaudit_store_diris not a mount point — without stopping the daemon, without leaving callers hanging on an adopted socket, and without opening the store it is refusing over.Scope
The check, and where it goes
One cheap predicate answers "is
audit_store_dira mount point": itsst_devdiffers from its parent's. It runs inresolve_registrar_service, inside the arm that has an activated endpoint — which is what gates it on[registrar_endpoint] enabled— and ahead of thebuild_registrar_handlercall, so it precedes every dependency that function resolves, theAuditRecordStore::openinside it included, and the refusal costs no writes and no credential load.That ordering is load-bearing for more than cost: it is what stops the daemon manufacturing the store it is refusing over. Refusing before the open closes the residual above, and closes it without touching
prepare(), which stays byte-unchanged.This is deliberately not the installer's deep identity proof. The installer's phase 3 reads
/proc/self/mountsand resolves a loop device's backing file through sysfs to prove that the mount is the derived image; that is the installer's job. Putting that parse and that sysfs read on the daemon's startup path buys nothing the cheap comparison does not at the moment it matters, and it is not added here.The choice is made once, at start, matching the check's position: a mount established afterwards takes effect on the next daemon start, and both manuals say so.
The daemon names the condition once at startup, through
tracing, with the store path and the expected mount unit, so an operator reading the journal sees the cause without a caller having to provoke it.directorymode reaches none of this, and neither does a host whose endpoint is not enabled: nothing is checked, nothing is refused, and no diagnostic is added.What fails closed is the endpoint, never the daemon's renewal duties
Neither of the two obvious mechanisms delivers that, and the reasons are the design.
The verdict is not returned as an error.
run_daemoncallsresolve_registrar_servicewith?atsrc/daemon.rs:186, ahead of the profile and fast-poll loops, so anything that function returns as an error stops the whole daemon. Taking certificate renewal down over a misconfigured audit mount is a second and larger outage than the one being prevented: the fast-poll loop and per-service ACME renewal must keep running exactly as they do today.Resolving to no endpoint is equally wrong, and the socket makes the reason concrete.
bootroot-registrar.socketisAccept=no: systemd creates, owns and holds/run/bootroot/registrar.sockand the service inherits the one listening descriptor, so a daemon that adopts that socket and never accepts leaves every caller connecting successfully and then waiting — a hang rather than a named refusal, whichresolve_registrar_service's own doc comment already identifies as strictly worse than a failed start.RegistrarServicebeing anOptionis therefore not the mechanism.The mechanism is a deliberately limited handler, and the endpoint's own seam is built for one. In the arm that has an activated endpoint,
resolve_registrar_serviceruns the mount-point check ahead of thebuild_registrar_handlercall; where the store is not a mount point infilesystemmode it pairs that endpoint with a refusing handler instead of the production one. Everything downstream is unchanged: the accept task is spawned exactly as it is today, and the endpoint accepts, completes its handshake and its peer check, and answers.run_daemonreturns no new error on this path. Comment the departure where it lands, because it deliberately differs from its neighbours: every other endpoint dependency failure keeps stopping the daemon, and nothing here changes that.The refusing handler
It resolves none of the endpoint's dependencies. It opens no
AuditRecordStore, loads no internal credential, reads no state file, and builds no verbs and no rate limiter — that whole set is what not callingbuild_registrar_handleravoids, and it is why the refusal costs no writes. Its construction must therefore be reachable without any of them.What a caller gets is a typed refusal in the vocabulary that already exists, not a closed connection and not a new wire shape: a
RefusalResponsecarryingclass: permanentanderror: RegistrarUnavailable { reason: AuditUnwritable }.RegistrarUnavailableReasonis a closed, externally owned set that already carries that reason, andvalidate_refusal_classalready requiresPermanentfor everyRegistrarUnavailable: add no variant, no field and no refusal class. A mount that is not there is the audit trail being unwritable, which is exactly what the caller needs to know and act on.registration_idis absent, nothing having been registered, andregistrar_healthis the default empty snapshot — populating any member of it belongs to the issues that own them.The response's
request_idis generated here, because there is nothing to echo. Neither wire request carries one: aRegisterRequesthasservice_name,delivery_mode,host,instance,spec,wrap_ttlandidempotency_key, and aDeregisterRequestthe same without the mint-only parts.request_idexists only on the three response shapes, and today it is a correlation handle a verb mints at entry when it opens its audit context. So the handler callsRequestId::generate()itself — a counter bump and a read of the random source, reaching no verb, no audit context and no store — and puts a fresh handle on each refusal. That is no new coupling: the handler seam already importsCallerIdentityfrom the same module. Do not substitute the caller'sidempotency_key: it is caller-chosen, so two callers can collide on it, and the protocol defines this field as the registrar's own handle rather than a value echoed back.A generated handle that appears nowhere on the host is decoration, so the handler logs one line per refusal carrying it. That line is what the id correlates with, and it is the only thing: there is no audit record under it, the store being unwritable is why the refusal exists at all. It carries the generated
request_id, the operation, the caller identity, the store path and the expected mount unit — enough that an operator handed arequest_idby a failing caller can find, in the journal, which path was wrong. It deliberately does not carry the connection diagnostic id: that handle belongs to the transport, never appears on the wire, and does not cross the handler seam, which passes only the operation, the payload and the caller. Do not widen that seam to reach it.The per-refusal line sits at the level the endpoint's other per-connection diagnostics use, not at warning level. The condition is already announced loudly once at start; a caller that retries must not be able to fill the journal with warnings, and a refusal is
Permanentprecisely so a well-behaved one does not.A payload the handler cannot decode takes the existing
HandlerRefusalpath, exactly as the production handler's does, so the transport's refusal taxonomy is untouched. That is the one reason the handler decodes at all — it binds nothing from the decoded request — and it is deliberate: reporting a malformed payload asAuditUnwritablewould blame the deployment for a client's bug and hide it behind an operator-facing condition.No verb runs, so nothing is audited, and that is the point — the reason for refusing is that the audit trail cannot be written. Do not record the refusal to the store, do not count it as an invocation in any limiter, and do not reach for the store to log that the store is missing.
The two documented invariants this reuse falsifies
Generating a
RequestIdoutside a verb makes two doc comments false, so this change broadens them rather than leaving them wrong.RequestId's own. It is documented today as "a correlation handle for one verb invocation, generated at entry" which "correlates an audit record with a response" — the second half being the sharper falsehood here, since the point is that no record was written. Its doc comment is rewritten to define the type as the registrar's correlation handle for one endpoint response: minted at verb entry where a verb runs, and by the refusing handler where none does; correlating a response with its audit record where there is one, and with the daemon's log line for that refusal where there is not. What stays is what is still true and worth keeping — not a secret, not an idempotency key, never caller-supplied, nothing cached under it.endpoint/refusal.rs's module header. It distinguishes the connection diagnostic id from "the verb layer's request id, which is a correlation handle for a verb invocation that did happen". That contrast is worth keeping and is restated on its real axis rather than deleted — a connection id never appears on the wire and correlates the lines of one connection, while a request id always appears on the wire and correlates one response with whatever was recorded for it. "A verb invocation that did happen" is a proxy for that distinction, and it stops being accurate once a refusal that ran no verb carries one.Do not introduce a second handle type for unaudited refusals. It would need its own generator and monotonic sequence, put two types behind one wire field that callers cannot tell apart, and buy nothing the broadened definition does not.
Encoding, and where the handler lives
One narrow protocol-owned encoder, and no widening of the general one. The by-fields helper that builds a
RefusalResponse,encode_refusal, is private toprotocol.rs, and thepub(crate)encode_refusal_responsetakes aVerbRefusalthis handler must not manufacture — theVerbErrorvariants that map toAuditUnwritabledescribe an audit write that failed inside a verb, and constructing one for a verb that never ran puts a false provenance into the mapping table. Soprotocol.rsgains one entry point of its own —encode_registrar_unavailable(request_id, reason, health)or a name of the module's choosing — which delegates to the existing privateencode_refusalwithRefusalClass::Permanent,EnrollError::RegistrarUnavailable { reason }and noregistration_id. It is an internal helper, not a wire change: same response struct, same closed reason set, samevalidate_refusal_class, same serialization. Do not makeencode_refusalitselfpub(crate)— that hands every caller an arbitrary class-and-error assembler, when the pairing is exactly whatprotocol.rsexists to keep decided.That helper is
pub(super), and the handler's placement is what allows it. The refusing handler is a module underregistrar::endpoint, besideproduction.rs— which is where the production handler already lives and whatbuild_registrar_handleralready constructs — sopub(super)on the helper reaches it, and reaches the endpoint's tests, which are a descendant of the same parent. Only the handler type and its constructor arepub(crate), becauseresolve_registrar_serviceinsrc/daemon.rsbuilds it, exactly as it buildsProductionHandler. Put the handler anywhere else and the helper has to widen; that is the wrong fix, and the placement is pinned here so nobody reaches for it.encode_refusal_responsekeeps its existingpub(crate), which is load-bearing —registrar::verbs's tests call it.Documentation
Document, in both
docs/en/anddocs/ko/, on the existing audit-store and configuration pages with nomkdocs.ymlnav change. The provisioning work already wrote those sections — the two modes, the phases, the four outcomes, the fresh-host sequence, the removal sequence and the residuals. Extend those passages; do not open a second audit-store section:filesystem-mode deployment whose store is not a mount point has its registrar endpoint refused at daemon start, with the store path and the expected mount unit named in the journal; that the check is made at daemon start, so a mount established afterwards takes effect on the next start; and that it does not run at all on a host whose registrar endpoint is not enabled or whose mode isdirectory.registrar_unavailablewith reasonaudit_unwritablerather than a hang or a closed connection, and retrying will not clear it. Itsrequest_idis freshly generated and has no audit record behind it — the store being unwritable is the reason for the refusal. What it does resolve to is one line the daemon logs for that refusal, naming the store path and the expected mount unit, so a caller's report can be traced without searching a store that holds no such record.records/beneath an unmounted store, rather than leaving both statements on the page: refusing before the record store is opened is what closes it. Leave the other residuals it recorded alone — including the boot-path one about the OpenBao container's bind source, which is separate work and whose passage this issue does not touch.Acceptance criteria
audit_store_diris a mount point whenever the endpoint is enabled andfilesystemmode is configured, refuses to serve the verbs when it is not, names the store path and the expected mount unit in its log once at start, and runs that check before the record store is opened. Because it precedesAuditRecordStore::open, afilesystem-mode host whose mount is absent has neitherrecords/noraudit_store_dircreated by the daemon, andprepare()itself is byte-unchanged.run_daemonreturns no new error. A test asserts both halves — the verbs refused and the renewal duties running — on one fixture. Every other endpoint dependency failure still stops the daemon; a change that generalises the new behaviour to them fails this criterion.RefusalResponsecarryingclass: permanent,error: RegistrarUnavailable { reason: AuditUnwritable }, noregistration_idand the default emptyregistrar_health. An implementation that resolves to no endpoint and leaves/run/bootroot/registrar.sockunaccepted fails this criterion, whatever it logs.AuditRecordStoreopened, no internal credential loaded, no state file read, no verbs and no rate limiter built, nothing written to the store and no invocation counted. No newRegistrarUnavailableReason,EnrollError,RefusalClassor response member is added for it.request_idfromRequestId::generate(), not a constant, not empty and not any value taken from the request — theidempotency_keyincluded. No second handle type is introduced, and the generator is not duplicated.RequestId's, which defines it as a handle for one verb invocation correlating an audit record with a response, andendpoint/refusal.rs's module header, which distinguishes the connection diagnostic id from a request id "for a verb invocation that did happen". The first becomes the registrar's correlation handle for one endpoint response, minted at verb entry or by the refusing handler and resolving to a record or to a log line; the second keeps the contrast on the axis that survives — never on the wire versus always on the wire. No comment in the tree still says a request id implies a verb ran.request_id, together with the operation, the caller identity, the store path and the expected mount unit, so the handle a caller reports resolves to something on the host. It carries no connection diagnostic id and the handler seam is not widened to obtain one, and it is not emitted at warning level. No audit record exists under the id, and both manuals say so.HandlerRefusalpath rather than being reported asAuditUnwritable.pub(super)helper inprotocol.rsthat delegates to the existing privateencode_refusal.encode_refusalitself stays private,encode_refusal_responsekeeps itspub(crate), noVerbRefusalorVerbErroris constructed to reach it, andvalidate_refusal_classstill gates the class-and-error pairing. The refusing handler is a module underregistrar::endpointso thatpub(super)suffices; only its type and constructor arepub(crate), forresolve_registrar_serviceto build.st_devcomparison and nothing more: it reads neither/proc/self/mountsnor sysfs, and it does not compare the image's size, owner or backing file.[registrar_endpoint] enabledfalse, or withaudit_store_enforcementset todirectory, the gate does not run: the daemon refuses nothing, adds no diagnostic, and its fast-poll and ACME duties run as before.bootroot init,bootroot reinit,bootroot infra upandbootroot infra installproduce the same outcomes, rendered commands and exit statuses as before this issue, and the rendered Compose override and its reader are byte-unchanged.enandko.docs/en/anddocs/ko/with nomkdocs.ymlnav change,./scripts/check-docs.shpasses, and the daemon-path residual the provisioning work recorded is replaced rather than left standing beside its fix.cargo clippy --all-targetsis warning-free under-D warningsandcargo fmt -- --config group_imports=StdExternalCrate --checkpasses.Constraints
[registrar_endpoint] enabled. Do not change behaviour, output or exit status for a deployment that has not enabled the endpoint, and do not add a warning for one whose configuration selectsfilesystemmode.audit_store_enforcementis the only input that selects a mode. Never infer one, never fall back fromfilesystemtodirectory, never write the key back, and never apply afilesystem-mode refusal to adirectorydeployment./run/bootroot/registrar.sockopen turns every caller into a wait, which is not a refusal./proc/self/mountsparse, no sysfs read, no image comparison on the startup path.AuditRecordStore::openorprepare(). The fix is a refusal ahead of the open, not an edit inside it.registrar_healthmember.request_id, and do not leave it empty or constant. Do not add a second correlation-handle type, and do not leaveRequestId's orendpoint/refusal.rs's doc comments asserting an invariant this change breaks.encode_refusal's visibility, and do not construct aVerbRefusalor aVerbErrorto reach an encoder that takes one.audit_record_dir, the derived image path or unit name, or the rendered systemd artifacts' content. Do not add anaudit_store_*key.bootroot init,bootroot reinit,bootroot infra up,bootroot infra install, the rendered Compose override or its reader.mkdirbeneath afilesystem-mode store, nochown/chmod.unwrap()in production code; no[]indexing.Out of scope
st_devcomparison alone.bootroot infra upgaining--agent-config, running phases 1 and 3 and refusing the bring-up before the OpenBao container starts, and the audit bind declaredcreate_host_path: falseso Docker refuses to manufacture the bind source on the boot path. That is separate work with its own issue; this one changes no installer surface and no Compose rendering, and leaves that residual's manual passage as the provisioning work wrote it.statvfs-backed capacity probe, the usage measurement, the headroom arithmetic, the low-water alarm and anyregistrar_healthmember for it are separate work; this issue computes none of it and leaves the health snapshot on the refusal empty.Test plan
filesystemmode configured andaudit_store_dirnot a mount point,resolve_registrar_serviceselects the refusing handler without callingbuild_registrar_handlerand names the store path and the expected mount unit in its log; correctly mounted, it selects the production handler. Drive thest_devcomparison through injected metadata so the test needs no root.RefusalResponsewhoseclassispermanent, whoseerrorisRegistrarUnavailable { reason: AuditUnwritable }, whoseregistration_idis absent and whoseregistrar_healthis{}; an undecodable payload takes theHandlerRefusalpath; and the connection is answered rather than left open — the regression an implementation that spawns no accept task fails by timing out instead.idempotency_keynor any other request field — the regression an implementation echoing a caller-supplied value fails. A refusal for a request whoseidempotency_keyrepeats an earlier one still gets its own id.tracingcapture: one event per refusal, carrying the samerequest_idthe caller received plus the operation, the caller identity, the store path and the expected mount unit, and emitted below warning level. Two refusals produce two events with the two different ids.AuditRecordStore::open, so a refusal opens no record store, writes nothing, and leavesrecords/andaudit_store_diruncreated on a host whose mount is absent.filesystem-mode daemon whose store is not a mount point starts, serves the verbs only as refusals, and runs its fast-poll and ACME renewal duties withrun_daemonreturning no error — and, on a second fixture, that an unopenable audit record store still stops the daemon, so the exception has not leaked to its neighbours. Assert no audit record is written on the refusal path and that the store is never opened./proc/self/mountsnor/sys/block, and does not compare the image's size, owner or backing file.encode_refusalis still private,encode_refusal_responsestillpub(crate), the new entry point ispub(super)and reachable from the handler module and the endpoint tests, and the response it produces still passesvalidate_refusal_class. NoVerbRefusalorVerbErroris constructed anywhere on this path.directory-mode test: with[registrar_endpoint] enabledfalse andaudit_store_enforcementset to each offilesystemanddirectory, and with the endpoint enabled underdirectorymode, the daemon starts, serves its fast-poll and ACME duties, refuses nothing and emits no new diagnostic.bootroot init,bootroot reinit,bootroot infra upandbootroot infra installproduce the same outcomes, rendered commands and exit statuses as before this issue, and the rendered Compose override andread_audit_override_store_dirare byte-unchanged.enandko../scripts/check-docs.shpasses, and a review checklist confirms the en/ko pair covers the runtime refusal, what a caller sees, the start-time nature of the check, and that the daemon-path residual is replaced rather than left standing beside its fix while the other residuals the provisioning work recorded are untouched.tempfile::tempdir()and never a fixed path; no test binds a fixed port or reaches the network.Dependencies
Depends on #923, the issue that provisions and verifies the reserve — the loopback image, the generated mount unit and its two ordering drop-ins, and the store-creation rule that leaves
audit_store_diralone infilesystemmode. The expected mount unit this refusal names is the one that work derives, and the residual this issue closes is the one it records.The installer's fail-closed half, #924 —
bootroot infra upwith--agent-configrunning the phases and refusing the bring-up, and thecreate_host_path: falsebind guard on the boot path — is a sibling of this issue rather than a predecessor or a successor: it depends on the same provisioning work, shares no code with the daemon, and may land before or after. Both write into the same audit-store passage of the two manuals and both add message-catalogue entries, so whichever lands second extends what the first wrote rather than opening a second section. Part of #773, which is part of #775.Pointers
src/daemon.rs:607(resolve_registrar_service) — where the mount-point check goes, in the arm that has an activated endpoint and ahead of thebuild_registrar_handlercall at:472, so it precedes theAuditRecordStore::openat:533and everything else that function resolves; its doc comment already states why an adopted socket with no accept task is worse than a failed start.src/daemon.rs:186— the?that makes anything returned as an error there stop the daemon, and why this verdict is not one;src/daemon.rs:634(spawn_registrar_endpoint) — the accept task that must still be spawnedsrc/registrar/endpoint/handler.rs— theRegistrarRequestHandlerseam the refusing handler implements, andHandlerRefusalfor an undecodable payload;src/registrar/endpoint/tests.rs:763,:780— existing substituted handlers to model it on and to drive it through the endpointsrc/registrar/endpoint/production.rs:61(ProductionHandler) — the sibling module the refusing handler is placed beside, and thepub(crate)shape its type and constructor followsrc/registrar/endpoint/protocol.rs:226(RefusalClass),:235(RegistrarUnavailableReason, already carryingAuditUnwritable),:247(EnrollError),:350(RefusalResponse) and:476(validate_refusal_class, which pinsPermanentforRegistrarUnavailable) — the vocabulary this refusal reuses and does not extend;:443(encode_refusal_response, which takes aVerbRefusaland is therefore not the entry point here) and the privateencode_refusalat:457— the helper the newpub(super)entry point delegates tosrc/registrar/endpoint/protocol.rs:132(RegisterRequest) and:150(DeregisterRequest) — the two wire requests, neither of which carries arequest_id;src/registrar/verbs/outcome.rs:29–:32—RequestId's doc comment, both sentences of which this change rewrites;:35(RequestId) and:39(RequestId::generate) — the correlation handle and the generator this handler calls directly;src/registrar/verbs.rs:305— where a verb mints one when it opens its audit context, which is what does not happen heresrc/registrar/endpoint/refusal.rs:35(ConnectionId) — the transport's own correlation handle, which never appears on the wire and does not cross the handler seam; its module header at:13–:19carries the request-id contrast this change restates.src/registrar/endpoint/serve.rs:320,:351— the per-connection diagnostic events whose level the per-refusal line followssystemd/bootroot-registrar.socket—Accept=no, systemd-owned pathname, one inherited descriptor: why an unaccepted socket hangs callerssrc/registrar/audit.rs—AuditRecordStore::openandStoreInner::prepare, which createaudit_store_dirandrecords/and are not changed here;src/registrar/audit_store.rs—layout_ofand theRECORDS_SUBDIR/STORE_DIR_MODE/ANCESTOR_DIR_MODEconstantssrc/config.rs:110–118— the fouraudit_store_*fields the daemon reads;AuditStoreEnforcementat:357src/i18n/audit_store.rs,src/i18n/en.rs,src/i18n/ko.rs— every new refusal and diagnostic needs both localesdocs/en/operations.md:252/docs/ko/operations.md:247— the "shared audit store" sections;docs/en/configuration.md:479/docs/ko/configuration.md:455docs/rfcs/0001-registrar-role-and-non-self-propagation.md§5.6, §6