test(datastore): guard the version-type table enumerations against drift

What this delivers

One new test file, internal/datastore/version_type_table_invariants_test.go, and one new Markdown sidecar beside it, internal/datastore/version_type_table_invariants.md. No production file is edited. A second test file, internal/datastore/maven_invariants_test.go, gets two changes. Its mavenJetTablePackageIdent constant is renamed to jetTablePackageIdent, because two guards read table.<Name> selectors through it and only one of the two is Maven. Its doc comment, which described Maven store files only, is corrected with it.

A fourth file, docs/dev/storage-accounting.md, loses one ## Known gaps row and one sentence. !1973 (merged) landed a row saying the recompute's version-type table list has no exhaustiveness guard, tracked by work item 919. This merge request adds the guard and closes that item, so the row goes, and so does the sentence in the work-item-438 row that named it. ### ADR-007 and !1973 carries the merge order behind that edit. The ## Merge order subsection named for the file carries the conflict surface it opens.

The version-type tables behind namespace_statistics.components_count and repositories.artifacts_count are listed by hand at five sites in five files. Before this change, no test failed when a new format's table reached one list and missed another. This guard closes that gap. It changes no production behavior and corrects no production defect. What it catches is future drift.

The guard reads the version-type table set from the generated go-jet catalog under internal/datastore/jet/artifact_registry/public/table/. A table is version-type when its SQL name ends in _versions or _manifests, minus the names in versionTypeTableExclusions. That exclusion list is empty today. The catalog holds 46 files. 45 of them declare one table each, and table_use_schema.go declares none. The rule returns exactly six of the 45: container_manifests, container_remote_manifests, maven_versions, maven_remote_versions, npm_versions and npm_remote_versions. npm_virtual_repositories.go, npm_virtual_repository_upstreams.go, npm_virtual_upstream_rules.go and container_virtual_repositories.go arrived on main while this branch was open, and the rule matches none of them.

The naming rule is a deliberate choice

The issue gives two mechanical definitions of "version-type table" and leans toward the second one, a single shared declaration that the other lists derive from. The chosen definition is the first one: the naming rule over the generated catalog, with the exclusion list empty on day one. The issue body assigns the pick to whoever does the work, and the body is not amended. The pick is recorded on the issue in two notes instead, 3746421894 and 3764036309, so a reader of the issue alone reaches it without opening this merge request.

Each site keeps its own predicate

The guard asserts five hand-maintained enumerations against the catalog-derived set. Each one is asserted against its own predicate, not against one identical set. Two of the five are lifecycle-scan lists that hold only the version-type tables that carry soft_deleted_at. container_manifests carries no such column, so it cannot appear at those two sites. The guard does not assert that every version-type table carries soft_deleted_at.

The sentence this branch falsifies, and the block it cannot reach

RepositoryReconcileStore.RecomputeArtifactsCount tells the reader of work item 472 decision 2 that if container_manifests gains soft_deleted_at, "nothing in this package fails at that moment". This branch falsifies that sentence. TestInvariant_VersionScansCoverEverySoftDeletableTable and TestInvariant_TombstoneCountsContainEverySoftDeletableTable both go red on the day the column lands, measured in a scratch tree carrying the extra catalog field, while the same field on a clean origin/main leaves the package green.

The correction is not made in place, and that is a decline rather than an oversight. scripts/ci/check-comment-caps.sh charges the block at internal/datastore/reconcile_repository.go:264 at 27 counted lines against an exported cap of 3, and the correcting sentence takes it to 32. Compression of that block alone is not available either. docs/dev/go-style.md says a block other doc comments cite by name comes to the cap with them, and recomputeContainerManifestsStmt's block at :626 cites it and measures 28 counted lines against a cap of 1. The gate is not weakened, skipped or bypassed anywhere on this branch, and no commit uses --no-verify.

What lands instead is the free slot docs/dev/go-style.md names for a test, assertion-message text. Both new guards' why arguments now state that going green takes no soft-delete predicate onto the newly soft-deletable table's own counted row, and the sidecar carries the same statement beside its container_manifests narrowing.

The residual is stated rather than closed. The sentence at internal/datastore/reconcile_repository.go stays false on main, and no wording escapes that without the two-block rewrite. Work item 1086 owns that rewrite.

The guard's own fail-open, found and closed on this branch

The guard's readers carried three faults, and all three were fail-open. The catalog reader first continued in silence past any generated file with a variable shape it cannot match. It then returned on the first matching variable, so a file that declares two tables lost the second. The fixture reader took the first []componentsFixture literal in the function body and skipped every later one, so an entry in a second literal never reached the surplus check. In each case the guard passed on a set it had no right to agree with, which is the exact failure the issue exists to close. The two catalog faults shortened the catalog-derived set, and the fixture fault hid a fixture staging a table the catalog does not name.

Each fault was measured in two directions, not argued from a reading of the code. The two catalog faults were measured with a probe catalog file: with the old reader all five guards stayed green at exit 0 on a probe the rule admits, and with the corrected reader they fail at exit 1 and name the file. The fixture fault was measured with a second literal added inside componentsFixtures(): the old reader stayed green and the extra table stayed invisible, and the corrected reader fails and names the file. Both probes were removed and the tree is clean.

A second []componentsFixture literal beside the first is now fatal. This scan reads the enumeration out of one literal, so a second one means entries moved out of its reach. componentsFixtures() holds one literal today, which makes that arm unreached and the change a guard against a later edit.

Coverage

These rows come from the hand-back notes for this branch. Each row maps one bound enumeration to its predicate and to the test that holds it.

# Site Predicate Test
1 reconcile_namespace.go, recomputeNamespaceComponentsCountStmt equality with the six TestInvariant_ComponentsCountNamesEveryVersionTypeTable
2 reconcile_repository.go, repositoryReconcileWalks resolved through each walk's countBuild equality with the six, over the map values and not its keys TestInvariant_ReconcileWalksCountEveryVersionTypeTable
3a lifecycle_scan_versions.go, the versionScanColumns values equality with the version-type tables carrying SoftDeletedAt — five today TestInvariant_VersionScansCoverEverySoftDeletableTable
3b lifecycle_scan.go, artifactTombstoneCountTables containment of those five, never equality TestInvariant_TombstoneCountsContainEverySoftDeletableTable
4 reconcile_namespace_integration_test.go, componentsFixtures() equality with the six, as bare SQL names TestInvariant_ComponentsFixturesCoverEveryVersionTypeTable

Site 4 is covered from the untagged source scan, and not by a runtime assertion inside the integration test. go/parser.ParseFile ignores build constraints, so the untagged guard reads the //go:build integration fixture file. The guard runs in the default build, reaches no database, and takes about 0.05 s.

Acceptance criteria 1 and 2 are met per site by the five tests above, each against its own catalog-derived predicate. Criterion 3 is met by nine fail-closed arms that fire when a scan finds nothing. Four more arms fire when the generated catalog changes shape: a file that declares no table, a file that declares a second one, a table variable that is not a struct, and a table variable with no column struct where one is expected. Criterion 4 is met by the file's own header, which names the five symbols, their five files, and the escape hatch, and by the sidecar the header points to, which inventories every hand-maintained enumeration of the set and says what checks each one.

Verification

The shell exports GOFLAGS=-tags=integration,development_stubs, so every untagged run below cleared GOFLAGS first.

Check Mode Result
go test -run 'TestInvariant_' -count=1 ./internal/datastore/ untagged ok, 0.048 s
golangci-lint run --max-same-issues=0 --max-issues-per-linter=0 ./internal/datastore/... untagged 0 issues.
golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 ./internal/datastore/... tagged no finding names the new file
go vet ./internal/datastore/ untagged and tagged clean
scripts/ci/check-comment-caps.sh --base origin/main OK, exit 0

The new file carries no //nolint directive, because no linter fired on it.

End-to-end scenario catalog

No scenario in docs/testing/ is added or affected. The change adds no endpoint, no migration, no storage path, no configuration key and no middleware. The guard reads source files at test time and reaches no database, no network and no secret.

Diff size

git diff --numstat d11d0f22300ab85900b69c72e541b5cdc78eccfd 809245c4a returns 1 2 docs/dev/storage-accounting.md, 4 6 internal/datastore/maven_invariants_test.go, 128 0 internal/datastore/version_type_table_invariants.md and 872 0 internal/datastore/version_type_table_invariants_test.go, re-derived at 2026-09-01T19:19Z. That is past the 500 reviewable-LOC line in docs/dev/development-model.md.

File group Added Deleted
internal/datastore/version_type_table_invariants_test.go 872 0
internal/datastore/version_type_table_invariants.md 128 0
internal/datastore/maven_invariants_test.go 4 6
docs/dev/storage-accounting.md 1 2
Production code 0 0

A split does not help here. The file is one guard with five scans over one shared catalog reader, and most of its volume is diagnostic messages and fail-closed t.Fatalf arms. A split puts the reader in one half and its five callers in the other, so neither half is reviewable alone.

Merge order

The comment-caps gate and !2033 (merged)

The merged comment-caps gate splits a comment run at each ── … ── banner and charges every segment on its own. This file passes that gate today: scripts/ci/check-comment-caps.sh --base origin/main prints OK and exits 0.

!2033 (merged), "ci: remove the banner carve-out from the comment caps gate", removes that split. While !2033 (merged) is unmerged, this file's header is charged segment by segment and the gate passes. Once !2033 (merged) merges, the same header is charged as one block at lines 3 to 43, 28 comment lines against a cap of 2. The header carries 14 banner segments at this head, and all three figures were re-measured from the file at 2026-08-31T17:25Z.

The gate charges only the comment segments a diff touches. An untouched block stays out of the count, and !2033 (merged) keeps that ratchet. If this merge request merges first, its header lands as an untouched block, and !2033 (merged) needs no change to it. If !2033 (merged) merges first, the rebase of this merge request must compress the header to the cap before it can merge. In that case the longer rationale needs a new home, and the sidecar this merge request adds is that home: internal/datastore/version_type_table_invariants.md already carries the extent, and a compressed header keeps its pointer. docs/dev/go-testing.md has no section on the source-invariant test pattern, and no issue and no merge request carries one.

The next version-type table and !1011 (closed)

!1011 (closed), "Draft: feat(pypi): implement S34 PyPI hosted format", adds internal/datastore/jet/artifact_registry/public/table/pypi_versions.go to the generated catalog. That table carries SoftDeletedAt, and its migration 20260721210303_create_pypi_versions.sql declares soft_deleted_at timestamptz, so the naming rule admits it and the soft-deletable narrowing keeps it. !1011 (closed) extends none of the five bound enumerations, and none of their five files exists on its head.

Whichever of the two merge requests lands second turns all five TestInvariant_* tests red until the five lists are extended. That is this guard doing its job: without it, namespace_statistics.components_count and repositories.artifacts_count undercount every PyPI row in silence, and PyPI tombstones reach no discovery scan.

!1011 (closed) is the only open merge request that adds a version-type catalog table, measured over 87 open merge requests at 2026-08-31T15:26Z.

ADR-007 and !1973 (merged)

docs/adr/007_database_schema.md is the durable source for the six-table set, and the file's header points there.

!1973 (merged), "docs: convert S22 spec and plan into storage-accounting documentation", merged at 98a7b2612 on 2026-08-31T23:23:12Z, so this merge request is the one landing second. It moved the S22 prose to docs/dev/storage-accounting.md and left docs/specs/S22-storage-accounting.md a 26-line stub, which says of itself that it is "retained only as a stable landing point for inbound links". The six-table set, the (format, kind) rule and the statement that container_manifests carries no soft_deleted_at all survive at docs/dev/storage-accounting.md. The release half does not: !1973 (merged) carried no GA statement and no pointer to work item 472. That half is carried by merged text either way, at docs/adr/007_database_schema.md and at origin/main:internal/datastore/reconcile_repository.go:288, which is the work-item-472 pointer's own line.

!1973 (merged) also landed the ## Known gaps row for this work, reading "The recompute's version-type table list has no exhaustiveness guard". Landing second is what makes that row this merge request's to retire, and 809245c4a removes it, together with the sentence in the work-item-438 row that named it. 598e028aa on !1973 (merged) is the precedent: it retired a row rather than rewording it once that gap shut, and repaired the sentences the removal falsified. Both targets were found by their text rather than by a line number, because the row sat at :1397 at 98a7b2612 and at :1387 at d11d0f223.

docs/dev/storage-accounting.md

809245c4a puts that file in this merge request's diff, so the branch now shares it with seven open merge requests. Measured at HEAD 809245c4a and origin/main 7f766481c, with every head read from refs/merge-requests/<iid>/head and every pair tested with git merge-tree --write-tree. git merge-tree --write-tree origin/main HEAD is clean.

The seven are !2210 (merged), !2202 (merged), !2169 (merged), !2163 (merged), !2162 (merged), !2123 (merged) and !2040 (merged). None of them touches the row this branch deletes, and none edits the work-item-438 row this branch shortens; !2210 (merged) and !2123 (merged) carry that row as an unchanged context line. !2163 (merged), !2162 (merged), !2123 (merged) and !2040 (merged) merge cleanly with this branch. !2210 (merged), !2202 (merged) and !2169 (merged) conflict, and none of the three conflicts on this file: !2210 (merged) on internal/format/npm/metrics.go, !2202 (merged) on internal/datastore/lifecycle_scan.go, and !2169 (merged) on config.example.yaml. Each of those three carries the same conflict against origin/main on its own, so all three are that branch's own drift.

The same measurement ran before the edit and after it and returned the same seven results, so adding this file to the diff moved none of them. It is a measurement rather than a property of the diff, so it holds until one of the seven moves a row beside the two this branch touches.

One limit worth stating

The file carries 33 t.Fatalf arms, and no test exercises any of them from a test binary. Reaching an arm needs no seam: go test -overlay swaps the parsed source at the path the guard reads, and both directions of the exclusion-key arm were measured that way. Asserting one fired from inside the package is the part with no cheap shape. It means re-running the test binary as a subprocess and reading the child's exit status, or making the guard return errors rather than fail in place, which changes every caller. That is a redesign of the guard rather than an added test, and the evidence that the arms fire is the two-direction probe measurement above. go vet is clean in both tag modes. That removes the broken-format-string class, which is the one class that makes an unexercised arm worth its own test.

Work item 1008

The sidecar is what discharges work item 1008, so the description closes that item rather than relating to it. The item asks for three things, and each one has its evidence in this merge request.

What 1008 asks for Where it is answered
The inventory is derived again, with the command or query that derives it ## How the inventory was derived in internal/datastore/version_type_table_invariants.md. It gives the set command, the two route greps, the per-symbol pass and the non-Go pass, each as a runnable command, and the counts they return.
Each entry says whether the guard binds it The Checked against column of ## The inventory. It names a TestInvariant_* on the five bound rows, and states what each of the other eleven is checked against.
The result reaches main where a reader who adds a format finds it, and the choice of place is recorded with the reason The sidecar sits beside the guard, and the guard's header points at it. The file's own opening records why the inventory is not in that header: scripts/ci/check-comment-caps.sh caps a _test.go comment block at two lines.

internal/datastore/reconcile_namespace_components_count.md on main is the precedent for that place, and it gives the same reason.

The item's body still reads as though both positions on where the inventory belongs were open. That text is stale from this merge request onward, because the inventory is in the sidecar.

Closes #919 (closed) Closes #1008 (closed)

This is a bot message 🤖 — /smurfit

Edited by Pawel Rozlach

Merge request reports

Loading
Loading