Skip to content

ADR 0040: Product-authored attributes of a machine live on one NodeIdentity per node, and never on a CR that copies the Node

Status: Accepted Date: 2026-08-06 Issue: #1243

Context

Production hostnames are rarely chosen for readability, so #1210 gave a chassis a dcs.io/display-name annotation and every surface renders it. The annotation lives on the Node and #1221 settled that every surface reads it back from there at request time, so a name the deployment layer set at enrolment and a name an engineer typed in the UI are equally authoritative and there is one answer to what a machine is called.

#1229 gave the Controller detail an editor for it: an engineer writes Controller.spec.displayName and the physical operator applies it to the bound node. A node that is not a Controller — a control-plane node, an unclassified worker — has no product-owned object to author through, so it had no editor at all and naming it meant kubectl annotate. The founder split that remainder out of #1229 on 2026-08-04 rather than letting a whole design question ride on one string.

The obvious fix is closed, and it stays closed

The gateway cannot write the annotation. ADR 0034 gives it no write privilege against a Node, and Kubernetes RBAC cannot scope a patch to one annotation key. There is no narrow grant to write: permitting the annotation permits spec.unschedulable, spec.taints and metadata.labels with it, which is the plant-wide denial-of-service surface ADR 0034 closed. Any proposal that begins "just let the gateway patch the node" is proposing that grant, whatever it says about intent.

And so is the other obvious fix

ADR 0033 § 4 forbids a CR that mirrors an object the cluster already reconciles, and forbids a Server CRD by name on that ground: kubelet reconciles the Node continuously, so a CR carrying a copy of it is stale by construction and hands an operator a second answer that disagrees with the first.

What is left, and the question that has to be answered first

An object that records authored intent rather than node state. That is the same basis ADR 0034 used to permit NodeMaintenance, and it survives ADR 0033 because nothing in it is a copy of anything the cluster writes.

#1243 asked the design question explicitly, and it is the reason this ADR exists rather than a commit message: does that object carry only the display name, or every product-authored node attribute? A rack position and an asset tag are the obvious next two, and a CRD per string is not a pattern worth starting.

Decision

Product-authored attributes of a machine live on a cluster-scoped NodeIdentity, one per node, named for the node it authors. The physical operator converges it onto the Node; the gateway writes only the NodeIdentity; every surface keeps reading the resulting name back from the node at request time.

1. One object per node, not one per attribute

NodeIdentity.spec carries displayName today. A second product-authored attribute becomes a second field on the same object, not a second kind.

What may never go in it is anything the cluster reconciles. That is the line that keeps this object on the right side of ADR 0033 § 4, and it is the line a future field has to be checked against: the test is not "is it about a node", it is "does anything else author it".

2. It is named for the node, and the reconciler enforces that

metadata.name is the node name, and a spec.nodeName that disagrees is Refused rather than applied. Node names are unique across a cluster (ADR 0033 § 8), so this makes the apiserver the thing that prevents two objects claiming one machine, rather than a list scan that would have to pick a winner and would pick a different one on a different day.

3. A controller-bound node is named through its Controller, and this refuses

Controller.spec.displayName (#1229) keeps naming the nodes a Controller has adopted. A NodeIdentity for such a node is Refused, with the owning Controller named in the status so the operator knows where to type instead. The gateway makes the same refusal synchronously.

Two authors converging on one annotation would each undo the other on every reconcile, and ADR 0033 § 4 already says one physical thing gets one record. The binding tested is Controller.status.nodeRef — the product's own record of the adoption (ADR 0004) — because that is exactly the condition under which the Controller reconciler writes, so the refusal and the write cannot disagree.

4. Clearing takes back only what this product applied

status.appliedDisplayName records the value the operator last wrote. An empty spec.displayName removes the annotation only while the node still carries that exact value. A name the deployment layer set survives being cleared here, and a name somebody changed by hand between the apply and the clear is left alone with tracking dropped.

This is reconcileNodeDisplayName's rule, deliberately reused rather than re-derived. Two ownership models for one annotation would be worse than the two authors this ADR just forbade.

5. Every disagreement is a sentence, never an empty field

The authored value and the node's value legitimately differ in three ways, and each is reported rather than left to be inferred from an editor that seems not to work:

What is true What the operator is told
The node was named at enrolment and nothing was authored here Where the name came from, and that clearing here cannot remove it
An edit is authored and not yet applied That the request is recorded and the operator is applying it
A name was cleared here but the node carries something else That it was left in place, and what it now says

The surface reports the request rather than the rename, because the gateway has not touched the machine and the annotation it can read is still the old one.

6. servers:set-display-name sits at PermAdmin

The default for a new action (ADR 0034 § 6, founder ruling 2026-08-04), and no exception is claimed. The act looks presentational, but this name is what alarms, audit records and every operator surface call the machine, so a wrong one misdirects an engineer to the wrong box. A plant that wants a narrower grant adds the action to a role's ADR 0024 allow list, which keeps the decision a reviewable line in its roles file.

Alternatives Considered

Let the gateway patch the Node annotation. One object fewer and no convergence delay. Rejected: no such grant exists to write. Kubernetes RBAC has no sub-resource granularity for a single annotation key, so this is the spec.taints grant with a different justification.

A Server CRD carrying the name. Rejected by ADR 0033 § 4 by name, on the drift argument #1168 made.

A ConfigMap in the operator's namespace. No new kind, no CRD to install. Rejected on four counts: no schema or validation, no RBAC granularity separating this from every other ConfigMap the operator reads, no status to report the Refused and Pending outcomes above, and no canonical address (ADR 0015). A CRD is this codebase's idiom for a record with a lifecycle, and this record has one.

A CRD per attribute (NodeDisplayName, later NodeLocation). Each object would be trivially simple. Rejected explicitly in #1243: the second one arrives soon, and a kind per string is a pattern that only gets worse. It would also multiply the RBAC surface and the reconciler count for no gain.

Make NodeIdentity the single author and have Controller.spec.displayName delegate to it. Genuinely cleaner, and the shape this may eventually take: one author for one annotation, with the Controller detail editing a NodeIdentity behind the scenes. Not taken now because the founder scoped #1243 to the non-Controller case deliberately, and migrating a shipped field is a change with its own upgrade story. The two authors are kept apart by the § 3 refusal rather than by hoping they never overlap, so this stays a future simplification rather than a latent bug.

Do nothing, and leave kubectl annotate as the answer. Rejected because the machines an operator most wants to name are exactly the ones with no Controller: the control-plane boxes named after their provisioning template, which #1174 has appearing in every System screenshot.

Consequences

  • A new cluster-scoped kind, physical.dcs.io/NodeIdentity, and a reconciler in the physical operator. The gateway gains nodeidentities in its role and no new privilege against a Node.
  • Renaming is asynchronous. The surface reports a request in flight, which is the same shape every other infrastructure action already has under ADR 0034.
  • The second product-authored node attribute is a field on an existing object and a new column in an existing status, rather than a design conversation.
  • A deployment that names its nodes at enrolment is unaffected and keeps authority over those names.