Skip to content

Status conventions

Every Omnia CRD reports its health the same way. One piece of tooling, whether a dashboard, a kubectl wait or a script, then works across all of them without knowing each kind’s private vocabulary.

Every CRD that carries status.conditions sets a Ready condition. It is the roll-up: True means the resource is usable, False means it is not.

Terminal window
kubectl wait --for=condition=Ready promptpack/my-pack --timeout=60s

Every such CRD also exposes it as a print column:

Terminal window
$ kubectl get providers
NAME READY TYPE MODEL
anthropic True claude claude-sonnet-5
openai False openai gpt-4o

Sub-conditions (SourceValid, CredentialConfigured, ToolsDiscovered and others) explain why Ready holds the value it does. They help with diagnosis but make a poor health check: they vary per kind, and a resource can carry a False sub-condition while remaining usable.

Ready is written on every path that persists status, including early returns. A roll-up refreshed only on success goes stale when a reconcile fails early, and a stale Ready=True on a resource that failed validation reports it as healthy.

Omnia uses the kstatus/Flux vocabulary instead of inventing its own, so generic tooling behaves as expected. The four shared types are declared once, in api/v1alpha1/conditions.go:

  • Ready is the roll-up. True means usable.
  • Reconciling is True while the controller is still working toward the observed generation. It differs from Ready=False, which means settled and broken.
  • Degraded has negative polarity. True means the resource is serving, but not as specified.
  • Stalled has negative polarity. True means progress has stopped and a requeue will not help; only a spec change will.

Ready and Degraded answer different questions. A ToolRegistry serving three of five tools is Ready=True, Degraded=True: usable, but incomplete. status.phase cannot express that because it is a single word.

flowchart LR
Reconciler[Reconciler] -->|SetConditionWithEvent| Conds[status.conditions]
Reconciler -->|on transition| Events[Kubernetes Events]
Conds --> Ready[Ready roll-up]
Conds --> Sub[Sub-conditions<br/>SourceValid, CredentialConfigured]
Conds --> Neg[Negative-polarity<br/>Degraded, Stalled, ContentDrift]
Ready --> Col[READY print column]
Ready --> Wait[kubectl wait]

The reconciler writes every condition and its event in one call. Consumers read Ready for health and the other conditions for the reason.

Six condition types mean trouble when True:

  • Degraded
  • Stalled
  • ContentDrift, a PromptPack whose ConfigMap was edited under the published digest
  • Superseded, a newer version has been published on the channel
  • DuplicateVersion, a PromptPack that lost an equal-precedence tiebreak
  • InsecureEgressTestHosts, a SandboxClaim whose egress reaches into the cluster

A condition is only a type string plus True, False or Unknown, so nothing in the data says which value is the bad one. Anything that derives a colour, an icon or an event type from a condition must consult the registry:

if v1alpha1.IsNegativePolarity(cond.Type) { /* True is the bad state */ }
import { isNegativePolarity } from "@/types/conditions";

The TypeScript list mirrors the Go one, and TestConditionVocabularyMatchesTypeScript compares them. Adding a negative-polarity type in Go without the TypeScript counterpart fails go test ./....

Neither ContentDrift nor Superseded gates Ready. A superseded pack still serves, and drift is loud but does not break the pack. Folding them in would mark every superseded pack as failed.

Unknown means the state has not been determined yet. A transient probe failure must not take a Provider out of service, so controllers do not fail closed on it. Consumers should render it as a warning and never as an error.

Most CRDs also carry status.phase. The kinds use many different words, with three synonyms for healthy (Ready, Active, Running) and two for broken (Error, Failed). Colouring a UI from phase therefore needs a lookup table per kind, and that is how a codebase ends up with divergent status renderers.

The convention splits the work. Ready means one thing everywhere, so it decides whether a resource is healthy or broken. phase carries the kind-specific vocabulary, such as Fetching, that a five-value enum would flatten, so it supplies the label. An ArenaProject mid-fetch reads “Fetching”, coloured by Ready. Phase serves display only and should never stand in for a health check.

Every condition carries observedGeneration, the metadata.generation it was computed against. When it is behind, the condition describes a spec the user has already replaced:

condition.observedGeneration < metadata.generation -> stale

Without it, a stale condition reads as authoritative forever. SetCondition and SetConditionWithEvent set it on every write, so a controller never has to remember.

A condition transition records its own Kubernetes event from the same call:

SetConditionWithEvent(r.Recorder, obj, &obj.Status.Conditions, obj.Generation,
v1alpha1.ConditionReady, metav1.ConditionFalse, "CredentialInvalid", err.Error())

The event’s reason is the condition’s reason, by construction. Setting a condition and emitting an event as separate steps leaves two surfaces to maintain independently, and they drift apart.

Three properties keep this usable on a real cluster:

  • Events fire only on a transition, which is a change of status or reason. Reconcilers run constantly, and an unchanged condition does not re-emit.
  • A change of message alone is silent. Messages carry counts, durations and wrapped errors, so keying on them would emit events for a resource whose state never moved.
  • Event type follows polarity. A condition arriving at its bad state is a Warning and at its good state a Normal, so a drifted PromptPack does not record trouble as routine.

A nil recorder degrades to a plain SetCondition, so a reconciler without one still writes correct conditions but emits no events. SetCondition remains for internal conditions not worth an event.

A single roll-up gives up detail. Consumers that want to know why must read sub-conditions, whose names differ by kind, and the convention accepts that cost to keep the health check uniform.

Keeping polarity in a registry rather than in the condition itself means each consumer must consult it, and the Go and TypeScript copies must agree. The alternative, renaming negative conditions so True is always good (NotDegraded), would break the kstatus names that generic tooling already understands.