feat(oci): serve the referrers list endpoint (S12 Step 17)

Why

S12 Step 17: the referrers list endpoint (GET /v2/<name>/referrers/<digest>), the OCI 1.1 discovery mechanism that lets a client find every manifest referencing a subject digest through its subject field. Signatures, SBOMs, and attestations all rely on it. This is the last endpoint before the Step 18 conformance close.

Built on main (Steps 1-12 merged), independent of the in-flight Phase D steps 13-16: referrers is a read-only query over the Step-3 datastore (ListReferrersPage, already merged) wired at the Step-6 dispatcher, with no manifest-push dependency.

What (non-obvious)

  • Missing subject returns 200 empty, not 404. A subject digest with no referrers, or absent entirely, returns 200 with an empty image index per OCI 1.1 and S12. Only a malformed subject digest is a 400 (DIGEST_INVALID). This is the one place the endpoint diverges from the usual "unknown name → 404" shape.
  • Discrimination is structural (P-6). Image Index children (rows with NULL subject_digest) never appear in a referrers response, regardless of the artifactType filter or page position, because the query filters on subject_digest =. The exclusion is a predicate, not a post-filter, so no page boundary can leak a child.
  • Streamed response, bounded memory. The image index streams descriptor-by-descriptor via json.Encoder against the ResponseWriter. A worst-case page (1000 referrers × ~520 KB annotations ≈ 520 MB) is never buffered twice. A deterministic unit test asserts the no-second-buffer property through a TotalAlloc delta; a DB-backed integration test confirms correctness at scale.
  • Pagination ceiling is pagination_max_size (1000), not the tag-list default (100). n is validated to [0, 1000]; last is a manifest digest parsed through ParseDigest (malformed → 400 PAGINATION_NUMBER_INVALID). n=0 returns an empty index with no Link header. OCI-Filters-Applied: artifactType is emitted only when the filter is set.

Commits. The two-agent flow lands a failing test skeleton, then the implementation. Follow-up fix(oci): commits apply review feedback: the S03 wide-event field (operation) and citation corrections, the dispatcher prefix-closure wiring guard with generic panic messages, the probe-row trim before size resolution, and the oci.stream_truncated signal for mid-stream write failures, with matching error-path and wiring-gap tests.

Test plan

go test ./internal/format/oci/... (unit, rapid property, and DB-backed integration), go vet (default and integration tags), and golangci-lint 2.12 all pass. The referrers spec runs under conformance:oci.

Spec coverage (S12 Step 17):

Criterion Tests
P-6 referrers discrimination TestPropertyReferrersDiscrimination, TestReferrersExcludeIndexChildren (integration)
AC-19 referrers (with filter) TestReferrersArtifactTypeFilter, TestReferrersDescriptorRendering
AC-20 missing subject → 200 empty TestReferrersMissingSubjectReturns200Empty, TestReferrersMissingSubjectIntegration (integration)
E-4 DIGEST_INVALID (malformed subject) TestReferrersInvalidSubjectDigest
E-10 NAME_UNKNOWN (repo/image absent) TestReferrersRepositoryNotFound, TestReferrersImageNotFound
E-15 PAGINATION_NUMBER_INVALID TestReferrersPaginationValidation
S-12 pagination max 1000 TestReferrersPaginationValidation, TestReferrersResponseStreamingBoundedAllocation

Related to #19 (closed)

Context for LLM reviewers

Design rationale.

  • S-13 → S-12 in the Step 17 coverage slice. The plan slice originally listed S-13 ("digest verification on all writes"). The referrers endpoint is read-only and performs no writes, so S-13 cannot apply; the plan already assigns S-13 to Step 12 (manifest push, the write path). The referrers-relevant security row is S-12 (pagination max 1000), enforced by the n-ceiling rejection. The slice and the test comments now say S-12.
  • TestReferrersUsesIndex is delegated, not deferred. The plan named an EXPLAIN-asserted index test. The referrers query is built unexported in the datastore (buildReferrersQuery), so the EXPLAIN assertion lives there as TestListReferrersPage_PartialIndexScan (merged Step 3), which proves single-partition pruning and Index Scan. Duplicating it in the handler package is forbidden by the test-author contract.
  • Wide-event field. This MR emits the S03 discriminator as operation="referrers.list". The merged manifest-push emitter still uses oci.push_step and carries no operation field; aligning the whole OCI emitter family on operation is a separate follow-up, not smuggled into this read-only step.

Spec/ADR drift found while fixing (not addressed here). S12's ADR reference list attributes "pagination 100/1,000" to ADR-004, but ADR-004's text enumerates only repos-per-org and tags-per-artifact, no pagination limit. The authoritative value lives in S12's Configuration section, which the code now cites. Correcting the attribution belongs upstream (ADR-004 is handbook-synced) or in a spec edit, not on this branch.

Non-goals (deferred, not omissions).

  • Garbage collection, soft-delete, and referrer-orphan cleanup: S20 (this endpoint reads only).
  • Size accounting and counters: S22.
  • operation-field alignment for the merged manifest-push and blob-upload emitters: cross-step observability follow-up.
  • Phase D manifest steps 13-15: tracked separately; referrers has no dependency on them.
Edited by Hayley Swimelar

Merge request reports

Loading
Loading