feat(api): version statistics contract (S17 Phase 8 plan: 2/13)
Why
Phase 8's statistics surface has four scopes. feat(api): aggregate statistics contract (S17 P... (!1964 - merged) • Hayley Swimelar • 19.4 declared the three aggregate ones, and this declares the fourth. Nothing is built against a scope the document does not carry: each handler step drains its arm out of the contract sweep's pending set, and the sweep reads its arms out of api/openapi/v1.yaml.
Plan: docs/plans/2026-08-12-s17-phase8-statistics.md, ### Step 2: Contract: artifact statistics. Spec: docs/specs/S17-rest-management-api.md, Statistics (Phase 8), AC #111 (closed) and AC #118 (closed).
What (the non-obvious parts)
One schema, not a branch per format. RepositoryStatistics needs a oneOf over three closed branches because a container repository's figures differ from an npm repository's. A version's do not, so VersionStatistics is one open schema serving both {format} arms. It stays open for the reason those branches close: closure is what makes a oneOf decidable, this schema sits in none, so closing it would only re-price every added figure as breaking (docs/dev/api-style.md#closed-response-branches).
files_count and size_bytes disagree on the same version by design. The count drops a tombstoned file from the mark onward. The footprint keeps its bytes until the reap and garbage collection remove them. Both sit in one response, so a caller reading a version mid-delete sees a count that has moved beside a byte figure that has not.
No downloads_count, and the schema says why. Every other statistics scope has one. No column counts downloads per artifact, so the field could only carry a zero no caller can tell from a version nobody downloaded. Omitted rather than stubbed, and a test pins the absence.
Hosted-only, while the artifact routes on the same prefix are not. Phase 6 widened those to remote repositories and left statistics alone. A remote repository's version is readable at /{format}/versions/{version_id} and answers 404 at .../statistics.
AC #118 (closed) is enforced as a closed set. ADR-009 declares image, container tag, package, and npm dist-tag statistics paths this phase does not serve. Four literal absence checks would pass vacuously the moment a spelling drifted, so TestContract_StatisticsPaths_AreExactlyTheServedScopes collects every path that ends in /statistics or carries the statistics tag, and matches that set against the seven served scopes.
docs/dev/bruno.md is outside the plan's Step 2 file list. !1964 (merged) wrote three claims into it that the seventh Bruno request here falsifies: "the namespace, format, and repository statistics reads", "the six statistics reads", and a variables row calling versionId the id "one delete addresses". All three are corrected, and nothing else in that file is touched.
A spec correction this MR does not make
docs/specs/S17-rest-management-api.md:1260 explains the four withdrawn scopes' unreachability as "Each carries a segment past the artifact patterns' last wildcard, so it matches no route". That causality is wrong, and this MR's own path is the counterexample: versionPattern + "/statistics" is exactly that shape, and imageTagsPattern, imageManifestsPattern, and packageVersionsPattern are all a literal segment past their last wildcard and all registered (internal/managementapi/handler.go:570, :572, :601). The four are unreachable because nothing registers them, and nothing will, for the product reason the same spec gives two paragraphs earlier: no design surface calls one, and their figures are the per-row figures. The assertion message here states that reason rather than repeating the routing claim. The spec sentence needs its own correction MR.
Test plan
go test ./internal/managementapi/ -count=1green, and green under-race.npx @redocly/cli@2.34.0 lint --config api/openapi/redocly.yamlvalidates both documents, at the versionlint:openapipins.golangci-lintreports 0 issues plain and under--build-tags=integration.- The sweep skips the two new format arms while they answer the mux's
404, and fails if one starts serving without its entry dropped, or if an entry names an arm the document does not declare. - Every assertion was checked against a mutated document rather than trusted. Closing the schema with
additionalProperties: falsefails the family-shape test, and so does anadditionalPropertiesschema, which aHas-only predicate cannot see. Respellingsize_bytesas[integer, 'null']without aminimumfails the floor walk, which theType.Isform silently skipped. A statistics path spelled outside the/statisticssuffix fails the closed-set test. Inlininglast_downloaded_atfails the$refpin, and a lossy inline drops the RFC 3339 check with it. SwappingpackageFormatforrepositoryFormatfails the enum pin and the sweep'sdockerandociarms.
No e2e catalog scenario is added or affected: docs/testing/ mentions statistics nowhere, and no handler is registered here.
Related to #316