feat(managementapi): the platform triple on the manifest responses (S17 Container Manifest Reads plan: 7/20)
Why
S17 AC #131 (closed) puts architecture, os, and os_variant on every manifest response, and nothing serialized them: Step 5 projected the columns onto ContainerManifestListRow and left them unread. Step 7 of the container manifest reads plan serves them off containerManifestBase, so both arms of the manifests list carry the triple and the detail route inherits it from the same base. Steps 3, 4, and 5 merged 2026-09-10 (docs(openapi): split the container manifest sch... (!2517 - merged) • Hayley Swimelar • 19.4, chore(datastore): widen the manifests-list proj... (!2508 - merged) • Hayley Swimelar • 19.4) and Step 6 merged 2026-09-11 (feat(managementapi): serve the container manife... (!2536 - merged) • Hayley Swimelar • 19.4), so this targets main rather than stacking.
What is not obvious
null travels per value, not as a unit. An index and a failed config read give three nulls, a config declaring no variant gives os_variant alone. assertContainerPlatformTriple asserts each key separately, so a serializer that drops a key, nests a platform object, or collapses the triple fails a named key rather than passing an aggregate compare.
The fake dropped the triple, so those readings were unreachable. containerManifestListRows projects the corpus through a keyed literal missing the three columns. Step 5's review found the gap and assigned it here.
Step 6 landed first, so this pays the detail reconciliation (feat(managementapi): serve the container manife... (!2536 - merged) • Hayley Swimelar • 19.4). Its containerManifestDetailFromRow reaches the shared base through a hand-built ContainerManifestListRow literal, so each new base column needs a line there too. Without those three the detail serves null for a populated row while the key set and the list arm both stay green, which is the gap its reflective projection guard exists to name. TestContainerManifestDetail_Hosted_PopulatedColumns now pins the served JSON as well, and it is the assertion that goes red if the copy is dropped again.
Both YAML blocks are end-appended, because Step 10 appends tags_count to the same properties and required blocks with no dependency edge, making that a keep-both merge.
Two contract claims this branch falsified are fixed here: the description's "three fields read differently" count, now count-free, and a null-cause list that named two of platformValue's three drop conditions and omitted a config that parses and declares nothing.
No e2e row and no recipe edit: no route changed, and the manifests list carries no e2e scenario at the plan's base, a pre-existing gap this plan does not close.
Spec coverage
| AC | Criterion | Tests |
|---|---|---|
| #131 (closed) | Both responses carry the triple, each serializing the stored column | TestContainerManifestList_ResourceFields/{docker,oci}, TestContainerManifestDetail_Hosted_PopulatedColumns, TestContainerManifestDetailFromRow_CopiesEveryProjectedColumn, TestContract_ContainerManifestSchema_DeclaresPlatformTriple |
| #131 (closed) | An index and a failed config read serialize three nulls |
TestContainerManifestList_ResourceFields/{docker,oci}, each key asserted present-and-null on its own |
| #131 (closed) | A config carrying no variant serializes os_variant alone as null |
TestContainerManifestList_ResourceFields/{docker,oci}, two strings and one null on a present key |
| #137 (closed) | The platform triple is null on a remote repository |
TestContainerRemoteManifestList_ResourceFields, and TestContainerRemoteReadsIntegration_ManifestWalkAndNulls against a real database |
Related to rather than Closes: AC #131 (closed) closes here now that the detail route serves the triple, but the plan assigns AC #137 (closed) to whichever of Steps 16, 18, 19, and 20 lands last.
Reviewable LOC
| Group | + | - | Reviewable |
|---|---|---|---|
Source, api/openapi/v1.yaml |
47 | 8 | 55 |
Source, container_resources.go |
16 | 2 | 18 |
| Test, six files | 247 | 53 | 300 |
| Total | 310 | 63 | 373 |
Under the 500-line guardrail. The 53 test deletions are the two rewritten ResourceFields suites, three reworded detail-test doc comments, and the comment blocks the caps gate charged, each stating a field count this branch falsifies. Load-bearing claims moved to trailing comments and assertion messages on their own code lines, the 590 KB annotations figure included.
Test plan
mise exec -- go test ./internal/managementapi/ -count=1
ARTIFACT_REGISTRY_DATABASE_TEST_DSN=<pg> mise exec -- go test -tags=integration ./internal/managementapi/ -count=1
mise exec -- golangci-lint run --build-tags=integration --max-same-issues=0 \
--max-issues-per-linter=0 --uniq-by-line=false ./internal/managementapi/
bash scripts/ci/check-comment-caps.sh --base origin/mainOne integration-tagged file carries an edit without adding a test, hence the tagged lint run. Local Postgres needs max_locks_per_transaction=4096.
Context for LLM agents
Why three explicit nils rather than an omission. D2 asks that a reviewer checking whether the remote arm got a field have two functions to read. model.ContainerRemoteManifests has no platform columns, so containerManifestBaseFromRemoteModel states the answer in the keyed literal instead of leaving it to zero-value inference. Exactly five base fields lack a remote column, and exactly those five are nilled.
The contract names one drop condition the spec omits. S17's Manifest resource fields row names the empty and over-clamp per-value drops. platformValue also drops a value carrying a NUL byte, because PostgreSQL text cannot store one. The contract names all three. The divergence runs in the safe direction (the document promises less than the spec implies), and the spec row is the one owed a correction, not the schema.
Rejected: widening the base builders now. Step 10 takes both to (row, facts). Pre-widening would collide with Step 6's live sibling diff on the same file and put a parameter no caller fills into review a step early.
Rejected: asserting the triple as one aggregate. A single Equal over a struct passes when the serializer nests the values under a platform object or collapses them into one absent key, which is the shape AC #131 (closed)'s "per value, not as a unit" rules out.
Not owed: a backfill for rows predating the platform migration. Reviewed and dropped rather than deferred. 20260723120000_add_container_manifests_platform_columns.sql records that the columns are brand-new and carry no data and that no production deployment exists, and every staging container repository was created after it, so the pre-migration class is empty and the contract's cause enumeration is complete.
Non-goals. The manifest detail route and its contract case (Step 6, merged). This branch touches only the three lines its base projection owes and the assertions pinning them. tags_count and the page-facts seam (Step 10). annotations (Step 19), whose half of the withheld-column note stays in place. Splitting extract_reason=success so it distinguishes a populated triple from a config that declared none: this step is what makes that null user-visible, and the metric is unchanged here. A reflection guard forcing the next column onto both sides of the fake's hand-mirrored projection, which sits on a seam Steps 10, 12, 16, and 18 all extend.
Known stale consumer spec, tracked. docs/specs/monolith/S14-version-list.md's Platforms row says the list response withholds the triple, which this merge falsifies. Editing another spec from here is cross-spec creep, and monolith/S06 and S14 Phase 2 tables are stale a... (#1186) • Unassigned already names that row and asks for the condition-and-event rewrite.
Related to #1150 (closed)