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 lintthrough CI's own command exits 0 with zero warnings.TestContract_ContainerManifestSchemas_ShareOneBaseis red atd598e8cb6and green atf89d85bc8. Fourv1.yamlmutations kill it: the detail leaf dropping itsallOf, a leaf redeclaring a base property, a leaf property outsiderequired, and arequiredname naming no property.TestContainerHandlers_ResponsesMatchOpenAPIContractpasses unchanged, and the three key-set assertions oncontainerManifestResourceKeysare untouched.go build ./...,gofmt -l,go vet, andgolangci-lint runclean, plain and under--build-tags=integrationon./internal/managementapi/....- Unit and integration suites pass for
./internal/managementapi/...against a Postgres tuned to CI'smax_locks_per_transaction. scripts/ci/check-comment-caps.shreports OK againstorigin/mainand 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.
Related to #1150 (closed)