feat(oci): OCI remote foundation and walking skeleton, Steps 26-30 (S08 remote, batch 9/12)

Summary

Related to #47 (closed). Divergence index: #54.

The OCI band's foundation and its walking skeleton, per the S08 remote goal-run's annex §7 MR clusters ("OCI client gaps, env, catalog, matchers, preflight"). Lands Steps 26 through 30.

Step What it adds
26 HTTPError.Allow, the terminal request's verb, and an upload-route URL builder
27 the env's upstream client, HasUpstream and accessors
28 the catalog split into per-kind buckets, and the kind branch
29 the shared Range and Docker-Content-Digest matchers
30 oci.remote.preflight, its seeding contract, and the remotefake OCI adapter

Originally branched off !259 (merged), since Steps 26 to 30 depend only on Band A. Both !258 (merged) and !259 (merged) have merged, so this now targets main directly and has been rebased onto it: the Band A commits it carried are gone, and so are two test-only clock-slack commits whose final form main already has.

Read this first: the addressing gap is closed, and the evidence predates the fix

The suite builds every OCI URL as base + "/v2/" + image, where the image is conformance/<runID>/<slug>. AR serves /v2/ at the root, with the repository path as the image name's leading segments. Measured during this batch:

Request Status
/v2/conformance/container/oci-upstream/<image>/tags/list 200
/conformance/container/oci-remote-tls/v2/ 404
/v2/conformance/<runID>/<slug>/tags/list 404

Route 1 has landed on main: !268 (merged) added --oci-name-prefix, and S07 §Configurable name prefix names the AR case directly. This batch is rebased onto it and wires the new row through repoNameFor, so a remote row's coordinate now carries the prefix exactly as every hosted row's does.

It does not remove the shim, and I checked rather than assumed. The reference is one AR instance serving both roles from two repositories, oci-remote-tls and oci-upstream. Addressing it without a shim needs two different prefixes on one base URL, and neither half is expressible: --oci-name-prefix is a single global flag, and validateUpstreamURLCrossField requires --upstream-url to differ from --registry-url. Measured:

Topology Result
no shim, both URLs at AR, one --oci-name-prefix upstream-url: must differ from registry-url, exit 2
registry direct with the prefix, upstream shimmed PUT /v2/conformance/container/oci-upstream/conformance/container/oci-remote-tls/..., the doubled coordinate R20 already records
both shimmed, no prefix flag PASS, and it is what the evidence below uses

The second row is the interesting one: the prefix is applied to the upstream coordinate too, so the surviving shim prefixes an already-prefixed path. That is the §Open question below reached from the other end, and it is why the shim stays for now. Full detail is validation/reference-quirks.md R20.

Reference validation

Re-run on 2026-09-04 against a live Artifact Registry with the rebased binary, using validation/ref-env/run-oci-remote.sh and mutation-matrix-step30.sh. Both shims verified to have bound before each verdict was read: 5446/5447 were held by another session's Maven proxies, so these ran on 5456/5457.

Case Verdict What it shows
control PASS, exit 0 the shims alone do not change the verdict
M1, corrupt the relayed manifest FAIL, exit 1 names the served digest
M2, relay read answers 404 FAIL, exit 1 the read reached the repository under test
M3, seeding write refused 403 on the manifest PUT SKIP, exit 2 the run-attributable credential row, naming --upstream-url

M3 only ever faulted the terminal PUT, which is the one request where the reported verb already equals the write verb, so the recorded matrix could not see the defect §Fixed after review describes. Added a case that refuses the seeding write at the session-establishing POST, where a registry actually rejects a push credential, and ran it against both binaries:

before this MR after
reason "the seeding write's terminal request reported POST rather than the format's write verb PUT, so a followed redirect replayed it with the body dropped" "the upstream refused the seeding write with status 403, and the run carries one credential, so a credential the upstream does not accept ends the run"
upload-init POSTs sent 2 1
statuses on the wire 403, 403 403

No redirect occurred. The second POST is the re-attempt §Preflight forbids for a refused credential. The script is not in the tracked tree; it is the recorded matrix's run_case with the fault moved to -only-method POST -only-path /blobs/uploads/, and it belongs in mutation-matrix-step30.sh as M3b.

Getting the first verdict took four fixes, and every one was a harness fault wearing a registry's error code. Listed because the same shapes will recur for Steps 31 to 36, and two of them presented as the registry refusing something:

Symptom Actual cause
404 MANIFEST_UNKNOWN, doubled /v2/ the runner passed /v2/ in --registry-url
404 MANIFEST_UNKNOWN, correct URL the R20 model mismatch itself
400 MANIFEST_BLOB_UNKNOWN the shim re-prefixed AR's already-prefixed upload Location, so blobs landed at a doubled coordinate
401, "the upstream registry rejected this registry's credentials" the shim prefixed /v2/auth/token, which is not a repository path

A fifth for the list, hit during this re-run: a stale proxy from another session held the shim's port, the shim logged address already in use, and the requests were served unprefixed by the foreign listener. The row reported a 404 that looked like the registry refusing the coordinate. What kept all five out of #54 as AR findings was tracing the wire path rather than reading the error body.

Fixed after review

Three findings, each verified by mutation before and after the fix.

The cause sort read the wrong verb, in both directions. SeedResult.Method is documented as the terminal request's verb, and S08 §Preflight's sort tests it against SeedSpec.WriteVerb before it reads any status. Step 26 added the terminal verb to the success shapes (BlobUploadResult.Method, PutManifestResponse.Method, both from resp.Request.Method) but not to *HTTPError, which carries the verb the client asked for. So:

  • A refusal landing before the completion PUT reported its own verb. The session-establishing POST is where a registry rejects a push credential, and with a Basic credential the realm exchange fails ahead of both and carries the realm's GET, so against a token-auth target a refused --upstream-credential never reached the credential arm at all. It reached the method-switch arm, which told the operator "a followed redirect replayed it with the body dropped" and granted the re-attempt §Preflight forbids. A 404 there also crossed AC #15 (closed)'s row-attributable skip into AC #33's exit 2.
  • A write the origin really did replay reported PUT, so the sort found no switch and sent a write that stored nothing to the residue skip, where AC #33 requires the transient row and AC #15 (closed) excludes the case by name.

TerminalMethod() reports resp.Request.Method with a presence bit, and seedResultFromWriteError normalizes the reported verb to the flow's write verb unless a redirect actually replayed the write.

The batch did not compile on main. newRemoteFixture called repoNameForRunID, which !268 (merged) replaced with repoNameFor. Renaming the call alone would have compiled and left oci.remote.preflight addressing an unprefixed repository while every hosted row was prefixed, which is the R20 symptom again.

Six doc and comment claims contradicted the code or the spec, including the AC #37 409 trigger the description below said had been corrected and had not, a "superseded text" label that swallowed two live requirements, and remoteCatalog's doc stating a NeedsUpstream rule AC #35 contradicts.

Two things a reviewer should look at closely

Step 28 deletes and restores the 46-row catalog literal. The panic-skeleton phase removed it, so the implementation restores 45 rows into hostedCatalog() and one into protocolBaselineCatalog(). It was extracted from git rather than retyped, then re-extracted and diffed against the source: byte-identical. wantHostedRows pins order, membership and priorities, so a retyped restore could not have drifted silently.

Step 30 reopens the double's core, adding a SessionAdapter optional interface the core type-asserts for. The plan's §Naming Conventions sanctions exactly this ("the OCI upload session (Step 30)"), and the optional shape means the Maven and npm adapters authored in parallel implement nothing new. OCI's two-phase upload cannot go through Coordinate: the POST addresses no coordinate, and the PUT names its coordinate in a query parameter.

Plan corrections this batch made

  • Step 30's Files list contradicted its own text. It requires that a config blob's 401 reach the cause sort as a 401, then pointed the callback at uploadBlobSingleOrFail, which discards the BlobUploadResult and makes the terminal verb POST on the single-request path. The callback uses the two-request flow instead. Choosing that flow is necessary but not sufficient, which the corrected text now says: see §Fixed after review.
  • Step 30's Acceptance asked for an AC #37 409 trigger OCI cannot have: S08 makes OCI's duplicate-refusal set empty, since the push is idempotent and a 409 is BLOB_UPLOAD_INVALID. AC #37 rides on Preexisting in both directions instead. This is landed here; the first revision of this description claimed it while the edit was missing.

Step 29 also carried a finding forward rather than acting on it inside a refactor: the extracted 206 matcher checks only that Content-Range is non-empty, which is weaker than R5 justifies. Strengthening it belongs to Step 32, which owns the Range row, and the plan now says so at that step.

Spec coverage

Per-step tables live in the five test(...) commit bodies rather than being restated here. Combined, this batch covers:

Spec Rows
S08 AC #1 (closed), #2 (closed), #11 (closed), #13 (closed), #14 (closed), #15 (closed), #18 (closed), #24 (closed)-#29 (closed), #33, #35, #37
S07 AC #26 (closed), #29 (closed)

Test plan

  • go test -race ./... green across all 19 packages.
  • golangci-lint run ./... reports 0 issues. That is the real gate here: this clone has no installed git pre-commit hook, so git commit runs nothing of its own (validation/deviations.md D2).
  • oci.remote.preflight passes against a live Artifact Registry, re-run on 2026-09-04 with the rebased binary, plus the four recorded controls and the new upload-init refusal case. See §Reference validation.
  • Every step's assertions verified by mutation: Step 26 fourteen, Step 27 fifteen, Step 29 fifteen, Step 30 thirty-eight plus thirty-two. Step 28's count is not recorded.
  • The review fixes verified by mutation both ways: disabling the verb normalizer kills six table rows across two tests, and making the client report the sent verb again kills the client test and the redirect row.

Known gap: #60 (closed)

A remote OCI run cannot address a registry whose remote and upstream repositories live on one host, because the suite builds one repository name for both clients and --oci-name-prefix is a single value. That is #60 (closed), filed against Artifact Registry staging and carrying three candidate fixes.

This MR does not close it and does not try to. It prefixes both coordinates, which is what S08 §Out of Scope already assumes ("Name remapping between the client path and the upstream path. The seeding model assumes identical coordinates"), so the behaviour here is the model's rather than a choice this batch made.

That is also why the reference evidence above still runs through the R20 addressing shim. The shim is what stands in for the name remapping S08 declares out of scope, so it is not a workaround that --oci-name-prefix made obsolete; it is holding the place of a decision #60 (closed) owns.

Process

From a goal run whose annex §7 defines the batch's scope. Judgement calls are in validation/decisions.md, reference divergences in validation/reference-quirks.md and #54, departures in validation/deviations.md.

One correction a reviewer may notice in the ledgers: at Step 23 I ruled that the absence-body gap should be worked around with a format-local stub rather than by widening the double, and recorded that as the precedent for Steps 13 and 35. The plan's §Naming Conventions had already sanctioned adding the seam. D10a now carries the correction, and Step 35 should add the seam in SessionAdapter's shape.

Edited by Sylvia Shen

Merge request reports

Loading
Loading