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 COMMITTED transaction, in order: referential-integrity check (409 MANIFEST_REFERENCED when the manifest is an active Image Index child) → hard-delete its tags → hard-delete its parent and child container_manifest_relationships rows → hard-delete the container_manifests row → hard-delete its blob_storage_attachments link, but only when no sibling manifest in the namespace still references it. The CAS blob_storage_blobs row stays for S20 reachability GC.
  • The cascade lives in datastore.ContainerManifestDeleter, not format/oci (ADR-023: format packages never import database/sql). A manifestDeleteTxRunner seam crosses only primitive types, no oci.Digest, so the datastore deleter does not create an oci↔️datastore import cycle. The oci ManifestDeleter adapter translates oci.Digest to 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_manifests had no delete method, container_tags.Delete was by-name only, and the cascade needs delete-by-manifest-id. New container_manifest_deleter.go holds the transaction; delete methods land on container_manifest.go, container_tag.go, and container_manifest_relationship.go, plus the namespace-scoped orphan check on blob_storage_attachments.go.
  • oci.cascade.tags_count rides the DELETE wide event (tag rows the cascade hard-deleted; 0 on a by-tag delete).
  • Wiring switched to NewManifestHandlerWithDelete at the composition root, mirroring blob DELETE (Step 11). The boot smoke test now drives a real DELETE through the wired handler and asserts it reaches serveDelete (404 NAME_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.ContainerManifestDeleter keeps format/oci free of database/sql, matching the rest of the format layer. The manifestDeleteTxRunner seam crosses only primitive types (raw []byte, ints) precisely so the datastore package can implement it without importing oci for oci.Digest, which would cycle. The oci-side ManifestDeleter adapter does the oci.Digest[]byte translation.
  • CAS row survives, attachment link does not. S12's Manifest Delete "Transaction ordering" step 5 hard-deletes the orphaned blob_storage_attachments link (the namespace-scoped name→content binding) once no sibling manifest in the namespace references it, but leaves the content-addressable blob_storage_blobs row 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.
  • blobSHA256 32-byte guard. The by-digest cascade converts the row's blob_sha256 to a fixed [32]byte storage key. A non-32-byte value means a broken upstream invariant; the guard returns errBadSHA256Bytes (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 UNAVAILABLE on 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 (GetContainerManifestByDigestErrNotFound), 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.

Merge request reports

Loading
Loading