feat(oci): container_remote_repositories schema (S16 Step 1a)
What
Adds the container_remote_repositories table — the root cache table for
kind=2 (remote) container repositories — as the first of Step 1's three MRs in
the S16 container remote vertical slice. One goose migration, the regenerated
jet types and structure.sql dump, and an integration test suite asserting the
schema shape and every constraint's accept and reject paths.
No Go production code and no behavior change: nothing reads or writes this
table until Step 6 (remote repository finder) and Step 9 (credential and
auth_status writes).
container_remote_images (1b) and container_remote_manifests (1c) follow in
their own MRs. Each takes the next free timestamp after the previous one
merges — migrations.go sets goose.WithAllowOutofOrder(false), so two
branches opened together would produce one that merges second carrying the
earlier timestamp, which the runner then refuses on any database that already
applied the later one.
Reviewing guide: the migration and the three test files are the surface that wants reading. Every column, constraint, and index decision is documented at the statement it governs in the migration; the derivations behind the two non-obvious ones — child index naming and the FK-target lock arithmetic — live in the plan's Step 1. This description covers only what the diff cannot show.
Schema
Shape: PARTITION BY HASH (namespace_id) × 64 with children in the partitions
schema, PK (id, namespace_id) on application-generated UUIDv7, unique
(namespace_id, repository_id), a composite FK to
repositories(id, namespace_id) ON DELETE CASCADE, and a namespace_id FK to
namespaces(id) with NO ACTION. Eight CHECK constraints. All of it reads off
the migration.
Three things the diff cannot show:
- Container-only columns.
auth_statusmemoizes the OCI auth-challenge verdict (0=unknown,1=none,2=bearer) and has no equivalent on the npm or Maven remote-repository tables, and no ADR-007 equivalent at all — neither the column nor either of its two CHECKs.#30carries that amendment. There is nometadata_cache_validity_hours: container has no separate metadata tier the way Maven'smaven-metadata.xmldoes, socache_validity_hoursis the only freshness window. last_health_statusgains a range bound the siblings lack. S13 defines the column; the probe writes it, so it is service-written on the same terms asauth_status. The npm and Maven tables declare it unbounded and are not backfilled here — both are empty pre-production, so the change is cheap, but those tables belong to the npm and Maven slices.#486tracks it; until it lands the three tables disagree. The absence there traces to ADR-007 rather than to a decision, so bounding it here contradicts nothing.- Deliberate absences, asserted by tests. The interim
tmp_plaintext_username/tmp_plaintext_passwordtextcolumns stand in for ADR-007'sencrypted_*byteashape, matching what merged Maven and npm remote carry; encryption at rest is GA-blocking in#417. The 2048-character credential cap and the RFC 7617 colon rule stay Go-side per ADR-007, and a test asserts their absence from the catalog so a later DB-side cap cannot land unnoticed. Thehttps-scheme and resolved-address rules on the discovered realm likewise stay on the outbound request path, not in the schema.
Down section drops each partition directly
No DETACH PARTITION pair. DROP TABLE auto-detaches, and IF EXISTS on every
statement leaves the section replayable after a mid-sequence interruption —
which a DETACH/DROP pair cannot be, since DETACH takes no IF EXISTS
guard and a retry fails on the partitions already detached. The pair also holds
far more FK-target locks; the migration states the shape and the plan derives the
counts.
#448 tracks converting the older migrations to this shape. main already
carries both, so this file starts on the one that issue moves to rather than
adding another for it to fix.
Cross-cutting edits
Two, both in test helpers, no production code. They are why a schema MR touches sibling suites:
assertDownDropsEveryPartitionAndParenttakes a down-section style. It asserted exactly 64DETACHes, so a migration dropping them had to widen the helper in the same MR or every format's schema suite fails. The Maven remote suite passesdetachThenDropand keeps every assertion it had; the container suite passesdropWithoutDetach, which asserts zeroDETACHrather than skipping the check, so a later container remote table cannot quietly regain the pair. The helper also now requiresIF EXISTSon everyDROP, which both Maven remote migrations already satisfy.- One shared
readMigrationFileinschema_helpers_test.go, replacing three per-format copies —readOCIMigrationFile,readNPMMigrationFileandreadMavenMigrationFile— withkeysOfmoving across alongside it. Raised in review: this suite's Down-walk reached into the hosted OCI suite file for its reader, which is the couplingschema_helpers_test.go's header exists to prevent. Every schema suite that walks a Down section now shares one reader, so the edit touches the hosted npm, npm remote, npm remote Step 2, hosted OCI, hosted Maven and Maven remote suites — a wider surface than a schema MR would otherwise carry, accepted deliberately rather than deferred, because 1b and 1c would each add another consumer of a helper owned by the wrong file. The Maven copy came in a second round: its bare-substring match looked like a blocking difference, but all six Maven tokens match uniquely undertoken + ".sql", so the shared reader is a drop-in and the stricter of the two, since it also skips directory entries.
Plan amendment
docs/plans/2026-07-30-container-remote.md described Step 1's rollback as "the
symmetric DETACH/DROP down path", written before #448 was filed. That phrase is
replaced with the drop-only shape and its reason, both cross-cutting edits are
recorded as named accepted ones with the reader hoist called out as the wider of
the two, the child-index-naming derivation moved in
from this description, and Step 1's Files entry now names the mechanical
companions that adding any migration forces — the two generated jet files, the
table_use_schema.go line, and the knownHeadVersion bump — so 1b, 1c, and
2a–2c do not each re-litigate whether they belong.
Tests
Three files (integration tag; raw SQL is permitted in the migrations package to
assert constraints): container_remote_test_helpers_test.go for the shared
fixtures, container_remote_schema_integration_test.go for catalog shape,
partition routing and the static Down checks, and
container_remote_constraints_integration_test.go for constraint rejections.
Raised in review: one file would have passed 1000 lines at this table and grown
past 3000 by the third, so the split lands here rather than being promised for 1b.
Every acceptance clause has a named asserting test:
| Acceptance clause | Test |
|---|---|
| Applies cleanly | _TableAndPartitionsExistPostUp |
| Reverts and replays cleanly | package-level TestMigrations_UpDownUp, plus _DownLockBudget and _DownReversesEveryUpObject as the static per-migration half |
structure.sql regenerates with no drift |
CI db:structure-check |
Credential CHECK all-or-none rejects a half-set pair |
_CredentialsAllOrNoneCHECK (6 polarities incl. both-empty-strings) |
auth_status = 2 with null auth_url rejected |
_AuthStatusAuthURLCHECK (bearer_without_auth_url_rejected) |
last_health_status accepts 0, 1, 2 and rejects outside |
_LastHealthStatusRangeCHECK (all three defined values as positive hits, since each is a state the probe writes; 3, -1, 99 rejected) |
auth_status outside 0, 1, 2 rejected |
_AuthStatusRangeCHECK (3, -1, 99, each against a null auth_url so the pair CHECK admits it and only the range CHECK can reject — verified by deleting the constraint and watching all three inserts succeed) |
Non-null auth_url at any other status rejected |
_AuthStatusAuthURLCHECK (unknown_with_auth_url_rejected, none_with_auth_url_rejected) — both defined non-bearer statuses, so "any other" is exhausted rather than sampled |
| No DB-level credential validation | _CredentialColumnsCarryNoDBLevelValidation |
No metadata_cache_validity_hours |
_NoMetadataCacheValidityColumn |
cache_validity_hours >= 0 accepts 0 |
_CacheValidityHoursCHECK |
1024-char url / auth_url caps |
_URLLengthCHECK, _AuthURLLengthCHECK |
Empty auth_url rejected at auth_status = 2 |
_AuthURLLengthCHECK (empty_rejected; deleting the constraint makes it fail while the two cap cases still pass) |
Empty url accepted, pending #486 |
_URLLengthCHECK (empty_accepted_no_lower_bound, pinning the deliberate asymmetry) |
| Partitioning, PK, unique index, routing | _PartitionsByHashOfNamespaceID, _PrimaryKey, _Columns, _ColumnDefaults, _UniqueRepositoryIDIndex, _UniqueRepositoryIDRejectsDuplicate, _PartitionRoutingByHashOfNamespaceID |
| Both FK directions enforce | _FKRejectsAbsentRepository, _FKRejectsAbsentNamespace, _RepositoryFKCascadesOnRepositoryDelete, _NamespaceFKIsNoAction (pins confdeltype='a') |
Spec: S16 container remote. Its acceptance criteria are an auto-numbered Markdown list with no stable ids, so rows cite an item's text rather than a number the next insertion shifts.
Error cases
| S16 error case | Test |
|---|---|
Every row in S16 ## Error Cases |
Out of scope for Step 1a. All of them are request-path HTTP status mappings — upstream 401/403 propagation, digest mismatch, unknown reference — and this step ships no request path. Steps 10 through 16 carry them. |
Security considerations
| S16 security consideration | Test |
|---|---|
Token and credential hygiene, over tmp_plaintext_username / tmp_plaintext_password |
The columns ship here, the hygiene rules do not act on them until a reader exists. What this step pins: _CredentialsAllOrNoneCHECK (no half-set pair can be stored) and _CredentialColumnsCarryNoDBLevelValidation (the 2048-char cap and RFC 7617 colon rule stay Go-side, asserted absent from the catalog so a later DB-side cap cannot land unnoticed). Encryption at rest is GA-blocking in #417. |
Auth-challenge trust — SSRF covers on the discovered realm |
Storage-side only here: auth_url takes a database length cap at both ends, so an empty or arbitrarily long realm cannot be persisted. The scheme, port, and resolved-IP checks belong to the outbound request path (S13 covers, already shipped) and are not re-asserted by a schema test. |
Auth-challenge trust — credential exfiltration to an upstream-named realm host |
Not addressed by this step and not addressable in the schema: the realm-host allowlist is GA-blocking and tracked confidentially. No column this migration adds bounds it. |
| Cross-origin redirect credential stripping | S13-owned, in the upstream HTTP client. Not tested in this MR. |
| Outbound path-segment safety | Request-path behavior, Steps 10 through 16. Not tested in this MR. |
| No amplification via retries | Token-caching behavior, Step 9. Not tested in this MR. |
| Upstream auth failures do not leak into client-facing auth | Response-shaping behavior, Steps 10 through 16. Not tested in this MR. |
Constraint-rejection assertions pin the exact SQLSTATE (23514, 23503,
23505) rather than "an error occurred": before the migration exists every
insert fails with 42P01, which a bare error assertion would accept.
All three auth_status values and both container parent formats (docker, oci)
are covered as positive hits, not one positive plus a negative. The suite
was diffed subtest-by-subtest against the Maven remote equivalent for dropped
coverage; every Maven subtest scoped to the repositories table has a
counterpart, except SnapshotFlagAcceptsExplicitTrue (a Maven-only column) and
the metadata_cache_validity_hours cases, whose column this table does not have
and whose absence _NoMetadataCacheValidityColumn asserts directly.
One deliberate deviation from go-testing.md: every CHECK-constraint table is
an inline literal of a named type passed to one shared runner, rather than a
local slice named tests per test. Eight tables differ only in their insert
statement and their bind values, so one assertion body beats eight copies of the
Maven loop, and the runner's doc comment says so. Raised in review: this was
originally justified as dupl avoidance, which was never a real reason —
.golangci.yaml sets no build tags, so dupl never analyzes these
//go:build integration files in CI at all.
MR size
Roughly 4,800 insertions across 18 files. Almost none of it is hand-written:
| Insertions | File | Kind |
|---|---|---|
| ~2,200 | structure.sql |
Regenerated pg_dump, one parent plus 64 partitions |
| ~1,670 | the three container remote suite files | Test |
| ~500 | the migration | Mostly mechanical partition DDL; roughly a third of the file is comment |
| <200 | schema_helpers_test.go |
Shared helper, widened |
| <200 | jet model and table types | Generated by db:jet-gen |
| <200 | plan and spec | Prose |
| <100 | Maven and npm test call sites, checksum test | Consequences of the two above |
The three largest rows are rounded and the rest are upper bounds, on purpose. An exact insertion count in prose is invalidated by the next commit — including the commit that corrects it, which is how this section went stale three review rounds running.
The two largest contributors are not review surface. structure.sql is a dump
regenerated by db:dump-structure and verified by CI's db:structure-check; it
is never edited by hand and diffing it line by line finds nothing the migration
does not already say. The jet types come from db:jet-gen and are checked by
jet:generate-check. Most of the migration is the 64-partition CREATE and
DROP statements — near-identical, reviewable as a block once the first is read.
That leaves the table body and its comments and the test suites as the surface that actually wants reading.
Why one MR
Splitting the partition DDL from the table it partitions produces a migration
that does not apply. Splitting the regenerated dump or the jet types from the
migration that causes them breaks db:structure-check and jet:generate-check,
which exist to keep exactly those artifacts in the same commit as their schema
change. So the floor for a partitioned-table migration is the whole set.
This is the shape every sibling schema MR took, and this one sits at the top of their range rather than outside it:
| MR | Files | Insertions |
|---|---|---|
| !978 (merged) npm remote packages (S15 Step 1b) | 7 | 2949 |
| !977 (merged) npm remote repositories (S15 Step 1a) | 7 | 3135 |
| !1189 (merged) maven remote packages (S14 Step 2) | 10 | 3377 |
| !1120 (merged) maven remote repositories (S14 Step 1) | 9 | 3759 |
| !979 (merged) npm remote versions (S15 Step 1c) | 8 | 4393 |
| This MR | 18 | ~4,800 |
| !1044 (merged) npm remote cache tables (S15 Step 2) | 12 | 28515 |
That makes this the second-largest of the seven, above !979 (merged). The per-file
reasoning above does not depend on the ranking: each of these MRs is dominated by
its own regenerated structure.sql for the same reason. Touching the plan and the
spec alongside the migration also follows them: four of those six amended one or
both in flight, and !1189 (merged) amended both, as this MR does.
The plan originally budgeted far less test than the ~1,670 here. The gap is the
container-only surface Maven's 1a had no equivalent for — auth_status needs six
accept/reject polarities against auth_url plus its own range cases,
last_health_status needs all three defined values as positive hits, both parent
formats need positive-hit coverage, and the absent
metadata_cache_validity_hours needs an asserted absence — plus doc comments
carrying the reasoning for each. The estimate was low; the coverage is not
padding.
End-to-end scenario catalogs
No scenario added or invalidated. This step ships no request path, so there is
no observable behavior to cover. Step 18's hermetic proxy harness is the
automated coverage for the S16 read paths; when it lands it drops the
"Virtual and remote (proxy/cache) repositories" bullet from the
"Out of scope until the capability ships" list in both
docs/testing/e2e/oci.md and
docs/testing/e2e/docker.md.
Conformance
Not applicable: this step implements no Container/OCI protocol behavior. The conformance suite runs against the read and write paths that Steps 10 through 16 build.
Database Review Evidence
Migrations
Note
Timings are from CI (db:migrate matrix, goose verbose) against an
empty database, in apply / rollback order per PG version.
Production-scale validation via Database Lab is not yet available. See
Database review evidence
for the matrix rationale and how to read the numbers.
Collected from pipeline
2732538482
run on the merge result of branch head 830c105d (a merge-result pipeline
reports its own ephemeral merge commit, so its page shows a different SHA).
A dated record, not the head pipeline: merge-request changes:
rules match the whole MR diff, so db:migrate re-runs on every pipeline here
even for a docs-only push. No commit since has touched the migration, so these
are the timings for the SQL under review.
| Migration | PG 16 | PG 17 | PG 18 |
|---|---|---|---|
20260804120000_create_container_remote_repositories.sql |
OK (1.09s / 456.28ms) | OK (766.63ms / 522.53ms) | OK (280.86ms / 197.37ms) |
Migration notes:
- The migration stays in boot rather than going out-of-band. Apply straddles the
1-second empty-database threshold on the slowest version from one run to the
next — 1.09s here, 726ms on the previous run of the same DDL — so the call is
made on what the cost is rather than on where a sample lands: it is fixed
DDL — 65
CREATE TABLEstatements plus one unique index recursing into 64 empty partitions — on a table that does not exist yet, so none of it scales with production row counts the way a backfill would. Against the 5-minute boot cap (lock-wait plus apply, see Time budget) that leaves effectively the whole window for lock-wait on the FK targets —namespaces,repositories, and each ofrepositories' 64 partitions, since that table is itself hash-partitioned and the composite FK reaches all of it. The migration documents that exposure at the statement that takes it. - Rollback comes in under apply on every version, as expected: the Down section
is 65
DROP TABLEstatements with no index rebuild. - The spread between PG versions is runner noise and is not worth reading as a version difference. Two pipelines have now run this identical DDL — the later one differs only in comment text — and the ordering reversed between them. PG 18 was the slowest on both phases in the first run and is the fastest in this one; PG 17 apply moved from 277.51ms to 766.63ms. Whole-suite rollback moved the same way, PG 18 from 189s down to 77s while PG 16 went from 53s up to 105s. Empty-database timings on shared runners carry that much run-to-run variance, so they are evidence that the migration applies and rolls back, not evidence about which version handles it better.
- Re-Up is
OKon all three versions, so the Down section leaves a state the Up section re-applies cleanly.
Related to #288