feat(managementapi): serve maven's virtual kind (S17 Phase 6 plan: 36/42)

Why

Phase 1 shipped create with a hosted-only kind gate and no removal contract for the virtual kind, so a virtual create answered 422 and a virtual DELETE answered 500 whatever destructive carried: Repository DELETE answers 500 for the virtual k... (#819 - closed) • Unassigned. Phase 6 ends that one format at a time, gated on each format's virtual schema. Steps 37 and 38 closed npm and the container family, feat(managementapi): serve npm's virtual kind (... (!2665 - merged) • Hayley Swimelar • 19.5 and feat(managementapi): serve the container virtua... (!2666 - merged) • Hayley Swimelar • 19.5. Maven was the last format still refusing, and maven_virtual_repositories reached main with the S30 slice's first step, feat(maven): `maven_virtual_repositories` schem... (!2697 - merged) • Moaz Khalifa • 19.5. So this closes the defect rather than citing it.

Acceptance is the Maven slices of AC #71 (closed) (a virtual create's 201 carries no settings key and an empty upstream list) and AC #109 (closed) (204 under either destructive value, associations removed in the same transaction).

What

One dispatch entry, and the map stops being partial. Step 38 built the seam: virtualChildDispatch maps a format to its {insert, lock} pair, virtualChildInsert and virtualChildDeleteLock read it, and VirtualRepositoryCreateSupported publishes the same verdict to the handler so the 422 and the store cannot drift. Maven is one entry plus one store file. It is also the fourth, so the map now covers every defined format and TestVirtualChildDispatch_CoverEveryFormat nets it the way TestFormatChildInserts_CoverEveryFormat and TestRemoteChildCreates_CoverEveryFormat net theirs. Six comments claiming a format lacks an arm were falsified by that and rewritten.

The refusal sentinels stay, and are no longer dead weight. ErrRepositoryCreateVirtualKind and ErrRepositoryDeleteVirtualKind now answer only a format value outside the taxonomy, which is the fail-closed second line of defense errUnknownRepositoryFormat and errRepositoryDeleteUnknownKind already document. repositoryDeleteKindGate is untouched and still load-bearing: RepositoryReaper.finalize calls it and depends on the virtual arm refusing, so a tombstoned virtual repository's purge fails loudly instead of reporting a repository removed that is still there.

The junction table exists, has no writer, and its cascade is now tested. maven_virtual_repository_upstreams landed on main while this branch was building. No production Go writes it, so no association can exist yet, but the delete already clears one: the foreign key onto the virtual child is ON DELETE CASCADE, so the single parent DELETE removes junction rows in the same transaction. Both halves are tested against a directly seeded row rather than asserted from the schema. maven_virtual_upstream_rules does not exist, and Step 36 does not need it, so the plan's gate is met for what this step does.

ADR-009's virtual-upstream window is plan-sequenced. fk_mvru_upstream_repository_id_repositories declares no ON DELETE, so a repository some virtual repository lists as an upstream takes ErrRepositoryNotEmpty rather than ADR-009's static 409. Steps 37 and 38 opened that path for npm and container and it now reaches Maven. Nothing can reach it without a writer, the guard belongs to Step 41, and the sequencing was ruled acceptable on 2026-09-15 with no ADR amendment.

Contract. api/openapi/v1.yaml loses the paragraph documenting the 500 as a live defect and the kind enum's "returns 422 for the rest" clause, because no format refuses the kind now. No schema moved, so Bruno gains only the matching request.

No settings and no audit event. A virtual create refuses the settings key with 400 and emits no audit event, matching the other three formats.

Size. 1952 reviewable lines, of which 1631 are tests and 144 the contract, Bruno, docs, and recipe, leaving 177 of source. Steps 37 and 38 measured 1398 and 2214 on the same shape. Splitting create from delete would leave a Maven virtual repository undeletable between the two MRs, which is the defect this closes.

Test plan

# Criterion Tests
AC #71 (closed) 201 carries no settings key TestCreateHandler_VirtualKind_Maven_Returns201, TestVirtualMavenIntegration_CreateReadDeleteWalk
AC #71 (closed) the upstream list is empty TestRepositoryStore_Create_VirtualMaven
AC #71 (closed) create writes parent, format child, virtual child, and link in one transaction TestRepositoryStore_Create_VirtualMaven, TestRepositoryStore_Create_VirtualMavenRollsBackWhole, TestRepositoryStore_Create_VirtualMavenOccupiesCapRoom
AC #71 (closed) the format serves the kind in both transactions TestVirtualChildDispatches_ServeMaven, TestVirtualChildDispatch_CoverEveryFormat, TestVirtualChildDispatches_AgreeOnEveryFormat
AC #109 (closed) 204 under either destructive value TestDeleteHandler_VirtualMavenRepository_Returns204AndEmitsNoAuditEvent, TestVirtualMavenIntegration_CreateReadDeleteWalk, TestVirtualMavenIntegration_DeleteFreesTheName
AC #109 (closed) associations removed in the same transaction TestRepositoryStore_Delete_VirtualMavenRemovesAssociations
AC #109 (closed) the delete removes every child under the child-row lock TestRepositoryStore_Create_VirtualMavenThenDelete, TestRepositoryStore_Delete_VirtualMavenTombstonedParent, TestRepositoryStore_Delete_VirtualMavenWithoutChildRow, TestRepositoryStore_DeleteVirtualMaven_MissingParentIsSilent, TestRepositoryStore_Delete_VirtualMavenNotFound
AC #109 (closed) the lock is FOR UPDATE on both key columns and prunes to one partition TestLockMavenVirtualRepositoryStmt_TakesForUpdateOnBothKeyColumns, TestLockMavenVirtualRepositoryStmt_PrunesToOnePartition, TestMavenVirtualRepositoryKeyPredicate_KeysBothColumnsAndedInOrder
Error path Tests
settings on a virtual create: 400, and no credential echo TestCreateHandler_VirtualKind_Maven_WithSettings_Returns400, TestCreateHandler_VirtualKind_Maven_WithCredentials_Returns400
deleting a repository a virtual repository lists as an upstream TestRepositoryStore_Delete_VirtualMavenUpstreamTargetBlocked
a concurrent delete leaves the lock read empty rather than failing TestLockMavenVirtualRepository_ReportsAbsenceRatherThanRefusingIt
nil context, nil handle, nil transaction, or a zero id on either write TestMavenVirtualRepositoryWrites_ArgumentGuards, TestMavenVirtualKeyArgs, TestMavenVirtualRepositoryWrites_GuardsRunBeforeAnyStatement, TestMavenVirtualRepositoryWrites_StatementFailuresSurface, TestMavenVirtualSentinels_NameTheirOwnTable
a format outside the taxonomy still fails closed on both sentinels TestCreateKindMessageFor_ServedAndUnservedFormats, TestRepositoryCreateKindGate, TestRepositoryStore_Create_KindDispatchGuards, TestDeleteHandler_VirtualKind_StoreRefusal_Returns500
create emits no audit event TestCreateHandler_VirtualMavenCreate_EmitsNoAuditEvent

Gates: build, go vet untagged and -tags=integration, both unit suites, both integration suites against a Postgres at max_locks_per_transaction=4096, golangci-lint in both modes, the comment-caps gate, redocly, shellcheck, markdownlint, and Vale. The suite was diffed against both mirrored suites for dropped subtests: one item is unmirrorable, the rule-row half of npm's association test, because Maven has no upstream rules table.

e2e scenarios: docs/testing/e2e/maven.md listed virtual Maven repositories as wholly out of scope, which these two routes falsify, so its scope note carves them in and holds the client journeys and upstream associations out. No scenario is added, for the reason the plan's Testing Strategy gives.

Conformance: not applicable, no protocol-path behavior changes.

Three disclosures

The run recipe was driven on a non-default rig. driver.sh's smoke arm for kind=virtual on maven moves from 422 to 201, and smoke ran green at 74 passed, 0 failed on head 2f127be3a, that check green at its position. Another up held the default run dir and ports, so the run took AR_RUN_DIR and the container and port knobs, which build from the checkout exactly as the defaults do.

A log key changed on a record Steps 37 and 38 shipped. The missing-child warning emitted its format code as format, an integer under a key internal/managementapi and two other sites emit as a string, which collides in a typed log index. It is now format_code.

A rolling deploy has a window. A Maven virtual repository created on a new pod and deleted through an old one answers 500 until the rollout finishes. It self-heals on retry, and Steps 37 and 38 carried the same exposure.

Merge order

feat(datastore): npm virtual upstream store and... (!2701 - merged) • Hayley Swimelar • 19.5 conflicts on internal/datastore/repositories.go and repositories.md, and docs(testing): add the maven remote e2e scenari... (!2653 - merged) • Tim Rizzi conflicts on docs/testing/e2e/maven.md. Both merge cleanly into main, so whichever lands second rebases. CI nets the dispatch entry, nothing nets the comments, so re-read the four rewritten sites at that rebase.

Context for LLM agents

Rationale

  • Delete the two dispatch-miss sentinels and createKindMessage, now that every defined format serves the kind. Rejected: virtualChildInsert and virtualChildDeleteLock take an int16, so an undefined format still reaches them, and this package answers that case with a fail-closed sentinel twice already. A format added later reappears in the gap as a one-entry data change, and a collapsed message would hand it the wrong text.
  • Widen repositoryDeleteKindGate to dispatch on format. Rejected for the reason Step 37 gives: its other caller, RepositoryReaper.finalize, runs a bare DELETE on the walk chunk's handle and holds no child-row lock.
  • Write internal/datastore/maven_virtual_repositories.md. Rejected: Step 37 wrote an npm sidecar and Step 38 deliberately wrote no container twin, because the reasoning is shared rather than per format. Maven follows Step 38 and points at npm's.
  • Mark the junction cascade untestable because no Maven writer exists. Rejected on review: Step 37 had no npm junction writer either and tested the cascade by seeding the row directly. Without those tests AC #71 (closed)'s empty upstream list and AC #109 (closed)'s association clause would ship unasserted.
  • Suppress the per-format mirror's dupl report with a file-level //nolint:dupl on both ends. Rejected on measurement: with every such directive in internal/datastore disabled, golangci-lint at the pinned version reports 104 issues and names none of the three virtual store files. The anchors suppressed nothing, so npm_virtual_repositories.go returns to main byte for byte.

Consequences: a format's virtual arm is one map entry plus one file, the reaper keeps failing closed on every virtual kind, and the dispatch is now total and netted.

Non-goals

  • The Maven upstream association store and routes, and the delete-side 409. Step 41 owns them. maven_virtual_repository_upstreams exists with no writer, so nothing can reach that path, and maven_virtual_upstream_rules does not exist at all.
  • The unbounded wait on the virtual child row lock. server.timeouts.write does not cancel the request context, so the first delete phase parks for as long as the holder holds it. Pre-existing across every format and owned by The repository DELETE's hard-delete phase waits... (#902) • Unassigned, not reopened here.
  • Two spec and lint corrections this branch surfaced and left alone: docs/specs/S20-a-lifecycle-closed-beta.md asserts a delete mechanism Steps 37 and 38 already falsified and is another spec's to fix, and npm's lock statement still lacks the LIMIT(1) this one added, which is a semantic no-op under its unique index and out of this step's file list.
  • ./scripts/adr-freshness.sh reports three unsynced handbook commits, none bearing on this diff. Re-run it before merge.

Database Review Evidence

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17 container (matching GL_PG_CURR_VERSION from .gitlab-ci-other-versions.yml), with synthesized seed data rolled back per query and the container torn down at the end of the run. Numbers reflect moderate cardinality and do not capture production-scale effects. See Database review evidence for seed sizing, methodology, and the anomalies the skill flags. Expand each row's details for the seed shape, rendered SQL, bound args, and raw plan.

Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
datastore.insertMavenVirtualRepository Insert n/a 0 / 0 0.01 0.189ms 26 / 0 1/64
datastore.lockMavenVirtualRepository Limit unique_maven_virtual_repositories_namespace_id_repository_id 1 / 1 8.31 0.011ms 4 / 0 1/64

No migration files changed, so migration mode did not run. internal/datastore/repositories.go is in the diff but builds no new or changed statement: its only non-comment edits are one virtualChildDispatch entry and the logKeyVirtualChildFormat value.

datastore.insertMavenVirtualRepository

Summary: Plan matches the method's intent. The partition key is a bound value, so the row routes to a single maven_virtual_repositories partition rather than fanning out, and the two foreign-key triggers that dominate the 2.956ms execution are the composite (repository_id, namespace_id) check against repositories and the namespace_id check against namespaces, both against a cold one-row seed. No anomalies.

Seed shape: namespaces=1, repositories=1, maven_virtual_repositories=0

Rendered SQL:

INSERT INTO public.maven_virtual_repositories (id, namespace_id, repository_id)
VALUES ($1::uuid, $2::uuid, $3::uuid);

Bound args: [75c006bb-06cc-7a51-a77b-6af0a99c5e2c, 3474ed07-3272-7a68-b5d7-16d9262640b4, 381e2cdd-e2cf-7f92-9d77-cb7f7740a229]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on maven_virtual_repositories  (cost=0.00..0.01 rows=0 width=0) (actual time=0.189..0.189 rows=0 loops=1)
   Buffers: shared hit=26 dirtied=5 written=3
   ->  Result  (cost=0.00..0.01 rows=1 width=48) (actual time=0.001..0.001 rows=1 loops=1)
 Planning Time: 0.034 ms
 Trigger for constraint fk_maven_virtual_repositories_namespace_id_namespaces on maven_virtual_repositories_p37: time=0.362 calls=1
 Trigger for constraint fk_maven_virtual_repositories_repository_id_repositories on maven_virtual_repositories_p37: time=1.844 calls=1
 Execution Time: 2.956 ms

Timings: planning 0.034ms, execution 2.956ms, total 2.990ms.

datastore.lockMavenVirtualRepository

Summary: Plan matches the method's intent. LockRows sits under Limit over an Index Scan on the partition-local child of unique_maven_virtual_repositories_namespace_id_repository_id, so the FOR UPDATE takes a row lock rather than scanning, and both key columns are index conditions. The bound namespace_id prunes to one of 64 partitions, plan and actual rows agree at 1, and execution is 0.028ms against 5000 seeded rows in the read partition. No anomalies.

Seed shape: namespaces=1, repositories=5000, maven_virtual_repositories=5000

Rendered SQL:

SELECT maven_virtual_repositories.id AS "maven_virtual_repositories.id"
FROM public.maven_virtual_repositories
WHERE (maven_virtual_repositories.namespace_id = $1::uuid) AND (maven_virtual_repositories.repository_id = $2::uuid)
LIMIT $3
FOR UPDATE;

Bound args: [11b91b0e-9e6e-75c0-a990-699bc2619e38, 7cb6e3d3-805c-7c69-82c5-c4a32dfd2b44, 1]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..8.31 rows=1 width=26) (actual time=0.011..0.011 rows=1 loops=1)
   Buffers: shared hit=4
   ->  LockRows  (cost=0.28..8.31 rows=1 width=26) (actual time=0.010..0.011 rows=1 loops=1)
         Buffers: shared hit=4
         ->  Index Scan using maven_virtual_repositories_p32_namespace_id_repository_id_idx on maven_virtual_repositories_p32 maven_virtual_repositories  (cost=0.28..8.30 rows=1 width=26) (actual time=0.008..0.008 rows=1 loops=1)
               Index Cond: ((namespace_id = '11b91b0e-9e6e-75c0-a990-699bc2619e38'::uuid) AND (repository_id = '7cb6e3d3-805c-7c69-82c5-c4a32dfd2b44'::uuid))
               Buffers: shared hit=3
 Planning:
   Buffers: shared hit=3
 Planning Time: 0.096 ms
 Execution Time: 0.028 ms

Timings: planning 0.096ms, execution 0.028ms, total 0.124ms.

Closes #819 (closed)

Related to #314 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading