docs(openapi): split the container manifest schema over a shared base (S17 Container Manifest Reads plan: 3/20)

Step 2 merged 2026-09-10, so this MR now targets main: chore(managementapi): the manifest read seams a... (!2515 - merged) • Hayley Swimelar • 19.4

Step 4 merged into this branch on 2026-09-10 and merges to main with it: docs(openapi): declare the three container mani... (!2521 - merged) • Hayley Swimelar • 19.4

Why

Step 3 of the merged S17 container manifest reads plan. The manifests-list element and the manifest detail carry the same eight fields, and Step 4's detail operation needs a schema to point at. Two flat sibling schemas would duplicate those fields' 26 lines of description blocks and let them drift. This splits the resource over ContainerManifestBase and two allOf leaves and mirrors the split in Go. No route is served and no response byte moves.

What

ContainerManifest keeps its name, so ContainerManifestList's items $ref holds untouched. Step 3's YAML diff is 13 lines because the flat schema is renamed in place rather than re-emitted under a new key.

ContainerManifestDetail had no operation while Step 3 stood alone, which left redocly lint one no-unused-components warning. Step 4's operation declaration rides in this MR and clears it, so lint now reports zero warnings.

In Go the base is embedded first because encoding/json emits promoted fields in index-path order, and that placement is what holds today's key order. Measured byte-identical against a flat replica of the pre-split type, on a populated value and an all-nulls one.

Two comment edits are the comment-caps gate rather than taste. SubjectDigest's field comment reflows from 3 lines to 2 with every word unchanged, and containerManifestFromRow's 7-line doc becomes a one-line doc on the new base builder plus the subject_digest pointer rationale as a trailing comment on the line it explains.

A consumer regenerating a typed client sees ContainerManifestBase and an allOf-composed ContainerManifest where it saw one flat schema. That is source-level only.

Spec coverage

This step closes no acceptance criterion. It is the schema the later field steps add to.

Schema Criterion it serves Step that closes it
ContainerManifestBase AC #131 (closed) platform triple, and the four counts both responses carry 7, 10, 12, 16, 18
ContainerManifest AC #130 (closed), #132 (closed), and #135 (closed), the three list previews 10, 16, 18
ContainerManifestDetail AC #128 (closed) detail field inventory, AC #129 (closed) annotations 6, 10, 16, 18, 19

Reviewable LOC

Step 4 merged into this branch, so this MR carries both steps: 612 insertions and 47 deletions over 9 files, with api/openapi/v1.yaml the one file both touch.

  • Step 3: 92 insertions and 19 deletions over 3 files. 40/19 in source (13/1 YAML, 27/18 Go), 52 added in test.
  • Step 4: 521 insertions and 29 deletions over 7 files. 350/29 in contract and fixtures (223/19 YAML, 116 Bruno, 11/10 docs), 171 added in test.

659 changed lines is past the 500-line guardrail. A split is not available because Step 4 already merged here, under its own review. By file group: 362/29 in OpenAPI YAML, Bruno fixtures, and docs, 27/18 in Go source, and 223 added in Go test.

Test plan

  • redocly lint through CI's own command exits 0 with zero warnings.
  • TestContract_ContainerManifestSchemas_ShareOneBase is red at d598e8cb6 and green at f89d85bc8. Four v1.yaml mutations kill it: the detail leaf dropping its allOf, a leaf redeclaring a base property, a leaf property outside required, and a required name naming no property.
  • TestContainerHandlers_ResponsesMatchOpenAPIContract passes unchanged, and the three key-set assertions on containerManifestResourceKeys are untouched.
  • go build ./..., gofmt -l, go vet, and golangci-lint run clean, plain and under --build-tags=integration on ./internal/managementapi/....
  • Unit and integration suites pass for ./internal/managementapi/... against a Postgres tuned to CI's max_locks_per_transaction.
  • scripts/ci/check-comment-caps.sh reports OK against origin/main and against the parent branch.
  • No e2e scenario row and no run-recipe edit: no route is served and no response byte moves.
Context for LLM agents

Why neither leaf declares type: object. kin-openapi validates each allOf branch against the same value, so the base's $ref branch already applies the type, and a second copy is a second place to edit. ManifestDeleteConflict is the in-document precedent for a description plus allOf with no type.

Why the pin does not assert the leaves are empty. Steps 10, 16, 18, and 19 each add properties to a leaf, so a zero-property assertion would be a tripwire every one of them has to delete. The third assertion carries the value instead: a property added without required fails the pin, and required added without the serializer fails TestContainerHandlers_ResponsesMatchOpenAPIContract.

A correction against the plan's D2. D2 records the kin-openapi 3.1 path trying a JSON Schema 2020-12 walker, failing to resolve the leaf's $ref, and falling back to the built-in walker, and files it as a caveat this split introduces. Measured on both the before and after documents: the pre-split flat schema fails the same standalone compile, because its properties $ref shared scalars. The caveat is document-wide and predates this change.

What the split does not buy. Both branches leave additionalProperties open, so a list response wrongly carrying a detail-only field still validates. The two absences AC #130 (closed), #132 (closed), and #135 (closed) need are test-enforced per field, by the step that lands each one, exactly as the withheld-column comment above ContainerImage prescribes.

Why the base builders take the row alone. Step 10 widens both to (row, facts) when the first page fact lands. Extracting them here, with the step that owns the file's shape, means Step 7 finds them declared and a base field is two edits forever.

Non-goals. No route: Step 4 declares the three operations here and Step 6 serves them, and each sits in contractPendingOperations until its handler lands. No property beyond the eight that exist today. No ContainerManifestChild: Step 18 creates that schema and extends the pin's scope to it. The plan file and its Status table are untouched, per the single-writer rule.

🤖 Generated with Claude Code

Related to #1150 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading