feat(managementapi): untag a container image tag

Why

Step 18 of the merged S17 Phase 4 plan (docs/plans/2026-08-10-s17-phase4-artifact-writes.md) gives the management API the container untag: DELETE on .../{format}/images/{image_id}/tags/{tag_name}. The route answers 501 from the Step 8 scaffold today.

What's non-obvious

  • datastore.ContainerManifestDeleter.DeleteManifestByTag is keyed on (namespace, image, name) with no repository scoping, so the handler resolves the image through the repository chain first. That resolve is what blocks an image id of another repository in the same namespace from being untagged through this URL. Pinned at both test tiers (a handler that skips the resolve fails them).
  • The handler screens a tag name PostgreSQL cannot store (NUL byte, invalid UTF-8) after the image resolve and answers the same existence-hiding 404 as a miss. Without the screen the statement itself fails whole and the route answers a client-triggerable 500. This is not grammar validation: an ungrammatical but storable name still probes the deleter and 404s on the miss (the spec reserves grammar checks for the tag PUT).
  • No event and no counter dispatch: the spec's route table carries none for tag operations, and the protocol-equivalent OCI untag path maintains no persisted tag counter.
  • First write route to swap its 501 placeholder for a handler: the write-route sweep table gained a served flag, placeholder sweeps filter served rows (with non-empty guards), and a new sweep drives served rows with positive assertions.

Reviewable size

1,497 reviewable LOC: production 221 across 3 files, tests 1,276 across 8. Splitting the tests from the code they pin would not reduce review load.

Test plan

  • Unit table over per-seam fakes: status matrix per format arm, cross-repository scoping, unstorable and ungrammatical names, deleter failure, and no-event via a tracker that fails the test on any call.
  • Integration walk against Postgres: untag leaves the manifest listable per the spec's acceptance criteria and the sibling tag intact, repeat delete 404s, no event at the composition tier.
  • Contract sweep now requires the two drained deleteContainerTag arms to answer served.
  • Uncapped integration-tagged lint clean on both touched packages.

This MR adds no docs/testing/ scenario: the plan's Testing Strategy assigns the container management-delete e2e journey to Step 16.

Context for LLM agents

Design rationale:

  • The consumer seam takes an image reader beside the tag deleter because the deleter is not repository-scoped.
  • The unstorable-name screen sits after the image resolve so resolution failures keep their own taxonomy.
  • Rejected alternative: pre-validating tag grammar on DELETE. The spec probes existence, it does not police the name.

Non-goals:

  • Tag PUT grammar and upsert (its own plan step).
  • The e2e catalog journey (Step 16's).
  • Bulk tag deletes (the bulk track).
  • The plan's Status table row (a follow-up MR fills the whole table at once).

Related to #313 (closed)

Merge request reports

Loading
Loading