Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,23 @@ func runController(ctx context.Context, logger *slog.Logger, args []string) erro
return err
}

startedAt := time.Now()
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("loading configuration: %w", err)
}
// Counts and switches, never a name from the file: a digest of a handful of
// short namespace names is reversible, and the list it would expose is the
// one the customer chose to hide (ADR 0054 §2).
configShape := cfg.Describe(*configPath, startedAt)

clientset, restConfig, err := connect()
if err != nil {
return fmt.Errorf("connecting to cluster: %w", err)
}
logger.Info("connected to cluster", "host", restConfig.Host)

return run(ctx, logger, clientset, restConfig, cfg)
return run(ctx, logger, clientset, restConfig, cfg, configShape, startedAt)
}

// connect builds a clientset from the in-cluster service account when
Expand All @@ -133,12 +138,14 @@ func connect() (kubernetes.Interface, *rest.Config, error) {
return clientset, config, nil
}

// coverageInterval is how often the aggregate coverage counters are logged.
// coverageInterval is how often the aggregate coverage counters are logged and
// written as a payload. Unlike every other flush it runs whether or not there is
// anything to report: staleness here is a fact about the agent (ADR 0054 §5).
const coverageInterval = time.Minute

// run is the agent's lifecycle: it starts, works until ctx is canceled, and
// returns.
func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interface, restConfig *rest.Config, cfg config.Config) error {
func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interface, restConfig *rest.Config, cfg config.Config, configShape config.Shape, startedAt time.Time) error {
logger.Info("agent starting", "version", version)
defer logger.Info("agent stopping")

Expand Down Expand Up @@ -531,6 +538,29 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac
)
}
logger.Info("coverage", attrs...)

// The same counters as a payload. The log serves whoever is reading the
// agent's own output; this serves the report, which otherwise cannot
// tell an empty cluster from a blind agent (ADR 0054).
if spool == nil {
return
}
var inv *inventory.Counters
var scan *inventory.ScanCoverage
if goStore != nil {
ic := goStore.Counters()
sc := goStore.ScanCoverage()
inv, scan = &ic, &sc
}
agentInfo := sink.AgentInfo{
Version: version,
Config: configShape,
UsageSignals: usagePoller.Signals(),
}
if err := spool.WriteCollectionCoverage(time.Now(), startedAt, agentInfo,
podWatcher.SourceHealths(), c, podWatcher.PlacementDrops(), inv, scan); err != nil {
logger.Error("spooling collection coverage", "error", err)
}
}

// Watchers run until ctx is canceled; a failing watcher must bring the
Expand Down
4 changes: 3 additions & 1 deletion cmd/agent/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ func TestRunStopsOnContextCancel(t *testing.T) {
done := make(chan error, 1)
// A minimal REST config; node intake is disabled in this config, so it is
// never used to build a JWKS client.
go func() { done <- run(ctx, logger, fake.NewClientset(), &rest.Config{}, config.Config{}) }()
go func() {
done <- run(ctx, logger, fake.NewClientset(), &rest.Config{}, config.Config{}, config.Shape{}, time.Now())
}()

cancel()
select {
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0012-payload-registry-and-provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Date: 2026-08-20
Status: Accepted
Amended by: 0013, 0014, 0017, 0020, 0021, 0022, 0027
Amended by: 0013, 0014, 0017, 0020, 0021, 0022, 0027, 0054

## Context

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0033-policy-sources-degrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Date: 2026-08-24

Status: Accepted
Amends: 0032 §1
Amended by: 0035
Amended by: 0035, 0054

Changes how the agent behaves when it cannot read a policy source, and adds an
`unavailable_sources` line to `workload_policy` and `cluster_policy`. No new
Expand Down
125 changes: 125 additions & 0 deletions docs/adr/0054-coverage-says-how-much-was-hidden-never-what.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 0054. Coverage says how much was hidden, never what

Date: 2026-08-30

Status: Accepted

Amends: 0012 §2, 0033

Adds the payload kind `collection_coverage` and a fifth provenance class,
`agent`. The counters it carries already existed; none of them left the cluster.

## Context

"Nothing found" is a result, and it is the result a customer most needs to trust.
An empty report and a broken agent produce identical bytes today: the backend
receives some payloads and has no way to say "we looked at 412 pods, 37 of them
were excluded by your own namespace filter, and one cache we hold a grant for
never synced".

Everything needed to say that is already computed. The filter keeps twelve
counters, the placement reduction keeps two, the node scanner keeps five, the
inventory store keeps nine, and the watch health of every non-gating source is
tracked continuously (ADR 0033, ADR 0035). All of it goes to a structured log
line and stops there. `security.md` §11 promises that "filtering behavior is
verifiable from output rather than only from configuration" — and the output is
the agent's own log, which travels the customer's log pipeline and reaches
nobody who renders a report.

The node scanner's counters are worse off still: they cross the channel inside
`nodescan.Report`, arrive at the controller, and are dropped on the floor by a
join that reads only the binaries beside them.

## Decision

**1. A fifth provenance class, `agent`.** The four classes of ADR 0012 —
`structural`, `measured`, `journal`, `sampled` — all answer "where in the
cluster did this come from". This payload's subject is the agent itself. Calling
it `measured` would be the discriminator lying in the one payload whose entire
purpose is to be trusted about its own limits, so the vocabulary widens by one
and `registry_test.go` records the widening.

**2. No hash of the configuration. Counts and a time instead.** The backend
needs to tell "the data changed" from "the filters changed". The obvious
mechanism — a digest of the effective configuration — is wrong here, for a
reason specific to what that file holds.

The configuration is a handful of short namespace names. A digest of a
low-entropy value is reversible by trying the plausible ones, and the list it
would expose is the **deny list** — precisely the set the customer chose to hide
from us. The allow list is no secret (those namespaces are named in every other
payload); the denied one is the whole of the privacy question, and hashing it
would ship it in a form that merely looks safe.

So the payload carries the configuration's *shape*: how many entries each list
holds, which switches are on. Nothing to reverse, because nothing is concealed —
these are numbers we are willing to state plainly. And it carries `config.since`,
the modification time of the file, which answers "have the filters changed since
the last upload" better than an opaque token does. A time is not content.

The file cannot change under a running agent — the chart stamps a checksum of the
ConfigMap onto the Deployment, so an edit replaces the pod — which is why this
needs no watch and no reload path.

**3. Effective access is measured, not declared.** The payload reports, per
non-gating source, whether its cache filled and whether it is failing. This is
better evidence than the `SelfSubjectRulesReview` the earlier plan called for: a
grant the ClusterRole holds but an admission webhook or a network policy defeats
reads as *granted* in any review of the rules, and as *failing* here. A report
resting on a source needs to know which of those is true.

It also avoids a question that deserved its own decision rather than a quiet
slip: `SelfSubjectRulesReview` is a `create` call, made by an agent whose
customer document says it only ever gets, lists and watches. That call is not
made, and if it is ever wanted it arrives with its own ADR.

The gating caches — pods, namespaces, nodes, the owner chain — are absent from
the list on purpose. Their failure stops the agent (ADR 0035), so a payload
arriving at all is the evidence about them.

**4. Node scan counters are summed, and the sum is of latest passes.** Aggregate
rather than per node: "how many processes did the fleet skip" is a cluster
question, and a per-node breakdown would grow the payload with the fleet to
answer it in a shape nobody asks. Latest pass per node rather than a running
total, because the counters describe one walk of a process table — adding
successive walks would count one long-lived process once per scan interval.

**5. It ships on every flush, including one with nothing to say.** This is the
only kind for which "nothing changed" is not a reason to stay silent: its
staleness is a fact about the agent where silence in every other kind is a fact
about the cluster, or about nothing. An old `captured_at` says the agent
stopped.

## Consequences

**Easier.** A report can state what it rests on and what it did not see. "Nothing
found" becomes a sentence with evidence behind it rather than an absence
indistinguishable from a failure. Two `[planned]` markers leave `security.md`,
and `backend-requirements.md` — which has described this payload as arriving
since it was written — stops disagreeing with the code.

**Harder / given up.** One inference channel is not closed by any of this, and
it is honest to name it rather than let a reviewer find it (ADR 0039).

The backend sees every collected workload. If one disappears from the collected
set at the moment `excluded_workload_annotation` moves from 0 to 1, which
workload was hidden follows by subtraction. The counter does not create that
channel — the disappearance is observable without it — but it does explain it.
The alternative is silence, under which the backend concludes the workload was
deleted and tells the customer something false. Explaining an observable change
is the better of the two, and the customer's protection against the inference is
that the object was already collected before they opted it out, not that the
count is secret.

Nothing here closes it: a count fine-grained enough to be useful is
fine-grained enough to subtract. What the design does guarantee is that no name
of an excluded namespace, workload or pod appears in this payload or any other,
and `TestCoverageNamesNothingItExcluded` asserts it against a configuration
whose deny list holds real names.

**Not changed.** No RBAC, no new read, no new API call — every counter in this
payload was already being computed and thrown away. The `create` verb still
appears nowhere.

This ADR records decisions implemented in the same pull request, per the process
in [`README.md`](README.md).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,4 @@ line to paste.
- [0051. Topology-aware routing is a request the cluster can silently decline](0051-topology-routing-is-asked-and-not-granted.md)
- [0052. The peak the kernel remembers, and the second file the scanner opens](0052-the-peak-the-kernel-remembers.md)
- [0053. The network counters are the pod's, and they say nothing about where the bytes went](0053-network-counters-are-the-pods.md)
- [0054. Coverage says how much was hidden, never what](0054-coverage-says-how-much-was-hidden-never-what.md)
21 changes: 21 additions & 0 deletions docs/backend-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,27 @@ in nothing. A `GODEBUG` variable in `workload_metadata.runtime_env` overrides
both at startup; it is a fact about a workload, this is a fact about a build, and
the backend MUST NOT merge them into one value.

**`collection_coverage` is how the backend knows an empty report is empty rather
than broken** ([ADR 0054](adr/0054-coverage-says-how-much-was-hidden-never-what.md)).
It arrives on every flush, including one where nothing else did, and supersedes
its predecessor. A stale or absent `captured_at` is therefore a statement about
the **agent**, not about the cluster, and the backend SHOULD treat it as one: no
other kind's silence means anything.

Its counters are cumulative from `since`, which is when that process started
counting; a restart moves the base, and a drop across a change in `since` MUST
NOT be read as a decline. `agent.config` carries the *shape* of the customer's
configuration — entry counts and switches — and never a name from it, and never
a hash of it; `agent.config.since` is when that configuration took effect, and it
is what separates "the data changed" from "the filters changed". `sources[]`
reports what the agent's reads actually did, so a source that is `synced: false`
or `failing: true` bounds every finding resting on it — the backend MUST NOT
present such a finding as complete. The scan block is a fleet aggregate over the
latest pass of each reporting node and MUST NOT be summed across flushes.

No field of this payload names an object the customer excluded, and the backend
MUST NOT ask for one.

**`network_window` says how much moved, never where it went**
([ADR 0053](adr/0053-network-counters-are-the-pods.md)). The counters are read at
the pod's own interfaces, so the payload carries no destination, no peer and no
Expand Down
4 changes: 2 additions & 2 deletions docs/prose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ var ceilings = []struct {
lines int
section int
}{
{"security.md", 960, 310},
{"backend-requirements.md", 530, 385},
{"security.md", 975, 325},
{"backend-requirements.md", 555, 405},
}

func TestNoCommentRunIsLongerThanAPointerToAnADR(t *testing.T) {
Expand Down
31 changes: 22 additions & 9 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ test fails if a kind ships without appearing here (ADR 0022).

| Payload | What it carries | Names pods? |
|---|---|---|
| `collection_coverage` | What the agent did rather than what it found: how many pods and Jobs it observed, how many each of your four controls excluded, how many placement terms the reduction dropped, what the node scanners walked and skipped, which of the agent's reads worked, its version, and the shape of your configuration. **Aggregate counts only — no name of anything you excluded appears here or anywhere else** ([ADR 0054](adr/0054-coverage-says-how-much-was-hidden-never-what.md)) | no |
| `usage_snapshot` | The still-open hour's resource rollup: per (namespace, workload, container) histograms of CPU and memory, throttling and PSI counters, plus how much of the window was observed. Shipped once a minute, each replacing the last | no |
| `usage_window` | The same shape, final, when the hour closes | no |
| `network_window` | Per collected workload and closed hour: bytes and errors received and transmitted, summed over its pods and their interfaces. Interface **names are not read** — only how many there were. Counters are the *pod's*, so nothing here is attributed to a container, and a `host_network` workload's counters are the **node's** and describe the whole machine ([ADR 0053](adr/0053-network-counters-are-the-pods.md)). The kubelet counts bytes at the interface and never says where they went: there is no destination, no peer and no address in this payload | no |
Expand Down Expand Up @@ -761,9 +762,12 @@ Node-level totals (allocatable, aggregate node usage) are collected regardless o
workload filters. They attribute to no workload and are what reconciles cluster
cost against your invoice.

**[planned]** A payload carrying the per-filter exclusion counts and a
fingerprint of the effective configuration with the time it last changed. The
counters exist in the agent; no payload carries them out yet.
The per-filter exclusion counts leave in the `collection_coverage` payload,
beside the *shape* of the configuration in force — how many entries each filter
list holds and when the file last changed, never a name from it. There is
deliberately **no hash of your configuration**: a digest of a few short
namespace names is reversible by trying the plausible ones, and what it would
expose is your deny list ([ADR 0054](adr/0054-coverage-says-how-much-was-hidden-never-what.md)).

### Transport **[planned]**

Expand Down Expand Up @@ -916,12 +920,21 @@ one; it was never decided in an ADR and never existed in the configuration

### What the agent says about itself

The agent tallies pods observed and pods excluded by each filter, so filtering
behavior is verifiable from output rather than only from configuration.
Delivering those counts out of the cluster is **[planned]**, as is a startup
self-audit that would enumerate the agent's effective permissions via
`SelfSubjectRulesReview`. What holds today: a 403 degrades with a log line, never
a crash-loop or retry storm.
The agent tallies pods observed and pods excluded by each filter, and those
counts leave in `collection_coverage`, so filtering behavior is verifiable from
what we receive and not only from what you configured. The same payload reports
which of the agent's own reads worked — measured from its caches, not read back
from the rules, so a grant defeated by a webhook reads as failing rather than as
granted. No startup self-audit via `SelfSubjectRulesReview` is performed: it is a
`create` call, and this agent makes none (ADR 0054 §3). A 403 still degrades with
a log line, never a crash-loop or retry storm.

**What those counts can and cannot tell us.** They say how much was excluded and
under which of your four controls, never which object. One inference remains and
is stated rather than hidden ([ADR 0039](adr/0039-stated-limits-are-measured-limits.md)):
a workload that was collected and then opted out disappears from the data, and
that disappearance is visible whether or not a counter explains it. The counter
explains it; without it the conclusion would be that you deleted the workload.

### Opt-out

Expand Down
24 changes: 12 additions & 12 deletions internal/collector/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,26 +208,26 @@ func (f *Filter) countUnresolvedWorkload(reason UnresolvedWorkload) {
// report (docs/security.md §11): full information about what is collected,
// only counts about what is not.
type Coverage struct {
PodsObserved int64
ExcludedNamespaceFilter int64
ExcludedNamespaceAnnotation int64
ExcludedWorkloadAnnotation int64
ExcludedPodAnnotation int64
PodsObserved int64 `json:"pods_observed"`
ExcludedNamespaceFilter int64 `json:"excluded_namespace_filter"`
ExcludedNamespaceAnnotation int64 `json:"excluded_namespace_annotation"`
ExcludedWorkloadAnnotation int64 `json:"excluded_workload_annotation"`
ExcludedPodAnnotation int64 `json:"excluded_pod_annotation"`
// WorkloadUnknownKind and WorkloadNotCached are the blind spot: pods
// admitted without their workload-level opt-out being checked. They are
// kept apart because they mean different things — the first is a standing
// property of a cluster running operators the agent does not read, the
// second should sit at zero.
WorkloadUnknownKind int64
WorkloadNotCached int64
WorkloadUnknownKind int64 `json:"workload_unknown_kind"`
WorkloadNotCached int64 `json:"workload_not_cached"`

// Job counters, kept apart from the pod ones on purpose: one Job produces
// many pods, so a shared counter would answer neither question.
JobsObserved int64
JobsExcludedNamespaceFilter int64
JobsExcludedNamespaceAnnotation int64
JobsExcludedWorkloadAnnotation int64
JobsExcludedAnnotation int64
JobsObserved int64 `json:"jobs_observed"`
JobsExcludedNamespaceFilter int64 `json:"jobs_excluded_namespace_filter"`
JobsExcludedNamespaceAnnotation int64 `json:"jobs_excluded_namespace_annotation"`
JobsExcludedWorkloadAnnotation int64 `json:"jobs_excluded_workload_annotation"`
JobsExcludedAnnotation int64 `json:"jobs_excluded_annotation"`
}

// Snapshot returns the current coverage counters.
Expand Down
Loading