Commit Graph

90 Commits

Author SHA1 Message Date
benvin 2652778306 Merge pull request 'Serve fact queries live and drop the cache headers' (#26) from benvin/drop-fact-caching into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #26
v0.4.0
2026-09-13 15:04:26 +10:00
unkin-agent 5c9d1e9055 Merge main into benvin/drop-fact-caching
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Both sides added jsonContentType; keep one. The no-entry 502 test kills the
backends outright, since main replays a unanimous 500, and the cached
content-type test moves to /nodes, the only cached path left.
2026-09-13 14:54:08 +10:00
benvin 3bea2e9b71 Merge pull request 'Replay every unanimous upstream status, not just 4xx' (#27) from benvin/replay-upstream-errors into main
Reviewed-on: #27
2026-09-13 14:31:56 +10:00
unkin-agent 85c9088293 Say that owner-first covers a certname with no resolved owner
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-13 14:13:44 +10:00
unkin-agent 72380f27e6 Serve the owner's own answer on the per-certname routes
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
Unanimity is the right rule for a fan-out of peers, but the per-certname
routes are not one: a backend that does not hold the certname answers 404
to say so, not to disagree, so requiring it to agree turned the owner's
real 500 into a 502 that described neither backend.

- Add askOrder, which says whether a set of backends was asked as peers
  or owner-first, and resolve each round's replies under its own rule.
- Serve the first backend that answered on owner-routed paths, so an
  unreachable owner still falls back rather than collapsing to 502.
- Keep unanimity for the merged, meta, metrics and pass-through routes.
- Cover the owner routes: owner errors against a non-owner 404, both
  erroring differently, an unreachable owner, and a non-owner error
  behind the owner's 200.
- Record what clientRefusal's 4xx exemption assumes about client certs.
2026-09-13 14:11:44 +10:00
unkin-agent 121bfacc2f Replay every unanimous upstream status, not just 4xx
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
openvoxdb does not reserve 5xx for its own faults: the same malformed
query is a 400 on /nodes and a 500 on /facts, and /metrics answers a flat
403, so a 4xx-only replay rule made pdbmux's behaviour depend on the
route. The meta and metrics handlers held their own copy of the gateway
error and bypassed the replay entirely.

- Replay any status from 400 up that every backend agreed on, with the
  backend's own body and content type.
- Keep 502 for backends disagreeing on the status, or a backend that
  answered nothing at all.
- Route /pdb/meta, /metrics and the pass-through path through the same
  rule as the merged query handlers.
- Count a unanimous 5xx as a failed round and let it fall back to a stale
  cache entry; only a unanimous 4xx stays exempt from both.
- Answer successful queries with openvoxdb's application/json;charset=utf-8.
2026-09-13 13:35:21 +10:00
unkin-agent 24f6d73d5c Serve fact queries live and drop the cache headers
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Fact answers must be as current as a backend's own, and X-Cache/Age are
headers PuppetDB never sends.

- serve /facts, /facts/<name>[/<value>] and /fact-names live on every request
- keep the in-memory cache on merged /nodes only
- drop X-Cache and Age everywhere; /healthz still reports cache state
- answer successful queries with PuppetDB's application/json;charset=utf-8
2026-09-13 13:34:52 +10:00
benvin 5207a79ad4 Merge pull request 'Resolve the per-certname routes to the node's owning backend' (#25) from benvin/merge-per-node-fact-routes into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #25
v0.3.0
2026-09-12 20:33:22 +10:00
unkin-agent eeb44057db Resolve the per-certname routes to the node's owning backend
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
The paths keyed on one certname took the pass-through, so a node both
backends hold answered from whichever was configured first while /facts
answered from whichever held its newer report.

- add a route claiming /pdb/query/v4/{nodes,factsets,catalogs}/<certname>
- order the backends for it by the freshness map the /facts merge uses
- try the remaining backends after the owner, replaying upstream's 404 when none holds the certname
- assert ownership, the upstream 404 body, a failed backend and query forwarding against captured openvoxdb shapes
2026-09-12 17:58:38 +10:00
benvin 94769a7864 Merge pull request 'Match backend addresses case-insensitively when redacting' (#24) from benvin/redact-backend-case into main
Reviewed-on: #24
2026-09-08 07:02:01 +10:00
unkin-agent d787d4ff95 Match backend addresses case-insensitively when redacting
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
DNS names are case-insensitive, so a backend named back in a different
case escaped redaction and reached the client.

- Fold ASCII case when replacing a backend's URL, host and hostname
- Pin the redaction vectors, the same-status rule and the bare-GET
  premise behind treating 406 and 415 as replayable
2026-09-07 23:09:56 +10:00
benvin 6a3b277e5e Merge pull request 'Replay a unanimous upstream rejection instead of a 502' (#23) from benvin/upstream-errors into main
Reviewed-on: #23
2026-09-07 23:07:55 +10:00
unkin-agent bfe28b488d Replay a unanimous upstream rejection instead of a 502
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Every backend gets the same query, so one they all refuse is the client's
mistake; flattening it into "all backends failed" threw openvoxdb's own
explanation away and logged a typo as an outage.

- Carry status, content type and body on a typed upstreamError
- Replay the status and explanation when every backend refuses alike
- Redact backend addresses from replayed bodies
- Keep a refused query out of the partial counters and the cache
2026-09-07 22:39:58 +10:00
benvin 85d134a449 Merge pull request 'Scope the goroutine-leak assertions to pdbmux's own goroutines' (#22) from benvin/e2e-goroutine-flake into main
Reviewed-on: #22
2026-09-07 20:02:15 +10:00
benvin 82c8c9aaf2 Merge pull request 'Sum /events aggregates instead of keeping one backend's row' (#21) from benvin/events-aggregates into main
Reviewed-on: #21
2026-09-07 20:00:20 +10:00
unkin-agent 8baa511ef9 Refuse an /events aggregate that also asks for distinct_resources
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
distinct_resources sends /events to openvoxdb's legacy compiler, which has no
function or group_by, so every backend failed and the client saw a generic 502
instead of the reason.

- Refuse an aggregate carrying a truthy distinct_resources with 400, before any fan-out
- Read the param the way openvoxdb does, so any capitalisation of "true" counts
- Leave non-aggregate distinct_resources queries and every other route alone
- Document the refusal
2026-09-07 18:23:48 +10:00
unkin-agent c886617d72 Sum /events aggregates instead of keeping one backend's row
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
An extract carrying a ["function", ...] column returns counts, not events, so
the union's verbatim-record key folded two backends' identical rows into one
number.

- Route /events through the central aggregate guard with its own fan-out path
- Drop its unsummed opt-out so the route-table property tests cover it
- Document the combined path on /events
2026-09-07 17:58:37 +10:00
unkin-agent c6a5e5fcd5 Count only pdbmux's own goroutines in the leak assertions
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Asserting an absolute runtime.NumGoroutine() fails roughly one run in
three under -tags e2e, where the harness keeps a live server, its prober
and testcontainers goroutines alive alongside these tests.

- Match goroutine stacks naming the flight group, fan-out or prober
- Derive those frame names from method expressions
- Compare against a baseline sampled the same way
- Poll the prober's settle check instead of sampling it once
2026-09-07 17:56:01 +10:00
benvin 2ea4ba82c5 Merge pull request 'Combine aggregate columns per function instead of summing every one' (#20) from benvin/aggregate-combiners into main
Reviewed-on: #20
2026-09-07 17:41:02 +10:00
unkin-agent 25d773ac60 Assert the route table and the per-function combiners compose
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
An aggregate reaching a guarded route through the dispatch table folds each
column by its own operation, so max is a maximum rather than a blanket sum.
2026-09-07 14:02:45 +10:00
unkin-agent 3eb7d53fe8 Refuse a group_by naming an aggregate column
A group_by key that repeats a folded column made the column a grouping
key and an aggregate at once, and for avg it left the upstream query
grouping on a column the rewrite had removed, so the request failed as
an opaque 502.

- refuse a group_by field that names a folded aggregate or the avg column
- cover the function-then-field ordering of the existing clash check
2026-09-07 14:00:44 +10:00
unkin-agent b499e962af Refuse duplicate aggregate columns and page aggregates after the fold
A repeated extract function names one response column twice, which openvoxdb
aliases as <name>_2: unknown to the merge spec, it froze at the first backend's
value. A limit pushed upstream truncated each backend's groups before the
cross-backend fold, so a group could be partly counted or missed.

- Refuses any extract projecting one response column twice, naming the clash
- Fetches every group and applies limit/offset after the fold
- Documents the float64 avg divergence from Postgres numeric
2026-09-07 14:00:44 +10:00
unkin-agent 66ed7b615c Combine aggregate columns per function instead of summing every one
sumRows folded every numeric column by addition, which is only correct
for count and sum, so min/max returned a sum, avg an average of
averages, and a to_string extract collapsed into one empty-key row.

- Combine count and sum by adding, min and max by the extreme, on text
  columns as well as numeric ones
- Rewrite an avg extract into an upstream sum and count and divide the
  totals, answering under the avg key the client asked for
- Refuse an aggregate pdbmux cannot merge with 400 naming the clash
- Treat to_string and jsonb_typeof as row functions that group rather
  than fold, and key groups on every non-aggregate projected column
- Give the e2e fixture per-node resource line numbers and titles whose
  extremes differ per backend
2026-09-07 14:00:44 +10:00
benvin e889cf8f7f Merge pull request 'Guard aggregates at the query dispatch, not per route' (#19) from benvin/central-aggregate-guard into main
Reviewed-on: #19
2026-09-07 13:57:04 +10:00
unkin-agent 629721a71f Guard aggregates at the query dispatch, not per route
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
A merged route that forgets the parseAggregate check silently returns one
backend's rows: an aggregate row carries no certname, hash or name, so the
identity-keyed merges collapse every backend's numbers into one. That is how
/facts/<name> shipped broken.

- Resolve every /pdb/query/v4 request through one route table
- Sum extract/function queries in handleQuery, before any route's handler runs
- Make each route that is not summed name its reason; the zero value is guarded
- Assert the guard over the route table, so a new route inherits the assertion
2026-09-06 23:15:30 +10:00
benvin f0f232664c Merge pull request 'Merge the /facts/<name> and /fact-names routes' (#18) from benvin/merge-fact-routes into main
Reviewed-on: #18
2026-09-06 22:54:26 +10:00
unkin-agent 6bf6a8024c Assert the stale drilldown keeps its owner filter
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-06 17:10:34 +10:00
unkin-agent e299f64b07 Assert the drilldown's no-fan-out path marks no partial round
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
The empty answer for an unknown value never reaches a backend, so it must
leave /healthz reporting a whole estate.

- assert partial_rounds stays 0 after an unknown-value drilldown
2026-09-06 16:53:11 +10:00
unkin-agent abf565b0f6 Stop the source-fact drilldown fanning out per pinned value
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
The <value> path segment is client-supplied and reached a full unfiltered
/facts fan-out, keyed per value, so every distinct value was a fresh
whole-estate query and a fresh cache entry.

- validate <value> against the configured backend names, answering [] with
  no fan-out when it names none
- key the drilldown's fetch on the fact name alone and apply <value> to the
  shared record set, so all values share one entry and one fan-out
- report every configured backend on the no-fan-out empty response, which is
  complete rather than partial
2026-09-06 16:51:00 +10:00
unkin-agent b6e190f1f2 Merge main into benvin/merge-fact-routes
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Main summed /facts aggregates; keep both branches' route tables and
aggregate documentation.
2026-09-06 16:18:04 +10:00
benvin 1410f9f603 Merge pull request 'Sum /facts aggregates across backends' (#17) from benvin/facts-aggregates into main
Reviewed-on: #17
2026-09-06 16:10:39 +10:00
unkin-agent 148be4fe0f Synthesise the /facts/<source-fact> drilldown
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
/fact-names advertises the fact, so its drilldown must not be a dead link.

- Serve the source fact's own path from the /facts merge that produces the
  records, so certname set, owner and environment match /facts.
- Filter /facts/<source-fact>/<value> by the owning backend.
- Keep the aggregate, query-gate and disabled paths answering as before.
2026-09-06 16:09:55 +10:00
unkin-agent cc71902a0d Sum aggregates on the /facts/<name> routes
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
An aggregate row carries no certname, so the per-certname merge collapsed
every backend's row into one bucket and served a single backend's count as
the estate's — no error, no warning, X-Backends still 2/2.

- Route an aggregate query on /facts/<name>[/<value>] to the summing path.
- List the injected fact's name in /fact-names instead of hiding it.
- Reject an order_by on any field but name, as the backends do.
2026-09-06 15:50:49 +10:00
unkin-agent c8efa26383 Merge the /facts/<name> and /fact-names routes
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Both fell to the unmerged pass-through, so one backend's answer was served
as if it were the estate's: Puppetboard's fact drilldown lost the other
backend's nodes and its facts overview lost that backend's fact names.

- Serve /facts/<name> and /facts/<name>/<value> through the /facts merge.
- Serve /fact-names as a deduped, re-sorted, re-paged union of name arrays.
- Gate provenance on the path: only /facts/<source-fact> may be injected.
- Keep the owned fact name out of /fact-names while the feature is on.
- Cache both alongside the merged /facts and /nodes record sets.
- Turn the two recorded e2e gaps into positive assertions.
2026-09-06 15:18:29 +10:00
unkin-agent 71b823fe71 Name the extract functions that do not merge
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-06 15:10:08 +10:00
unkin-agent 49ce1293de Sum /facts aggregates across backends
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
A `/facts` aggregate row carries no certname, so the per-certname fact
merge collapsed every backend's rows into one bucket and served a single
backend's numbers.

- Route a `/facts` query whose `extract` carries a `function` column to
  serveSummed, as /nodes, /resources and /reports already do
- Document which extract functions combine correctly across backends
2026-09-06 15:08:33 +10:00
benvin 0720d0930b Merge pull request 'Test the merge against real openvoxdb backends' (#16) from benvin/e2e-tests into main
Reviewed-on: #16
2026-09-06 15:02:30 +10:00
unkin-agent c87ecf65e8 Test the merge against real openvoxdb backends
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Every merge rule, AST gate and provenance decision was derived from reading
upstream source and proven only against fake backends, so nothing had ever run
against a real PuppetDB.

- Add an e2e suite behind the `e2e` build tag and a `make e2e` target
- Stand up two openvoxdb backends on their own PostgreSQL with testcontainers
- Load facts, reports and catalogs over the command API, waiting on processing
- Assert the union, freshness dedupe, summed aggregates, provenance gating,
  X-Backends, backend death and recovery, and the report paths
- Drive Puppetboard and node-lookup against pdbmux as real clients
- Record three known gaps as skips that fail once the gap closes
2026-09-06 11:22:36 +10:00
benvin 8f84da94ff Merge pull request 'Gate a backend only on a probe that has answered for it' (#15) from benvin/health-failure-window into main
Reviewed-on: #15
2026-09-06 10:28:50 +10:00
unkin-agent ac0a2c32ae Split the probe-reply kinds cleanly in the README
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-06 00:56:01 +10:00
unkin-agent e16ca9b701 Drop the recovery test the per-state table now covers
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
2026-09-06 00:55:25 +10:00
unkin-agent 6ebe0b4a44 Describe probe_unsupported by the latch in the healthz report
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
2026-09-06 00:54:38 +10:00
unkin-agent 31283969c3 Gate a backend only on a probe that has answered for it
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
The decaying failure window let a repeating cycle of one failure and a run
of rejections readmit a dead backend once per cycle, forever: any rule that
readmits on "no real failure lately" flaps under a periodic pattern.

Replace it with a per-backend latch: has this probe endpoint ever answered
with a verdict we can read? Until it has, there is no health signal, so the
backend is never gated and stays in service as probe_unsupported. Once it
has, the path works and every unsuccessful probe counts, rejections
included. The latch never clears, so no pattern can argue a backend back in.
2026-09-06 00:53:32 +10:00
unkin-agent 7fd5f72de1 Bound a probe failure's weight to a window of probes
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
A backend whose health_probe_path is wrong rejects every probe, so no
success can ever arrive to clear the sticky run-failed flag. One transient
failure in the middle of those rejections excluded such a backend from the
pool for the life of the process, reintroducing the stranding bug.

Decide unhealthy vs probe_unsupported on whether a real failure landed
within the last health_probe_failures probes, floored at 3 so the window
always spans a reject/reject/failure cycle. Sustained alternation keeps a
failure in every window and stays unhealthy; an aged-out failure leaves a
pure-rejection run on probe_unsupported and back in service.
2026-09-06 00:28:01 +10:00
benvin d34782b028 Merge pull request 'Health-check backends and skip the ones that are down' (#14) from benvin/backend-health into main
Reviewed-on: #14
2026-09-06 00:18:36 +10:00
unkin-agent 8f9e4125da Count one run of failed probes, whatever kind they are
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Resetting the run on every change of outcome kind meant only consecutive
same-kind probes accumulated, so a backend failing every probe but
alternating kinds — a fronting proxy flipping 404 and 503 — never reached
the failure threshold and kept taking every query.

- count one run of consecutive not-OK probes for the down decision, and use
  the kinds only to pick which non-healthy state the run enters
- send a run containing any real failure to unhealthy; only a run of nothing
  but refusals enters probe_unsupported and stays in service
- classify 429 as a real failure: it is the backend reporting its own
  capacity, so an overloaded backend gets backed off
- document that probe_unsupported means "not verified" and that reachable is
  the /healthz field carrying actual reachability
2026-09-06 00:14:55 +10:00
unkin-agent 42fdc36737 Keep a backend whose probe path is wrong in service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
A backend that 404s on the health probe path but serves queries fine was
marked down and excluded from every fan-out for good, since the fail-open
only triggers when no backend is left healthy.

Classify a probe reply that refuses the request itself - any 4xx, plus 501 -
as evidence about the probe, not the backend. Such a backend keeps serving
queries and reports the distinct probe_unsupported state on /healthz.
Transport failures and 5xx, 503 included, still mark a backend down.
Log the misconfiguration once per transition with the backend, probe path
and status. Track failure runs per outcome kind so a 404 run and a 503 run
never add up to one threshold.
2026-09-05 23:55:12 +10:00
unkin-agent 514377c7cb Health-check backends and skip the ones that are down
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
A down backend costs a full timeout stall on every request, since fan-out
has no way to know before it asks, and the client is never told the answer
came from fewer backends than are configured.

- poll each backend's status endpoint in the background, one goroutine per
  backend, with failure/success thresholds so a blip cannot flap it
- skip backends the prober has down, and fall open to querying all of them
  when none is left healthy
- treat a not-yet-probed backend as healthy so a restart drops no traffic
- log only up/down transitions
- stamp merged responses with X-Backends: <contributed>/<configured>
- report per-backend probe state and the last round's partiality on /healthz
- add health_probe_enabled, health_probe_path, health_probe_interval,
  health_probe_timeout, health_probe_failures and health_probe_successes,
  with matching PDBMUX_* env vars and a --health-probe flag
2026-09-05 23:30:28 +10:00
benvin 83c89ad426 Merge pull request 'feat: cache merged /facts and /nodes in memory, stale on backend failure' (#12) from benvin/cache-facts into main
Reviewed-on: #12
2026-09-05 23:15:43 +10:00
unkin-agent de61ec5081 Cache the merged body with its provenance already injected
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Rebasing onto main brought in the per-request sourceInjector, which now
runs inside the cached build: what a cache entry holds is the merged body
with pdbmux_source already stamped and upstream records of that name
already dropped.

Injecting on the way out instead would mean storing the un-injected
records plus a per-certname backend map and re-marshalling every record on
every hit, which is the work the cache exists to avoid. Baking it in stays
correct because the value names the backend that supplied the data — a
property of that fetch, not of the caller reading it — so it ages out with
the body it labels, and because the injection gate is a pure function of
path and query, both of which are already in the cache key.

Update the two cache tests whose byte-exact bodies predate the fact, and
add tests for the composition: attribution survives a cache hit on /facts
and /nodes, it ages with its entry rather than tracking a node that moved,
gated and ungated queries cache separately, and suppression of an upstream
fact of that name survives into the entry.
2026-09-05 23:05:54 +10:00