docs(specs): add S32 container virtual repositories spec

What

Adds docs/specs/S32-container-virtual.md, the spec for container virtual repositories (kind=1), and links it from the spec index. Docs-only — no code changes.

A container virtual repository composes hosted (S12) and remote (S16) container repositories into one ordered upstream list and resolves each request through S13's single-winner algorithm. This spec owns the container-specific pieces layered on that: repository-kind dispatch, the three virtual tables, the container allow/deny target fields, the container Probe operation and how it acquires an upstream token, error mapping to the OCI envelope, and the read-only client surface.

Tag listing and referrers stay unsupported on a virtual repository for the MVP — merging a paginated collection across upstreams with independent cursors is the deferred part.

Why

A client points Docker at one repository URL and pulls both the organization's own images and images from external registries, without knowing or caring which repository answered. That is the feature. This spec is what an implementer builds it from.

The pieces exist separately and nothing connects them. S13 defines the resolution algorithm for every format; S12 and S16 define the two container upstream kinds. What is unwritten is the container half: which tables back a Lookup per upstream kind, how a Phase 2 Probe acquires an upstream token, what a fan-out failure returns inside an OCI error envelope, and what the schema looks like. Container is the counterpart to the merged npm virtual spec (S31), so the shape is settled and the format-specific answers are what this adds.

On main today every route on a kind=1 container repository answers an interim 501. That is the seam this spec fills.

884 insertions across two files, past the 500-reviewable-LOC threshold. A spec cannot be split across MRs; the reasoning and the per-file split are in the section below.

For LLM Agents

Merge order

The S13 amendment in !1502 (merged) had to merge first, because this spec reads three pieces of it: target_field scoping moved into the foundation matcher, the fixed Retry-After value, and upstream-kind eligibility with its AllUpstreamsIneligible aggregate. While that MR was open, main's S13 said the opposite of the last one. !1502 (merged) has merged and this branch is rebased onto it, so nothing holds this MR.

main is merged into this branch. It had drifted 468 commits and six days, and the review found a claim that had gone false underneath it — the Fetch seam's expected digest landed on main while this branch sat still. S13 moved +107 lines and S16 +157 over the same window and this spec quotes both, so it now reads against current main. All 42 cross-spec anchors resolve.

The Depends on clauses in the spec, none of which gates this MR — every one is ordered against an S32 implementation MR:

What it waits for Item Which MR it gates
The container arm of the repository-delete guard, and no association row written before it runs #314, Step 42 the schema MR
The container request-builder seam #596 the handler MR that composes single-flight
A Retry-After carve-out for the data-error veto, plus a way for a slice to tell that 503 from a plain outage 503 #875 the MR implementing the veto row
A tag fetch producing a digest-disagreement verdict a slice can act on #433, gap 2 the tag-addressed digest-mismatch criterion

The digest gate is now half closed. #433's gap 1 has merged — FetchOptions.ExpectedDigest reaches the fill's commit — so the by-digest criterion is assertable and carries no gate. Gap 2 is still open, so only the tag-addressed criterion is gated. An earlier revision of this spec collapsed the two, which reported both as unreachable when half of it is buildable.

Still owed before this MR merges, and not recorded by any review so far. The spec asks the S13 DRI to accept four resolver seam shapes — the batched Lookup per upstream kind, health carried on the upstream-list read, the Probe's deadline split, and per-position records on the return value — and routes maintainer review here for exactly that reason. An earlier note in this MR asked about three; the count moved when the per-position record was folded in, so an approval given against that note would clear the gate while covering three of four.

Key decisions

  • The container Probe is S32's, and it reads S16's token cache. S16 defines no Probe emitter — a standalone remote has one upstream and wants the body — and hands both the token acquisition and its timing here. The Probe takes its Authorization from the bearer-token cache S16 keys by (namespace_id, remote_repository_id, scope), so a Probe and the Fetch after it share one token and the handshake runs once per upstream per TTL. The handshake is charged to container_remote.token_exchange_timeout and the HEAD leg to resolution.probe_timeout.
  • A digest mismatch on the winner is a winner failure with no fall-through. S16 left this to S32 because only a virtual read has somewhere to fall through to. A mismatch is deterministic rather than transient, so falling through would route around a faulty upstream on every request with nothing in the response ever surfacing that it serves content failing its own digest. Content safety is not the argument — every position's bytes are digest-verified independently — observability is.
  • An all-ineligible read answers 500, not the 404 an all-denied read answers. A corrupt rule or an upstream of an unexpected kind is not a verdict about the image, so folding it into the not-found arm reports an absence nothing established, and a Docker client treats a manifest 404 as a naming failure rather than something to retry.
  • tags/list and referrers keep their 404 NAME_UNKNOWN. S16 serves both on a remote repository as a live proxy, and that does not carry over: merging across independent cursors is the part that was deferred, and a hosted winner has no upstream to proxy. Serving them would make the response depend on which upstream kind won, the difference ADR-009's uniform-URL rule exists to remove.
  • S32 owns a metric family for the fan-out, and adds the probe operation value to S16's upstream-response counter and oci_remote_proxy event — the label shape S16 deliberately left for it. S12's request metrics cannot see a hosted Lookup inside the resolver, and S16's describe a standalone flow that never probes, so without this the fan-out carries no telemetry and a 503 names no upstream.
  • Single-flight is shared per remote upstream, not per request, with the end-to-end composition test that sharing needs. The shared instance must not capture the request's Accept or the auth_status/auth_url snapshot; fixing that seam is #596, required scope rather than a follow-up.
  • A manifest index is served as-is and its children resolve independently. The two upstream kinds carry opposite guarantees about the same object — a hosted upstream validates every child at push, a remote one caches an index before any child is fetched — so serving as-is is the only behavior that holds for the union. Safe because children are digest-addressed.
  • Position order is the only control against tag shadowing. A public remote listed above an internal hosted upstream serves the public registry's myapp:latest for a tag both hold, which is dependency confusion in container form. Resolution cannot detect it: both upstreams answered honestly.
  • Delivery mode is inherited, not decided. A blob a virtual read serves goes through S06's serve path like any other stored blob, so it follows the ADR-005 effective mode — instance default with a per-namespace override — and can answer 307 with a pre-signed URL and no body. Redirect is the shipped default, so this arrives whether or not anyone decides it; the spec states it because two of its own sentences were false under it. A manifest read is never redirected, a client HEAD is never a 307, a streamed full cache-miss GET is mode-independent, and a ranged miss is not in that carve-out. Criteria are scoped to proxied responses, as S16 scopes its own.
  • No configuration of its own: no proto field and no YAML key. Resolution and health knobs come from S13's virtual_repositories, token settings from S16's ContainerRemoteConfig.

Open questions: two, both opened in review

The two the previous revision carried were settled above this spec rather than in it, and are recorded in ## Resolutions:

  • target_field scoping is the foundation's. The slice supplies its two target fields and the values the request carries, and S13 decides which rules that leaves in play; S32 filters nothing. The npm virtual slice reached the same requirement independently, which is what placed it below both. A slice-side filter also cannot keep the fail-closed arm, since a rule dropped before the matcher runs is a rule the matcher cannot reject.
  • Rule CRUD is out of the closed-beta management surface. S17 settles this for every virtual slice: ADR-009 names no upstream rule routes, no closed-beta consumer exists, so associations ship rule-less and container_virtual_upstream_rules stays SQL-only until a rule-CRUD patch lands. S32 needs no schema or read-path change when it arrives.

Review opened two new ones. Both are about the write surface rather than the read path, and both go to the S13 and S17 DRIs together because they apply to every virtual slice rather than to container alone:

  • Whether an upstream may be less visible than the virtual repository that lists it. A public virtual repository over a private hosted upstream republishes that upstream's content, which follows from S13's model of authorizing only the addressed repository. The entry mirrors the one S31 already carries, so the two virtual slices carry one question rather than two differently-worded ones.
  • Whether the association surface should prevent a public remote from outranking hosted content. The dependency-confusion exposure, which reaches the referrers metadata surface as well as image content — and by-digest pull discipline does not cover the metadata half, because the referrers fallback is tag-addressed. Answering this is GA-blocking, recorded alongside S16's realm-host allowlist (#263) and credential-encryption (#417) gates. The gate exists because a question whose options include the status quo can otherwise be settled by nobody doing anything.

The second is tracked as #792, and its closure is what clears the GA gate — the closing note has to name the option chosen and the DRIs who chose it, because a bare closure cannot be told apart from nobody having answered. That matters more than usual here: the item now carries a recommendation, and the recommendation is the status-quo option.

Per docs/specs/TEMPLATE.md, ## Open Questions must be empty before implementation begins, so both gate implementation starting. Neither gates merging this spec — S31 is the precedent for a virtual-slice spec that merged carrying one.

Follow-ups this review opened

None of these gates merging. Each is recorded so a reader does not have to reconstruct it from a resolved thread.

  • #875S13 amendment. S13's Retry-After rule is keyed on the verdict, and the data-error-veto row maps UpstreamUnavailable, so S13 as it stands assigns that row the health interval. Withholding it is an amendment S32 requests, not a gap S32 fills, and the spec's criterion is explicitly contingent on it. The amendment also has to say how a slice learns the cause: the resolver returns one ErrUpstreamUnavailable for the veto 503 and a plain outage 503.
  • #876S31 twin. This spec adds probe_absent for a Probe that answered 404; S31 has the identical vocabulary with the identical gap, so it takes the same value or the two formats diverge on a cross-format filter.
  • #867a repositories row missing its format child row, over S30, S31 and S32 together. Not S32's to answer alone: the production create path writes the child in the parent's transaction, so the shape needs hand-written SQL, and a hosted read of that row answers the same way.
  • #434two delivery-mode questions S32 inherits and does not settle, whether a 307 carries Docker-Content-Digest and how Range interacts with redirect delivery. Both are S12's; this spec points at them rather than restating them, and scopes its own criteria to proxied responses so they hold under either answer.

#866 (closed) is closed: it held six wording fixes deferred from an earlier round, and all six are on this branch, two of them corrected by the round that followed.

Self-review findings, fixed on this branch

A pass against S13, S16, and S31 found three places where an implementer following S32 would have shipped behavior the foundation forbids, plus one criterion whose fixture could not produce the result it asserts. All four are fixed here.

  • S13's data-error veto was missing entirely. S13 states that a resolution carrying a data-error position never returns FileNotFoundOnUpstreams, however definite the positions that did answer were — the excluded position was never consulted, so nothing rules out that it holds the image. S32 covered only the aggregate case where every position is ineligible, so a corrupt rule beside a hosted miss fell through to the 404 that S32's own text argues against. S31 carries this rule in two places; S32 now carries it in four (no-winner text, an error row, an acceptance criterion, and the ineligibility resolution), with a paired fixture that pins the difference between a data error and a policy denial.
  • The single-flight 503 claimed a handler-supplied Retry-After. S13 fixes it at 1 for a coalescing failure, the same value across Maven, npm, and Container, and says a slice must not substitute the health interval. S32 had the reasoning right — a follower's wait is not an upstream-health condition — and stopped one step short of the number S13 supplies in its place. S31 cites it correctly. Fixed at all three sites plus a new criterion.
  • The winner-fetch criterion was over-broad. It asserted a response built from the winner's status for any Fetch failure; S13 splits that three ways, and on a transport failure before the stream begins there is no status — the cache fallback applies. The error table had already scoped its row to a non-2xx, so the criterion contradicted its own spec. Both transport cases now have rows and a criterion, and Wide events says which resolution_outcome each lands on.
  • Acceptance criterion 1's fixture was unsatisfiable as written. It asked for a Phase 2 Probe from a fixture where both upstreams hold the tag; a fresh remote row wins at the Phase 1 short-circuit with no probe issued. Now states the row is stale and why.

Verified correct and left alone: the probe_timeout scope rule, the health taxonomy counting 401/403/404 as healthy, the S13 mixed-absence scenario, Local-upstream visibility, the 5 GB/50 GB blob gap, all eleven taxonomy names, S16's token-cache key and config path, the empty Allow, the five bare-403 route pairs, the status-fidelity mapping, Range-on-HEAD, lazy index caching, cold content negotiation, the oci_remote_proxy operation list, both GA gates, and all three claimed S31 divergences (CHECK (position >= 0), the no_winner outcome name, the packument/tarball integrity failure).

Two imprecisions are shared with S31 and deliberately not treated as defects of this MR: resolutions_total described as "Completed resolutions" while its outcome set includes cancelled, and neither slice stating S13's within-one-position rule that a data error outranks a policy denial at the same position. The second constrains implementations — rule evaluation cannot stop at the first deny match — and belongs on S13 if it is to be written down once.

Corrections this revision made

Each is recorded where it applies rather than silently substituted, so a reader cross-referencing an earlier revision can tell a deliberate correction from a transcription error.

  • ADR-007 no longer diverges. The previous revision recorded the three virtual tables as an ADR-007 divergence pending the container primary-key work item's handbook MR. That reconciliation has landed: ADR-007 as published gives all three an application-generated UUIDv7 id, so the divergence and its pointer are gone.
  • The blob-upload 405 sends Allow present and empty, not an omitted header. S16 corrected the same ambiguous sentence for the same condition, explicitly so the next format would not have to guess; this spec was that next format and carried the ambiguous wording in four places.
  • AllUpstreamsUnhealthy needs every rule-eligible position to be an unhealthy remote. The previous revision said "every eligible remote upstream", which misses that a hosted upstream anywhere in the eligible set establishes an absence on a miss and takes the read into the not-found arm instead.
  • A winner's 5xx keeps its own status per S16's status-fidelity mapping — 500INTERNAL, 503UNAVAILABLE, other 5xxUNAVAILABLE — rather than collapsing the class to UNAVAILABLE.
  • The bare-HEAD health probe is S13's default, used because S16 contributes no container-specific health-check path. The previous revision called it "S16's probe".
  • UpstreamUnavailable is in the taxonomy list, closing the inconsistency an automated review flagged: the Dependencies table named five error types while Error Cases referenced a sixth.
  • Retry-After is health_check.scheduled_interval rounded up to the next whole second, not that interval "in whole seconds". The two agree at the 5m default and diverge below one second, where truncation gives the Retry-After: 0 the round-up exists to prevent. !1502 (merged) fixed the identical wording in S31; S32 had carried it independently at three sites.

Coverage this revision added

Foundation behavior an earlier revision did not map, all of it reachable on a virtual read.

  • S13's three no-winner branches and its cache fallback, replacing a flat 404. An outage answered as not-found invites a client to cache an absence no upstream reported, which at the manifest makes a Docker client stop on a tag that merely could not be reached.
  • The resolution-time upstream-kind check, so a kind=1 upstream inserted by direct SQL fails closed instead of recursing on a client-reachable read path.
  • The abandonment and infrastructure error classesResolverCancelled, SingleFlightTimeout, HeaderInjection, and the CacheFill-wrapped failures — with the rule that nothing outside the answerable-terminal class maps to 404.
  • The Retry-After values, S13's health interval on the upstream-condition verdicts and Retry-After: 1 on a coalescing failure.
  • Schema detail an earlier revision left out: ON DELETE actions, the range and length CHECKs, DEFERRABLE as a constraint rather than an index, the soft-deleted-upstream skip, ADR-004's 20-upstream cap, and the S17 delete guard with its merge order.
  • The gates that decide ahead of the kind branch, so an unauthorized requester never receives the 405 that would confirm the repository exists and is virtual.

MR size

884 insertions, past the 500-reviewable-LOC threshold development-model.md sets. Split by file group:

File group Insertions Deletions
docs/specs/S32-container-virtual.md (new) 883 0
docs/specs/README.md (index row) 1 1

Splitting would not help, and the reason is structural rather than a preference. A spec is one document whose approval gates are whole-document properties: a single **Status:**, an ## Open Questions section that must be empty before implementation begins, and an ## Follow-ups list where every bullet must carry a linked issue. A fragment cannot satisfy any of them. A partial spec on main would also leave Dependencies, Error Cases, and Acceptance Criteria pointing at sections that do not exist yet — the document is held together by internal cross-references, every one of which was verified to resolve.

The sibling npm virtual spec merged as a single MR, !862 (merged), at well over this length — 1,288 lines against 883 — so one-MR-per-spec is the established shape for this family rather than an exception claimed here.

Test plan

Docs-only. markdownlint, lychee, and Vale pass in pre-commit, and every cross-file link and anchor in the spec was verified to resolve programmatically. The acceptance criteria are the testable form of the change.

Two expected review findings: the H2 headings are title case per the spec template, which GitLab Duo flags under its sentence-case rule; and no e2e scenario is added, since this MR ships no behavior.

Related to #239

Edited by Radamanthus Batnag

Merge request reports

Loading
Loading