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, andisReferrersRequestname onlyserveRemoteRepositoryas 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 theValidatePathboundary, the bare-200 trap, and the response-interceptor trap, which Steps 15 and 16 need.go docreprints the declaration and drops that trailing comment, soWithVirtualManifestandWithVirtualBlobboth 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 dispatchsaid every route on akind=1container repository answers501, and gave the design reason that container follows npm rather than Maven, because Maven's shared arm advertisesAllow: GET, HEADand so names read verbs a container virtual repository does not implement either. The write verbs now carry exactly thatAllowwhile 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
403displaces "the remote405or the virtual501". Both kinds now answer405for a manifestPUT, so it names the405either kind's arm would otherwise answer, which is whatTestRemoteDispatch_SuspendedWrite_403AheadOfKindBranchasserts.
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=1repository that reaches the kind branch is501." Writes now answer405and the two list reads404. 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 answer501. - The remote-write Error Cases row's closing sentence, which says the
Allow: GET, HEADadvertisement "does not extend to a virtual repository … that is why every verb there answers501". The manifest and blob write verbs now carry that sameAllow.
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