Repository DELETE answers 500 for the virtual kind, empty or not

What happens

DELETE /api/v1/{slug}/repositories/{repository_name} answers 500 for every virtual repository. Empty or not, and whichever value the destructive query parameter carries.

datastore.RepositoryStore.Delete reads the row's kind and applies repositoryDeleteKindGate before it issues the DELETE. That gate returns ErrRepositoryDeleteVirtualKind for RepositoryKindVirtual, because the virtual kind's removal contract, dissociating the repository's upstream associations, is not implemented by that method.

managementapi.writeDeleteStoreError has no arm for that sentinel. It matches ErrRepositoryNotEmpty and ErrNotFound; everything else falls to logAndWriteInternalError, which logs at ERROR and writes the shared 500.

Two consequences follow from the gate running ahead of the DELETE:

  • The 409 arm is unreachable for the virtual kind. ErrRepositoryNotEmpty comes from the foreign keys the DELETE trips, and the DELETE never runs.
  • The 202 arm is unreachable for the same reason, because it branches off that same sentinel.

A virtual repository's delete therefore never reaches the not-empty selector at all, and the answer does not depend on whether the repository holds anything. This predates the destructive query parameter and is unchanged by it.

Why the severity is low

The management API refuses kind: virtual and kind: remote on create with 422 kind must be hosted, so a virtual repository exists only where something other than this API inserted it. That is what has kept the 500 unnoticed.

What a fix has to decide

The Repository entry point group of docs/specs/S20-a-lifecycle-closed-beta.md names no arm for the virtual kind, so the answer is not written down anywhere. Two candidates:

  1. Map ErrRepositoryDeleteVirtualKind to a refusal of its own, naming the unimplemented removal contract as the reason. This is a mapping change, and it needs a status decided rather than borrowed: the two the route already uses both carry meanings that do not fit.
  2. Implement the removal contract, which is an edit to repositoryDeleteKindGate. Note that datastore.RepositoryReaper.finalize is that gate's second caller: its own DELETE FROM repositories takes no child-row lock and removes no association row, so dropping the virtual arm answers for that call site too. RepositoryReaper.finalize's doc comment states that obligation in full.

Either way the fix changes what the delete surface delivers rather than correcting a mapping mistake, which is why it is filed here instead of folded into the destructive-delete work that surfaced it.

Where it is written down in the tree

  • internal/managementapi/delete.go — writeDeleteStoreError's default arm.
  • internal/datastore/repositories.go — repositoryDeleteKindGate and RepositoryStore.Delete.