feat(managementapi): children preview, count, and the detail array (S17 Container Manifest Reads plan: 18/20)

Why

Step 18 of the S17 Container Manifest Reads plan. It serves children_preview and children_count on every container manifests-list element and children on manifest detail, so a multi-platform index's children are readable without one detail call per child. Third of the four field families hanging off Step 10's page-facts seam, and the second relationship direction over container_manifest_relationships.

Depended on chore(datastore): the windowed child-edge read ... (!2581 - merged) • Hayley Swimelar • 19.5 for ListChildrenByParentIDs, which merged first. This MR now targets main.

What

The cached arm issues no children statement, rather than one that returns nothing. While container_remote_manifest_relationships has no writer, both cached arms answer [] and 0 as constants. Once S16's relationship population lands they answer from that table. So fetchContainerManifestPageFacts branches on remote for tags and not for children: Step 9 gave the remote arm its own cached tag read, and there is no cached children read to give. AC #138 (closed)'s children share is one statement hosted and zero cached.

children_count is the statement's own COUNT(*) OVER, not len(Children), computed before the row-number filter, so the count and the preview beside it agree even where the window truncates.

children_preview declares maxItems: 10 and children declares none. The preview's window is containerManifestPreviewLength, a service constant, so the bound holds at every limit and the document states it. The detail array is complete under the operator-configurable container.manifest_max_references, where a bound would promise a shape a raised setting breaks. The contract pin asserts both directions, which gives Steps 12 and 16 the rule for parents_preview. tags_preview carries no bound yet. It is Step 10's property, and a follow-up should bound every preview together.

children_count appends after tags_count in containerManifestBase. encoding/json emits embedded fields in index-path order and the three key-set assertions sort both sides, so an insertion would reorder the wire without reddening anything. Verified by marshalling both leaves directly.

ContainerManifestChild joins the required-completeness loop of TestContract_ContainerManifestSchemas_ShareOneBase, not its scope. The child schema composes no base and restates digest and the platform triple the base declares, so adding it to scope would fail that test's property-disjointness assertion.

Spec coverage

Spec: docs/specs/S17-rest-management-api.md (Manifest resource fields, Page weight)

# Criterion Tests
AC-128 Detail carries children, and no children_preview TestContainerManifestDetail_ChildrenArray, TestContainerManifestChildren_KeyAbsenceBothDirections, assertContainerManifestDetailShape. The criterion's other three detail fields belong to Steps 12, 16, and 19.
AC-132 One entry per child with the four keys, child-digest-byte order, children_count the array's length, children_preview the first ten, [] and 0 for an image manifest and an empty index, neither leaf carrying the other's key TestContainerManifestList_ChildrenPreviewAndCount, TestContainerManifestDetail_ChildrenArray, TestContainerManifestChildren_ReferenceCapServesBothLeaves, TestContainerManifestChildren_ThreeEmptyReadingsAreIndistinguishable, TestContainerManifestChildren_KeyAbsenceBothDirections, TestContainerManifestList_ResourceFields
AC-137 The cached arm serves [] and 0 on both fields and [] on the detail, issuing no read TestContainerRemoteManifestList_ChildrenAreCachedConstants, TestContainerRemoteManifestDetail_ChildrenArray, TestContainerRemoteManifestChildren_StoreFailureNeverReached, TestContainerRemoteManifestList_ResourceFields. The criterion's annotations, platform, referrers, parents, and raw clauses belong to Steps 12, 14, 16, 19, and 20.
AC-138 A page costs one children statement at limit=1 and at limit=100, and the detail the same for one child and 200 TestContainerManifestList_ChildFactsCostOneReadPerPage, TestContainerManifestDetail_ChildCapCostsTheSameReads, TestContainerManifestList_EmptyPageIssuesNoChildRead. The children share only.
Empty readings serve [] and 0, never null TestContainerManifestChildren_NilMarshalsAsEmptyArray on the type, raw-body assertions in TestContainerManifestChildren_EmptyServesEmptyArrayNotNull
Contract: three placements, the child schema, every property required, the preview bounded and the array not TestContract_ContainerManifestSchema_DeclaresChildrenPreview, TestContract_ContainerManifestSchemas_ShareOneBase, and TestContainerHandlers_ResponsesMatchOpenAPIContract, which now seeds eleven children so a populated entry reaches the response validator
The page-facts seam's two call shapes TestFetchContainerManifestPageFacts_BuildsThePreviewShape, TestFetchContainerManifestDetailFacts_BuildsTheCompleteShape, TestContainerManifestFactsFrom_AbsentManifestReadsAsZero
Per-format positive hits on docker and oci, both kinds, both routes (guardrail 6) TestContainerManifestChildren_PerFormat

Error cases

# Condition Tests
E-1 The children read fails: the whole page or detail is a logged 500 naming the children read, never a partial count TestContainerManifestChildren_StoreFailureIsALogged500, TestFetchContainerManifestFacts_StoreErrorFailsTheWholeRead
E-2 An empty page issues no read, because the store rejects an empty id set TestContainerManifestList_EmptyPageIssuesNoChildRead, TestFetchContainerManifestPageFacts_EmptyPageMakesNoCall
E-3 A forgotten call-shape field reaches the handler as a 500 The fake applies D3's exclusive-pairing guard. The store-side pin is Step 17's.
E-4 A cached request never reaches the read at all TestContainerRemoteManifestChildren_StoreFailureNeverReached arms the failure on both cached routes and asserts 200 with zero calls

Reviewable LOC

1,448 across 17 files, past guardrail 18's 500. Split by group:

Group Lines
Production Go 124
Contract YAML 89
e2e catalogs 2
Test Go 1,233

Source is 213 against the plan's ~205 forecast. The test half is 1,233 against ~210, and that is where the size sits: AC #138 (closed) wants a query count at two page sizes on both arms, AC #132 (closed) wants both key absences asserted in both directions because kin-openapi leaves additionalProperties open on both leaves, and the three empty readings need three fixtures to prove they are indistinguishable. 85% of the diff is test code mirroring container_manifest_tags_test.go row for row (guardrail 6), so splitting the family from its suite would move the same lines into a second MR with nothing to verify them against.

Test plan

  • Unit: go test ./internal/managementapi/... ./cmd/artifact-registry/... green, 23.6s and 3.1s.
  • Integration against a real Postgres (postgres:17-alpine with CI's .pg-service-options): internal/managementapi green in 82s, cmd/artifact-registry green in 192s.
  • golangci-lint 2.13 plain: 0 issues. With --build-tags=integration and the full flag set: 0 issues on internal/managementapi, and 0 new on cmd/artifact-registry against this branch's base. That package carries 11 pre-existing findings in integration-tagged files byte-identical to main, untouched here.
  • redocly lint at the pinned 2.34.0: valid.
  • scripts/ci/check-comment-caps.sh: OK against both the base and main.
  • 13 mutations of the production code, 13 reddened. The load-bearing ones: children_count as len() instead of the statement total, the two call shapes swapped, the emit order reversed, the if !remote guard defeated, the children error swallowed, all four builders emptied, and MarshalJSON returning null. Reversing the fake's comparator also reddens, which is what proves the ordering assertions do not share the fake's sort.

No route changes, so no e2e scenario is added (guardrail 12) and no run-recipe edit is owed (guardrail 21). The two manifest-detail-via-api catalog rows gained children and children_count. No operation was added, renamed, or removed, so the Bruno collection is unchanged (guardrail 15).

Coordination

This branch conflicts with feat(managementapi): serve annotations on the c... (!2574 - merged) • Hayley Swimelar • 19.4 (Step 19) and feat(managementapi): serve the raw manifest pay... (!2587 - merged) • Hayley Swimelar • 19.5 (Step 20), both of which merge cleanly into main on their own. The conflicts are this branch's, so land this one first and rebase those two after. Step 16 will collide on cmd/artifact-registry/wire_management.go and internal/managementapi/read_handlers_integration_test.go for the same reason.

The plan gives Step 16 the adapter's relationships field, and this step lands it because it merged first, the out-of-order case D10 names. Whoever writes Step 16 drops the field half and keeps only its forwarding method.

Context for LLM agents

Design rationale, and what was rejected

The cached arms are constants, not a cached read. container_remote_manifest_relationships has no writer, so there is nothing to read. Wiring a cached children read now would ship a statement against an always-empty table plus a fake to match. The spec states the condition and names the event that ends it, so the arms are constants with that condition commented on both fetchers.

One windowed statement per family, the preview limit as a parameter. D3. The count travels with the rows, so the same-statement guarantee holds by construction, and the window keeps a hostile-but-legal index's edges out of handler memory. Fetching the capped array and slicing ten in Go was rejected for the parents family and the shape is shared across families.

Statements run sequentially, never over an errgroup. D3: a page holds one pooled connection at a time and the facts cost the sum of the landed families rather than their maximum. Fanning out buys back at most 300 ms at every ceiling at once, priced against one pooled connection per family for every in-flight page.

ListChildrenByParentIDs widens ContainerManifestReader rather than taking its own seam. D10 decides this. Two per-direction readers would each add a Deps field and each write the same one-line bump to the seam-count literal in handler_test.go, which merges clean and leaves the count one short while requireDeps panics at construction. The cost, one seam over two stores, is in the plan's Accepted code smells.

ContainerManifestChildren.MarshalJSON renders nil as []. []T(nil) marshals as null and the contract declares [] for all three empty readings. Putting the rule in the type is what makes the cached arm, the zero value, and a manifest absent from the facts map correct without a per-arm reminder. Third copy of the pattern after ContainerTagNames and DistTagNames. D3 directs the copy, and a generic array type is worth it once a fourth family lands.

children carries objects rather than bare digests, because the child's row holds the platform triple in the same join and a bare digest would force one detail call per child.

Non-goals

  • referrers_count, parent_digests, parents_preview, parents_count, and annotations. Steps 12, 16, and 19 each add one family to the same seam.
  • The child-edge statement, its argument guards, its unit and integration tests, and its EXPLAIN pin and ceiling timing. Step 17 owns those and this step only calls them.
  • A cached children read, per the rationale above.
  • maxItems on tags_preview. Step 10's property, and a follow-up should bound every preview together.
  • A configuration knob for the preview length. Ten is the contract's figure, not an operator setting.
  • Any route. None is added, changed, or re-registered.

Known gaps, for the next family author

  • TestContainerManifestChildren_ThreeEmptyReadingsAreIndistinguishable exercises two code paths, not three: the image-manifest and empty-index rows differ only in media_type, which no production path reads for this field. That is what the spec asks to be true, so the test is documentary for two of its rows rather than discriminating.
  • The remote serializer's pass-through is untested because the guard makes the cached answer always zero. That matters the day S16's relationship population lands.
  • container_manifest_tags_test.go carries the same false fixture-order comment this MR corrected in the children twin. Out of this step's file set.

🤖 Generated with Claude Code

Related to #1150 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading