chore(datastore): the referrers list-row page over the partial referrers index (S17 Container Manifest Reads plan: 13/20)

Why

Step 13 of the S17 container manifest reads plan. The referrers route needs a page of the manifests-list row, and the tree's only referrers read is the OCI protocol's ListReferrersPage, whose sixteenth column is annotations: 59.1 MB on a hundred-row page the response never renders (plan D7). This adds ListReferrerListPage, the same keyset over the same partial index, projecting the eleven list columns. Step 14 is its first caller.

What

Order is a scan direction, not a sort. The route exposes digest ascending only, but every S17 list promises Link with rel="prev", so a backward page scans the opposite way and Step 14 reverses the rows into display order, the pageDirection.fetchOrder convention already on main. The spec author ruled this in on 2026-09-11, after the plan, which names neither the direction nor rel="prev". Two commit bodies cite (D7) for it and are wrong: the authority is S17's list-conventions line and the shared Link component in api/openapi/v1.yaml.

The cursor is width-bounded, and that is a defect fix. digest carries CHECK (octet_length(digest) = 32), and in bytea a prefix sorts below the value it prefixes. An off-width boundary serves that row twice ascending and skips every digest between the prefix and the row descending. An earlier draft called it an infinite page loop, which needs the client to replay its own truncated cursor, and a server-issued Link carries only served full-width digests. A duplicated row or a silent gap is the exposure. ListReferrersPage carries the same one and is untouched here.

The comment caps are the whole deletion count. scripts/ci/check-comment-caps.sh charges a whole block to its cap the moment a diff touches one line, so naming a second reader on ListReferrersPage's 23-line doc cost that block, and two more blocks stopped naming a single reader. Every claim was relocated rather than dropped. 34 of the 35 deletions are that comment text: the rebase onto main dropped one of the three blocks, because Step 11 had already compressed ContainerManifestStore's doc to the plural "management reads" this step needed.

The read lives in its own file. Review asked for the split and this block is the one that moves cleanly. container_manifest_referrer_list.go is a pure move: 116 lines out, the same lines back, plus a package clause, a two-line header, and six imports. container_manifest.go is 1,057 lines after it, still over 1,000, because Step 11's CountReferrersBySubjectDigests is what carries it there and is not this step's code.

Spec coverage

# Criterion Covered by
AC #134 (closed), direct referrers only A referrer of a referrer, a referrer under another image, and a non-referrer row are all absent TestListReferrerListPage_ScopedToOneSubjectAndOneImage, plus a foreign-namespace arm addressing the owner's real image and subject
AC #134 (closed), keyset by digest ascending TestListReferrerListPage_KeysetWalkForwardAndBackward, _CursorIsStrictlyExclusive, _UnmatchedCursor (boundary between rows, a deleted boundary, and a stale cursor past the end), both directions
AC #134 (closed), limit default 20 maximum 100, no total Step 14's handler owns the clamp and the response. This read binds the requested limit unclamped and returns no hasMore: TestListReferrerListPageStmt_SQL, _ProbeRowIsTheCallers
AC #134 (closed), no referrers is an empty page TestListReferrerListPage_EmptyResultSet. The 200 is Step 14's
AC #129 (closed), no annotations on a list row assertProjectsManifestListColumns on all 16 statement variants rejects the five unprojected columns by name, and _RowFidelity pins all eleven projected ones on a populated row and five nil mappings on a sparse one
Plan Step 13, the EXPLAIN pin TestListReferrerListPage_PartialIndexScan: four arms (both directions × opening and resumed page), the truncated child-index suffix container_image_id_sub_idx, one partition, the keyset bound folded into Index Cond rather than a Filter:, and sort-free with Index Scan Backward on the descending arm

The seven argument guards carry ten arms in TestListReferrerListPage_ArgumentGuards, and _QueryFailure covers the error path. AC #134 (closed)'s 404 for a digest naming no manifest is Step 14's resolution and is unreachable from this read. AC #138 (closed) and AC #139 (closed) bound the manifests-list page and price the four count families, so no timing figure and no row in database-query-patterns.md is owed (the plan's Step 13 acceptance says so).

Reviewable LOC

Group Lines
Source: container_manifest_referrer_list.go, container_manifest.go, query_names.go 144 added, 35 deleted
Tests: container_manifest_test.go, container_manifest_integration_test.go 1,012 added
Total 1,191

Past guardrail 18's line on tests, and past the plan's ~120 source / ~300 test forecast. Source is 144, 131 of it the new file. Splitting does not help: the statement, its guards, its integration suite, and the EXPLAIN pin are one read, so the pin alone would land with nothing to pin and the tests alone would break test-first authorship. The test count is guardrail 6's cost. This read mirrors two suites, TestListReferrersPage_* for the referrers semantics and TestListContainerManifests_* for the keyset and projection, and the dropped-subtest diff against both added the cross-namespace, unmatched-cursor, row-fidelity, and query-failure arms.

Test plan

mise exec -- env -u GOROOT go test -count=1 ./internal/datastore/...

ARTIFACT_REGISTRY_DATABASE_TEST_DSN="postgres://<user>:<pw>@<host>:<port>/<db>?sslmode=disable" \
  mise exec -- env -u GOROOT go test -count=1 -tags=integration \
  -run 'ListReferrer' ./internal/datastore/

mise exec -- env -u GOROOT golangci-lint run --build-tags=integration \
  --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false \
  --new-from-rev origin/main ./internal/datastore/...

bash scripts/ci/check-comment-caps.sh --base origin/main

The local database needs CI's max_locks_per_transaction: a stock container's 64 fails this package's cascade cleanups with SQLSTATE 53200.

Database Review Evidence

Query mode only. This MR adds one query-producing method, ListReferrerListPage (container_manifests_select_referrer_list_page), and no migration, so migration mode does not apply.

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17.10 container (matching GL_PG_CURR_VERSION from .gitlab-ci-other-versions.yml) started under CI's .pg-service-options flags, autovacuum=off included, worst of three runs. The fixture is the step's own EXPLAIN-pin fixture scaled up: 25,000 container_manifests rows, seeded committed and VACUUM (ANALYZE)d rather than rolled back, because under autovacuum=off a rolled-back seed leaves heap bloat that moves the plan. One namespace holds 20,000 of them in a single partition: 5,000 referrers of the addressed subject under the addressed image, 5,000 of a second subject under the same image, 5,000 of the same subject under a sibling image, and 5,000 non-referrers whose subject_digest is NULL. A second namespace holds 5,000 more referrers of the same subject digest, which is what makes the namespace predicate load-bearing rather than decorative. Numbers do not capture production-scale effects. See Database review evidence for methodology.

Every variant is one statement builder's branch, so the projection and FROM are shared:

SELECT container_manifests.id, container_manifests.digest, container_manifests.media_type,
       container_manifests.artifact_type, container_manifests.subject_digest, container_manifests.size,
       container_manifests.created_at, container_manifests.last_downloaded_at,
       container_manifests.architecture, container_manifests.os, container_manifests.os_variant
FROM public.container_manifests
WHERE ((container_manifests.namespace_id = $1::uuid)
  AND (container_manifests.container_image_id = $2::uuid))
  ...
Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
datastore.ListReferrerListPage.Ascending Limit over Index Scan container_manifests_p21_..._container_image_id_sub_idx 20 / 20 5.41 0.037ms 4 / 0 1/64
datastore.ListReferrerListPage.Descending Limit over Index Scan Backward container_manifests_p21_..._container_image_id_sub_idx 20 / 20 5.41 0.032ms 4 / 0 1/64
datastore.ListReferrerListPage.AscendingResumed Limit over Index Scan container_manifests_p21_..._container_image_id_sub_idx 20 / 20 5.87 0.046ms 4 / 0 1/64
datastore.ListReferrerListPage.DescendingResumed Limit over Index Scan Backward container_manifests_p21_..._container_image_id_sub_idx 20 / 20 22.53 0.028ms 7 / 0 1/64
datastore.ListReferrerListPage.ProbeRow Limit over Index Scan container_manifests_p21_..._container_image_id_sub_idx 21 / 21 5.66 0.027ms 4 / 0 1/64
datastore.ListReferrerListPage.ArtifactTypeFiltered Limit over Index Scan container_manifests_p21_..._container_image_id_sub_idx 20 / 20 40.82 0.031ms 5 / 0 1/64
datastore.ListReferrerListPage.UnmatchedCursor Limit over Index Scan container_manifests_p21_..._container_image_id_sub_idx 1 / 0 8.44 0.022ms 3 / 0 1/64

Query notes:

  • Every branch rides the partial referrers index, pruned to one partition of 64. The scan is container_manifests_p21_namespace_id_container_image_id_sub_idx, the partition child of index_container_manifests_on_ns_id_ci_id_subject_digest_digest. The child name is exactly 63 characters on all 64 partitions, which is PostgreSQL's identifier limit, so the spelling is a truncation of the parent's rather than a chosen name. This is the name TestListReferrerListPage_PartialIndexScan asserts by its distinctive container_image_id_sub_idx suffix.
  • No branch sorts. The index carries digest as its trailing key under a two-column equality prefix, so both directions are index-ordered and the descending pages come back as Index Scan Backward with no Sort node anywhere in the plan.
  • The keyset bound is resolved by the index, not rechecked above it. On both resumed pages the digest > $4 or digest < $4 comparison appears inside Index Cond. A bound that landed in a Filter would read the whole set and discard the served prefix on every page.
  • The partial index covers only the referrer rows. It holds 1,992 kB for the 15,000 rows with a non-NULL subject_digest and skips the 5,000 non-referrers in the same partition, which is what the WHERE subject_digest IS NOT NULL clause buys.
  • Estimates match reality on every page branch, 20 / 20 and 21 / 21. The exhausted-walk branch plans 1 row against 0 actual, which is the planner's one-row floor for an empty range rather than a stats divergence.
  • Planning cost is paid once per backend, not per page. The first plan in a fresh session reads 744 shared buffers of partition catalog and takes 4.380 ms. Every later branch plans in 0.14 ms to 0.26 ms against 4 to 8 buffers.
  • The artifact-type branch is the one that pays a Filter. artifact_type is not an index key, so the predicate is rechecked above the scan and Rows Removed by Filter: 20 records 40 entries read to serve 20. The referrers route exposes no such filter and its caller leaves the field empty, so no production read pays it, and indexing a column no route binds would buy a write cost with no reader.
  • The LIMIT is caller-fed and clamped one layer up. This read binds params.Limit unclamped by design and rejects a non-positive value with errInvalidLimit before any SQL runs. The route's clamp to 100 belongs to the handler Step 14 lands, and there is no production caller until then.
  • No timing budget claim is owed and none is made. AC #138 (closed) prices the manifests-list page and AC #139 (closed) the four count families, neither of which is this keyset read, so this section adds no row to database-query-patterns.md and asserts no budget. The figures above are execution evidence, not a bound.
  • No anomalies. No sequential scan, no partition fan-out, no sort above an index that should provide the order, no estimate off by more than the empty-range floor, and zero buffer reads against 3 to 7 hits on every branch.
datastore.ListReferrerListPage.Ascending

Summary: The opening page, and the shape the route serves. The planner takes the partial referrers index on one partition of 64 and stops after 20 of the 5,000 matching rows, so cost and buffers are bounded by the page rather than the set. Estimate and reality agree exactly. No anomalies.

Seed shape: namespaces=2, repositories=2, container_repositories=2, container_images=3, blob_storage_blobs=2, blob_storage_attachments=2, container_manifests=25000

Rendered SQL (predicate tail, the eleven-column projection and FROM are identical across every variant and are written out above):

... AND (container_manifests.container_image_id = $2::uuid))
AND (container_manifests.subject_digest = $3::bytea)
ORDER BY container_manifests.digest ASC
LIMIT $4;

Bound args: ['01920000-…-000000000001', '01920000-…-000000000002', '\xabab…abab', 20]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Limit  (cost=0.41..5.41 rows=20 width=274) (actual time=0.029..0.037 rows=20 loops=1)
  Buffers: shared hit=4
  ->  Index Scan using container_manifests_p21_namespace_id_container_image_id_sub_idx on container_manifests_p21 container_manifests  (cost=0.41..1877.04 rows=7500 width=274) (actual time=0.029..0.035 rows=20 loops=1)
        Index Cond: ((namespace_id = '01920000-0000-7000-8000-000000000001'::uuid) AND (container_image_id = '01920000-0000-7000-8000-000000000002'::uuid) AND (subject_digest = '\xabababababababababababababababababababababababababababababababab'::bytea))
        Buffers: shared hit=4
Planning:
  Buffers: shared hit=744
Planning Time: 4.380 ms
Execution Time: 0.067 ms

Timings: planning 4.380ms, execution 0.067ms, total 4.447ms.

datastore.ListReferrerListPage.Descending

Summary: The same index read backward, which is what the Order field buys. Index Scan Backward with no Sort above it, identical cost and buffers to the ascending page, so the backward direction is not paying for a materialized reversal. No anomalies.

Seed shape: namespaces=2, repositories=2, container_repositories=2, container_images=3, blob_storage_blobs=2, blob_storage_attachments=2, container_manifests=25000

Rendered SQL (predicate tail, the eleven-column projection and FROM are identical across every variant and are written out above):

... AND (container_manifests.container_image_id = $2::uuid))
AND (container_manifests.subject_digest = $3::bytea)
ORDER BY container_manifests.digest DESC
LIMIT $4;

Bound args: ['01920000-…-000000000001', '01920000-…-000000000002', '\xabab…abab', 20]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Limit  (cost=0.41..5.41 rows=20 width=274) (actual time=0.027..0.032 rows=20 loops=1)
  Buffers: shared hit=4
  ->  Index Scan Backward using container_manifests_p21_namespace_id_container_image_id_sub_idx on container_manifests_p21 container_manifests  (cost=0.41..1877.04 rows=7500 width=274) (actual time=0.027..0.030 rows=20 loops=1)
        Index Cond: ((namespace_id = '01920000-0000-7000-8000-000000000001'::uuid) AND (container_image_id = '01920000-0000-7000-8000-000000000002'::uuid) AND (subject_digest = '\xabababababababababababababababababababababababababababababababab'::bytea))
        Buffers: shared hit=4
Planning:
  Buffers: shared hit=4
Planning Time: 0.185 ms
Execution Time: 0.048 ms

Timings: planning 0.185ms, execution 0.048ms, total 0.233ms.

datastore.ListReferrerListPage.AscendingResumed

Summary: The resumed forward page. The keyset bound lands inside Index Cond rather than as a Filter, so the scan starts at the cursor instead of re-reading the page it just served. No anomalies.

Seed shape: namespaces=2, repositories=2, container_repositories=2, container_images=3, blob_storage_blobs=2, blob_storage_attachments=2, container_manifests=25000

Rendered SQL (predicate tail, the eleven-column projection and FROM are identical across every variant and are written out above):

... AND (container_manifests.container_image_id = $2::uuid))
AND (container_manifests.subject_digest = $3::bytea)) AND (container_manifests.digest > $4::bytea)
ORDER BY container_manifests.digest ASC
LIMIT $5;

Bound args: […, '\xabab…abab', '\x0000…09c4', 20]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Limit  (cost=0.41..5.87 rows=20 width=274) (actual time=0.040..0.046 rows=20 loops=1)
  Buffers: shared hit=4
  ->  Index Scan using container_manifests_p21_namespace_id_container_image_id_sub_idx on container_manifests_p21 container_manifests  (cost=0.41..1792.02 rows=6562 width=274) (actual time=0.039..0.045 rows=20 loops=1)
        Index Cond: ((namespace_id = '01920000-0000-7000-8000-000000000001'::uuid) AND (container_image_id = '01920000-0000-7000-8000-000000000002'::uuid) AND (subject_digest = '\xabababababababababababababababababababababababababababababababab'::bytea) AND (digest > '\x00000000000000000000000000000000000000000000000000000000000009c4'::bytea))
        Buffers: shared hit=4
Planning:
  Buffers: shared hit=13
Planning Time: 0.262 ms
Execution Time: 0.063 ms

Timings: planning 0.262ms, execution 0.063ms, total 0.325ms.

datastore.ListReferrerListPage.DescendingResumed

Summary: The resumed backward page, the one Step 14's rel="prev" link drives. The bound flips to digest < and rides the same index backward, still inside Index Cond. Cost rises to 22.53 against the opening page's 5.41 because the planner prices 937 candidate rows below the cursor rather than the whole set, and execution is unchanged. No anomalies.

Seed shape: namespaces=2, repositories=2, container_repositories=2, container_images=3, blob_storage_blobs=2, blob_storage_attachments=2, container_manifests=25000

Rendered SQL (predicate tail, the eleven-column projection and FROM are identical across every variant and are written out above):

... AND (container_manifests.container_image_id = $2::uuid))
AND (container_manifests.subject_digest = $3::bytea)) AND (container_manifests.digest < $4::bytea)
ORDER BY container_manifests.digest DESC
LIMIT $5;

Bound args: […, '\xabab…abab', '\x0000…09c4', 20]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Limit  (cost=0.41..22.53 rows=20 width=274) (actual time=0.019..0.028 rows=20 loops=1)
  Buffers: shared hit=7
  ->  Index Scan Backward using container_manifests_p21_namespace_id_container_image_id_sub_idx on container_manifests_p21 container_manifests  (cost=0.41..1036.84 rows=937 width=274) (actual time=0.018..0.026 rows=20 loops=1)
        Index Cond: ((namespace_id = '01920000-0000-7000-8000-000000000001'::uuid) AND (container_image_id = '01920000-0000-7000-8000-000000000002'::uuid) AND (subject_digest = '\xabababababababababababababababababababababababababababababababab'::bytea) AND (digest < '\x00000000000000000000000000000000000000000000000000000000000009c4'::bytea))
        Buffers: shared hit=7
Planning Time: 0.183 ms
Execution Time: 0.043 ms

Timings: planning 0.183ms, execution 0.043ms, total 0.226ms.

datastore.ListReferrerListPage.ProbeRow

Summary: The caller's Limit = n + 1. One extra index entry and the same four buffers, which is the cost of the probe row this read leaves to its caller. No anomalies.

Seed shape: namespaces=2, repositories=2, container_repositories=2, container_images=3, blob_storage_blobs=2, blob_storage_attachments=2, container_manifests=25000

Rendered SQL (predicate tail, the eleven-column projection and FROM are identical across every variant and are written out above):

... AND (container_manifests.container_image_id = $2::uuid))
AND (container_manifests.subject_digest = $3::bytea)
ORDER BY container_manifests.digest ASC
LIMIT $4;

Bound args: […, '\xabab…abab', 21]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Limit  (cost=0.41..5.66 rows=21 width=274) (actual time=0.022..0.027 rows=21 loops=1)
  Buffers: shared hit=4
  ->  Index Scan using container_manifests_p21_namespace_id_container_image_id_sub_idx on container_manifests_p21 container_manifests  (cost=0.41..1877.04 rows=7500 width=274) (actual time=0.022..0.025 rows=21 loops=1)
        Index Cond: ((namespace_id = '01920000-0000-7000-8000-000000000001'::uuid) AND (container_image_id = '01920000-0000-7000-8000-000000000002'::uuid) AND (subject_digest = '\xabababababababababababababababababababababababababababababababab'::bytea))
        Buffers: shared hit=4
Planning Time: 0.142 ms
Execution Time: 0.043 ms

Timings: planning 0.142ms, execution 0.043ms, total 0.185ms.

datastore.ListReferrerListPage.ArtifactTypeFiltered

Summary: The only variant carrying a Filter. artifact_type is not a key of the partial index, so the predicate is rechecked above the scan and Rows Removed by Filter: 20 says it read 40 entries to serve 20. The route exposes no filter and its caller leaves the field empty, so production never pays this. Worth knowing rather than fixing: indexing artifact_type for a field no route binds would be a write cost with no reader.

Seed shape: namespaces=2, repositories=2, container_repositories=2, container_images=3, blob_storage_blobs=2, blob_storage_attachments=2, container_manifests=25000

Rendered SQL (predicate tail, the eleven-column projection and FROM are identical across every variant and are written out above):

... AND (container_manifests.container_image_id = $2::uuid))
AND (container_manifests.subject_digest = $3::bytea)) AND (container_manifests.artifact_type = $4::text)
ORDER BY container_manifests.digest ASC
LIMIT $5;

Bound args: […, '\xabab…abab', 'application/vnd.example.sbom', 20]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Limit  (cost=0.41..40.82 rows=20 width=274) (actual time=0.021..0.031 rows=20 loops=1)
  Buffers: shared hit=5
  ->  Index Scan using container_manifests_p21_namespace_id_container_image_id_sub_idx on container_manifests_p21 container_manifests  (cost=0.41..1895.79 rows=938 width=274) (actual time=0.020..0.029 rows=20 loops=1)
        Index Cond: ((namespace_id = '01920000-0000-7000-8000-000000000001'::uuid) AND (container_image_id = '01920000-0000-7000-8000-000000000002'::uuid) AND (subject_digest = '\xabababababababababababababababababababababababababababababababab'::bytea))
        Filter: (artifact_type = 'application/vnd.example.sbom'::text)
        Rows Removed by Filter: 20
        Buffers: shared hit=5
Planning:
  Buffers: shared hit=8
Planning Time: 0.175 ms
Execution Time: 0.050 ms

Timings: planning 0.175ms, execution 0.050ms, total 0.225ms.

datastore.ListReferrerListPage.UnmatchedCursor

Summary: A cursor above every stored digest, the exhausted-walk case. The index answers it from three buffers with no rows. rows=1 planned against 0 actual is the planner's one-row floor for an empty range, not a stats problem. No anomalies.

Seed shape: namespaces=2, repositories=2, container_repositories=2, container_images=3, blob_storage_blobs=2, blob_storage_attachments=2, container_manifests=25000

Rendered SQL (predicate tail, the eleven-column projection and FROM are identical across every variant and are written out above):

... AND (container_manifests.container_image_id = $2::uuid))
AND (container_manifests.subject_digest = $3::bytea)) AND (container_manifests.digest > $4::bytea)
ORDER BY container_manifests.digest ASC
LIMIT $5;

Bound args: […, '\xabab…abab', '\xffff…ffff', 20]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Limit  (cost=0.41..8.44 rows=1 width=274) (actual time=0.022..0.022 rows=0 loops=1)
  Buffers: shared hit=3
  ->  Index Scan using container_manifests_p21_namespace_id_container_image_id_sub_idx on container_manifests_p21 container_manifests  (cost=0.41..8.44 rows=1 width=274) (actual time=0.022..0.022 rows=0 loops=1)
        Index Cond: ((namespace_id = '01920000-0000-7000-8000-000000000001'::uuid) AND (container_image_id = '01920000-0000-7000-8000-000000000002'::uuid) AND (subject_digest = '\xabababababababababababababababababababababababababababababababab'::bytea) AND (digest > '\xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'::bytea))
        Buffers: shared hit=3
Planning Time: 0.232 ms
Execution Time: 0.041 ms

Timings: planning 0.232ms, execution 0.041ms, total 0.273ms.

Context for LLM agents

Design rationale, and what was rejected

  • A second read over reusing ListReferrersPage. D7. The protocol read's row type is datastore.ContainerManifest, no production conversion to ContainerManifestListRow exists, and its sixteenth column is the one AC #129 (closed) keeps off a manifests page. The protocol read keeps its columns and its own caller.
  • The store derefs s.client.DB() rather than taking a qrm.DB. The store's own type doc states the rule: protocol methods take a handle so a caller composes them in a format-handler transaction, and a management one-shot read derefs the client, because a caller-supplied handle would invite a management handler to hold a transaction open across an HTTP response. ListContainerManifests is the precedent. Step 14's forwarding method on containerManifestReaderAdapter is therefore a pass-through, like that read's, rather than a managementPool(a.client) call like GetContainerManifestByDigest's.
  • The probe row stays the caller's. D7 puts the Limit = n+1, the trim, and the hasMore derivation in Step 14's handler, mirroring ListReferrersPage. So this read calls neither probeLimit nor splitProbeRow and returns no hasMore, and _ProbeRowIsTheCallers asserts the page length is min(Limit, N) against a copied trim.
  • An empty page is nil, not an empty slice. ListContainerManifests and ContainerRemoteManifestStore both branch to nil citing the RepositoryStore.List and maven_files contract, and three test sites pin it with assert.Nil because assert.Empty passes either way. Review pass 2 found this read on the other shape with assert.Empty pinning neither, so it now matches the package.
  • ArtifactType is kept even though Step 14 leaves it empty. D7 names the field as the thing the handler must leave empty, so removing it is plan drift. A non-empty value silently shortens the page, which the field's own comment now says.
  • The cursor width guard rejects off-width rather than clamping. A forged or truncated boundary is not a page the client can be served, and errContainerManifestInvalidCursor already existed for the sibling read's typed cursor.
  • referrerListPageHint is a package-scope function with its own unit test. The returned page's capacity comes from containerManifestListRowsFromModels's make(..., 0, len(rows)), so it tracks the row count and no assertion on the returned slice can pin the clamp. Its max(..., 0) floor is unreachable through the method, which rejects a non-positive Limit first, and it stays because this is the one clamp in the file that is a function rather than an inline min beside its own guard: a second caller can arrive here and the failure mode is a make panic. The siblings at ListReferrersPage and ListContainerManifests cannot acquire one, which is why they carry no floor.
  • The pin asserts sort-free, which the other four pins in this plan must not. Three of the four count families sort outside an index, so copying a sort-free assertion onto them fails against correct code (D4). This index carries digest as its trailing key under a two-column equality prefix, so both directions are index-ordered. The plan's risk table grants the exception to Step 11's referrers count, and this read's shape earns it for the same reason. An almost-empty partition is not the weakest case for index preference: at ten rows the sort-free plan and a Filter-plus-Sort plan cost the same to the cent, so the fixture seeds 2,000 more manifests on the image to price the alternatives out.
  • No index, no migration, no knob. The plan's Dependencies section says so, and a knob would trigger guardrail 14.

Non-goals

  • No production caller until Step 14. The plan's accepted-code-smells section takes this interval for Steps 8, 9, 11, 13, 15, and 17, with !1131 and !1133 as the same-day precedent under the same chore(datastore) prefix. The tests are the read's only exercise.
  • The error path is tested because a swallowed error is a false negative. It would render an empty page and tell a signature or SBOM consumer there are no referrers on evidence the database never supplied.
  • No cursor 400. The spec puts the referrers route's cursor, sort, order, and limit rejections on the handler's list-parameter rules, and Step 14's file list owns parseContainerReferrerListQuery. The store's width guard is a second line, not that status.
  • No e2e catalog row and no run-recipe edit. Guardrail 12 scopes the catalogs to feat and fix, and this step serves no route.
  • No fix to ListReferrersPage's own gaps. It lacks the zero-image and empty-subject guards whose sentinels sit beside it, the cursor width bound, and its AllocationHintCeiling test asserts a capacity that tracks the row count. All three are on a shipped protocol read outside this step's file list. This read's own copy of that last one is deleted rather than fixed: TestListReferrerListPageStmt_SQL already binds the limit unclamped at 100, 101, 1000, and 1<<30, so an integration arm over three rows could not fail against a clamped LIMIT.
  • No ADR work. Every governing ADR was read for a conflict rather than checked by title. ADR-014's Open Question 3 records that ADR-009 does not specify page-size limits, so this route's 100 against the protocol's 1,000 is spec-level only. The local mirror is one commit stale, on ADR-009's repository-delete 409, unrelated.

Falsified while building

  • The plan's coordinates for stale-claim sites 5 and 20 are 400ef5976's. They sit at :734-736 and :561-586 today, and the block at site 20 is 23 counted lines rather than the 5 the plan's range implies.
  • The plan lists two falsified sites for this step. There are three: ContainerManifestStore's type doc named ListContainerManifests as the management read, which this read's signature makes false (guardrail 17). The plan's table does not carry it.
  • The plan forecast ~120 source and ~300 test. Actual is 144 and 1,008. The source figure held. The test figure is off by 3.4x, and Steps 8 and 9 missed their own test forecasts by a comparable factor, so the forecast method rather than this step is what drifted.
  • The plan does not list Step 13 among the five steps it expects past 500 LOC. It is past it, on tests.
  • The plan's Step 13 file list names container_manifest.go as this read's home and adds no file. Review asked for the split and the read now lives in container_manifest_referrer_list.go, same package, same Symbols added line. Recorded for the corrections MR.
  • Two claims review pass 1 added did not survive pass 2's checking: a MUST log namespace_id clause that database-query-patterns.md says the API's own 500 writers already add, and a cursor-width precedent credited to ListContainerManifests, which has no width bound (maven_files owns that half).

🤖 Generated with Claude Code

Related to #1150 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading