chore(datastore): add npm_versions.attestation_predicate_type (S11 Provenance Attestations plan: 5/20)
Why
The packument render has to answer two questions about every version: whether to
emit dist.attestations at all, and which predicate type to name when it does.
S11 answers both from one nullable column on npm_versions, so the render gets
what it needs from the single-table SELECT it already runs and never reaches the
blob store. NULL means the version has no stored bundle, and that absence is the
presence flag on purpose.
The value is stored rather than assumed because it varies by CI provider.
libnpmpublish emits https://slsa.dev/provenance/v0.2 from its GitLab CI
branch and https://slsa.dev/provenance/v1 from its GitHub Actions branch.
Nothing reads or writes the column until Step 9 plumbs it through the store. The plan records that as an accepted smell.
Targets main. It was stacked on Step 2, which has merged as
refactor(npm): split the publish charge into a ... (!2803 - merged) • Hayley Swimelar • 19.5. The dependency was chain-only: this
migration uses nothing Step 2 landed. The transplant onto main needed no
re-stamp, because 20260918231856 still sorts above main's newest migration,
20260918120000.
What
The two timeout rules are satisfied rather than suppressed, and that departs
from the plan's text. Step 5 asks for "the same three squawk-ignore-file
headers the size_bytes precedent carries". This file carries no file-level
header and one statement-level suppression. Stripping every pragma and running
squawk 2.64.0 fires three rules: require-lock-timeout and
require-statement-timeout on the Up's ALTER TABLE, and ban-drop-column on
the Down's DROP COLUMN. The first two have a compliant form that
docs/dev/database-migrations.md names, so both sections bracket their
ALTER TABLE with SET lock_timeout = '5s' and SET statement_timeout = '30s'
and reset each after it. Only ban-drop-column is suppressed, on the line
squawk reports it on, because PostgreSQL offers no way to un-add a column
without dropping it. Guardrail 25 requires that derivation, so please read this
as a deliberate improvement rather than drift from the plan.
The precedent the plan points at is stale twice over. Its own header claims "No
migration in this repo sets lock_timeout", which 21 files now falsify, and its
third header prefer-robust-stmts does not fire here at all, because both
statements carry IF NOT EXISTS and IF EXISTS. A probe confirmed the plan's
squawk-ignore-file require-timeout-settings still silences both timeout rules
under 2.64.0, so following the plan literally would have gone green while
excusing a finding that has an answer.
The two values come from
20260915153000_drop_npm_remote_plaintext_credentials.sql, whose Down
brackets a restoring ALTER TABLE ... ADD COLUMN at the same pair, over a
65-relation family of its own in npm_remote_repositories. 30s covers the
accumulation case of six waits that each stay under the 5s lock_timeout. Six
waits that each reach 5s is not a case it has to cover, because lock_timeout
aborts at the first of them with 55P03 and does not retry. Worth knowing for
review: the same-table precedent, 20260811120000_add_npm_versions_size_bytes.sql,
sets no timeouts at all and defers the question to
Set lock_timeout on the migration Up pass, prov... (#548) • Unassigned, so it is a precedent for the column and
not for these values.
text with no length bound, while every other text column on this table
bounds at 255 characters. The spec bounds this value only through the bundle
cap. A column-level rule would be a second and tighter bound, and the plan takes
no such decision, recording the gap for the spec's owner. Two tests pin it in
both directions: no CHECK constraint names the column, and an 8 KiB value
round-trips verbatim. Nothing writes the column yet, so the bound question
arrives with the publish path rather than with this migration.
Two files beyond the plan's declared list, both forced by a failing gate.
migrations_checksum_test.go moves its knownHeadVersion pin, which its own doc
comment says a landing migration does. npm_schema_integration_test.go gains
three lines, because TestNPMSchema_Columns asserts an exact column count on
npm_versions. The pin rides the implementation commit rather than the test
commit on purpose: that file carries no build tag, so bumping it earlier would
have made TestHeadVersion red under the go-test hook and forced a bypass.
Deferring it is why this branch carries zero --no-verify commits.
Size
482 added lines, inside the 500 reviewable-LOC guardrail and further inside it
once generated files come out: 274 test, 88 SQL, and 120 generated
(structure.sql 65, the jet model and table 55).
Test plan
docker run --name ar-pg-s5 -e POSTGRES_USER=artifact_registry \
-e POSTGRES_PASSWORD=password -e POSTGRES_DB=artifact_registry \
-p 5432:5432 -d postgres:16-alpine -c max_locks_per_transaction=4096
ARTIFACT_REGISTRY_DATABASE_TEST_DSN="postgres://artifact_registry:password@localhost:5432/artifact_registry?sslmode=disable" \
mise exec -- go test -tags=integration ./internal/datastore/migrations/| Covers | Test |
|---|---|
The column is text, nullable, and carries no default |
TestNpmVersionsAttestationPredicateType_ColumnIsTextNullableNoDefault |
| An insert omitting the column leaves NULL, not the empty string | TestNpmVersionsAttestationPredicateType_AbsentValueReadsAsNull |
| A real predicate type round-trips off a hash partition | TestNpmVersionsAttestationPredicateType_ValueRoundTripsToPartition |
| No length bound and no CHECK, so an 8 KiB value stores verbatim | TestNpmVersionsAttestationPredicateType_LongValueStoresUnbounded |
| The Up adds a bare nullable column, with no default, CHECK, or index | TestNpmVersionsAttestationPredicateType_UpAddsBareNullableColumn |
| Both sections set each bound before the statement and reset it after | TestNpmVersionsAttestationPredicateType_BothSectionsBoundTheirLocks |
The Down drops exactly one column, from npm_versions |
TestNpmVersionsAttestationPredicateType_DownDropsColumn |
Reversibility rides TestMigrations_UpDownUp, which executes this Down on its
way to zero, plus the static Down test above.
Each assertion was checked against the mutation it exists to catch: a
varchar(255) column, text NOT NULL DEFAULT '', a second DROP COLUMN, a
DROP COLUMN retargeted to npm_packages, a SET and RESET pair in the
wrong order, an added CREATE INDEX, and an out-of-order stamp each fail a
named test. A DROP COLUMN appearing only inside a comment does not, because
the count strips SQL line comments first.
Guardrails
- 12 (e2e scenarios): none affected. The column has no reader and no writer, so no request path changes.
- 24 (
docs/user/): no page affected. Nothing a customer can call, see, or configure moves. - 22 (ADRs): ADR 007 governs the npm data model, and its
npm_versionsentry does not carry this column. Its own preamble disclaims exhaustiveness, so the migration does not wait on an amendment, and S11's Data Model is where the column is specified.scripts/adr-freshness.shreports the mirror one upstream commit stale, touching only012_usage_data_collection.md, which does not govern this change. - 9 (hook bypasses): none. All seven commits ran the full pre-commit chain.
- Open-MR overlap: two open MRs still touch these files. The
knownHeadVersionpin is a migration-chain serialization point, resolved by taking the lexically latest version, which is how the transplant settled it against fix(datastore): serve the npm_versions package-... (!2800 - merged) • Moaz Khalifa • 19.5. The other pin holder is refactor(datastore): tie a relationship edge to... (!2779 - merged) • Hayley Swimelar • 19.5. Its migration stamps 20260921135818, above this one, so if it lands first the pin takes its version. The three added lines land inside a test deleted outright by test(migrations): drop schema-shape tests struc... (!2782 - merged) • João Pereira • 19.5.
Database Review Evidence
Migrations
Note
apply / rollback per PG version, from goose verbose against an empty
database. The numbers come from local postgres:<version>-alpine containers
running the db:migrate job's own three-phase script at the three matrix
versions, measured before this MR had a pipeline, so the version headers carry
no job links. Production-scale validation via Database Lab is not yet
available. docs/dev/database-migrations.md covers the matrix rationale and
how to read the numbers.
| Migration | PG 16 | PG 17 | PG 18 |
|---|---|---|---|
20260918231856_add_npm_versions_attestation_predicate_type.sql |
OK (12.46ms / 23.8ms) | OK (13.29ms / 18.06ms) | OK (12.91ms / 25.93ms) |
Migration notes:
- No anomaly. Apply spans 12.46ms to 13.29ms across the three versions and
rollback 18.06ms to 25.93ms, so the slowest version is 1.03x the
second-slowest on apply and 1.09x on rollback, well under the 2x divergence
bar. Every phase reported
OKon every version, and the slowest number is 25.93ms against a five-minute boot budget. - Rollback runs 1.4x to 2.0x the apply on each version. That is run-to-run noise rather than an asymmetric Down: the excluded re-up phase re-applied the same migration at 15.87ms, 13.77ms, and 18.86ms, so apply alone varies 1.5x between runs of one version on this host.
- The apply is metadata-only, established from the catalog rather than from the
statement text. An md5 over
pg_class.relfilenodefor all 65 relations in thenpm_versionspartition tree is identical before and after theALTER TABLEon all three versions (PG 17:7ca5ad0a082a58317e76ee92c60e3807both times), while the count of relations carryingattestation_predicate_typegoes from 0 to 65. The statement rewrites no heap. - The statement locks 65 relations, as the file claims. Running the exact
ALTER TABLEinside a transaction and readingpg_locksgives 1AccessExclusiveLockon the partitioned parent plus 64 on the leaf partitions, and no other relation heldACCESS EXCLUSIVE. Identical on PG 16, 17, and 18. - An empty database measures the uncontended cost only. It does not exercise
the lock wait those 65 acquisitions queue behind on a live fleet, which is
what the
lock_timeoutandstatement_timeoutbounds in the file are for.
Context for LLM agents
Rationale
- Copy the
size_bytesprecedent's threesquawk-ignore-fileheaders, which is what the plan's Files entry asks for. Rejected because guardrail 25 anddocs/dev/database-migrations.mdboth require deriving each suppression from what actually fires, and the two timeout rules have a compliant form. A probe confirmed the copiedsquawk-ignore-file require-timeout-settingsstill silences both rules under squawk 2.64.0, so the copy would have passed CI while excusing a finding that has an answer. The precedent's third header fires on nothing here. - Keep
statement_timeoutat the 60s the first draft used. Rejected because 60s appears in no other migration (eight siblings carry 5s, 30s, or 120s), and the only justification available for it bounds the value from above without deriving it, which would equally justify 120s. 60s doubles the window in which the parent'sACCESS EXCLUSIVEblocks every packument render. - Scope the bounds with
SET LOCAL. Rejected because-- +goose NO TRANSACTIONmeans there is no transaction to scope to. - Bound the column with
varchar(n)or a length CHECK, matching the table's other text columns. Rejected because the bound is a spec decision the plan explicitly declines to take. - Chosen: satisfy both timeout rules with a bracketed
SETandRESETpair at the two values the structurally comparable sibling derives, and suppress onlyban-drop-column.
Consequences: the SET and RESET pair leaks the session bounds when a
statement between them fails. goose pins one connection for a NO TRANSACTION
migration, returns on the first error without resetting the session, and hands
that connection back to the pool. All 21 sibling migrations setting these bounds
behave identically, and the blast radius is closed by wiring rather than by SQL:
NewMigrationClient forces MaxConns=1 and Runner.Start shuts the client down
on a failed Up. Diverging here alone would buy nothing and break the house form.
Non-goals
- No index. Nothing filters or orders on the column, and the packument render reads it off a row it already has.
- No backfill. Every version that exists predates the store path, so NULL is already the correct value for all of them.
- No store, handler, or render wiring. Later steps plumb the column through the
store, write it at publish, and render it into
dist.attestations. - No ADR 007 amendment. ADR 007 is not a complete table definition, and S11's Data Model is where this column is specified.
- No answer to the absent length bound. That is a spec decision, recorded in the plan for the spec's owner, and a migration is the wrong place to settle it.
Related to #1273