Skip to content

docs(adr): ADR-049 Amendment 6 — incumbent classification at boot - #2236

Closed
ohdearquant wants to merge 8 commits into
mainfrom
docs/adr049-incumbent-classification
Closed

docs(adr): ADR-049 Amendment 6 — incumbent classification at boot#2236
ohdearquant wants to merge 8 commits into
mainfrom
docs/adr049-incumbent-classification

Conversation

@ohdearquant

Copy link
Copy Markdown
Owner

Adds Amendment 6 to ADR-049. Documentation only; no code in this PR.

Problem

Daemon boot answers "may I clean up the rendezvous and bind?" using a probe
predicate that returns a bare boolean. A boolean cannot distinguish "nothing is
serving this socket" from "something is serving it that this client may not
use", so three responses emitted by a demonstrably live daemon — a
protocol-version mismatch, a config mismatch, and a metrics reply — take the
same unlink-and-bind path as an absent endpoint.

Unlinking a live daemon's socket and pid file does not stop it. The result is a
second listener bound beside a running owner, which contradicts the convergence
requirement already stated in Amendment 3: exactly one live, identity-matching
daemon owning the rendezvous.

What the amendment specifies

  • Separates liveness (is anything serving this socket?) from
    acceptability (is it a peer I can share it with?), and states that
    unlink-and-rebind is licensed by a negative answer to liveness alone.
  • Enumerates eleven incumbent states with a normative disposition per state.
    Only two of them permit binding. The other nine are refusals today collapsed
    into cleanup.
  • Replaces the boolean with a MayBind / Refuse { pid, reason } disposition,
    since a single bit is what invites the collapse.
  • Makes exit codes normative, because supervisors read them: a supervisor
    configured to restart only on unsuccessful exit treats exit 0 as a deliberate
    stop. Configuration-class refusals exit 0; state-class refusals exit nonzero.
  • Specifies --replace-incumbent as the only path that may remove a live
    incumbent: operator-elected, signal then wait for observed process death,
    refuse on timeout with the socket intact, never an implicit SIGKILL.
  • States test obligations that assert outcomes — incumbent still alive,
    socket still present, no second listener — rather than return values, since a
    return-value-only test passes while the socket is unlinked under a live
    process.

Two readings recorded because they are easy to invert

  • The Consequences section calls the daemon disposable in the sense that killing
    and respawning it is safe. That licenses replacement being safe; it does not
    make replacement something boot performs unprompted.
  • Amendment 3 already anticipates multiple launch attempts and lets the
    daemon-side fence pick the sole owner, so losing that fence is legitimate
    rather than an error, and that state exits cleanly.

Scope

One state in the table is reachable only defensively: the boot probe's request
frame does not set the metrics flag, and the daemon's metrics reply is gated on
that flag in the requesting frame, so boot cannot elicit a metrics-only reply.
It is retained and labelled as defensive, because a reply carrying metrics is
still unambiguous proof of life and misreading it as absence would be the same
defect.

Amendments 3, 4 and 5, the client-side recoverer lock, and the daemon-side boot
fence are unchanged. This amendment constrains only what boot may conclude from
a probe result and what it may do about it.

Boot conflated two questions: whether a daemon is live, and whether it is one
this process would choose to talk to. A probe predicate returning a bare boolean
cannot carry that difference, so responses emitted by a demonstrably live daemon
— a protocol-version mismatch, a config mismatch, a metrics reply — were routed
to the same unlink-and-bind path as an absent endpoint. Removing a live
daemon's rendezvous files does not stop it, and binding a second listener beside
it violates the convergence requirement of exactly one live, identity-matching
owner.

The amendment separates liveness from acceptability, states that
unlink-and-rebind is licensed by a negative answer to liveness alone, enumerates
eleven incumbent states with a normative disposition and exit code for each, and
replaces the boolean with a MayBind/Refuse disposition. Exit codes are normative
because supervisors read them: configuration-class refusals exit 0, state-class
refusals exit nonzero.

Removal of a live incumbent becomes operator-elected via an explicit opt-in that
signals, waits for observed process death, and refuses on timeout with the
socket intact.

Records two readings that are easy to invert: the daemon being disposable
licenses replacement being safe rather than making boot perform it, and losing
the daemon-side boot fence is legitimate rather than an error, so that state
exits cleanly.
Review found the state table not exhaustive, and the gap is the one the
amendment exists to close. A daemon built before the probe frame existed
deserializes the frame, ignores the unknown probe field, dispatches the empty
operation string, and returns an ordinary success or error response. That reply
is well-formed, comes from a live process, and is not an acknowledgement, so it
matched no row and an implementer had no disposition for it. The daemon source
already documents this case; the table omitted it. Adds it as a catch-all state
for any parseable non-acknowledgement, plus a separate state for a reply whose
served config id is absent or unequal with no mismatch flag set, since an
identity that cannot be established is not one that was refuted. During a
rollout with two builds present this is the expected observation, not a corner
case.

Adds an explicit precedence rule, since one reply can satisfy several rows, and
states that anything reaching the end of the response rows without matching is
the catch-all and must never fall through to cleanup.

States plainly that the amendment is normative and that current code does not
satisfy it, so no rule here is read as an account of present behaviour, and
marks the replacement opt-in as a capability that does not exist yet, leaving
its command surface to the implementing change.

Gives the transient no-socket-live-pid state an explicit justification for its
clean exit, which the exit-class rule would otherwise contradict: the
distinguishing invariant is who resolves the condition, not how long it lasts.
There the resolver is the other, already-booting process, so restarting this one
cannot help.

Replaces "one test per state" with the four elements each state's test must
declare — peer setup, probe input, rendezvous postcondition, and listener count
— since listener count is the assertion that actually detects a double bind and
nothing else substitutes for it. Names the mechanism for the two states that
bind and for the state reachable only synthetically.
… matrix

The state-6 explanation was factually wrong. It described a pre-probe daemon as
deserializing the probe frame, ignoring the unknown field, dispatching the empty
operation and returning an ordinary response. The pre-probe daemon declares
protocol version 1 and its handler tests the frame's protocol version first,
returning version_mismatch before the namespace check and before any dispatch,
while the probing build declares version 4. That peer therefore produces state
2, and a fixture built from the old text would have exercised a peer that does
not exist. The state now names a same-protocol peer that does not recognise
probe_only, and says so explicitly.

Classify refusals by who resolves them rather than by how long they last. The
transience rule put the fork-and-bind case in the retryable class, which would
drive a supervisor restart loop against a condition only the other process can
end. Under the new rule a refusal exits 0 when an operator or another process is
the resolver and nonzero when a retry of this process is. Exit codes are now
distinct numeric values rather than the class word "nonzero", so a supervisor and
an end-to-end test can separate silent, malformed, refused-with-live-pid,
identity-unresolved and other-connect-error outcomes. Exit 1 is reserved so an
unclassified crash cannot read as a classified refusal.

Define acknowledgement shape syntactically and independently of identity, and
replace first-match-by-row-number with an explicit evaluation order. Under the
old rule the catch-all absorbed every identity-unresolved reply, because such a
reply can also be described as not an acknowledgement, so the identity diagnosis
could never be produced. Row numbers are now stable labels and the catch-all is
evaluated last.

Add a state for a namespace-mismatched reply. The probe predicate has always
tested that flag, but the table had no row for it, so such a reply matched no
state at all.

Broaden the refused-connection cleanup state to cover an absent, empty or
unparseable pid record. A refused connection on a bound socket path already
establishes that nothing is listening, which is the liveness question cleanup
turns on; the pid record corroborates it rather than carrying it. Previously
socket-present plus refused plus no usable pid file satisfied no state.

Require replacement to bind the pid to the socket before signalling. The
classification proves something live serves the socket and what it speaks, never
that the process named by the pid file is that something, so a stale or reused
pid could be signalled on evidence about a different process. Replacement now
requires the acknowledgement to carry the serving process's own pid and to match
the recorded one, and is unavailable when it cannot. That field does not exist
yet; adding it is part of implementing the section.

Instantiate the four required test elements per state as a binding matrix giving
each state its exact peer fixture, probe input, rendezvous postcondition,
listener count and exit code, and split the same-process incumbency case into
its own row, since it is the only branch where a live pid ends in binding and a
test that asserts only a successful bind cannot tell it from a collapse into
cleanup.
…tate

The state table gained a namespace-mismatch row and listed it among the
states from which an operator-elected replacement may proceed. That was
wrong. ADR-096 Fork 1 removed the namespace reject: a conforming daemon
serves a differently-namespaced frame under the frame's own namespace over
the shared warm registry rather than refusing it, and no serve path sets
the flag. The row survives only as a legacy observation from a peer built
before that change, and an obsolete signal is the last thing that should
authorise signalling another process. Replacement now proceeds only from
the states where a live, acceptable daemon identity was positively read;
the row refuses, names both namespaces, and leaves the rendezvous intact.

Also: the no-socket/live-pid refusal must print the incumbent's command
line beside its pid. That state exits 0 by design, so a supervisor that
restarts only on unsuccessful exit will not retry it. A pid file whose pid
has been reused by an unrelated process therefore wedges boot permanently
while every rule above behaves correctly, because nothing in the sequence
can ask whether the live pid is a daemon at all. The command line is the
evidence that answers it.
…bent amendment

Replacement no longer authorises SIGTERM on a responder-supplied pid. The
previous text required the probe acknowledgement to carry the serving process's
own `served_pid` and justified self-reporting as the portable choice. That was
circular: the threat the step exists to answer is an unrelated process answering
on the socket, and the remedy asked exactly that process to name itself, which a
same-uid squatter satisfies with the recorded integer. The portability premise
was also false here, since the tree already performs the platform-specific
peer-credential lookup the text called unavailable. The binding evidence is now
the kernel's answer for the peer of the probe connection, and replacement is
unavailable where the platform exposes no peer pid.

State 3's row required `served_config_id` to be present while precedence routed
any frame carrying `config_mismatch` to it; the row now matches precedence, and
the echoed identity is a diagnostic rather than a classification predicate.

An unequal `daemon_protocol_version` now classifies as state 2 even when
`version_mismatch` is unset. The two are independent response fields, so an
older peer can answer ack-shaped with the current config and its own lower
version. Precedence previously sent that frame to state 5, which called a
refuted protocol identity an unresolved configuration identity and gave it a
retry-resolved exit code that no retry can satisfy.

State 13 moves from exit 5 to exit 0. Amendment 4 classifies non-`ENOENT`,
non-`ECONNREFUSED` connect errors as unreachable and forbids retry, while the
exit-class rule assigns nonzero only when a retry of this process is the
resolver. A filesystem-policy denial is operator-resolved. Exit 5 is retired
rather than reused so a supervisor keyed on the old value reads a retired code.

The scripted matrix rows are declared abbreviated rather than literal.
`namespace_mismatch` carries no serde default, so rows transmitted as written
would fail to deserialize and classify as state 8, which is the state they exist
to be distinguished from.

State 14 is relabelled defensive rather than a live legacy observation. A peer
old enough to set the flag is old enough to fail this build's version check
first, and the pre-Fork-1 daemon evaluates protocol before namespace in one
if/else chain, so it answers with `version_mismatch` and lands in state 2. Its
no-unlink, no-replace disposition is unchanged.
…pid against the kernel peer pid

Supersedes the previous commit's treatment of step 2, which removed `served_pid`
outright on the grounds that a self-reported pid proves nothing. That was too
strong for the case the section actually guards.

The hazard is accidental pid reuse. A pid recycled onto an unrelated process is
a process that does not speak this protocol, so it never produces an
acknowledgement — it lands in state 6, 7, or 8, where replacement is already
unavailable. The daemon that does answer reports its own true pid, which fails
equality against a stale record. `served_pid` binds socket to process for that
case, and the section now says so rather than leaving the reader to infer it.

What it does not establish is resistance to a process that deliberately lies,
and that limit is now stated instead of assumed away. A same-uid process able to
squat the rendezvous is inside the trust boundary already, since the accept path
admits peers by same-uid rather than same-principal, and could signal the daemon
directly without involving this sequence.

Where the platform exposes a kernel peer pid — `SO_PEERCRED` on Linux,
`LOCAL_PEERPID` on macOS — replacement now requires it to equal both
`served_pid` and the recorded pid, and refuses on any disagreement. That makes
the self-report a cross-check wherever a cross-check exists, which is both
platforms this daemon targets. The document says plainly that a platform
exposing neither falls back to `served_pid` alone and that this is the weakest
evidence in the section.

Test obligations gain the cases that make the evidence load-bearing: a
mismatched `served_pid` and an absent one, each asserting that no signal is
sent rather than only that the call refused, and a kernel-peer-pid pair where
agreement proceeds and disagreement does not. The disagreeing case is what
proves the cross-check is read; without it the agreeing case passes whether or
not the lookup is wired up.
The replacement sequence specified a capability that does not exist on any
command surface, and it could not be made self-consistent inside this
amendment: its safety depends on a kernel-authenticated peer pid, which the
response frame does not carry and which is unavailable on one of the three
platform arms this daemon compiles for.

Replace the section with a one-paragraph out-of-scope note carrying three
named open items for the ADR that will specify it: ownership evidence, the
step-6 unlink race against a new owner, and platform reach. The corresponding
test obligations go with it.

Classification fixes in the same amendment:

- State 3's disposition no longer forward-references replacement, and states
  its unlink rule directly.
- Test-matrix row 12 expects 0 listeners. Its observation is a live pid with
  no socket, so nothing is bound; sibling row 9 already used 0 for the same
  shape.
- Test-matrix row 6's fixture uses a JSON string result. The wire field is
  Option<String>, so a non-string value fails to deserialize and would land
  in row 8, the state row 6 exists to be distinguished from.
- Rows 10 and 11 enumerate the same four pid-record cases as the shared
  "no usable pid record" predicate they both key on.
…the fixture recipe out

Five review rounds of Amendment 6 kept finding defects, and they clustered in
two places. Both are addressed by narrowing what this amendment claims.

Contract fixes:

- State 5 covered `served_config_id` absent OR unequal and gave both exit 6.
  The amendment's own class rule says the exit turns on who resolves the
  condition, and a peer that published a different identity has refuted this
  build positively: no restart of this process changes what it echoes. The two
  halves are now state 5 (absent, exit 6, a retry may resolve it) and state 15
  (present and unequal, exit 0). This is the same distinction the precedence
  already drew for an unequal `daemon_protocol_version` two steps earlier.
- The same-process `MayBind` branch was a conditional clause inside state 12's
  disposition, so an implementer following the exit enumeration had no named
  outcome for it while state 12's exit 0 was correct only for the foreign-pid
  half. It is now state 12s with its own row, named in the precedence and in
  the "no exit because it binds" sentence.

Scope:

- The per-state fixture recipe is removed and will be specified in a follow-up
  ADR. Instantiating peer setup, response frame, and listener count for fifteen
  states is a second normative surface with its own failure modes, and it is
  where the review findings concentrated. The classification contract can be
  read and implemented without it. Two constraints established here ride along
  as named inheritances: a fixture frame must both deserialize and pass the
  version check, which rules out the abbreviated form AND the
  every-field-at-zero form; and state 13's listener count is not observable, so
  it needs a seam or a named substitute rather than a waiver.

The state table and the exit table were cross-checked mechanically after the
change: 16 states, both directions, no disagreements.
@ohdearquant

Copy link
Copy Markdown
Owner Author

Superseded by #2240, which carries this amendment's final content unchanged. Closing so the change has a single home.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant