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
PUTreported its own verb. The session-establishingPOSTis where a registry rejects a push credential, and with a Basic credential the realm exchange fails ahead of both and carries the realm'sGET, so against a token-auth target a refused--upstream-credentialnever 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. A404there also crossed AC #15 (closed)'s row-attributable skip into AC #33's exit2. - 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
401reach the cause sort as a401, then pointed the callback atuploadBlobSingleOrFail, which discards theBlobUploadResultand makes the terminal verbPOSTon 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
409trigger OCI cannot have: S08 makes OCI's duplicate-refusal set empty, since the push is idempotent and a409isBLOB_UPLOAD_INVALID. AC #37 rides onPreexistingin 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, sogit commitruns nothing of its own (validation/deviations.mdD2). -
oci.remote.preflightpasses 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.