Skip to content

Commit a31d8f0

Browse files
kfeofantovclaude
andauthored
feat: revisions belong to whatever controls the ReplicaSet (ADR 0049) (#83)
ADR 0030 scoped revisions to Deployments, and what made a Deployment special was that the agent knew the name of its revision annotation — not anything about the object graph. An Argo Rollout creates and scales ordinary ReplicaSets, which the agent already lists, already resolves, and already reports as `workload_kind`. - Any ReplicaSet whose controller is a collected workload is reported. A bare ReplicaSet is not: it is the workload, not a revision of one. - The revision number is read for Deployments and no one else. Other controllers number under keys of their own, and no finding needs the number — ordering comes from `created_at`, which ships. Learning vendor keys would be a second list to keep in step with other people's releases. - `deployment_revisions` is renamed `workload_revisions`, spool file with it. The old name would be false the moment the first record arrived under another kind. No new RBAC, no new field, no new payload kind. StatefulSet and DaemonSet revisions stay uncollected, and the custom resource itself stays unread: this uses the object the agent was already watching. The e2e fixture is a ReplicaSet under a `Widget` kind that has no CRD in the cluster — a dangling owner reference survives the garbage collector and is exactly the shape Argo produces, without installing Argo. make test, lint, chart-lint: green. Claude-Session: https://claude.ai/code/session_011YRKuL97KRndaopfpixrhp Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6edd9cb commit a31d8f0

14 files changed

Lines changed: 350 additions & 87 deletions

‎cmd/agent/main.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac
376376
// a consumer pair a revision with a shape from a different moment
377377
// (ADR 0030).
378378
revisionRecords := revisions.Aggregate(podWatcher.ReplicaSets())
379-
if err := spool.WriteDeploymentRevisions(capturedAt, revisionRecords); err != nil {
379+
if err := spool.WriteWorkloadRevisions(capturedAt, revisionRecords); err != nil {
380380
logger.Error("spooling deployment revisions", "error", err)
381381
}
382382
// Policy rides the same instant for the same reason: what bounds a

‎docs/adr/0030-deployment-revisions.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
Date: 2026-08-23
44
Status: Accepted
55

6+
Amended by: 0049
7+
68
Adds the payload kind `deployment_revisions`. Nothing existing changes shape.
79

810
## Context
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# 0049. Revisions belong to whatever controls the ReplicaSet, and the payload is renamed for it
2+
3+
Date: 2026-08-29
4+
5+
Status: Accepted
6+
7+
Amends: 0030 §1–2
8+
9+
Widens `deployment_revisions` to every workload that manages ReplicaSets and
10+
renames it `workload_revisions`. No new RBAC, no new field, no new payload kind.
11+
12+
## Context
13+
14+
ADR 0030 scoped revisions to Deployments, and at the time that was the whole of
15+
what manages ReplicaSets in a cluster this agent could see. It is not. Argo
16+
Rollouts is widely deployed, and a `Rollout` creates and scales ordinary
17+
ReplicaSets exactly as a Deployment does — the agent already lists them, already
18+
resolves their pods, and already reports `workload_kind: Rollout` (ADR 0048 §2).
19+
The only thing standing between those sets and the payload was a kind check.
20+
21+
Measured on kind (Kubernetes 1.36.1) against Argo Rollouts v1.10.0, mid-canary
22+
with `setWeight: 25` on four replicas:
23+
24+
payments-7bd58f4db4 rev 2 desired 1 current 1 ready 1 pause:3.10
25+
payments-f47dc5486 rev 1 desired 3 current 3 ready 3 pause:3.9
26+
27+
That is the shape ADR 0030 §2 already describes — two revisions with non-zero
28+
counts is a rollout in flight, and the split is the weight it is running at. The
29+
record needs no new field to say it; it was simply never emitted.
30+
31+
`revisionHistoryLimit` defaults to 10 on a Rollout as it does on a Deployment,
32+
so the bound ADR 0030 leaned on to keep the snapshot small holds unchanged. With
33+
the limit set to 3 the cluster retained five sets: three historical, the stable
34+
one, and the one in flight.
35+
36+
Two things this does not reach, and both are worth stating so the widening is
37+
not read as more than it is. A `Rollout` paused at a step and a `Rollout`
38+
degraded look identical from the ReplicaSets — `status.phase` and
39+
`pauseConditions` are on the custom resource, which the agent does not read. And
40+
StatefulSets and DaemonSets are still absent: their revisions live in
41+
`controllerrevisions`, which ADR 0030 declined and this does not revisit.
42+
43+
## Decision
44+
45+
**1. Any ReplicaSet whose controller is a collected workload is reported**, and
46+
the kind check goes. What made a Deployment special was that the agent knew the
47+
name of its revision annotation, not anything about the object graph. Scope is
48+
still inherited from the admitted pod index, so an excluded workload is absent
49+
here exactly as before, and the namespace and pod opt-outs reach these workloads
50+
even where the workload-level one cannot (`docs/security.md` §11).
51+
52+
**2. A ReplicaSet with no controller is not a revision.** A bare set is the
53+
workload rather than one version of one, and it has no history to report; its
54+
pods already resolve to the set itself. It is skipped rather than reported as a
55+
revision of nothing.
56+
57+
**3. The revision number is read for Deployments and no one else.** Every
58+
controller numbers its revisions under a key of its own —
59+
`rollout.argoproj.io/revision` for Argo — and the agent reports `revision:
60+
null` for all of them rather than learning vendor keys.
61+
62+
No finding needs the number. Deploy frequency needs the ordering, which
63+
`created_at` gives; which revision is live needs the replica counts, which ship;
64+
"this revision is that build" needs the image and the instant, which ship. A
65+
table of vendor annotation keys would be a second list to keep in step with
66+
other people's releases, bought for a field nothing reads. Records with no
67+
number sort by age, so the payload stays deterministic.
68+
69+
**4. The kind is renamed `deployment_revisions` → `workload_revisions`**, and
70+
the spool file with it. The old name would be false the moment the first Argo
71+
record appeared, and a payload kind whose name contradicts its contents is the
72+
defect ADR 0022 exists to prevent. The rename costs a registry row, a golden and
73+
three documents today; after a backend exists it is a protocol migration. ADR
74+
0019 renamed `go_dependencies` → `go_build` on the same reasoning.
75+
76+
## Consequences
77+
78+
**Easier.** The regression watch — CPU per replica across an image change — now
79+
covers Argo-managed workloads, which was one of the three things such a workload
80+
lost. Deploy frequency and rollout-in-flight follow for every ReplicaSet-based
81+
controller at once, including operators nobody here has heard of, because
82+
nothing in the reduction names a vendor.
83+
84+
**Harder / given up.** A consumer can no longer assume a revision number exists.
85+
It was already optional — an unannotated ReplicaSet yielded null before this —
86+
but it was rare, and it now happens for a whole class of workload. The backend
87+
contract states it.
88+
89+
The payload can now carry a workload kind that is not a Kubernetes built-in, so
90+
`workload.kind` is a string from a CRD rather than from a closed set. It is a
91+
kind name, not free text, and it is already shipped in `workload_metadata` for
92+
the same workloads (ADR 0048 §2), so this exposes nothing new.
93+
94+
**Not changed.** No new RBAC — ReplicaSets were already granted and read. No new
95+
field, no new payload kind, no change to the natural key or the delivery
96+
discipline. StatefulSet and DaemonSet revisions remain uncollected, and the
97+
custom resource itself remains unread: what this decision uses is the object the
98+
agent was already watching.
99+
100+
This ADR records decisions implemented in the same pull request, per the process
101+
in [`README.md`](README.md).

‎docs/adr/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,4 @@ line to paste.
106106
- [0046. The informer cache is the source, so the fields the agent promises not to collect are removed there](0046-the-cache-is-the-source.md)
107107
- [0047. Five environment variables are named, kept and shipped; every other one is still dropped](0047-runtime-knobs-are-named-and-kept.md)
108108
- [0048. Four first-hour findings name fields the agent did not collect](0048-findings-name-the-fields-they-need.md)
109+
- [0049. Revisions belong to whatever controls the ReplicaSet, and the payload is renamed for it](0049-revisions-are-not-only-deployments.md)

‎docs/backend-requirements.md‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,14 @@ metadata are keyed per container, because requests, limits and CFS quota are
128128
declared and enforced per container. A pod with an injected sidecar therefore
129129
appears as several records sharing a workload and differing in `container`.
130130
Summing their totals gives the pod or workload total — that is the intended use.
131-
Facts that belong to the pod rather than the container are not repeated as
132-
top-level fields but nested in a `pod` object, so a reader can tell from the
133-
payload alone what a field is a fact about.
134131

135132
**`pod.phases` is placement and lifecycle, never health.** The counts are
136133
Kubernetes pod phases verbatim, and the backend MUST NOT read `Running` as
137134
"healthy": a pod in CrashLoopBackOff is `Running`, and so is one whose readiness
138135
probe has never passed. Readiness and restart counts are not in this payload.
139136
`Succeeded` and `Failed` pods are reported too — a CronJob's workload
140-
legitimately shows dozens of them consuming nothing — so `pod.replicas` should
141-
be read together with its breakdown rather than alone; the agent reports the
142-
phases and does not decide which of them count.
137+
legitimately shows dozens consuming nothing — so `pod.replicas` should be read
138+
with its breakdown, not alone; which of them count is not the agent's call.
143139

144140
**Measured payloads state what was observed, and the backend MUST use it**
145141
([ADR 0013](adr/0013-observation-completeness.md)). Each usage payload carries an
@@ -302,15 +298,14 @@ that does not exist.
302298

303299
**Structural snapshots say when they were taken, and the Go inventory says how
304300
complete it is** ([ADR 0017](adr/0017-build-facts-keyed-by-digest.md)).
305-
`go_inventory`, `workload_metadata` and `node_metadata` each carry `captured_at`
306-
— the instant the snapshot was assembled, which is not a window and not the age
307-
of the facts inside it. `go_inventory` additionally carries a `coverage` block
308-
whose counters are cumulative from the `since` instant carried with them:
301+
Every structural payload carries `captured_at` — the instant the snapshot was
302+
assembled, which is not a window and not the age of the facts in it.
303+
`go_inventory` adds a `coverage` block whose counters are cumulative from the
304+
`since` instant carried with them:
309305

310306
- `nodes_reported` — how many nodes *currently in the cluster* have delivered at
311-
least one scan; a node that has left is not counted. Read against the node
312-
count in `node_metadata`, a shortfall means part of the fleet is not
313-
reporting, and the inventory is incomplete for reasons no record can express.
307+
least one scan. Read against the node count in `node_metadata`, a shortfall
308+
means the inventory is incomplete for reasons no record can express.
314309
- `facts_received` / `facts_joined` / `facts_unjoined` / `facts_undigested` —
315310
what arrived and what could not be attributed. **The backend MUST NOT read a
316311
missing workload as "not a Go workload"** when these say otherwise.
@@ -321,10 +316,15 @@ The agent drops records whose workload container has left its filtered pod index
321316
— deleted, or opted out by annotation — so a record's disappearance between two
322317
snapshots means "no longer collected", not "not observed this time". The backend
323318
MUST treat a superseding snapshot as the complete current truth and MUST NOT
324-
carry forward records absent from it. A short-lived workload (a CronJob's pods)
325-
may legitimately appear in one snapshot and not the next; accumulating "has ever
326-
been a Go workload" across snapshots is the backend's job, and `captured_at` is
327-
what dates them.
319+
carry forward records absent from it. A short-lived workload may appear in one
320+
snapshot and not the next; accumulating history across them is the backend's
321+
job, and `captured_at` is what dates them.
322+
323+
**`workload_revisions.revision` is a Deployment's number and nobody else's**
324+
([ADR 0049](adr/0049-revisions-are-not-only-deployments.md)). A record of any
325+
other `workload.kind` carries none — that controller numbers revisions under a
326+
key the agent does not read — so the backend MUST NOT read the absence as
327+
missing data, and MUST order those revisions by `created_at`.
328328

329329
**`go_build` is write-once and keyed by image digest.** What a build is made of
330330
and how it was built — the Go version, the main module, the dependency module

‎docs/security.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ test fails if a kind ships without appearing here (ADR 0022).
489489
| `restart_counters` | Per flush: for each collected container that has ever restarted, the kubelet's restart counter as the agent last observed it, how much of it the agent has watched and since when, when the pod was created, how long the current incarnation has been running, and the reason, exit code and instant of the most recent termination. The counter and the watched-since figure are read from one observation, so their difference is never negative (ADR 0043) | **yes** |
490490
| `pod_disruptions` | Per hour: the pods the *cluster* removed — preempted, evicted under node pressure, drained, or removed via the eviction API — with the node and the instant | **yes** |
491491
| `job_runs` | Per hour: each Job that finished — when it started and finished, whether it succeeded, the failure reason, its pod success/failure counts, and its declared `parallelism`/`completions`/`backoffLimit` | **yes** |
492-
| `deployment_revisions` | Per flush: each ReplicaSet of a collected Deployment — its revision number, when it was created, its desired/current/ready replica counts, and each container's image reference | **yes** |
492+
| `workload_revisions` | Per flush: each ReplicaSet of a collected workload that manages ReplicaSets — a Deployment, or a custom resource such as an Argo Rollout ([ADR 0049](adr/0049-revisions-are-not-only-deployments.md)) — with when it was created, its desired/current/ready replica counts, each container's image reference, and a revision number for a Deployment only | **yes** |
493493
| `workload_metadata` | Declared shape per (namespace, workload, container, image digest): image, requests, limits, ports, probe schedules without what any probe checks, the five runtime knobs of the field-minimization list below, QoS, replica counts by phase, by node, and by unscheduled reason, the pod's reduced placement constraints (ADR 0031), and — for a Deployment, StatefulSet or DaemonSet — the update strategy by which the workload replaces its own replicas ([ADR 0048](adr/0048-findings-name-the-fields-they-need.md)). A workload of any other kind carries no strategy and nothing standing in for one | no |
494494
| `workload_policy` | Per collected workload, what bounds it from outside its own spec: disruption budgets covering it, autoscalers driving it, the volume claims its pods mount (ADR 0032), and the Services routing to it (ADR 0048); plus `unavailable_sources`, naming any of those the agent could not read — never granted, or no longer being fed (ADR 0033, ADR 0035) | not directly — but a claim is named, and a StatefulSet's claim is `<template>-<set>-<ordinal>`, so `data-db-0` identifies pod `db-0` (ADR 0039) |
495495
| `cluster_policy` | Per collected namespace, its LimitRanges and ResourceQuotas; plus the cluster's PriorityClasses and StorageClasses, which workloads reference by name (ADR 0032), and `unavailable_sources` for any of those the agent could not read — never granted, or no longer being fed (ADR 0033, ADR 0035). StorageClass `parameters` are never read | no |
@@ -589,11 +589,12 @@ recognized rather than assumed current.
589589

590590
### Object history, and the one place pods are named (ADR 0020)
591591

592-
`deployment_revisions` names ReplicaSets, which your cluster created and named.
593-
Deployments only — StatefulSet and DaemonSet revisions live in
594-
`controllerrevisions`, which the agent has no RBAC for — and a Deployment appears
595-
only while it has collected pods, read from the same admitted index everything
596-
else uses ([ADR 0030](adr/0030-deployment-revisions.md)). `job_runs` names the Job
592+
`workload_revisions` names ReplicaSets, which your cluster created and named.
593+
Any workload that manages ReplicaSets — a Deployment, or a custom resource such
594+
as an Argo Rollout — and only while it has collected pods, read from the same
595+
admitted index everything else uses ([ADR 0030](adr/0030-deployment-revisions.md),
596+
[ADR 0049](adr/0049-revisions-are-not-only-deployments.md)). StatefulSet and
597+
DaemonSet revisions live in `controllerrevisions`, which the agent does not read. `job_runs` names the Job
597598
object, a generated per-run name such as `rollup-29123456`, and the CronJob that
598599
scheduled it; a run whose Job, CronJob, namespace or pod template carries the
599600
opt-out annotation produces no record ([ADR 0029](adr/0029-job-runs.md)).

0 commit comments

Comments
 (0)