Repository DELETE answers 500 for a repository a virtual repository lists as an upstream

What happens

DELETE /api/v1/:slug/repositories/:repository_name answers 500 for a repository that any virtual repository lists as an upstream, under either destructive value.

RepositoryStore.Delete takes the repository row's update lock and checks the upstream listing before the cascading DELETE (lockRepositoryThenCheckUpstreams, internal/datastore/repositories.go:1028). checkRepositoryUpstreamAssociations (:1070) reads the npm junction table and returns ErrRepositoryIsVirtualUpstream (:798) when a row references the repository. Delete's doc comment lists that sentinel among its returns (:921).

managementapi.writeDeleteStoreError (internal/managementapi/delete.go:197) has no arm for it. It matches ErrRepositoryNotEmpty and ErrNotFound, and everything else falls to logAndWriteInternalError, which logs at ERROR and writes the shared 500.

ADR-009 requires a 409 here under either value. Its repository-deletion section reads that a repository any virtual repository lists as an upstream "cannot be deleted under either value, and the request returns 409 Conflict. Destructive intent does not reach it", and that "The 409 names the condition without listing the referencing virtual repositories." S17 AC #110 (closed) carries the same criterion. The same section names this outcome as the reason the API layer owns the translation at all: the referential action alone "either fails as a statement the API layer has to translate, or aborts a background purge that reaches no caller".

This is the defect Repository DELETE answers 500 for the virtual k... (#819 - closed) • Unassigned recorded for the virtual kind, one layer up: a typed datastore sentinel with no handler arm.

Why no caller can reach it yet

The refusal needs a junction row. The npm junction store exists and can write one (internal/datastore/npm_virtual_upstreams.go:685), but nothing reaches that insert: no upstream-association route is registered, no handler for the resource exists under internal/managementapi/, and the store is not wired into cmd/artifact-registry. Only a direct database write produces a row today.

The window opens with the association routes, which docs/plans/2026-08-13-s17-phase6-virtual-remote-repositories.md assigns to Steps 40 through 42. Whichever step registers the first association route makes the 500 reachable for npm, so the arm belongs in that step or ahead of it.

What a fix has to do

  1. Add the arm. The sentinel is not ErrRepositoryNotEmpty, so it already bypasses the handler's destructive && ErrRepositoryNotEmpty tombstone arm and lands in writeDeleteStoreError's default. The whole fix is one case errors.Is(err, datastore.ErrRepositoryIsVirtualUpstream) returning 409, which then holds under both destructive values without a second branch.
  2. Decide the message. ADR-009 wants the 409 to name the condition without listing the referencing virtual repositories. nonEmptyConflictMessage picks between two not-empty remedies and neither fits, so this needs a third static message pointing at dissociation.
  3. Correct a comment the same change falsifies. writeDeleteStoreError's doc says "the default 500 covers the virtual-kind refusal, reachable only for a format outside the taxonomy". The default also covers the upstream refusal, which is reachable for npm as soon as a junction row exists.
  4. Cover the handler. The datastore half is covered (npm_virtual_repositories_integration_test.go:440, npm_virtual_upstreams_integration_test.go:673 and :762). The handler half has no test, and a unit test over the existing fake RepositoryDeleter returning the sentinel is enough to pin the status and the message under both destructive values.

Adjacent, not the same

Virtual upstream association can commit between... (#1373) • Unassigned covers a race between the guarded delete's rollback and the destructive soft-delete, where an association commits in the gap and leaves a tombstone the purge cannot finish. That gap is about when the check runs. This one is about what the caller is told when the check fires.