feat(oci): virtual kind dispatch, write 405s, and list 404s (S32 plan: 13/19)

What

Replaces the OCI dispatcher's single virtual 501 branch with a per-route table in serveVirtualRepository. A kind=1 container repository now answers:

Route and verb Answer
manifests/<ref> PUT, DELETE; blobs/<digest> DELETE 405 UNSUPPORTED, Allow: GET, HEAD
every verb on blobs/uploads and blobs/uploads/<id> 405 UNSUPPORTED, Allow present and empty
GET tags/list, GET referrers/<digest> 404 NAME_UNKNOWN
GET/HEAD on the manifest and blob routes its own distinct interim 501, while the slot is unset
any verb no OCI route offers the dispatcher's fall-through 501

Both interim 501s and both list 404s carry Cache-Control: no-store. The 405s carry none, because they are permanent.

DispatchOption widens from func(*remoteReadSlots) to a slots value carrying both structs. Go allows one variadic per signature, so a WithVirtual* option cannot write to a separate struct otherwise. remoteReadSlots and virtualReadSlots stay distinct types with distinct writers, and every existing WithRemote* body changes by one line.

Steps 15 and 16 fill the two read slots.

Why

Implements Step 13 of the container virtual plan. See Spec: Repository kind dispatch, Write operations on a virtual repository, Tag listing, and Referrers.

Size

1,629 reviewable LOC:

Group LOC Files
Source 356 handler.go 61/-151, virtual_slots.go 95/0, remote_slots.go 11/-38
Test 1,271 virtual_slots_test.go 1,089/0, remote_dispatch_test.go 38/-113, remote_slots_test.go 9/-22
Docs 2 docs/specs/S16-container-remote.md 1/-1

Splitting does not help. The plan's Step 13 Seam interrogation bullet answers this directly: the 405 arms, the 404 arms, and the slots are one route table over one switch. Splitting them puts two steps on adjacent lines of serveVirtualRepository and on the same constructor tail, which is the shared-anchor conflict the plan avoids everywhere else by giving the file one writer.

Comment caps: blocks left over cap

go-style.md's fifth ratchet outcome asks for the decline in the description. The first two names are unexported and capped at 1 line, so widening either would delete its rationale block wholesale. RemoteReadHandler is exported and capped at 3.

  • serveRemoteRepository's doc still says "the two switches" and "either kind", where three switches now enumerate the five predicates. Correcting one word pulls the whole 29-line block into the cap; the block's own claims about the shared predicates and the fail-safe default arm are stated nowhere else.
  • The docs on isBlobRequest, isTagsRequest, and isReferrersRequest name only serveRemoteRepository as their reader. isManifestRequest's doc said the same and was already one line, so it is corrected here rather than declined: it now says every kind's switch reads the predicate.
  • RemoteReadHandler's doc block stays far over cap deliberately, closed at a trailing comment on the declaration line instead. Compressing it to three lines would delete the ValidatePath boundary, the bare-200 trap, and the response-interceptor trap, which Steps 15 and 16 need. go doc reprints the declaration and drops that trailing comment, so WithVirtualManifest and WithVirtualBlob both state the transferred obligation under their exported cap.

Four claims the caps took: npm's DispatchOption precedent; the note that dropping HEAD would leave the 405 naming a verb it had just refused; isUploadAction's reason that the upload handler reads the hosted tables, so a non-hosted arm must answer without delegating; and DispatchOption's reason for one option per arm, that the slots fill from different wiring sites so the two can land in either order. The last two are stated nowhere else in the tree.

The diff also compresses roughly 150 lines of pre-existing doc comment on DispatchHandler, ServeHTTP, serveByRepositoryKind, isRemoteReadMethod, and the remoteReadSlots / DispatchOption rationale. The serveByRepositoryKind block's deleted paragraph explained why the interim 501 discloses that the repository resolved. That acceptance still holds and is now stronger: the authorization tests show a denied caller receives the masked 404 before any 501.

ADR-009

Handbook MR !20929 marks all four tags/list and referrers lines (not available for virtual repositories in the MVP). It merged on 2026-09-01, so the two list 404s are sanctioned outright rather than pending. The local docs/adr/ mirror still carries the un-annotated lines until the daily sync job runs, so ADR-009 was read at the handbook.

Nothing client-visible changes either way. validateCreateKind in internal/managementapi/create.go answers 422 on kind=virtual, and repositoryCreateKindGate in internal/datastore/repositories.go refuses the kind at the datastore, so no virtual repository is creatable. #264 tracks the cross-upstream listing that would lift the limit.

Referrers error code

GET .../referrers/<digest> keeps 404 NAME_UNKNOWN. That choice was open when this branch started and is settled in #1019 (closed), which measured the client split: crane falls back to the referrers tag schema, while oras and Notation read the code as a missing repository and return the error. The cost is function rather than safety, and virtualReferrersNotFound records it as accepted.

S16 rows this changes

Two statements carried no "interim until S32" qualifier, so this MR corrects them in docs/specs/S16-container-remote.md:

  • ### Repository kind dispatch said every route on a kind=1 container repository answers 501, and gave the design reason that container follows npm rather than Maven, because Maven's shared arm advertises Allow: GET, HEAD and so names read verbs a container virtual repository does not implement either. The write verbs now carry exactly that Allow while both read slots stay unset, so that rationale argues against itself. The paragraph now states what S32 owns and drops it.
  • The suspended-namespace acceptance criterion said the 403 displaces "the remote 405 or the virtual 501". Both kinds now answer 405 for a manifest PUT, so it names the 405 either kind's arm would otherwise answer, which is what TestRemoteDispatch_SuspendedWrite_403AheadOfKindBranch asserts.

Three more carry the "interim until S32" qualifier, so the divergence is sanctioned and their text stands. A reader running S16's criteria will still hit them:

  • Acceptance criterion 1: "Every route on a kind=1 repository that reaches the kind branch is 501." Writes now answer 405 and the two list reads 404. Its own pointer leads to the corrected ### Repository kind dispatch.
  • The Error Cases row "Any verb on a kind=1 (virtual) container repository → 501 INTERNAL". Only the two unset read slots still answer 501.
  • The remote-write Error Cases row's closing sentence, which says the Allow: GET, HEAD advertisement "does not extend to a virtual repository … that is why every verb there answers 501". The manifest and blob write verbs now carry that same Allow.

S16's suspended-namespace behavior is unaffected: a write on a suspended namespace still answers 403 DENIED ahead of the kind branch, which this MR asserts. The #### Gates ahead of the kind branch sentence that named the virtual 501 now says no per-kind answer is reached rather than naming the answers.

Testing

virtual_slots_test.go drives a route-and-method table over all 36 route×method pairs, with TestVirtualDispatch_RouteAndMethodTable_CoversEveryPair requiring each pair to be named exactly once across the decided and fall-through tables. It asserts Cache-Control on the two 501s and the two 404s, and its absence on the 405s, separating an absent Allow from a present-and-empty one. TestVirtualDispatch_WriteVerbs_RefusedBeforeAnyBodyByte injects a body that fails on any Read.

The authorization ordering uses a denying authorizer, new work in this step: denyingPolicyChecker and denyingRelationshipResolver feed the real oci.DenialRenderer{}. Every case in authz_composition_test.go injects an allow-all pair, so copying its fixtures would have asserted the one criterion against a stub that always allows.

On the tags-list route the two authorization tables also assert the envelope message. That route answers 404 NAME_UNKNOWN from the virtual arm, and a masked denial answers the same status with the same code, so the status and the code alone let both the denial row and its control row pass with the middleware removed. The message is the only field that names the answering layer.

Per guardrail 6's mirror clause, the two modified remote suites were diffed against their pre-change state for dropped subtests, not only added ones. None were dropped: remote_dispatch_test.go and remote_slots_test.go lose only doc-comment prose and retarget their virtual rows from the deleted single 501 to the new per-arm answers. TestRemoteDispatch_KindBranch_PerKindRouting keeps its positive-hit row for every repositories.kind value, the unassigned zero included.

Conformance: the OCI distribution-spec suite exercises a hosted repository, so it cannot reach the kind=1 arm at all.

e2e: docs/testing/ is untouched. docs/testing/e2e/oci.md lists virtual repositories as out of scope until the capability ships, and the plan puts the e2e catalog in Step 18.

Run recipe: nothing in .claude/skills/run-artifact-registry/ becomes false, but the recipe cannot reach the new arm — the smoke run asserts kind=virtual is 422 on create, and no helper seeds a kind=1 container row. The arm waits for Step 14's composition root. driver.sh smoke: 72 passed, 0 failed.

Observability gap

The S03-B metric's route label is the mux pattern and the dispatcher mounts on the bare /v2/, so the new listing 404 merges with every resolver-miss 404, and no log line distinguishes them. LabKit's access log carries the request path and is the triage path until Step 17 defines the oci_virtual_* families.

Related to #291

Edited by Radamanthus Batnag

Merge request reports

Loading
Loading