ADR 0034: Infrastructure actions are operator-executed requests recorded on a NodeMaintenance object, and shutdown is offered only by a power channel that can also power on¶
Status: Accepted Date: 2026-08-04 Issue: #1225
Context¶
The Servers surface is read-only and carries exactly one action, servers:read
at the read tier. Taking a chassis out of service and returning it is the
management capability the surface exists to deliver, because for a customer
this UI is the only management surface the hardware has and the plant engineer
does not speak kubectl. Adding those verbs raises four questions that have to
be answered together: which component may hold the privilege, what records the
intent, what a power action even is, and what ceremony each verb carries.
The privilege already exists, and it is not where the proposal assumed¶
The epic proposed that the gateway perform cordon and uncordon directly on the grounds of low blast radius, with drain and the power verbs routed through an operator. Reading the two roles inverts the argument.
The gateway holds nodes: get, list (config/rbac/gateway_role.yaml). The
physical operator already holds nodes: create, delete, get, list, patch,
update, watch and policy/evictions: create
(config/rbac/physical_operator_role.yaml). That second grant is what the file
said at the time and it authorised nothing; see the #1821 amendment below, which
does not change this argument, because the operator is still where the grant
belongs. Routing an action through the operator therefore costs no new privilege
anywhere. Performing cordon in the
gateway costs a new write verb against a cluster-scoped object, in the
component with the largest attack surface.
The product already cordons, drains and deletes nodes, with no ceremony at all¶
ControllerReconciler removal (internal/controller/physical/controller_controller.go)
already does all three. It sets node.Spec.Unschedulable, calls drainNode,
and deletes the Node object. Two properties of that path matter here:
drainNodefires one eviction per pod and returns without waiting for any pod to terminate. It swallowsIsTooManyRequests, which is exactly the status the API server returns when a PodDisruptionBudget refuses the eviction.- The caller logs a drain failure and continues, with the comment "Continue
anyway — best effort drain", and then deletes the
Node.
So the capability that cluster 3 is about to wrap in ceremony is already reachable today through a delete confirmation, and it reports success while leaving PDB-protected workloads in place. Any answer here has to converge on that implementation rather than grow a second one beside it.
Kubernetes has no power verb, and in-band channels are asymmetric¶
Cordon, uncordon, drain and delete-node is the entire vocabulary the gateway can reach. Reboot and shutdown need a host-layer channel, which is the same wall #1168 hit when it omitted the VIP holder.
The decisive property is asymmetry, and it belongs to the channel rather than to any implementation. An in-cluster privileged agent can shut a machine down and can never power one back on, because the agent dies with the machine. The Talos API has the same shape, since a powered-off machine serves no API. Only an out-of-band channel such as a BMC over Redfish, a cloud provider API, or a switched PDU can do both. Shipping shutdown over a channel that cannot power on turns a remote action into a drive to the plant.
Quorum is already derived twice¶
internal/gateway/servers.go derives etcd quorum for the read surface, and
deriveEtcdQuorum in internal/controller/alarm/server_alarm_controller.go
derives it again for the ADR 0032 alarm rules. ADR 0032 recorded the
duplication as an accepted cost. A quorum guard
(#1238)
would be the third copy of one stacked-etcd assumption.
Constraints in force¶
- Action names are a product contract under ADR 0024, so a rename is a breaking configuration change and the names must be settled before the first release that carries them (#1211).
- ADR 0006 owns re-binding a unit's runtime, with a fencing certification and a typed reason.
- ADR 0033 forbids any CR that mirrors an object the cluster already reconciles.
- ADR 0004 delegates node join to the deployment layer.
- A BMC sits on the management network, which is a separate zone under the
IEC 62443 posture claimed in
docs/compliance/iec62443-4-2.mdCR 5.1.
Decision¶
The gateway holds no write privilege against a Node. Every infrastructure
action is a request the gateway authorizes, audits and records on a
cluster-scoped NodeMaintenance object, which the physical operator executes.
The power channel is a deployment-configured capability provider, and shutdown
is offered only by a provider that also advertises power-on.
1. One path, through the operator¶
All four verbs (cordon, uncordon, drain, and the power verbs) route through the maintenance object. The gateway's RBAC is unchanged by this epic, which is a checkable outcome rather than a stated intention.
Cordon joins the same path as the rest for three reasons. The operator already
holds nodes: patch, so the split buys no reduction in total privilege while
adding a node write to the internet-facing component. A gateway compromise that
can cordon every node in the cluster is a plant-wide denial of service, and
"instantly reversible" describes the operator who did it deliberately rather
than the one who has to discover it first. Cordon is also the first step of
every maintenance procedure, so making step one structurally different from the
steps after it is how a second implementation gets built.
The cost is honest and accepted: a cordon now takes a reconcile round trip and mints an object where a PATCH would have done. The object is required for the lifecycle regardless, and a maintenance abandoned after its cordon is precisely the state an operator needs to see and undo.
2. NodeMaintenance is a cluster-scoped CRD recording intent¶
It records an operator's intent and its lifecycle, and mirrors nothing, so ADR 0033's no-mirror rule permits it. It is cluster-scoped because a node is, and a maintenance on one chassis is one event even where the cluster hosts several sites.
One object per maintenance, and the intent declares how far it goes.
spec.intent is one of cordon, drain, reboot, shutdown, and the
lifecycle advances only as far as the intent requires. A cordon-only
maintenance reaches Cordoned and waits there for return to service. This
gives alarm shelving (#1236)
one scope to hang on, the quorum guard one chokepoint, and drain
(#1235) the
lifecycle that has to survive a gateway restart.
States. Requested → Cordoned → Draining → Drained → Rebooting |
PoweredOff → Returning → Complete, with Refused and Failed as terminal
states and an abandon path from any non-terminal state back through
Returning. Rebooting and PoweredOff are the two places a maintenance
parks once its power verb has been sent, and a reboot needs its own because
Drained is the phase that dispatches the verb. A reboot that stayed there
would be sent again on every wake-up. The pre-flight snapshot taken at request
time is recorded on the object, so what the operator was shown when they
decided is recoverable afterwards.
The physical operator owns it. It already holds every verb involved, it
owns Controller, Unit and node discovery, and it hosts the ADR 0032 server
alarm reconciler that shelving has to reach.
The existing drain becomes the one implementation. The controller-removal path and the maintenance path converge on it, which means the PDB-blindness and the missing wait get fixed once for both. Filed as #1242.
3. Node deletion is not an infrastructure verb¶
Removing a Node object is decommissioning. ADR 0004 delegates node join to
the deployment layer, so node departure belongs there too, and the product's
own record of un-adoption is the removal of the Controller. Deletion stays
where it is today and gains no button on the Servers surface.
4. The power channel advertises capabilities, and shutdown requires power-on¶
A capability provider is configured at the deployment layer. The UI renders only the verbs the configured provider advertises, and a deployment with no provider configured shows no power verbs at all. That is the correct default and it needs no feature flag.
The capability rule. A provider advertises reboot, shutdown and
power-on independently. A provider that does not advertise power-on may
not offer shutdown. Reboot over an in-band channel stays available, because
the machine returns by itself and the asymmetry never bites.
Amendment (2026-08-05, #1288): the rule was attached to the wrong thing.
What this section protects against is a shutdown nothing can undo: the machine is off, nobody is there, and somebody has to drive to the plant. That is a property of whether the shutdown is recoverable, and a channel that can power on is only one of the two ways to be. The other is a person at the rack who has said they are there.
The rule is therefore: a shutdown is offered only where it is recoverable,
by either route. A deployment declares the second through powerProvider.attendedRack,
and ADR 0038 § 5 makes the same
declaration per site for a whole-site outage.
Two things follow, and the second is why this amendment exists at all.
The Talos backend now advertises shutdown honestly, because it really can
perform one: Talos stops its own services, drains the kubelet and unmounts
cleanly, which is a graceful power-off rather than a cut. It still never
advertises power-on. The structural guarantee that used to live in
CapabilitySet — where a set carrying shutdown without power-on could not be
constructed — moves to power.OfferFor and power.Invoke, the two places that
know the deployment's recovery model. An interface shape could never have
expressed attendance anyway, because it is a fact about a place rather than
about a channel.
And the original rule made the product recommend the worse act. ADR 0038's attended form left the machines running so a human could remove power at the rack, which is a hard cut of a live operating system, chosen over the sequenced shutdown the product could already have asked Talos to perform. The asymmetry objection does not apply to that case, because the attended form's whole premise is that the person is already there.
This is what makes an in-band first backend safe. The Talos API is the sensible
first implementation, because it is what the bench runs, it can be proven on
our own metal, and it brings the VIP holder within reach through the same
channel. It advertises reboot and withholds shutdown. Redfish over a BMC
comes second and unlocks shutdown and power-on where the hardware has a
BMC. Cloud provider APIs are the third case, where the repairable versus
replaceable classification
(#1233)
says the useful verb is terminate.
An in-cluster privileged agent is rejected outright, because it is the one channel that offers no path to symmetry at any later date.
The provider runs in the operator, which already occupies the privileged
position, so the management-network conduit originates in one component rather
than in the gateway. A deployment with no provider opens no conduit at all,
which keeps the default posture unchanged. Provider credentials arrive as a
Secret reference supplied by the deployment layer. They are machine identities,
so engineering states the scope and blast radius and files the issue in
cndcs-operations, and that issue is part of #1237's definition of done.
5. Tier and ceremony¶
Infrastructure actions sit at PermAdmin, and configuring the power
provider does too.
This follows a founder ruling of 2026-08-04 that states a general default:
where an action needs elevated access, PermAdmin is the safe answer unless
there is a specific reason to sit lower. The reasoning is that a tier is
granted once and lived with for years, an over-granted tier is discovered only
after it is abused, and an under-granted one is discovered immediately by an
engineer who cannot do their job. The two errors are not symmetric, so the
default belongs on the side that fails loudly.
Applied here, a chassis leaving service is exactly the class of act that default reaches for. The plant-floor argument for a lower tier is real and it is answered by delegation rather than by the default.
Delegation runs through ADR 0024 in the allow direction. A plant that wants
its maintenance engineers to cordon a node without holding terminal access and
bulk export adds servers:cordon and servers:uncordon to the allow list of
whichever role those engineers hold. The per-action policy machinery works in
both directions, and starting from admin means every such grant is a deliberate,
reviewable line in the deployment's roles file. Starting from operate-lead would
have made the same access the silent default in every deployment that never
opened the file.
| Action | Ceremony |
|---|---|
servers:uncordon, return to service |
Reason |
servers:cordon |
Reason |
servers:drain |
Reason, plus acknowledgement of the pre-flight impact |
servers:reboot, servers:shutdown |
Reason, pre-flight acknowledgement, typed node name |
| Any action consuming the last of the quorum margin | Hazard tier: warning title icon, typed reason, typed node name |
| Any action that would drop ready control-plane nodes below quorum | Refused |
| Drain, reboot or shutdown of a controller node hosting live control | Routed through the ADR 0006 failover path first, with its fencing certification |
Every action carries a reason and every reason meets the deployment's reason policy, matching the #687 treatment the failover re-bind already gets.
A refusal is never upgraded into a ceremony. Below-quorum is refused outright, so clicking through a sufficiently frightening dialog never becomes the path to killing the cluster. A refusal explains itself with the member count, the quorum size and the margin.
Absent derivation refuses. A cluster whose control-plane nodes are not labelled derives no quorum, and ADR 0032 already records that it therefore raises no quorum alarm. A guard that passed because it derived nothing would be the same silence with worse consequences.
The quorum derivation collapses to one implementation. The guard does not become a third copy. The derivation moves to a shared package with three callers: the gateway read surface, the ADR 0032 reconciler, and the guard. That closes the divergence ADR 0032 accepted as a cost.
The guard is authoritative in the operator, which is the component that
executes, so no client and no direct API call can bypass it. The gateway runs
the same shared derivation at request time so the refusal is synchronous and
the operator sees the numbers immediately. A request that clears the gateway
and fails the authoritative check lands the object in Refused with the
numbers in status.
Virtual nodes refuse every action. Nodes labelled dcs.io/virtual-node are
the simulation fixtures the Controller reconciler creates, and ADR 0032 already
excludes them from alarms. The refusal is enforced in the operator rather than
by hiding buttons, because a hidden button is defeated by a direct API call and
the docs capture rig runs on exactly these nodes.
6. The action names, settled¶
Seven names in the servers: domain, which already covers controller-bound
chassis because servers:read does. Each is independently deniable under
ADR 0024.
| Action | Default tier |
|---|---|
servers:read (exists) |
PermRead |
servers:cordon |
PermAdmin |
servers:uncordon |
PermAdmin |
servers:drain |
PermAdmin |
servers:reboot |
PermAdmin |
servers:shutdown |
PermAdmin |
servers:maintenance-abandon |
PermAdmin |
Cordon and uncordon are separate names so a deployment can let a wider set of roles restore service than can withdraw it.
Amendment (2026-08-05, #1281):
the domain grew, and this table is no longer the whole contract.
ADR 0038 settles a site-scoped outage,
which is a different blast radius in a site: group of its own, and it adds an
eighth name here: servers:power-on at PermAdmin. Seven names covered
taking a machine out of service and none brought one back, so a shutdown that
reached PoweredOff had no route back through the product
(#1282). The
§ 4 capability rule governs it unchanged: a channel that cannot power on never
advertises it, which is the same channel that may not offer shutdown.
7. Audit records the human act, status records the machine's¶
The gateway mints the AuditRecord at request time, because it holds the
authenticated identity and already audits every tag write. It carries the
actor, the reason, the target node, and the name of the NodeMaintenance
object, so the trail and the object are joinable. The source is
Server/<node>, the identity ADR 0032 already uses.
The operator records execution outcomes on the object's status. These are two records of two different things: what a human asked for, which is what 21 CFR Part 11 and IEC 62443 want in the trail, and what the machine subsequently did.
Amendment (2026-08-07, #1324):
PoweredOff says what was requested, and a condition says what was observed.
A shutdown reaches PoweredOff the instant the channel accepts the request.
That is the whole of what an in-band channel can ever support, because the
machine stops serving the API it was asked through and its silence afterwards is
indistinguishable from a network fault. An out-of-band channel has its own
processor and its own power, so it keeps answering while the machine is dark and
can report that the machine reached off. Those are two different claims and a
plant record must not print the stronger one when it only holds the weaker.
The phase therefore never waits on a readback, and the PowerOffConfirmed
condition carries the stronger claim where a deployment can make it. True is an
observation, False is a readback still running, and Unknown is settled without
one — either the channel cannot see, or it never reported the machine off before
the window closed. status.poweredOffAt records when the channel accepted the
shutdown and is what bounds that window.
Who polls, and how often, is settled here too. The reading is taken for the
one machine this product just stopped, at a cadence of its own rather than the
drain's, and only until that shutdown lands or the window closes. It is never an
ambient property of the Servers list: a five-second UI poll multiplied across a
rack is a load pattern the management network did not ask for, and no surface
needs a general "off versus absent" distinction that a maintenance sitting at
PoweredOff does not already give. Consuming the reading at the moment it
changes what the product may claim is the whole of what the mechanism is for.
Two consequences. The power advertisement stopped serving its stateReadback
flag, which had been on the wire since #1237 and read by nothing: the fact is
consumed where the reading is taken, and an advertisement that promises a
capability no surface acts on is read as a promise the product keeps. And a
SiteOutage leg still completes on the phase rather than on the confirmation,
because a worker finishing its stop does not gate the next leg of a sequence.
Alternatives Considered¶
The gateway performs cordon and uncordon directly, as the epic proposed,
keeping the operator for drain and power. Rejected on the RBAC reading. The
operator already holds nodes: patch, so the split reduces no privilege while
adding a cluster-scoped write to the most exposed component, and it splits one
maintenance procedure across two implementations that the quorum guard would
then have to be written into twice.
Record the intent as an annotation on the Node, following the ADR 0006
precedent where failover is triggered by annotations on the Unit. Rejected on
three counts. It is a write to the object ADR 0033 keeps the product out of, it
has no status subresource to carry a lifecycle or a pre-flight snapshot, and
ADR 0031 already recorded that node-level state is lost on re-enrolment.
An in-cluster privileged agent for power. Cheapest to build and needs no credentials outside the cluster. Rejected because it can never power a machine on, and unlike the Talos API it has no path to symmetry later.
Redfish as the first backend. It is the symmetric channel and the eventual answer for bare metal. Rejected as the first one, because no bench hardware has a BMC, so the first backend could not be proven on our own metal, and the bench is what the product's hardware claims rest on. The capability rule keeps the asymmetry trap closed without requiring Redfish to come first.
Amendment, 2026-08-07 (#1284). Redfish is now the second backend and it is built. The ordering argument above survived, and the argument that had been made for putting a switched PDU ahead of it did not: that argument was the same "prove it on metal we own" reasoning, applied to a device we do not own either. A PDU client would have had to guess a vendor, a protocol and an authentication scheme for hardware nobody has chosen. Redfish is a published specification, so it is implementable against the spec and exercisable against an emulator, and it needs no purchase to be correct. A PDU remains the right channel for hardware with no BMC, which is most small plants, and it is a separate backend rather than a replacement.
Put infrastructure actions at PermOperateLead, the tier unit failover
already uses, on the reasoning that maintenance happens during a planned window
and is performed by maintenance engineers who should not need terminal access
and bulk export to cordon a chassis. This was the ADR's original position and
the founder overruled it on 2026-08-04, setting the general default recorded in
the Decision. The practical concern survives the ruling and is answered by an
ADR 0024 allow list, which makes the same access a deliberate line in a
deployment's roles file rather than the silent default everywhere.
Let a sufficiently severe ceremony authorize a below-quorum action. It respects the operator's judgment and covers the genuine emergency. Rejected because the failure it prevents is unrecoverable from the UI that offered it, and an emergency that truly requires it has kubectl.
One servers:power action covering reboot and shutdown. Fewer names to
maintain. Rejected because the two verbs differ in exactly the property this
ADR turns on, and a deployment must be able to allow reboot while denying
shutdown.
Consequences¶
- The gateway gains no write against a
Node. Itsnodesrule stays atgetandlist, and any future patch adding a node write toconfig/rbac/gateway_role.yamlis a signal that something has left this decision.
Correction, 2026-08-05. This bullet originally claimed the gateway's RBAC
was unchanged by the whole of cluster 3 and called that a checkable outcome.
It was checkable and it was false. The gateway role enumerates
physical.dcs.io resources explicitly with no wildcard, so it could not
create the NodeMaintenance object this ADR has it author, and every cordon
would have been refused in a real deployment. #1234 added
nodemaintenances at create, get, list, update. The decision is unaffected,
because that is a write against the product's own object rather than against
a Node, which is the privilege this ADR withholds.
- The existing drainNode becomes load-bearing, and its two defects have
to be fixed before the maintenance path uses it. It swallows PDB refusals and
never waits for pods to leave, and the removal path deletes the node anyway.
Filed as #1242.
- The quorum derivation improves rather than degrading. Collapsing the
gateway copy and the ADR 0032 copy into one shared package with three callers
closes a divergence that ADR 0032 accepted.
- #1236 gets its shelving scope, since the maintenance object carries the
window, and shelving expires with the object instead of depending on a clean
return path.
- #1237 gets a first backend it can prove. Talos over the bench, advertising
reboot and withholding shutdown, with Redfish adding the symmetric verbs.
Amendment, 2026-08-07. Talos no longer withholds shutdown, because #1288
moved the rule from the channel to the recovery. Redfish landed in #1284 and
is the first channel that advertises power-on, so a shutdown over it is
recoverable without anybody at the rack. servers:power-on still has no
request path; the channel can perform it and nothing in the product asks yet.
- A cordon is now a recorded event with a reason. Plants that treat
cordoning as a throwaway act will find this heavier than kubectl, which is
the intended trade in a regulated environment.
- The compliance surface moves once. docs/compliance/iec62443-4-2.md
CR 5.1 gains the management-network conduit the power provider opens, stated
as originating in the operator and absent by default. The Part 11 trail gains
infrastructure actions.
- A deployment that wants maintenance engineers to cordon must write a policy
line. That is the intended cost of the admin default: the access exists and
it is visible in the roles file rather than implied by a tier.
- Reversibility. The tier assignments and the ceremony ladder are cheap to
change before release. The seven action names are a product contract the
moment a release carries them, and the NodeMaintenance schema is a
migration once a deployment holds objects.
Amendment (2026-08-25, #1821): a drain that cannot evict gives the node back¶
§Decision.2 lists Failed as a terminal state and says nothing about what a
terminal state owes the node. The drain path took that silence to mean it owed
nothing, and never reached Failed at all: any error from an eviction was
recorded on the record and retried at the maintenance cadence, without bound.
A shutdown on the bench cordoned cp-1, was refused its first eviction, and
was still at Draining eleven hours later with status.cordonedByUs: true.
The cordon is what made that a product defect rather than a stalled procedure.
Refused, Failed and Complete are terminal, so the gateway's
activeNodeMaintenance stops seeing the object. servers:maintenance-abandon
then answers that the node has no maintenance in flight, and servers:uncordon
answers that the node "was taken out of service outside this product and has to
be returned the same way" — a sentence status.cordonedByUs on that same record
contradicts. There is no way back through the product. The bench was returned
to service with kubectl uncordon.
Two ways of not finishing, and they are different facts.
An eviction the apiserver refuses never reached the workload. The answer does
not change because an operator acts on the workload, and it does not change
because time passes. Past the drain timeout the maintenance goes to Failed,
undoes the cordon it placed, and names the refusal. That is
refuseAndReturn's rule applied to the other terminal state: a procedure that
achieved nothing leaves nothing behind. It is bounded rather than immediate
because an apiserver blip is an eviction error too.
An eviction the apiserver accepts that leaves pods on the node — a PodDisruptionBudget refusing, a pod inside a long termination grace period — is the normal outcome on a correctly configured cluster and it is a state an operator resolves by acting on the workload. It keeps its cordon and its retry. Past the drain timeout only the sentence changes, to name the delay and to name abandoning as the way out. Failing that one out and uncordoning underneath the operator would undo a maintenance somebody is in the middle of completing.
A power verb that fails after a completed drain still reaches Failed without
uncordoning, unchanged. There the node really is empty and out of service and
the operator's work is still ahead of them.
The stranded pre-flight is not evidence the drain will hang. The bench
record's spec.preflight.stranded named the two boundPV pods the drain then
tried to evict, which reads like the drain asking for something it had already
been told was impossible. Measured on a single-node cluster with a
WaitForFirstConsumer local PV pinned by nodeAffinity: the eviction of such a
pod is accepted (201), and its replacement is refused by the scheduler with
1 node(s) were unschedulable and sits Pending with an empty spec.nodeName.
It does not come back on the node, so isDrainablePod excludes it and the drain
completes. The cordon is placed before the drain, so the unschedulable filter
decides this before any volume does; the PV pin says where that workload can run
afterwards, which is the operator's decision and is what the pre-flight
discloses it for. Refusing a drain or shutdown intent on a stranded
pre-flight would block the legitimate case — taking a chassis down that carries
node-local storage — on a reading the measurement does not support.
The grant. The eviction the whole path rests on was authorised by nothing.
+kubebuilder:rbac:groups=policy,resources=evictions,verbs=create shipped for
as long as the drain has existed. Eviction is a subresource of pods in the core
group and RBAC authorises on the request path, so the rule that grants
POST /api/v1/namespaces/{ns}/pods/{name}/eviction is
apiGroups: [""], resources: ["pods/eviction"]. The policy group has no
evictions resource for the apiserver to match. It read correct because the
Eviction object is policy/v1, and nothing downstream could catch it:
controller-gen renders whatever a marker says, client/fake authorises
everything, and envtest runs controllers with admin credentials.
TestShippedGrantAuthorisesEviction binds the shipped per-binary role to a
ServiceAccount, impersonates it, and drives the drain itself, so the oracle is
the apiserver's own RBAC evaluator rather than a string comparison.
Amendment (2026-08-25, #1853): consent expires, and a power verb that reaches an aged one parks¶
§Decision.5 grades the ceremony by irreversibility and says nothing about when
it is spent. acknowledgedPreflightAt bounds the impact check at ten minutes,
which is a claim about the evidence at the moment of asking, and the gateway
enforces it once. The act it authorises can land arbitrarily later.
The gap is structural, not incidental. What sits between admission and the
power verb is a drain, and the #1821 amendment above deliberately leaves one
unbounded: an eviction a PodDisruptionBudget refuses is the normal outcome on a
correctly configured cluster, so continueDrain keeps its cordon and its retry
past drainTimeout and only changes the sentence. That ruling is right and is
not revisited here. Its consequence is that afterDrain can hand to power()
at any distance from the ceremony. The bench maintenance in #1845 was admitted
at 02:51 and was still Draining sixteen and a half hours later with a live
Talos channel and attendedRack: true behind it; with that drain defect fixed
the machine stops on a "shutdown for the night" nobody has looked at since the
small hours.
The guards re-run and consent is not one of them. guard re-evaluates the
virtual-node, hosted-unit-runtimes and etcd-quorum refusals on every pass, so
every consequential check is current whenever a machine stops. Consent is a
different kind of thing. It happened at one instant, in front of a person, and
no reconciler can re-derive it. A product that re-ran the checks and treated the
signature as permanent would be enforcing the cheaper half of its own ceremony.
A power verb reaching an authorisation older than powerConsentMaxAge parks.
One hour, chosen from both ends: longer than any drain the product still
considers to be working, which is the five-minute drainTimeout past which the
maintenance already says the drain is not finishing, and shorter than anything
an operator walks away from — a break, a shift change and a night all exceed it.
A bound measured in hours readmits the #1845 case by degrees.
Parking is not failing, and the distinction is the #1821 rule applied
forwards. failDrain uncordons because nothing was achieved. Here the drain
finished: the node is empty, the cordon is held, the census stands, and the
machine is untouched. Giving the node back would undo work somebody is in the
middle of completing. The phase stays Drained, which is honest — the drain
half really is done and the machine really has not been touched — and
PowerConsentFresh: False with reason ConsentAged is what says why nothing
further is happening. The status message names both ways forward, because a
refusal an operator cannot act on is only a wall.
A park is not silent, and it needed nothing new to be so. The #1236 alarm shelve expires on its own deadline whether or not the node has come back, and the overrun is annunciated in its own right. So a maintenance that parks overnight annunciates the node's server alarms again on the same schedule any other overrunning maintenance does. Adding an alarm for the park itself was rejected on #1669's line: a machine the operator has not yet authorised to stop is not a fault, and the plant already says the node is out of service for longer than the window allowed for.
Re-authorising is the same action name. Posting the same verb to the same
node is refused with 409 as before unless the operator has parked that
maintenance for exactly this, in which case it records spec.reconfirmed on
the object already open rather than creating a second one to fight it for the
node. It walks the whole § 5 ladder again — the reason, an
acknowledgedPreflightAt inside its own bound, the typed node name — because a
second signature cheaper than the first would be no signature at all. A separate
action name was rejected: re-authorising a shutdown has the blast radius of
authorising one, and ADR 0024 names are independently deniable, so a split would
let a deployment grant the power to stop a machine while denying the power to
stop it. The gateway decides whether a maintenance is parked by reading the
operator's own condition rather than re-deriving the bound, which is § 5's
mirrored-never-moved rule applied to a rule the gateway does not own.
The record gains a second snapshot, and that was worth having on its own.
spec.preflight is what the operator was shown when they asked.
spec.reconfirmed.preflight is what they were shown when they authorised the
machine to actually stop, and it is the one a reviewer reads beside a machine
that stopped. It is assembled by the gateway, at a re-authorisation, which is
what makes it cheap: assembling it in the physical operator instead would move
the whole pre-flight derivation out of the component that owns it and widen that
binary's grants to PersistentVolumes, PodDisruptionBudgets and node metrics, to
answer a question nobody had asked at that instant.
Only the latest re-authorisation is kept. An intermediate one that was itself
overtaken stopped no machine, the AuditRecord the gateway mints records that
it happened, and keeping every one would grow a cluster-scoped object by a
verbatim pre-flight body per park without a bound.
A drain intent is not bounded this way. No machine stops, the node stays cordoned and under the operator's control, and abandoning returns it. What ages badly is consent to an irreversible act.
Amendment (2026-09-09, #2112): a member's health is read, and readiness stands in only where it says so¶
The derivation this ADR collapsed into pkg/quorum counted a member as alive
when its node was Ready. Rep 1 of the cold-boot drill cut the rack under a
running batch, and cp-1 came back with its etcd database corrupted at the page
level. Its kubelet was fine. The node read Ready on the Servers page, the
system health read healthy, the return readout's quorum stage read
converged, and the quorum guard would have let either of cp-1's peers be
taken out of service. The cluster ran on two members of three for an hour, one
loss from losing every command, and the only instrument that said so was the
drill's own verify. That is #1905's
finding, a membership list counting a corpse, landed on the product's own
instrument.
The derivation takes a second input now. On a stacked control plane every
kube-apiserver is configured against the etcd member on its own machine, and
the API server's endpoint reconciler lists an apiserver in the kubernetes
Service's EndpointSlice only while it can write its lease to that store. So an
address in those endpoints is the apiserver's own verdict that its local
member answered it, and a control-plane node whose address is absent is a
member whose store is dead. quorum.ReadServing reads the slices and
reconciles them with the node list, Derive and WithoutNode take the result
as an explicit argument, and State carries Alive beside Ready with a
Basis naming which of the two the count is.
Three rules bound the read, and a later change must not drop any of them.
- No caller falls back in silence. The argument is required, and a caller
that did not read the endpoints passes
quorum.Unobserved(reason). Every payload carries the basis, and on the readiness basis it says why the endpoints were not read, because on that basis a member whose store is dead reads as present. - A read that matches no member is contradicted, not believed. An apiserver advertises whatever address it was configured with, and a topology where that is no control-plane node's address would read every member as not serving and call a healthy quorum lost. When nothing matches, the read comes back unobserved with that reason and readiness stands in.
- Alive is judged on one basis everywhere. The guard subtracts a serving member on the endpoints basis and a Ready one on the readiness basis, so servicing the node whose store is already dead costs nothing and servicing one of its peers costs the last of the margin.
The five consumers all read through quorum.Read. The gateway reads it with
its own client on every request. The physical operator reads it through the
manager's cache, scoped to that one Service's slices, and the server-alarm
reconciler watches them so the alarm does not wait for the resync. A
control-plane node that is Ready with its member not serving raises a High
alarm carrying the margin, under the source kind ADR 0032 settled and beside
the rule for a node leaving Ready, which it yields to.
What the read still cannot see is etcd's own view. A member that is serving but behind on the log, or still a learner, reads as serving, and every surface's disclosure says so.
Related Documentation¶
- ADR index
- ADR 0004: Node join is delegated to the deployment layer
- ADR 0006: Edge-runtime failover is hold-then-resume
- ADR 0024: Action-level authorization policies
- ADR 0032: Server degradation annunciates per site
- ADR 0033: The asset tree is a site-scoped Infrastructure branch
- Security Operations
- IEC 62443-4-2 compliance