0639 — The instance target is vocabulary, not a field poked after construction
Edit this pageContext. [0638] made the manager row’s target a real decision — 1 for the system’s row, NoInstanceTarget for a group’s — and that exposed a hole [0549] left. [0549] names TargetInstances as one of a Definition’s four data points, but NewDefinition takes only three, so the value arrives by mutating the struct afterward: definition.TargetInstances = cfg.TargetInstances in the manager, and BaseProvisioner stamping NoInstanceTarget over whatever the three consumer kinds declared. A spec built then patched is a patch-up step, and the constructor could not validate what it never received. The int also carries two questions at once — what shape of worker this is (fleet-counted, or one copy per claiming process) and how many — with the third state, 0, meaning suspended. K8s, which [0549] already cites as the Definition/Worker precedent, keeps those apart: a Deployment carries replicas, a DaemonSet carries no count because per-node is a different workload kind, and suspend is its own field.
Decision. worker.InstanceTarget is a named int type in the worker vocabulary, owning NoInstanceTarget, a Suspended() reader for the zero, and a Validate that accepts a positive count or NoInstanceTarget and nothing else. NewDefinition(name, ownerKind, targetInstances, metadata) takes it as a required parameter and validates it, so a declaration carrying 0 — suspension, which is an operator’s edit to a row and never a declaration — is unconstructible. Both mutations are deleted: the manager builds its definition from the target it was handed, and each of the three consumer kinds passes NoInstanceTarget itself while NewBaseProvisioner turns [0549]‘s silent stamp into a stated requirement, erroring on any other value. The same reasoning promotes it one layer up: NewManagerProvisioner(ds, targetInstances, cfg, provisioners...) takes it as a required parameter and ManagerConfig no longer carries it at all. It was a required value hiding in a config — zero could not mean “unset” without silently meaning “suspended”, WithDefaults had to fill it, and the value it filled (NoInstanceTarget) served one of three callers while being the expensive one, so a fourth caller who said nothing would have quietly restored the fleet-wide polling [0638] removed. It is identity, not tuning: which manager this is decides it. The type reaches WorkerData and WorkerConfig; the datastore’s row struct and RegisterWorker keep the column’s plain int, with the two controller adapters converting at the layer boundary. metrics.WorkerSnapshot.TargetInstances deliberately stays int — pkg/metrics is a vocabulary root, and the package rules let it import infrastructure only, never another domain.
Consequences. Every worker kind now states its own target where it states its name and owner kind, so a definition reads true on its own — before this, messageconsumer’s definition said nothing about a target and the base overwrote it out of sight. The invariant is restated at three consumer kinds instead of enforced once, which is the deliberate trade: three honest declarations plus one guard beat one stamp that made the other three lie. Narrows [0549]‘s clause “consumers inherit it from BaseProvisioner, which stamps NoInstanceTarget as a base invariant” — the invariant stands, its enforcement moves from mutation to a constructor guard; the rest of 0549 stands. Verified live against Postgres in a throwaway schema: a fresh install declares the system manager row at 1 and a registered consumer’s message_consumer row at -1. Rejected: leaving the target on ManagerConfig with a default, for the reason above; a plain required int, which fixes the poke but leaves 0/-1/N explained in a comment at five sites; two constructors split by worker shape (NewUnboundedDefinition), the most faithful reading of the K8s split but two constructors for one struct; and adopting the type in metrics, which would cross the domain-import rule for a cosmetic win.