chore(managementapi): scaffold the artifact write routes

Why

The artifact write surface adds 14 routes: a delete for every addressable artifact, container tag upsert and untag, and bulk delete on six collections. Fourteen later merge requests fill them, written in parallel, and each one adds a route registration, a Deps field, a nil guard, a store construction, and an interface assertion to the same two files. Without a scaffold they collide on every one of those lines.

This registers all 14 as 501 placeholders and seeds the insertion points, so each later change edits its own region and the set merges in any order. It also pins the two routing properties that are cheap to establish now and expensive to discover later: every write route sits behind the slug resolver and the namespace write gate, and the bulk_delete literal segment resolves against its sibling wildcard the way net/http.ServeMux actually behaves rather than the way it reads.

Plan: docs/plans/2026-08-10-s17-phase4-artifact-writes.md, Step 8.

What is worth your attention

The six format-dispatched shells read no store before answering 501, and a test asserts the repository reader records zero calls. That looks like a missing existence check and is not. The reason is the pending-operations sweep in feat(managementapi): declare the artifact delet... (!1445 - merged) • Hayley Swimelar • 19.3, whose predicate treats a JSON-envelope 404 as a served route, so a shell that resolved its repository first would fail that sweep's deletePackage, deleteVersion, and deleteFile entries as stale. I ran that sweep against this tree both ways: with the resolve, six subtests fail; without it, all six skip as pending and the sweep passes. Either merge order is green. The resolve, the hosted-repository narrowing, and the stored-format comparison land with the per-format arms, and the trunk's doc comment enumerates all five 404s an arm has to restore.

ServeMux matches the method before the path, so a literal bulk_delete beats its sibling wildcard only for the method it is registered under. Every other verb falls through to the wildcard, or answers 405 when neither pattern registers it, which is why a tag literally named bulk_delete stays addressable. The whole table was measured against a probe mux before it was asserted, Allow values included.

Reviewable size is 1356 lines against the 500 ceiling. 742 are tests and much of the remainder is anchor prose. The step boundary is the plan's: a partial route table leaves some routes answering the bare mux 404 while their contract entries expect 501, so splitting it relocates the problem instead of shrinking it.

Two commits read oddly side by side. 41c8ff27 adds a hosted-repository gate inside the dispatch trunk and 1b0b84da removes it along with the whole resolve, for the reason above. Rewriting history is not on the table here, so both stay, and 5b5e311c's body likewise still says the shells "resolve the repository first", which a later commit reversed.

Test plan

CI. Locally: internal/managementapi and cmd/artifact-registry pass, go-lint-ci reports 0 issues on both, and the integration-tagged lint run stays at its 10 pre-existing findings, all in files this branch does not touch.

Spec coverage

The rows this change pins. Every other Phase 4 criterion belongs to a later step, and the full mapping is in the <details> block below.

# Criterion Tests
AC-31 Each delete route exists and answers with the envelope TestHandler_WriteRoutes_Return501WithEnvelope
AC-63 Each bulk path is its list path plus bulk_delete, and no bulk route hangs off a top-level detail-by-id prefix TestHandler_BulkDeletePaths_ComposeOnListPaths, TestHandler_TopLevelDetailPrefixes_HaveNoBulkRoute
AC-65 POST reaches the bulk endpoint, other methods fall through to the sibling wildcard or answer 405 TestHandler_BulkDeletePaths_MethodFallthrough, TestHandler_ManifestDigestPath_IsDeleteOnly
AC-66 A tag named bulk_delete stays addressable TestHandler_TagNamedBulkDelete_StaysAddressable
AC-67 An out-of-family :format segment returns 404 TestHandler_PackageWriteRoutes_ContainerFormatIs404
Error cases Slug resolves to no namespace TestHandler_WriteRoutes_UnknownSlugIs404
Security A mutating method on a suspended namespace is refused TestHandler_WriteRoutes_SuspendedNamespaceIs403, with TestHandler_SuspendedNamespace_KeepsReadsServiceable as the read control
Interim contract Every arm answers as unfilled and reads no store TestHandler_PackageWriteRoutes_Answer501WithoutReadingTheStore, TestHandler_PackageWriteRoutes_AnswerAsUnfilled
Context for LLM agents

Design rationale, with the alternatives rejected

The dispatch trunk switches on the {format} path value, not on the resolved repository row. The plan's Step 8 scope says "the resolved repository's format", and this deviates. Two alternatives were rejected. Resolving first answers the existence-hiding JSON 404 under a repository reader holding no rows, which is what the contract sweep in feat(managementapi): declare the artifact delet... (!1445 - merged) • Hayley Swimelar • 19.3 classifies as a served route, so it fails that sweep's pending entries for the three package-family deletes. Dropping those pending entries instead is not available: the file belongs to that merge request, and the sweep separately rejects a 501 from a non-pending entry, so the entries have to stay while the arms are unfilled. The consequence is recorded in the trunk's doc comment: an arm resolves the repository itself and owns the hosted narrowing and the stored-format comparison.

The trunk and the 501 writer live in handler.go. internal/managementapi/artifact_write.go is a later step's file, so creating it here would collide with that change. handler.go already owns which handler serves which pattern.

Six shell files with two named arms each, rather than one table. The Maven and the npm arms are filled by separate merge requests, so they need distinct insertion points in distinct files. A table or a map would put both formats on shared lines. Same reasoning keeps the flat if x == nil { panic(...) } guard chains flat and keeps every anchor a one-line comment.

The bulk seam anchor is single-owned rather than split per bulk family. There is one BulkEnqueuer field, one construction, one guard, and one assertion, so four sub-lines would invite four declarations of one seam. chore(managementapi): bulk-delete enqueue seam ... (!1446 - merged) • Hayley Swimelar • 19.3 lands that seam in its own files and touches neither handler.go nor wire_management.go, so the first bulk route family to land declares the Deps field and the rest find it declared. The anchors say so.

Non-goals, and adjacent concerns a reviewer may raise

  • No arm is filled and no store seam is wired. Both belong to later steps, however close a one-line change looks.
  • The plan's Status row is deliberately untouched. Every branch in this wave would edit the same rows, so it is filled once for the whole wave in a separate change.
  • The hosted-repository narrowing is unpinned on the write surface right now. The assertion went with the resolve. Nothing else covers it, and the first arm to land reacquires it. The read side keeps its own checks in container_list.go and versions.go.
  • A proposed hardening was declined this round. The trunk's default folds an unbound {format} into a silent 404 where the read resolvers answer a logged 500 for the same wiring defect. Every current registration binds a dispatching wildcard, pinned externally by TestHandler_PackageWriteRoutes_ContainerFormatIs404 receiving the trunk's envelope rather than the mux's text/plain miss, and the read side's own per-format dispatch defaults answer the same 404. Worth adopting in the first step that lands an arm.
  • Two contract-side items belong to the contract steps, not here. The Error schema prose in v1.yaml says an undeclared method answers 405, which is falsified on six declared paths for as long as the placeholders stand, and their Allow headers widen. Separately, not_implemented becomes emittable on the management surface with no entry in the OpenAPI code enum and none in the contract test's emittable slice, and no plan step currently owns adding it.
  • The 14 placeholder 501s are marked as errors by LabKit tracing and will land in any status_code=~"5.." error-rate indicator. The carve-out belongs where that indicator is defined rather than in code, since answering 501 with the envelope is this step's stated acceptance and the contract sweep keys its pending mechanism on exactly that status. Live traffic is nil, because no client can reach a path the contract has not published.
  • oci has no positive hit on the seven container write rows, only docker. The scaffold-tier suite this mirrors covered docker, maven, and npm alone, and all seven container write patterns compose on the same shared pattern constant the read side already pins. The container handler steps are where a missing oci arm could actually go silent.
  • One correction for the contract change's author. Its pending-map comment says the change that wires the dispatch "drains every arm of that operation at once". With per-arm dispatch that is not right: with one arm filled and one still answering 501, dropping both entries reddens the unfilled arm. Only the filled arm's entry should go, and the map's per-format keys already allow that.

Full spec coverage

Spec coverage

Spec: docs/specs/S17-rest-management-api.md

Acceptance criteria

Criteria 1-29 are the repository CRUD and artifact read surfaces, shipped before this plan and untouched by it. The table covers the artifact-write block, criteria 30-67.

# Criterion Tests
AC-30 OpenAPI defines every write endpoint and validates in CI Contract steps (1-4). Not tested here.
AC-31 Each delete returns 202, target absent, missing target 404 Handler steps (16-26). This MR pins the routes exist and answer 501: TestHandler_WriteRoutes_Return501WithEnvelope
AC-32 Each delete removes the full subtree Composer and handler steps (11-15, 16-26). Not tested here.
AC-33 An interrupted reap leaves the target marked and completes later S20-A's purger. Owned by that spec's plan.
AC-34 Every read under a marked image answers 404 from the mark on Marker-predicate steps (6, 7, 16). Not tested here.
AC-35 A push reusing a marked image's name succeeds as a fresh row Partial-index swap step (7). Not tested here.
AC-36 npm version or file delete expires the packument cache npm composer steps (12, 13). Not tested here.
AC-37 Last active npm version removes the package npm version composer step (12). Not tested here.
AC-38 Whatever removes a row removes its blob attachment Attachment-widening step (10). Not tested here.
AC-39 No 409 except an indexed manifest, and no bulk 409 or 422 Manifest delete step (17) and the bulk tracks. Not tested here.
AC-40 Manifest delete by digest returns 202 with the deleter run Manifest delete step (17). This MR pins the route registers: TestHandler_WriteRoutes_Return501WithEnvelope
AC-41 An indexed manifest returns 409 with the parent digests Manifest delete step (17). Not tested here.
AC-42 Bulk subset with a blocked and a deletable manifest returns 202 Container bulk worker steps (30, 31). Not tested here.
AC-43 A bulk batch applies in dependency order Container bulk worker step (31). Not tested here.
AC-44 A skipped manifest survives, no per-entry response body Container bulk worker step (31). Not tested here.
AC-45 delete_all on manifests removes referrer rows too Container bulk worker step (31). Not tested here.
AC-46 delete_all on manifests empties the collection Container bulk worker step (31). Not tested here.
AC-47 Each selector takes its resource's own identifier Bulk selector decode step (27). Not tested here.
AC-48 Deleting a tag leaves its manifest readable Container tag delete step (18). Not tested here.
AC-49 Counters match the equivalent protocol operation npm composer and handler steps (12, 13, 23-26). Not tested here.
AC-50 One deletion event per named artifact Shared write helpers step (9) and the handler steps. Not tested here.
AC-51 Bulk delete applies its entries in a job Bulk worker steps (29-37). Not tested here.
AC-52 Tag upsert creates with 201 and retargets with 204 Tag upsert step (19). This MR pins the PUT route registers: TestHandler_WriteRoutes_Return501WithEnvelope
AC-53 Tag upsert rejects a bad digest and a bad tag name Tag upsert step (19). Not tested here.
AC-54 Tag upsert at the cap returns 422 limit_exceeded Tag upsert step (19). Not tested here.
AC-55 A batch at the cap is accepted, one over is rejected Bulk selector decode step (27). Not tested here.
AC-56 Repeated entries are applied as a set Bulk selector decode step (27) and the workers. Not tested here.
AC-57 An unknown or foreign entry is a no-op, resubmission is 202 Bulk worker steps. Not tested here.
AC-58 A generated subset body round-trips against the oneOf Contract steps (3, 4). Not tested here.
AC-59 Both, neither, or a false selector returns 400 Bulk selector decode step (27). Not tested here.
AC-60 delete_all returns 202 and empties the URL's collection Bulk worker steps. Not tested here.
AC-61 delete_all on an over-cap or empty collection returns 202 Bulk worker steps. Not tested here.
AC-62 An artifact newer than the acceptance time survives Bulk worker steps. Not tested here.
AC-63 Each bulk path is its list path plus bulk_delete, and no bulk route hangs off a top-level detail-by-id prefix TestHandler_BulkDeletePaths_ComposeOnListPaths, TestHandler_TopLevelDetailPrefixes_HaveNoBulkRoute
AC-64 A non-canonical entry returns 400 and no response echoes one Bulk selector decode step (27). Not tested here.
AC-65 POST reaches the bulk endpoint, other methods fall through to the sibling wildcard or answer 405 TestHandler_BulkDeletePaths_MethodFallthrough, TestHandler_TagNamedBulkDelete_StaysAddressable
AC-66 A tag named delete_all, all, or bulk_delete stays addressable and is removed individually Addressability half: TestHandler_TagNamedBulkDelete_StaysAddressable. Individual removal: container bulk steps (30, 32).
AC-67 A write route with a mismatched format, a non-UUID id, or a non-canonical digest returns 404 Out-of-family :format segment: TestHandler_PackageWriteRoutes_ContainerFormatIs404. Stored-format comparison, id, and digest halves: shared write helpers step (9) and the handler steps, which own the resolve.

Error cases

Endpoint Condition Tests
All Slug resolves to no namespace TestHandler_WriteRoutes_UnknownSlugIs404
All Malformed JSON body Bulk selector decode step (27) and the tag upsert step (19). Not tested here.
All Authentication missing or invalid S08-owned, an allow-all stub today. Not tested here.
All Authenticated but not permitted S09-owned, an allow-all stub today. The adjacent namespace write gate is pinned: TestHandler_WriteRoutes_SuspendedNamespaceIs403
List Invalid format, kind, sort, order, or limit Repository read surface, shipped earlier. Not in this plan.
List Undecodable or boundaryless pagination cursor Repository read surface, shipped earlier. Not in this plan.
Create Name conflict Repository write surface, shipped earlier. Not in this plan.
Create Invalid name, format, or visibility, or long description Repository write surface, shipped earlier. Not in this plan.
Create Non-hosted kind Repository write surface, shipped earlier. Not in this plan.
Create Per-format repository cap reached Repository write surface, shipped earlier. Not in this plan.
Detail, Update, Delete Repository missing Repository surface, shipped earlier. Not in this plan.
Delete destructive omitted or carrying another value Repository surface, shipped earlier. Not in this plan.
Delete destructive=false on a repository with artifacts Repository surface, shipped earlier. Not in this plan.
Update Immutable field present Repository surface, shipped earlier. Not in this plan.
Update Invalid field value Repository surface, shipped earlier. Not in this plan.
Artifact routes Repository format differs from the :format segment Out-of-family segment: TestHandler_PackageWriteRoutes_ContainerFormatIs404. In-family mismatch: the handler steps, which own the resolve.
Artifact routes Repository kind is not hosted Handler steps (16-26), which need a non-hosted fixture. Not tested here.
Artifact routes Parent or artifact id missing, invalid, marked, or foreign Partial: the fallthrough reads reject bulk_delete as an id in TestHandler_BulkDeletePaths_MethodFallthrough. The write-side rejections land with the handler steps.
Artifact lists Invalid sort, order, limit, include_referrers, cursor Artifact read surface, shipped earlier. Not in this plan.
Artifact delete Target missing or outside the URL's parent chain Handler steps (16-26). Not tested here.
Tag upsert Well-formed digest naming no manifest in the image Tag upsert step (19). Not tested here.
Tag upsert Malformed digest, or a tag name the grammar rejects Tag upsert step (19). Not tested here.
Tag upsert Tag cap reached, on creation only Tag upsert step (19). Not tested here.
Bulk delete Body matching neither selector branch Bulk selector decode step (27). Not tested here.
Bulk delete Empty, oversized, or ill-typed list, or an unknown field Bulk selector decode step (27). Not tested here.
Manifest delete Manifest is indexed by another manifest Manifest delete step (17). Not tested here.
Bulk delete Body over the request size cap Bulk selector decode step (27). Not tested here.
Bulk delete Job backend unavailable at enqueue Bulk enqueue seam step (28). Not tested here.
Artifact writes Method registered by neither the path's pattern nor a sibling wildcard TestHandler_BulkDeletePaths_MethodFallthrough, TestHandler_TopLevelDetailPrefixes_HaveNoBulkRoute
All Unexpected server failure Shared 500 paths, covered by the existing resolve and handler suites.

Security considerations

Concern Tests
Authentication and authorization S08 and S09 stubs. Not tested here.
Existence hiding TestHandler_WriteRoutes_UnknownSlugIs404, TestHandler_PackageWriteRoutes_ContainerFormatIs404
Input validation Bulk selector decode step (27) and the tag upsert step (19). Not tested here.
Injection Jet-builder-owned, no query in this change.
Name immutability Repository surface, shipped earlier. Not in this plan.
Tenant isolation The slug boundary is pinned by TestHandler_WriteRoutes_UnknownSlugIs404. Per-query namespace scoping lands with the handler steps, whose placeholders read no store.
Write authorization S09-owned. The namespace write gate that stands in front of it is pinned: TestHandler_WriteRoutes_SuspendedNamespaceIs403, with TestHandler_SuspendedNamespace_KeepsReadsServiceable as the read control.
Destructive routes ship behind the stubs A stated exposure, not a testable assertion.
Existence hiding on writes TestHandler_PackageWriteRoutes_ContainerFormatIs404, plus TestHandler_WriteRoutes_SuspendedNamespaceIs403 for the write gate
Echoed input Bulk selector decode step (27) and the handler steps. The placeholders echo nothing. Not tested here.
Bounded blast radius, except delete_all Bulk selector decode step (27). Not tested here.
Attribution exposure Artifact read surface, shipped earlier. Not in this plan.

Related to #313 (closed)

Merge request reports

Loading
Loading