OCI remote band: eight spec-amendment candidates (Opens 5, 5a, 6-11)
Ask
Amend S07 and S08 for the eight spec gaps the OCI remote band recorded as Opens 5, 5a and 6 to 11 in docs/plans/2026-08-21-remote-conformance.md §Spec-amendment candidates. All eight are narrow. None blocks a step MR; each one forks an implementation, which is why they are filed rather than assumed.
This is the OCI band's equivalent of #67, which carries the Band B three from Step 19.
The eight
Open 5: no spec section admits the remote rows into the pluggable-behavior carve-out their hosted siblings rely on. S07 §Pluggable behavior the spec does not pin covers oci.blob.range-request; S08 has no pluggable-behavior section of its own, naming the concept only by reference to S05's and to S07's. !275 (merged) narrowed this without closing it: AC #36 (closed) now says a row "can seed and then skip on a pluggable-behavior arm (S07 §Pluggable behavior), and that row completed", so the spec acknowledges the arm exists and that taking it still counts as establishing the fixture. What it does not say is that the arm is permitted for this row. AC #36 (closed) governs verdict accounting, not whether a 200 carrying the whole blob is conformant, so oci.remote.blob-range's non-enforcing arm still rests on inference. An implementer reading only S08's cell makes that 200 a StatusFail, failing a registry OCI's own specification calls conformant. Narrowest fix: a sentence in S08 §Per-format rows saying the remote rows inherit their hosted siblings' pluggable-behavior treatment. It also covers oci.remote.referrers-proxy (Step 34) before it meets the same question against a v1.0 upstream.
Open 5a: the hosted oci.blob.range-request matcher accepts a Content-Range total the remote row requires. checkPartialContent accepts bytes 32-95/*; checkRemotePartialContent requires bytes 32-95/512. The asymmetry is deliberate and is now stated at both sites: the remote row GETs the whole blob before asking for the slice, so the relay knows the complete length, which is the condition RFC 9110 §14.4's * allowance exists for the absence of. Open: whether the hosted row should issue a fill too and take the strengthened matcher with it. A registry that re-frames on a cold read passes the hosted row and fails the remote one against the same behaviour.
Open 6: Step 33's two departures from S08's head-parity row text. S08 states the row as "returns the GET headers and no body". The body clause is not covered at all, because net/http discards a HEAD response's body before any caller sees it, so an assertion over it could not fail. And Content-Length is judged only when declared, because no RFC requires it on a HEAD and a relay whose cache write has not settled can omit it while behaving, so requiring it would fail such a registry intermittently. Narrowest fix for both: a sentence in S08 §Per-format rows scoping the row to the headers a client can observe and saying a Content-Length is judged when present. docs/catalog/oci.md's row carries the wording to lift.
Open 7: S07 AC #26 (closed) and S07 §Docker-Content-Digest response header disagree on the by-tag basis. AC #26 (closed) compares against "the digest known from the request path (by-digest) or test setup (by-tag)"; the response-header section says a HEAD's header "repeats the digest the corresponding GET returns". oci.remote.docker-content-digest implements the second, because S08 scopes it to "the digest of the bytes served". A relay serving altered bytes under their own digest is correct about what it served, and that the bytes are not this run's is oci.remote.pull-through-cache-manifest's finding. Fix: qualify AC #26 (closed)'s by-tag clause to the hosted rows it was written for, or say the two readings apply to different row classes.
Open 8: two S08 sites justify the OCI strict-equality flip by a row count Steps 31 to 33 made wrong. AC #35's rationale says "OCI's section already holds oci.remote.pull-through-cache-manifest and oci.remote.pull-through-cache-blob at not started", and the §Downstream amendments row repeats it. Both are registered now, so the example no longer holds even though the deferral does. The replacement example this entry first offered has since gone the same way: it named oci.remote.not-found and oci.remote.write-refused-blob-upload as rows with no catalog row, and !272 (merged) authored both. Fix: restate both sites against the four ## Virtual rows (oci.virtual.upstream-resolution-order, tag-list-merge, degraded-upstream, ns-query-routing), which are non-optional and which no step in either band can register until a spec defines the multi-upstream surface they describe. One clause has to travel with them or the restatement invites the opposite error: those four are the right example for "non-optional set larger than descriptor set" and the wrong one for "why the flip is waiting", because inventory_test.go's virtualSlugPrefix already drops them and both directions pass today. Running those two things together is how this entry came to cite rows that then landed.
Open 9: AC #30 (closed)'s n counts settling descriptors, and oci.remote.referrers-proxy settles twice. --timeout's remote default is 2n × --settle-timeout + 5m, and S08 is explicit that "the factor of two is the initial attempt plus one re-attempt, not 'settles once per such row'", so the budget allocates each settling descriptor exactly two settle-timeouts. The referrers row needs a referrer to list and the shared fixture stages none, so it seeds its own through a second conformance.SeedAndSettle inside its verdict, and can consume up to four. With the band's eight settling rows the derived default is 16 × and the worst case is 18 ×, so the five-minute constant absorbs it at any ordinary settle timeout and it is not reachable today. It is still a definition that no longer matches the thing it counts, and the gap grows with every row that seeds more than one coordinate. Two fixes, and the choice is a spec decision: count settles rather than descriptors that settle, which means AC #30 (closed)'s counting entry point can no longer be SelectDescriptors alone; or require a row to settle once and push multi-artifact staging into the shared seeding contract, which wants doing before Maven's and npm's own listing rows land. Recorded rather than absorbed because a derived default that understates the work holds until a run is slow and then presents as a cancelled run rather than as a budget that was always too small.
Open 10: the Repeat read class says cache-state-dependent headers "reflect the hit", and does not say whether that is conditional. AC #16 (closed) pins byte identity and nothing else; the class description adds the header clause. For blobs the only signal a client can read is whether Content-Length was declared, and both readings of the clause fail a relay that is behaving. The unconditional form (a warm blob read must always be length-framed) fails a relay that buffers its fill, which no specification forbids. The conditional form (the warm read must declare a length only if the cold one did not) fails a relay that streams its fill while committing its cache asynchronously, because its second read goes out before the commit lands, so both reads chunk. oci.remote.repeat-read shipped with the conditional reading in !272 (merged) and the review of !272 (merged) removed it: the row was failing chunked-then-chunked while passing length-then-chunked, though neither arrangement expresses a transition, so the asymmetry was the defect rather than the choice between readings. The clause is now asserted by no row in any band, and a target that never reflects a cache hit in any header passes every repeat-read row. That is a real coverage gap, and it is here rather than in a row that flakes on framing, which is what remote_relay.go's contentLengthUndeclared, oci.remote.head-parity and oci.remote.blob-range each already decline to do. Fix: one sentence in the Repeat read class saying which reading applies.
Open 11: oci.remote.not-found reads a repository no row creates, which S07 says no row does. The row is Absence class, so it seeds nothing and its repository is one the run never populates, which makes both its reads an unknown-repository read rather than an absent object in an existing repository. It accepts only MANIFEST_UNKNOWN on the manifest route and BLOB_UNKNOWN on the blob route. S07 §Resolutions says the two conditions differ, records that hosted AC #39 (closed) and AC #40 (closed) create an image first precisely to avoid the harder one, notes that Container Registry answers the object-level codes "whether or not the repository exists" while "the setup targets Artifact Registry, which differs", and closed with "No row asserts an unknown-repository read path" until this row landed. The same section records that "some v1.1 registries 404 NAME_UNKNOWN on missing repos", and hosted AC #41 (closed) accepts NAME_UNKNOWN beside MANIFEST_UNKNOWN for exactly this reason, with errCodeNameUnknown already declared for it. So the row may be strictly narrower than the protocol admits for the condition it creates. Container Registry passes either way; Artifact Registry is the open question and validation/evidence/step35/ is what settles it. Two separable halves: S07 §Resolutions' closing sentence is wrong as of !272 (merged) whichever way the code goes and is corrected either way (!272 (merged) does that); whether judgeAbsence accepts errCodeNameUnknown is the substantive half, and S08's cell for the row needs the same widening if it does, because "the S07 not-found codes" currently reads as the two route-specific ones. Not fixed in !272 (merged) deliberately: widening an accepted code set is loosening a conformance assertion, which CLAUDE.md §Critical rules puts in its own MR.
Why one issue
Six of the eight are one sentence each in S08 §Per-format rows or S07, and Opens 5 and 5a are two halves of one row. Splitting them into eight issues would spread one spec MR's worth of edits across eight threads.
Opens 9, 10 and 11 arrived with !272 (merged) (Steps 34 to 36) and its review. Open 10 is the one with a behaviour change already shipped against it: the assertion it was filed about was removed, so the gap it records is currently uncovered rather than covered by a reading nobody pinned.