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/mainThe 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 ofindex_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 nameTestListReferrerListPage_PartialIndexScanasserts by its distinctivecontainer_image_id_sub_idxsuffix. - No branch sorts. The index carries
digestas its trailing key under a two-column equality prefix, so both directions are index-ordered and the descending pages come back asIndex Scan Backwardwith noSortnode anywhere in the plan. - The keyset bound is resolved by the index, not rechecked above it. On both resumed pages the
digest > $4ordigest < $4comparison appears insideIndex Cond. A bound that landed in aFilterwould 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_digestand skips the 5,000 non-referrers in the same partition, which is what theWHERE subject_digest IS NOT NULLclause 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_typeis not an index key, so the predicate is rechecked above the scan andRows Removed by Filter: 20records 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
LIMITis caller-fed and clamped one layer up. This read bindsparams.Limitunclamped by design and rejects a non-positive value witherrInvalidLimitbefore 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.mdand 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 msTimings: 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 msTimings: 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 msTimings: 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 msTimings: 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 msTimings: 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 msTimings: 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 msTimings: 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 isdatastore.ContainerManifest, no production conversion toContainerManifestListRowexists, 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 aqrm.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.ListContainerManifestsis the precedent. Step 14's forwarding method oncontainerManifestReaderAdapteris therefore a pass-through, like that read's, rather than amanagementPool(a.client)call likeGetContainerManifestByDigest's. - The probe row stays the caller's. D7 puts the
Limit = n+1, the trim, and thehasMorederivation in Step 14's handler, mirroringListReferrersPage. So this read calls neitherprobeLimitnorsplitProbeRowand returns nohasMore, and_ProbeRowIsTheCallersasserts the page length ismin(Limit, N)against a copied trim. - An empty page is
nil, not an empty slice.ListContainerManifestsandContainerRemoteManifestStoreboth branch tonilciting theRepositoryStore.Listandmaven_filescontract, and three test sites pin it withassert.Nilbecauseassert.Emptypasses either way. Review pass 2 found this read on the other shape withassert.Emptypinning neither, so it now matches the package. ArtifactTypeis 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
errContainerManifestInvalidCursoralready existed for the sibling read's typed cursor. referrerListPageHintis a package-scope function with its own unit test. The returned page's capacity comes fromcontainerManifestListRowsFromModels'smake(..., 0, len(rows)), so it tracks the row count and no assertion on the returned slice can pin the clamp. Itsmax(..., 0)floor is unreachable through the method, which rejects a non-positiveLimitfirst, and it stays because this is the one clamp in the file that is a function rather than an inlineminbeside its own guard: a second caller can arrive here and the failure mode is amakepanic. The siblings atListReferrersPageandListContainerManifestscannot 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
digestas 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
!1131and!1133as the same-day precedent under the samechore(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, andlimitrejections on the handler's list-parameter rules, and Step 14's file list ownsparseContainerReferrerListQuery. 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
featandfix, 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 itsAllocationHintCeilingtest 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_SQLalready binds the limit unclamped at 100, 101, 1000, and1<<30, so an integration arm over three rows could not fail against a clampedLIMIT. - 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-736and:561-586today, 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 namedListContainerManifestsas 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.goas this read's home and adds no file. Review asked for the split and the read now lives incontainer_manifest_referrer_list.go, same package, sameSymbols addedline. Recorded for the corrections MR. - Two claims review pass 1 added did not survive pass 2's checking: a
MUST log namespace_idclause thatdatabase-query-patterns.mdsays the API's own 500 writers already add, and a cursor-width precedent credited toListContainerManifests, which has no width bound (maven_filesowns that half).
Related to #1150 (closed)