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-alpinewith CI's.pg-service-options):internal/managementapigreen in 82s,cmd/artifact-registrygreen in 192s. golangci-lint2.13 plain: 0 issues. With--build-tags=integrationand the full flag set: 0 issues oninternal/managementapi, and 0 new oncmd/artifact-registryagainst this branch's base. That package carries 11 pre-existing findings in integration-tagged files byte-identical tomain, untouched here.redocly lintat the pinned 2.34.0: valid.scripts/ci/check-comment-caps.sh: OK against both the base andmain.- 13 mutations of the production code, 13 reddened. The load-bearing ones:
children_countaslen()instead of the statement total, the two call shapes swapped, the emit order reversed, theif !remoteguard defeated, the children error swallowed, all four builders emptied, andMarshalJSONreturningnull. 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, andannotations. 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
EXPLAINpin and ceiling timing. Step 17 owns those and this step only calls them. - A cached children read, per the rationale above.
maxItemsontags_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_ThreeEmptyReadingsAreIndistinguishableexercises two code paths, not three: the image-manifest and empty-index rows differ only inmedia_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.gocarries the same false fixture-order comment this MR corrected in the children twin. Out of this step's file set.
Related to #1150 (closed)