feat(managementapi): send cache-private headers on management reads (S09 Verdicts plan: 9/10)
What changes for a caller
Every response from the management API now says which credential it belongs to, and an error says it must not be kept at all:
HTTP/1.1 200 OK
Cache-Control: private, max-age=0
Vary: Authorization
X-Request-ID: 01K4...HTTP/1.1 404 Not Found
Cache-Control: no-store
Vary: Authorization
X-Request-ID: 01K4...One wrap inside NewHandler sets Vary and the private value before any route
runs, then swaps in no-store when the committed status is 400 or above. It
is the only thing that reaches the 404 and 405 the mux writes itself, which
no envelope writer ever sees. Writers that send no-store on their own — the
fail-closed denial, transport.WriteError — land on the same value, so the
order the two stamps arrive in cannot matter.
The authentication layer's 401 is written before routing, outside this
handler, and keeps its own header set. components/responses/Unauthorized is
therefore the one error response deliberately left without either header.
Why safe
Management reads answer per principal: visibility-filtered, role-gated, or
verdict-carrying. A shared cache that stored one and served it to a different
credential would leak another organization's data, and a cached
cross-organization 404 would hide a real namespace from its own members.
no-store forbids that storage outright instead of relying on a private cache
to key on the credential correctly.
No route behaviour changes. No new dependency, no Deps field, no route-table
edit. must-revalidate in place of the private value would be worse, since
RFC 9111 §3.5 names it as one of the directives that make an
Authorization-bearing response storable by a shared cache.
Each of the three header components is declared required with a single-value
enum, so the response validations the handler suites already run assert the
headers rather than just documenting them. The 503 responses join them here:
they pointed at a loose Cache-Control declaration carrying neither, which
asserted nothing.
The wrapping writer implements Unwrap, so http.ResponseController still
reaches the connection underneath and the destructive delete's write deadline
still arms.
Which OpenAPI blocks got the $refs
The shared error responses, and the 200 of 22 read operations. Three read
200s are deliberately untouched — the repository list, the repository detail,
and the namespace details endpoint — because sibling merge requests own those
operation blocks and a second writer there is a conflict. They carry the
headers at runtime today; their $refs arrive with their owners. Write success
responses are not declared either: the wrap is method-blind so they carry the
pair, but the obligation is stated over reads, and an undeclared response header
is permitted.
components/headers/Cache-Control is gone. It described a rule that no longer
holds — the private value on every status but an authorization-time 503 — and
its two successors state one value each: CacheControlPrivate on a success,
CacheControlNoStore on an error.
Spec coverage
Spec: S09 Authorization, the Caching section and its acceptance criterion on cache-private management reads.
| Clause | Test |
|---|---|
| The private pair on every success status | TestManagementHandler_CachePrivateHeadersOnEveryStatus — 200 on list, detail and namespace details |
no-store on every error status |
the same sweep's 404, 405 and 400 rows |
The 404 and 405 the mux writes itself, which no writer sees |
TestManagementHandler_MuxWrittenErrorsCarryNoStore |
The cross-organization and unknown-slug 404 |
.../unknown_slug_404 |
| Parameter present or absent | .../include_permissions_present, plus the seven rows without it |
A denial that already sends no-store agrees with the wrap |
TestManagementHandler_UnavailableDenialKeepsNoStore |
A 503 no denial rendered also sends no-store |
TestManagementHandler_DependencyUnavailableAlsoNoStore |
A 403 through the handler carries both headers |
TestDenialRenderer_WriteForbiddenMatchesGenuineForbiddenShape |
| Declared value and presence, across every validated response | the existing contract validations, via required + enum |
| The three header components exist and are strings | TestContract_CacheHeadersAreDeclared |
The authentication layer's 401 keeps S08's header set |
out of scope here: written before routing, so no request through NewHandler reaches it |
The three denial_test.go tests compare a whole header map between a response
served through NewHandler and a bare renderer call, so they replay the pair on
the renderer side. Nothing is weakened: the genuine side still comes from
production code, so a wrong or missing value fails all three.
Merge order
| Merge request | Overlap with this one | Order |
|---|---|---|
| !2295 (merged) repository list envelope | api/openapi/v1.yaml, disjoint regions — it owns the listRepositories block, this one never touches it |
either |
| Detail verdicts (not yet opened) | api/openapi/v1.yaml, the getRepository block it owns |
either |
| Namespace verdicts (not yet opened) | api/openapi/v1.yaml, the getNamespace block it owns |
either |
| !2224 (merged) namespace connection test | internal/managementapi/handler.go, export_test.go, api/openapi/v1.yaml — different anchors in each, so whichever lands second may need a small rebase |
either |
| !2266 (merged) unconfigured authorization posture | none | either |
!2316 (merged) no-store on error responses |
no file overlap; it amends S09 so error statuses carry Cache-Control: no-store, and stamps that value inside transport.WriteErrorWithDetails, which this surface reaches |
!2316 (merged) first |
!2316 (merged) had to land before this one, and it has: this branch is rebased on it and
the wrap is status-aware, so the two agree. In the other order git would still
have merged them cleanly and nothing would have flagged the clash — it would
have surfaced in CI, as the error-side assertions in cache_headers_test.go and
denial_test.go and every contract validation over an error response.
e2e scenarios
No scenario is added or affected. The catalogs under docs/testing/e2e/
cover format-client flows, which do not reach /api/v1, and cache directives on
a management response are not observable in any of them. The management catalog
the namespace-endpoint work introduces is not this change's to write.
Related to #670 (closed)