chore(managementapi): scaffold the S17 Phase 3 artifact routes
Why
S17 Phase 3 adds 13 read-only artifact endpoints, and the plan splits their handlers across three steps that ship in parallel: container, packages plus dist-tags, and versions plus files. All three edit the same two wiring files. With no seeded layout each would append to one route block, one Deps struct, one guard list, and one assertion block, which hands a conflict to whichever two land second and third.
This step registers all 13 routes as 501 placeholders in three separated per-step groups, seeds the matching anchors in cmd/artifact-registry/wire_management.go, and lands the resolve, parse, and serialization helpers every artifact handler consumes. Step 3 of the plan in docs/plans/2026-07-22-s17-phase3-format-artifact-reads.md.
What is not obvious
The two dist-tag routes are not {format} routes. The contract declares dist-tags for npm alone (.../npm/packages/:id/tags, .../npm/tags/:id), so those patterns carry a literal npm segment and bind no {format} path value. resolveArtifactRepository would compare the repository's format against the empty string and 404 every dist-tag request, so those two resolve through resolveNpmArtifactRepository. An empty format reaching the shared body is a logged 500 rather than a 404, so picking the wrong helper fails loudly.
parsePathID accepts only the canonical UUID spelling. uuid.Parse also accepts the unhyphenated, brace-wrapped, urn-prefixed, and uppercase forms, which would give one artifact five URLs against a contract that declares one format: uuid parameter. The round trip through String() mirrors parseCanonicalUUID in internal/gitlabapi/detail.go. A rejected id gets the same 404 as an absent one, so there is no syntax oracle.
The nil-guard block moved out of NewHandler into requireDeps. All three handler steps add guards, and the anchors that keep those three edits disjoint need room the route table does not leave.
Size: 1559 added lines, past the 500 ceiling in docs/dev/development-model.md. 1146 of them are tests, including the contract-to-route sweep relocated here from the contract MR at review's request. The three route groups have to land together for the merge isolation they exist for, so the registration does not split.
Spec coverage
| Spec or AC | Tests |
|---|---|
Artifact read routes, API Contracts: all 13 GET patterns registered |
TestHandler_ArtifactRoutes_Return501WithEnvelope, TestWireManagementAPIWithDeps_RegistersArtifactRoutes |
| AC #24: format segment differing from the repository's format returns 404 | TestResolveArtifactRepository_FormatSegmentMismatch_Returns404, TestResolveNpmArtifactRepository |
| AC #24: a path id that is not a valid UUID returns 404 | TestParsePathID_RejectedSpellings_Returns404 |
| Security Considerations: existence hiding, no syntax oracle | TestResolveArtifactRepository_MismatchIsIndistinguishableFromMissing, TestParsePathID_RejectionIsIndistinguishableFromNotFound |
Artifact resources: sha256:<hex> digests, lowercase-hex checksums |
TestFormatDigest, TestFormatChecksum |
| Slug resolution runs before every artifact route | TestHandler_ArtifactRoutes_UnknownSlugIs404 |
| Dist-tag routes are npm-only | TestHandler_DistTagRoutes_AreNpmOnly |
| A wiring or schema defect surfaces as a logged 500, not a 404 | TestResolveArtifactRepository_MissingFormatPathValue_Returns500, TestResolveArtifactRepository_UnmappedFormatEnum_Returns500 |
Test plan
go test ./internal/managementapi/ ./cmd/artifact-registry/
go-lint-ci ./internal/managementapi/... ./cmd/artifact-registry/...On a live rig (/ar-start-dev), with a namespace and one repository per format seeded:
curl -H "Authorization: Bearer $AR_BOOTSTRAP_TOKEN" \
"http://$AR_HOST/api/v1/<slug>/repositories/<repo>/docker/images"
# 501, {"error":{"code":"not_implemented",...}}No e2e scenario changes. The catalogs in docs/testing/e2e/ cover protocol-client journeys, and the artifact-browsing journey lands with the monolith slices that consume these endpoints.
Related to #312 (closed)
Context for LLM agents
Merge isolation is the load-bearing claim, and it was verified
The plan asserts Steps 10, 11, and 12 may merge in any order. That rests entirely on this step's three groups being disjoint hunks with unchanged context between them. Verified empirically rather than argued: three throwaway branches were built off this HEAD, each filling only its own anchors in both files (13 route replacements, 3 Deps fields, 3 requireDeps guards, and 4 wire regions per step), then merged in all six permutations. All six merged with no conflict, and the merged tree carried 19 insertions in handler.go and 12 in wire_management.go, which is every insertion from all three branches with none dropped.
Two layout constraints came out of that work and should not be undone:
- Each anchor is a two-line comment with a blank line between anchors, giving three unchanged lines between consecutive insertion points.
- Anchors sit above the
Trackerentry rather than at the end of their block, becausewslrejects a block ending in a comment. TheTrackerseam closing the list is defensible on its own terms: it is the one dependency that is not a datastore store.
Rejected alternatives
- Register the dist-tag routes under
{format}and enforce npm in the handlers. Rejected: it registers/maven/tags/:idand/maven/packages/:id/tags, which the contract does not declare, and it moves the npm constraint from the mux into handler code a later step must remember to write. - Treat an empty
{format}as "nothing to compare" and skip the format check. Rejected: the repository's own format would then go unverified on exactly the two routes where the mux cannot verify it either. - Keep the nil guards inline in
NewHandler. Rejected: three steps appending to one block conflicts, and the function exceeds the 70-linefunlenbudget once the anchors are added. - Hoist
parseCanonicalUUIDout ofinternal/gitlabapiinto a shared package. Rejected for now: it is outside this step's file list, and a cross-package refactor during a six-way parallel merge window is the wrong trade. The duplication is three lines. Follow-up candidate.
Non-goals
- No datastore stores are constructed. The
501placeholders read none, sowire_management.gogains anchors and no wiring. - The placeholders do not resolve the repository or check the format. That lands with the handler steps. A live probe of
.../repo-maven/npm/tags/:idanswers 501 today, not 404. - The OpenAPI document does not declare these operations yet (Step 1, a sibling MR). Until Steps 10-12 land there is a window where the server answers 501 on paths the contract will describe with 200, 400, and 404 and no 501. That is inherent to the plan's contract-first sequencing, and reviewers of Step 1 may want
501declared on the Phase 3 operations to close it. - No integration test. This step touches no database.
- The repository-level 404 message differs from the artifact-level one ("repository not found" against "artifact not found"), so a caller can tell which level of the chain broke. Both carry
not_found, which is all the spec pins. Repository visibility is unenforced until ADR-021 (S09). Once it is, an unauthorized caller stops at the repository lookup before reaching the artifact level. Within each level the responses are byte-identical, which the two indistinguishability tests pin.
Rig evidence
Validated on a live kind rig built from this branch (ar-dev-1, slot 1), against the real binary, real auth middleware, and real Postgres:
- All 13 routes: 501 with the S01 envelope and
not_implemented. - Unauthenticated: 401. Unknown slug: 404
not_found. Blocked namespace: 404. Suspended namespace: 501 on the read, 403 on a write, so the S33 gate ordering holds for the new routes. POSTandDELETEon an artifact route: 405 withAllow: GET, HEAD.- Undeclared
.../maven/tags/:idand.../maven/packages/:id/tags: 404, so no{format}-wildcard dist-tag route exists. - 8000-character path segments, a percent-encoded NUL in a tag name, CRLF in a tag name, and
../../../../etc/passwdin an image id: all 501 or 404, zero panics in the app log.
One tier difference worth knowing: inside NewHandler's private mux an unregistered path misses with the stdlib text/plain 404 page not found, which is what the unit tests assert. The deployed server converts a mux miss to the S01 JSON envelope, so in production the discriminator between a registered and an unregistered artifact path is 501 against 404, not the content type.