chore(datastore): the batched parent-digest read by child manifest ids (S17 Container Manifest Reads plan: 15/20)
Why
Step 15 of the S17 Container Manifest Reads plan adds the batched parent-edge read behind the list's parents_preview and the detail's parent_digests and parents_count (AC #135 (closed)). It is the plan's highest-ceiling batched family, at 25 times the tag family's rows. Step 16 serves it, so nothing calls it yet.
The first revision measured the preview at 1,018 ms against a 100 ms budget, and 671 ms of that was one COUNT(*) OVER per child. The plan owner ruled that parents_count leaves the list page and stays on the manifest detail, where it is the length of the array beside it. With no count to satisfy, the preview reads each child's first ten parents off its own index descent, 1,000 index entries for a hundred-row page against the 2,500,000 the count needed, and the family clears the budget at 3 ms.
Depends on two S17 amendments. The parents order by parent manifest id, docs(specs): order the parents family by parent... (!2649 - merged) • Hayley Swimelar • 19.5, merged. The count moving to the detail, docs(specs): serve parents_count on the manifes... (!2652 - merged) • Hayley Swimelar • 19.5, which must land first: until it does the spec asks the list page for a parents_count this read no longer produces.
What the ceiling decided
AC #139 (closed) asks for the figure at the family's ceiling before the read ships. At the ceiling Fetch mechanism prices, 100 children each referenced by container.image_max_manifests (25,000) index parents, 2,500,000 edges:
| Shape | Interleaved arrivals | Edges clustered per child |
|---|---|---|
Preview, PreviewLimit 10 over the whole 100-child page |
3.2 ms | 1.9 ms |
Complete over the one child id it accepts |
29.5 ms | 21.6 ms |
Rejected: the same preview with COUNT(*) OVER per child |
1,018 ms | 996 ms |
Rejected: parents_count alone, unbounded per child |
386 ms | 327 ms |
| Rejected: the preview ordered by parent digest | 10,603 ms | 2,285 ms |
Worst of three runs, EXPLAIN (ANALYZE, BUFFERS), PostgreSQL 17.10 under CI's .pg-service-options flags, seeded as committed data with VACUUM (ANALYZE), no sibling namespace. The two preview shapes and the two count shapes ran against the same two fixtures on the same container. Host one-minute load average 6.0 to 6.3, above this host's own floor of about 4.6, which the earlier paired runs measured as an 11% to 13% inflation. No verdict here has a margin near that.
The count is why parents_count left the list page rather than getting a counter. Counting a child's parents means reading every index entry the child has, so the unbounded count costs 386 ms whether a window, a lateral, or a separate statement issues it, and a denormalized counter buys the 66% the count contributes and leaves the miss. Capping the count at 1,001 per child reads 26 ms and turns the number into "1000+" on every row of a ceiling page, which is not a count. Moving it to the detail, where one manifest's array is already in hand, costs nothing.
Worth a second look
The page's manifest ids drive the statement. They arrive as a VALUES table, and a CROSS JOIN LATERAL descends the covering index once per id, ORDER BY parent_container_manifest_id LIMIT $N inside the lateral. Limit sits directly on the Index Only Scan, so each descent stops at ten entries and the page reads 1,000 rather than the 2,500,000 behind them. Buffers fall from 97,622 hit and 26,947 read to 228 and 206.
The digest join sits above the lateral, so it probes container_manifests once per previewed row rather than once per edge: Memoize reports loops=1000. The foreign key on the parent column makes that count-preserving, so no edge can lose its row to the inner join.
CROSS JOIN, not LEFT JOIN LATERAL. A child no edge references contributes no row and is absent from the returned map, which is the contract's []. A left join would key it with an empty array instead.
The driver table is deduplicated in Go. A VALUES list is a multiset where the IN the three sibling families bind is a set, so a repeated child id would descend twice and fold twice the preview under its one map key. The batch guard still counts the caller's slice, before the dedupe, so it rejects the same sets it always did.
The join binds the namespace literal, not an edge's column. Against a column the planner cannot prune container_manifests and the probe reads all 64 partitions. assertSinglePartition on both tables is that falsifier, and it is why the namespace binds twice.
Complete is the flat statement now. The derived table existed because a window cannot sit in a WHERE, and with no window there is nothing to cross. The arm keeps its contract: one child id, no LIMIT, and the whole array in parent-manifest-id order.
ManifestParentEdges drops Total. The detail's parents_count is len(Digests), so a second field carrying the same number could only disagree with it. The type stays as the family's result type.
PreviewLimit and Complete are exclusive, and Complete takes exactly one child id. Unchanged from the first revision. The zero value is rejected rather than read: a PreviewLimit silently treated as "complete" would walk all 2,500,000 rows behind a page. The shape guard is two booleans rather than one comparison, because Complete == (PreviewLimit > 0) still admits {Complete: true, PreviewLimit: -1}. The cardinality arm answers to the same sentinel, and without it a full page of Complete is 2,500,000 rows, about 417 MiB live heap against GOMEMLIMIT: 400MiB.
The covering index is still what the shape rests on, and more directly than before: the LIMIT can only end the descent at ten entries if the index supplies the order. index_cmr_on_ns_id_child_cm_id_parent_cm_id over (namespace_id, child_container_manifest_id, parent_container_manifest_id) gives an index-only scan with Heap Fetches: 0, and the digest order read 10,603 ms because no index carries a digest under a child-id prefix.
The migration is one file, create before drop. It builds the covering index ON ONLY the partitioned parent, then one CREATE INDEX CONCURRENTLY per partition behind a drop-before-create guard, then one ALTER INDEX ... ATTACH PARTITION per partition under a SET lock_timeout pair, and only then drops index_cmr_on_ns_id_child_cm_id, whose column list the new index prefixes. Under NO TRANSACTION a failure between the halves leaves the narrow index serving every reader rather than no index at all, which is the ordering 20260602142135_extend_container_tags_lower_name_index_with_name.sql took on the same shape.
Partition child index names are written out and abbreviated rather than auto-generated. Spelled out a child's name is 110 characters. PostgreSQL truncates to 63 and appends a global ordinal, which on this chain yields container_manifest_relations_namespace_id_child_container_idx64 through _idx99 and a one-character-shorter spelling for _idx100 through _idx127: names at the truncation boundary, sharing the retired index's own base, carrying an ordinal that depends on what existed when the build ran. Read off a migrated PostgreSQL 17.10, not derived. cmr_pNN_ns_id_child_cm_id_parent_cm_id_idx is 42 characters and abbreviates the way database.md prescribes.
Four readers move to the new index, all searching (namespace_id, child_container_manifest_id), which a b-tree answers from any prefix of its key list: the delete 409's parent lookup, ListByChild, the manifest-delete cascade's child arm, and the root-manifest peel's anti-join. TestContainerManifestRelationshipReads_RideTheCoveringIndex pins the delete 409's read by EXPLAIN on a low-parent-count child, because at the queried children's count that search covers a fifth of the partition and a sequential scan wins on cost.
The pin's PostgreSQL 16 gate is gone. The first revision gated the Index Cond assertion at 17, because the windowed statement searched child_container_manifest_id IN (...) and only 17's nbtree rework pushes an array into an index condition. The lateral searches one equality per descent, so 16.15, 17.10, and 18.4 all bind both key columns in the Index Cond and the assertion runs unconditionally. The 18 row-count tolerance stays.
ListParentDigestsByChild keeps its statement and its contract (D8). It serves the delete 409, declares no order, and AC #135 (closed) asks for the same set, so the parity test compares sets and pins the two readers to one length, which is what parents_count reports.
Two plan wording divergences, recorded rather than fixed here, both for docs(plans): explicit partition-child names and... (!2651 - merged) • Hayley Swimelar • 19.5. The Step 15 Acceptance line says the zero-value parameter struct rejects on errContainerManifestRelationshipParentsPreviewShape, and it rejects on errCMRZeroNamespace, because the namespace guard runs first, as it does in all three sibling families. The plan also names the statement's shape as a window and ManifestParentEdges as {Digests, Total}, which the ruling changed.
Spec coverage
Acceptance criteria
| # | Criterion | Covered by |
|---|---|---|
| #135 (closed) | parent_digests ascending by parent manifest id, no upper bound |
TestListParentDigestsByChildIDsStmt (childThenParentIDOrderRe, both arms), ..._CapShapedChild |
| #135 (closed) | parents_preview is the first ten in that order |
TestListParentDigestsByChildIDsStmt/the_preview_limits_each_child's_own_descent, .../a_preview_returns_at_most_the_limit_per_child... |
| #135 (closed) | Not the first ten by digest | .../a_preview_returns_at_most_the_limit_per_child... asserts the array is not reverseDigests(fx.DigestsA)[:2], which is what a digest-ordered read returns on a fixture whose digests descend as its ids ascend |
| #135 (closed) | The detail's parents_count is the array's length, and the list carries no count |
assertNoCountedWindow on both arms, and ..._ParityWithListParentDigestsByChild pins the two readers to one length |
| #135 (closed) | [] when no index references the manifest |
.../a_child_with_no_parents_is_absent_from_the_map, .../an_id_set_matching_no_row... |
| #135 (closed) | The same set the delete 409 reports |
..._ParityWithListParentDigestsByChild |
| #135 (closed) | List carries no parent_digests, detail no parents_preview |
Step 16's. No response exists here to carry either key |
| #139 (closed) | Rides the covering index, never scans the partition | TestContainerManifestRelationshipReads_RideTheCoveringIndex, both reads |
| #139 (closed) | Drives from the page's manifest ids | previewChildIDsDriverRe, .../excludes_parents_of_children_outside_the_id_set |
| #139 (closed) | The 100 ms figure at the ceiling | Not a test. Above, and one row of docs/dev/database-query-patterns.md |
Plan acceptance clauses
| Clause | Covered by |
|---|---|
The preview bounds each child's rows and Complete bounds none |
.../the_preview_limits_each_child's_own_descent, .../the_Complete_shape_bounds_nothing |
The zero value, the two-shapes call, and a Complete call naming any number of child ids but one are rejected |
..._ArgumentGuards, twelve rows, each asserting recordingDB.called == false |
One child id on Complete reaches the statement |
..._CompleteAdmitsOneChildID |
| A child with no parents is absent from the result set | .../a_child_with_no_parents_is_absent_from_the_map |
| The pin asserts no partition scan, the per-child limit on the index itself, and the previewed row count into the join | assertSinglePartition on both tables, previewLimitRidesTheIndexRe(10, 4), previewRowsIntoTheJoinRe(40) |
The migration applies up, down, and up again, and leaves the delete 409 on the new index |
internal/datastore/migrations suite, TestCMRChildParentIndex_*, and the delete 409 half of the read pin |
| Cross-tenant read, cross-partition probe, batch fan-out, error-text leak | ..._NamespaceIsolation, parentJoinBindsBothKeysRe, ..._BatchLimitBoundary, .../a_failed_read_wraps_its_cause... |
Reviewable LOC
| Group | Lines |
|---|---|
| Migration SQL (one file, 64 partitions) | 484 added, of which 192 are the per-partition build, guard, and attach statements and 286 are comments |
Source Go (container_manifest_relationship.go, query_names.go, lifecycle_reap_container.go) |
241 added, 47 removed |
| Test Go (unit, integration, and the migration's schema suite) | 1,172 added, 1 removed |
Docs (database-query-patterns.md) and the internal/managementapi cap registry |
12 added, 5 removed |
internal/datastore/migrations/structure.sql |
258 added, 258 removed, generated by mise run db:dump-structure, outside the reviewable count |
Past guardrail 18's 500 lines, and splitting would not help. The migration is one concern and one file, and its length is 64 partitions times the three statements each needs. Source is 241 lines. The test suite came to 1,172 because four tests exist to make a specific wrong implementation fail: the cap-shaped case kills a Go-side truncation of an array the contract declares unbounded, the fixture's descending digests kill a digest-ordered read, the repeated-id case kills an undeduplicated driver table, and the guard table pins twelve (Complete, PreviewLimit, len(ids)) shapes. The schema suite is the migration's own contract, which guardrail 6 asks for. Splitting tests from the read would land a store method with no coverage.
Test plan
mise exec -- env -u GOROOT go test ./internal/datastore/... ./internal/managementapi/...
ARTIFACT_REGISTRY_DATABASE_TEST_DSN="postgres://artifact_registry:test@<pg>/artifact_registry_test?sslmode=disable" \
mise exec -- env -u GOROOT go test -tags=integration ./internal/datastore/ \
-run 'ContainerManifestRelationship'
ARTIFACT_REGISTRY_DATABASE_TEST_DSN="postgres://artifact_registry:test@<pg>/artifact_registry_test?sslmode=disable" \
mise exec -- env -u GOROOT go test -tags=integration ./internal/datastore/migrations/
mise exec -- env -u GOROOT go-lint-ci --build-tags=integration ./internal/datastore/... \
--max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false --new-from-rev origin/mainThe integration runs need a PostgreSQL with CI's .pg-service-options flags. A stock container's 64-entry lock table fails the namespace-delete cleanups with SQLSTATE 53200. The EXPLAIN pin ran green on 16.15, 17.10, and 18.4, which is CI's matrix.
No e2e scenario changes: this step adds no route and no status code, so docs/testing/ is unaffected.
Database Review Evidence
Both modes apply. The MR adds one migration and one query-producing method.
Migrations
Note
Timings are from CI (db:migrate matrix, goose verbose) against an
empty database, in apply / rollback order per PG version.
Production-scale validation via Database Lab is not yet available. See
Database review evidence
for the matrix rationale and how to read the numbers.
| Migration | PG 16 | PG 17 | PG 18 |
|---|---|---|---|
20260915154000_extend_container_manifest_relationships_child_index_with_parent.sql |
OK (395.63ms / 191.14ms) | OK (235.8ms / 111.42ms) | OK (214.72ms / 43.12ms) |
Migration notes:
- The matrix is green on all three versions, from pipeline 2847875779. The
timings above were measured under the migration's previous stamp,
20260914151144. This revision adopts the automated rebase ontomainthat was pushed to this branch and re-stamps the file to20260915154000, so it sorts aftermain's20260915153000and the ordering gate passes. Only the file name changed, so the figures stand. The migration's SQL is unchanged in this revision apart from one comment, which the embedded checksum covers and the schema suite re-verified on 16.15, 17.10, and 18.4. The apply cost falls as the major version rises, 395.63 ms on PG 16 against 214.72 ms on PG 18, and the re-apply after each rollback succeeds, which is the idempotency check the matrix runs and the table excludes. Locally, againstpostgres:17-alpineunder CI's.pg-service-optionsflags on 64 empty partitions, the migration applies in 67 ms and reverses in 82 ms. - One migration, two halves, create before drop. The Up builds
index_cmr_on_ns_id_child_cm_id_parent_cm_idat the partitioned parentON ONLY, then oneCREATE INDEX CONCURRENTLYper partition behind aDROP INDEX CONCURRENTLY IF EXISTSguard, then oneALTER INDEX ... ATTACH PARTITIONper partition inside aSET lock_timeout/RESET lock_timeoutpair, and only then dropsindex_cmr_on_ns_id_child_cm_id, whose column list the new index prefixes. UnderNO TRANSACTIONa failure between the halves leaves the narrow index serving every reader rather than no index at all.20260602142135_extend_container_tags_lower_name_index_with_name.sqlis the in-tree precedent for that ordering and20260820224727_add_container_blobs_blob_sha256_index.sqlfor the three-phase build. - Four readers move to the new index. The delete
409's parent lookup (ListParentDigestsByChild),ListByChild, the manifest-delete cascade's child arm, and the root-manifest peel's anti-join all search(namespace_id, child_container_manifest_id), which a b-tree answers from any prefix of its key list.TestContainerManifestRelationshipReads_RideTheCoveringIndexpins the delete409's read on the new index byEXPLAIN, andTestCMRChildParentIndex_SubsumedIndexIsGonepins the retirement, including a sweep for unattached partition children the parent drop cannot reach. - Partition child index names are written out, not auto-generated. Spelled
out, a child's name is 110 characters. PostgreSQL truncates to 63 and appends
a global ordinal, which on this chain yields
container_manifest_relations_namespace_id_child_container_idx64through_idx99and a one-character-shorter spelling for_idx100through_idx127: names at the truncation boundary, in the same base as the retired index's own children, carrying an ordinal that depends on what existed when the build ran.cmr_pNN_ns_id_child_cm_id_parent_cm_id_idxis 42 characters and abbreviates the waydocs/dev/database.md(Constraint naming conventions) prescribes. Measured on a migrated PostgreSQL 17.10 rather than derived. - The Down is a blocking recursive build. Restoring the narrow index takes
ShareLockon the parent and all 64 partitions for the build, which is how20260526120400_oci_container_manifest_relationships.sqlcreated it. Acceptable in a Down and not in an Up:migrations.Downandmigrations.DownTohave no callers outside tests, and a rollback deploys an older binary rather than reverting the schema.
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) under CI's .pg-service-options flags, worst
of three runs, on the statement the Go builder emits with its binds inlined.
The ceiling fixture is seeded committed and VACUUM (ANALYZE)d rather than
rolled back: under autovacuum=off a rolled-back seed leaves heap bloat that
flips the plan, and the visibility map is what prices the index-only scan. One
namespace, no sibling. The two rows are the two call shapes the argument guards
admit, each on the interleaved-arrival fixture. Numbers do not capture
production-scale effects. The host carried a one-minute load average of 6.0 to
6.3 across the run, above its own floor of about 4.6. See
Database review evidence
for methodology and the anomalies the skill flags.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.ListParentDigestsByChildIDs.Preview |
Sort over Nested Loop, Limit per child |
cmr_p28_ns_id_child_cm_id_parent_cm_id_idx |
1000 / 1000 | 298.89 | 3.2ms | 228 / 206 | 1/64 |
datastore.ListParentDigestsByChildIDs.Complete |
Sort over Hash Join | cmr_p28_ns_id_child_cm_id_parent_cm_id_idx |
22167 / 25000 | 4399.08 | 29.5ms | 1647 / 266 | 1/64 |
Query notes:
- The preview clears the 100 ms budget by 31x at the family's ceiling. 100
children at
container.image_max_manifests(25,000) parents each is 2,500,000 edges, and the preview reads 1,000 of them. The plan is aNested Loopover the page's ids, withLimitdirectly on theIndex Only Scan,rows=10 loops=100,Heap Fetches: 0. The previous revision's window function read 1,018 ms on this fixture becauseCOUNT(*) OVER (PARTITION BY child_container_manifest_id)needs every edge of every partition and the row number'sRun Conditioncould not end the scan. - The count is the read this statement does not make. Counting one child's
parents costs 386 ms on its own at this ceiling, and no statement shape
changes that: it is every index entry the child has. Capping it at 1,001 per
child reads 26 ms and renders "1000+" on every row of a ceiling page. The
ruling moved
parents_countto the manifest detail, where it is the length of the array the same response carries, so the two cannot disagree. - The complete shape clears the budget with 70% headroom, 29.5 ms for one
child's 25,000 parents, which is the ceiling
image_max_manifestssets for the one child id the guard admits. The plan is aHash Joinover anIndex Only Scanof that child's edges,Heap Fetches: 0, and the sort is over the 25,000 rows the arm returns. - The join reads the preview, not the edges behind it. It sits above the
lateral, so
Memoizereportsloops=1000against 2,500,000 edges in the partition, with 990 hits and 10 misses, and 28 of the statement's 228 buffer hits are the parent probe. - The preview's row estimate is now exact, 1,000 planned against 1,000
actual, because the per-child
LIMITis a bound the planner can apply. The window shape was 1,585x high, because the count needed every row of each partition and the row-number filter could not push below it. - Both namespace predicates are load-bearing for pruning. The join binds
the namespace literal rather than an edge's own column: against a column the
planner cannot prune
container_manifestsand the plan probes all 64 partitions.TestContainerManifestRelationshipReads_RideTheCoveringIndexasserts one partition on each table, which is that falsifier. - Arrival order costs little. The clustered fixture reads 1.9 ms preview and 21.6 ms complete against the interleaved 3.2 ms and 29.5 ms. Both arms are index-only, so neither pays for the heap's physical order. The digest-ordered predecessor differed by 4.6x between the two fixtures.
Also checked:
- Namespace isolation. A second namespace holding the same child ids
resolves to none of its parents:
TestContainerManifestRelationshipStore_ListParentDigestsByChildIDs_NamespaceIsolation. - Set parity with the delete 409's reader.
..._ParityWithListParentDigestsByChildcompares the two as sets and asserts one length, which is what AC #135 (closed)'s same-set clause asks and what the detail reports asparents_count. - The batch bound from both sides.
..._BatchLimitBoundaryserves a set atcontainerManifestRelationshipChildIDsBatchLimitand rejects one past it before any SQL runs. - A repeated child id.
TestListParentDigestsByChildIDsStmt_DeduplicatesTheDriverpins two binds for three ids, and.../a_repeated_child_id_yields_that_child's_preview_oncepins the fold against a doubled array. - The pin falsifies. Pointing the lateral's
ORDER BYat a column the covering index does not carry puts aSortunder theLimit, andpreviewLimitRidesTheIndexRefails on it. - Migration up, down, and up again on PostgreSQL 16.15, 17.10, and 18.4,
with the catalog read back after each step: 64 attached children, the retired
index and its children gone, the parent index valid, and
lock_timeoutback to 0.
Context for LLM agents
The lateral here is not the lateral the plan's D3 rejected. D3 rejected folding each family into the list statement as a per-row lateral, which reads as zero further queries under AC #138 (closed), leaves the timing measurement no per-family figure, and carries a recorded prior of three times the plan work and forty-four times the buffers. This read is still one batched statement per family, so AC #138 (closed)'s four-query bound is intact. The lateral is inside it, driven by the page's ids, and it exists to give each child its own LIMIT. What changed is that D3's other rejected option, slicing ten per child in Go, was rejected because the array is uncapped, and that reasoning still holds: the Go side never sees more than ten per child now.
Rejected: a denormalized counter for parents_count on the list. It is the documented fallback for a family that misses the budget, and it does not fit a preview's miss. The count contributed 671 ms of the window shape's 1,018 ms, so a counter buys 66% and leaves a 347 ms read against a 100 ms budget. The order was the other half, and an index fixed that. With both remedies applied the count is the only thing left that reads every edge, so the spec moved it rather than materializing it.
Rejected: capping the count at 1,001 per child. It reads 26 ms and bounds the number without a counter, and at this ceiling every row of a hundred-manifest page saturates the cap, so the list would render "1000+" for all of them. A number that is the same on every row is not worth a column.
Rejected: LEFT JOIN LATERAL ... ON true. It would key a parentless child with an empty array. The contract says a child with no parents is absent from the map, which the serializer reads as [], and CROSS JOIN gives that for free.
Rejected: dropping ManifestParentEdges for map[uuid.UUID][][]byte. With Total gone the struct wraps one field, and the plan's own type table names it. Keeping the name keeps Step 16's seam stable and leaves the family room for a field that is not derivable from the array.
Rejected: keeping the join inside the derived table (carried from the previous revision, now moot). It probed container_manifests once per edge rather than once per previewed row, which measured 1,675 ms against 1,018 ms on the window shape. The lateral makes the placement structural: the join cannot sit under the LIMIT without the scan losing index-only.
Rejected: letting PostgreSQL name the partition child indexes. The two applied three-phase precedents spell out names in PostgreSQL's own auto-name shape, which for these three columns does not fit: the generated names are 63 bytes, share the retired index's base, and carry a global ordinal (_idx64 through _idx127 on this chain) that is an artifact of the retired index's 64 children existing at create time. docs/dev/database.md names that boundary case as the reason to abbreviate, and abbreviating also gives the EXPLAIN pin an exact name instead of an alternation over truncation spellings.
Rejected: a PreviewLimit upper guard. The remote tag twin has one, at 1,000, but its reason does not transfer: its Complete array is itself windowed at 1,000, so a larger preview would stop being a prefix of it. This family's Complete array has no window, so there is no prefix invariant to protect. Step 16 is where the constant 10 gets pinned at the handler.
Resolved: the Complete arm's cardinality. The earlier revision routed this as a plan question and AppSec raised it independently. The plan owner ruled: Complete accepts exactly one child id, rejected on the existing argument-shape sentinel, because Complete is manifest detail's read and detail addresses one manifest. That collapses the family's largest unbounded result from 2,500,000 rows to image_max_manifests, and the measured cost from a projected 417 MiB heap to 29.5 ms.
Resolved: whether the parents family belongs on the list page. Raised in review against the Figma frames, which show no parents column. docs/specs/monolith/S14-version-list.md:191 blocks the Referrers column and the "referenced by" parent link on a child manifest on the same AR extension, a referrers count and parent digests per manifest, so the link icon reads from this family rather than from a boolean and an EXISTS per row would not serve it. The preview stays on the list, the count does not.
Non-goals a reviewer may reasonably raise. No context.WithTimeout: no read path in internal/datastore or internal/managementapi arms one, and adding the first inside one family's step is a posture change D4 declines explicitly. No production caller: Step 16 is the serving step. Namespace-scoped rather than image-scoped, matching ListParentDigestsByChild, which is what keeps AC #135 (closed)'s same-set clause true. index_cmr_on_ns_id_child_cm_id is retired here rather than in a follow-up, because leaving a strict prefix of the new index in place costs every insert and delete a second index write for no search it alone can answer.
A conventions question, declined here. docs/dev/go-style.md (### Reference only what does not rot) forbids citing acceptance criteria, plan files, or plan steps in comments, and several new comment sites cite AC #135 (closed), AC #139 (closed), D3, D4, and an S17 section. The landed predecessor d8127cd49 cites AC #130 (closed), AC #139 (closed), D3, and D4 the same way, and the file already carries about twenty Step N citations at the base. Fixing only this step would split the family, so it wants a conventions ruling rather than a one-step edit.
The comment-caps ratchet reached one grandfathered block. reapRootManifestPageStmt's doc comment named the retired index, and the gate charges its whole 25-line run at an unexported cap of 1 for any touch. Compressed per docs/dev/go-style.md ("Collapse a run in the edit that touches the file"): the doc is one line, the two plan claims moved to the code they describe at two lines each, and the claim reapBlobPageStmt's doc cross-references now sits in the body rather than the doc, which leaves that sibling's wording one word stale and its own 14-line block uncharged.
ADR conformance. ADR-007 line 536 declares index_cmr_on_ns_id_child_cm_id and names its purpose as "find all parents of a given child manifest", which is this access path. This MR widens that index with a third key column and retires the two-column form, so the declared access path is unchanged and the index backing it is not. ADR-007 lists the index rather than fixing its column list, so the change conforms on the access path. The handbook amendment that records the new column list is open: Amend AR ADR-007: two container index lookups g... (gitlab-com/content-sites/handbook!21119 - merged) • Hayley Swimelar • 19.5. It covers this line and the container_tags line changed by chore(datastore): widen the container_tags mani... (!2659 - merged) • Hayley Swimelar • 19.5, and it trails both. ADR-004 caps versions per package at 25,000 and references per index at 200, neither of which bounds parents per child, so the unbounded Complete array matches S17's explicit "no upper bound". ADR-009 governs no surface here. The local mirror is stale by one upstream commit touching 009_api_design.md.
Related to #1150 (closed)