feat(oci): manifest DELETE with cascade (S12 Step 14)
Why
S12 Step 14 implements Manifest Delete: DELETE manifests/<reference> by tag and by digest. It is the delete counterpart to Step 12's manifest push (merged) and Step 13's pull (!569 (merged)). Without it a client cannot drop a tag or remove a manifest, and the OCI content-management conformance specs cannot pass.
Built on main (Steps 1-12 merged), parallel to the other unmerged Phase D steps (13 pull, 16 mount, 17 referrers). The cascade reads and writes only the container_* rows Step 12 persists, wired at the Step-6 dispatcher.
What (non-obvious)
- By-tag and by-digest are different deletes. A tag reference hard-deletes only that tag row and leaves the manifest in place. A digest reference runs the full cascade.
- The by-digest cascade is one
READ COMMITTEDtransaction, in order: referential-integrity check (409MANIFEST_REFERENCEDwhen the manifest is an active Image Index child) → hard-delete its tags → hard-delete its parent and childcontainer_manifest_relationshipsrows → hard-delete thecontainer_manifestsrow → hard-delete itsblob_storage_attachmentslink, but only when no sibling manifest in the namespace still references it. The CASblob_storage_blobsrow stays for S20 reachability GC. - The cascade lives in
datastore.ContainerManifestDeleter, notformat/oci(ADR-023: format packages never importdatabase/sql). AmanifestDeleteTxRunnerseam crosses only primitive types, nooci.Digest, so the datastore deleter does not create anoci↔️ datastoreimport cycle. The ociManifestDeleteradapter translatesoci.Digestto the datastore's raw-bytes seam. - The diff touches six datastore files, not the plan's
manifest.go+store.go. The plan's Files list predates the cascade design.container_manifestshad no delete method,container_tags.Deletewas by-name only, and the cascade needs delete-by-manifest-id. Newcontainer_manifest_deleter.goholds the transaction; delete methods land oncontainer_manifest.go,container_tag.go, andcontainer_manifest_relationship.go, plus the namespace-scoped orphan check onblob_storage_attachments.go. oci.cascade.tags_countrides the DELETE wide event (tag rows the cascade hard-deleted; 0 on a by-tag delete).- Wiring switched to
NewManifestHandlerWithDeleteat the composition root, mirroring blob DELETE (Step 11). The boot smoke test now drives a real DELETE through the wired handler and asserts it reachesserveDelete(404NAME_UNKNOWN) rather than the interim 501 an unwired handler returns.
Test plan
go test ./internal/format/oci/... ./internal/datastore/... (unit and DB-backed integration), go vet (default and integration tags), and golangci-lint 2.12 pass. The manifest-delete specs run under conformance:oci.
Spec coverage
Spec: docs/specs/S12-container-oci-hosted.md (Manifest Delete) Plan: docs/plans/2026-05-15-oci-hosted.md Step 14
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-15 | Manifest delete (success): DELETE by digest of an unreferenced manifest → 202; GET → 404 | TestManifestDelete_ByDigestSuccess (unit), TestManifestDeleteCascadeCorrectness (int) |
| AC-16 | Manifest delete (referenced by index): child of an active index → 409 MANIFEST_REFERENCED | TestManifestDelete_ReferencedByIndexReturns409, TestManifestDelete_ReferencedSingleParent (unit), TestManifestDeleteReferencedByIndex (int) |
| AC-17 | Manifest delete (by tag): hard-delete the tag row only; manifest and other tags survive | TestManifestDelete_ByTagSuccess (unit), TestManifestDeleteByTagLeavesManifest (int) |
Cascade behavior (plan Step 14 acceptance; S12 Manifest Delete "Transaction ordering")
| Behavior | Tests |
|---|---|
| By-digest cascade hard-deletes tags + parent/child relationship rows in one READ COMMITTED tx; referrers stay | TestManifestDeleteCascadeCorrectness (int) |
oci.cascade.tags_count rides the wide event (cascaded tag count; 0 on by-tag delete) |
TestManifestDelete_ByDigestSuccess, TestManifestDelete_ByTagSuccess (unit) |
Cascade bound: parent of an index at image_max_manifests (25,000) children completes ≤ 2 s |
TestManifestDeleteCascadeBoundedTransactionDuration (int) |
Step 5: orphaned blob_storage_attachments link hard-deleted when no other manifest in the namespace references the same blob_storage_attachment_id; CAS blob_storage_blobs row survives |
TestManifestDeleteCascadeDeletesOrphanedAttachmentLink (int) |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-8 | MANIFEST_UNKNOWN 404: no matching manifest by tag or by digest | TestManifestDelete_ByDigestNotFoundReturns404, TestManifestDelete_ByTagNotFoundReturns404 (unit), TestManifestDeleteByDigestNotFound (int) |
| E-9 | MANIFEST_REFERENCED 409: delete blocked, manifest is a child of an index | TestManifestDelete_ReferencedByIndexReturns409 (unit), TestManifestDeleteReferencedByIndex (int) |
| — | MANIFEST_INVALID 400: reference is neither a valid digest nor a valid tag | TestManifestDelete_InvalidTagFormatReturns400 (unit) |
| — | NAME_UNKNOWN 404: repository or image does not exist | TestManifestDelete_RepositoryMissingReturns404NameUnknown, TestManifestDelete_ImageMissingReturns404NameUnknown (unit) |
| — | INTERNAL 500: infrastructure failure inside the by-tag or by-digest path | TestManifestDelete_ByDigestCascadeFailureReturns500, TestManifestDelete_ByTagFailureReturns500 (unit) |
| E-17 | UNAVAILABLE 503 on GC-lock contention | Owned by S20 (GC lock, reachability events). Not in this MR. |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-14 | Referential integrity on delete: a manifest referenced by an index cannot be deleted | TestManifestDeleteReferencedByIndex (int), TestManifestDelete_ReferencedByIndexReturns409 (unit) |
| — | Hard-delete only; CAS blob_storage_blobs physical reclamation deferred to S20 |
TestManifestDeleteCascadeDeletesOrphanedAttachmentLink asserts the CAS row survives the cascade |
| — | No internal details in the 500 envelope (S12 invariant 9) | TestManifestDelete_ByDigestCascadeFailureReturns500, TestManifestDelete_ByTagFailureReturns500 (unit) |
Related to #19 (closed)
Context for LLM reviewers
Design rationale.
- Cascade in datastore, not format/oci (ADR-023). The seven-step delete is SQL-heavy and must run in one transaction. Putting it in
datastore.ContainerManifestDeleterkeepsformat/ocifree ofdatabase/sql, matching the rest of the format layer. ThemanifestDeleteTxRunnerseam crosses only primitive types (raw[]byte, ints) precisely so the datastore package can implement it without importingociforoci.Digest, which would cycle. The oci-sideManifestDeleteradapter does theoci.Digest⇄[]bytetranslation. - CAS row survives, attachment link does not. S12's Manifest Delete "Transaction ordering" step 5 hard-deletes the orphaned
blob_storage_attachmentslink (the namespace-scoped name→content binding) once no sibling manifest in the namespace references it, but leaves the content-addressableblob_storage_blobsrow for S20's reachability GC to reclaim. Rejected: delete the CAS row inline. It is shared content-addressable storage; another image or namespace may reference the same bytes, and reachability is an S20 concern. blobSHA25632-byte guard. The by-digest cascade converts the row'sblob_sha256to a fixed[32]bytestorage key. A non-32-byte value means a broken upstream invariant; the guard returnserrBadSHA256Bytes(wrapped) instead of letting the array conversion panic. Covered by a table-driven unit test (nil, empty, short, long, exact).
Non-goals (deferred, not omissions).
- Garbage collection, soft-delete, reachability reconciliation, and CAS-blob physical reclamation: S20. This endpoint hard-deletes only.
- 503
UNAVAILABLEon GC-lock contention (E-17): S20 owns the GC lock and reachability events. No test asserts it here, per the plan's "GC coordination deferred to S20". - "Subsequent GET returns 404" is asserted at the datastore layer (
GetContainerManifestByDigest⇒ErrNotFound), never via an HTTP GET, so Step 14 stays decoupled from the unmerged Step 13 pull handler. operation-field alignment for the OCI wide-event emitter family: a cross-step observability follow-up, same as Steps 13 and 17.