feat(maven): the virtual composition root (S30 plan: 15/25)
Summary
The process-lifetime wiring for a virtual Maven read: maven.NewHandler composes the Step 11b facade over the assembly's seams and the flight holder it already constructs, buildMavenDeps supplies those seams from production stores, and the composition root carries the production remote.UpstreamExclusionAuditSink — the LabKit-logged UpstreamExclusionEvent writer virtual.NewResolver requires before any virtual resolver can boot.
Three things this MR establishes:
- The composition stays inside
maven.NewHandler. The virtual seams arrive throughmaven.Deps—VirtualResolutionStore, the two batched lookups, the memoizing health reader, the exclusion sink — andNewHandlerbuilds the assembly and facade over its ownnewMavenRemoteFlightsholder through themavenVirtualFlightsadapter, so the coalescing criterion's shared-instance property holds without exporting any handler-internal type. A partially wired boot composes nothing. A supplied resolution block the resolver would refuse is refused at config load, naming both keys and both values, so this root builds no throwaway resolver to panic over one; and where every seam is present and a constructor still refuses, the handler build panics rather than dropping the stack to a silent interim501. - The production exclusion audit sink is consumed, not landed. !2940 (merged) (S32 container virtual step 14) merged first and landed the sink, its
position_index/resolution_phasekeys, and its catalog row, so this MR drops its copies of both. What it adds is the Maven half of that sink's boot contract:remoteAuditSinkkeeps its three seams andbuildMavenDepsasserts the exclusion one, so a root sink that records no exclusion fails the boot rather than handing the resolver nothing to record a denial through. - The slot keeps its interim answer.
buildMavenDispatcherpasses no virtual read handler, so every virtual read class still answers501 not_implementeduntil Step 17 swaps it.
Scope
One step of 25 in the S30 plan (Step 15). It wires and boots: the facade is composed but no route serves a virtual read as of this commit — the dispatch slot keeps the interim 501 — so no client-visible behavior changes.
Depends on
- Merged
mainata2242c7f6.maincarries the Step 11b squash (!2914 (merged)) and the S32 container virtual composition (!2940 (merged)); this branch merged that tip, so the diff is this step's own change set and no stacked parent remains.
Plan and spec amendments
Per docs/dev/conventions.md (### Review-driven plan and spec corrections), this MR carries the corrections its own review proves, and it supersedes the plan-correction MR !2948 (closed), which is closed unmerged.
docs/plans/2026-09-10-s30-maven-virtual.md, Step 15's Files list gainsinternal/format/maven/handler.go,internal/format/maven/handler_internal_test.go,cmd/artifact-registry/remote_audit_sink_test.go,internal/config/virtualrepositories.go,internal/config/virtualrepositories_resolvetimeout_test.go,config.example.yaml, anddocs/dev/configuration-reference.md, and moves the sink and its catalog row out of this step's entry — the S32 container virtual step 14 landed both. Until this lands, the plan onmainbooks four of this MR's twelve paths.- The same entry's boot claim is tightened: a supplied resolution block the resolver refuses is refused at config load, not by a panic in
buildMavenDeps.
Spec coverage
Spec: docs/specs/S30-maven-virtual.md (### Resolution, ### Health, ### Error Cases, ### Concurrency, ### Configuration). Plan: docs/plans/2026-09-10-s30-maven-virtual.md, ### Step 15.
Acceptance criteria
| Criterion | Test |
|---|---|
| A booted instance constructs the full virtual stack | TestBuildMavenDispatcher_BootsAndAnswersTheInterim501OnEveryVirtualReadClass, TestBuildMavenDeps_ComposesTheVirtualResolutionOnlyWhenFullyWired |
| The slot answers 501 on every virtual read class, GET and HEAD | TestBuildMavenDispatcher_BootsAndAnswersTheInterim501OnEveryVirtualReadClass (all eight classes) |
| The virtual stack and the kind=2 fill path share one flight registry | TestComposeMavenVirtualResolution_SharesTheHandlersFlightRegistry |
| The block an operator's own file produces composes | TestBuildMavenDeps_ComposesTheLoaderSuppliedResolution |
| A refused combination with every seam present fails the build, not the slot | TestBuildMavenDeps_FailsLoudlyWhenEverySeamIsPresentAndTheResolverRefuses |
| The wired audit sink is the production type | TestRemoteAuditSink_IsTheProductionUpstreamExclusionSink, TestBuildMavenDeps_WiresTheVirtualSeams (the exclusion-sink subtest) |
| A root sink that records no exclusion fails the build | TestBuildMavenDeps_RefusesASinkThatCannotRecordAnExclusion |
| The sink's records carry the reason token, on the Info floor | TestRemoteAuditLogSink_UpstreamExclusionPayload (every enumerated UpstreamExclusionReason column value), TestRemoteAuditLogSink_SurvivesTheConfiguredLevel (the exclusion subtest) |
A probe_timeout at or above resolve_timeout is refused at load, naming both keys and both values |
TestLoad_VirtualRepositories_ResolveTimeoutAtOrBelowProbeTimeoutIsRefused |
| The seams are production values, no test doubles | TestBuildMavenDeps_WiresTheVirtualSeams |
| The smoke run is green | the run recipe's 2026-09-25 re-run on the merged tree: driver.sh down && up && smoke, 80 passed / 0 failed |
Governing ADRs
- ADR-001 / ADR-007 — every seam is namespace-keyed; no schema change, no migration.
- ADR-004 — no limit is minted: the withdrawn per-repository budget is not wired, and the 20-upstream cap stays write-time.
- ADR-005 / ADR-009 — no route, envelope, or delivery decision; the interim
501is the spec's own staged landing of the client surface. - ADR-008 — no storage path; the flight holder changes object identity only, not keying.
- ADR-012 — the exclusion record is a LabKit
slogaudit record, not a Snowplow usage event; no usage event, dimension, or catalog constant is added (pull-usage emission is Step 17's).
Tests
go test -short -tags= ./... passes, and so do go build ./..., golangci-lint run, and the comment-cap gate. golangci-lint run --build-tags=integration ./cmd/artifact-registry/... reports 11 pre-existing issues in four files this diff does not touch (CI's lint job sets no run.build-tags, so it never compiles them). Each property the review asked for was checked by breaking it: the flight-sharing test against a second newMavenRemoteFlights at the composition, and the refused-combination test against a resolver-refusing block with every seam present. The merge onto main was checked the same way — go test -short -tags= ./... passes at 69 packages, and the run recipe's 2026-09-25 entry records a smoke re-run on the merged tree: down then up then smoke, 80 passed / 0 failed.
Overlap with open MRs
- !2940 (merged) (S32 container virtual step 14) — merged as
a2242c7f6. It landedremoteAuditLogSink.RecordUpstreamExclusionwithposition_indexandresolution_phase(the shape this MR had moved to after the two swapped once) and its owndocs/dev/observability.mdrow, so this MR drops its copies of the method and the row. Its payload test samples reason families; this MR keeps its every-enumerated-value table over the same record, which is the coverage its step's plan text claims. - !2916 (merged) (version-cap accounting) — shares
docs/dev/observability.mdwith different rows; whichever lands second rebases one row. - !2901 (merged) (Step 13) — shares the package, not this diff's files.
- !2856 (merged) (
hswimelar/s17-p6-step-42-p11) — merges cleanly intomainbut conflicts with this branch in.claude/skills/run-artifact-registry/SKILL.md, and no pipeline reports it. Merge order: this MR first, because this MR is mergeable intomainas it stands while !2856 (merged) is not — so the file that has to move is !2856 (merged)'s, and this MR rebases that file instead if the order is reversed. - !2948 (closed) — closed unmerged; its content is in this MR's
## Plan and spec amendments.
Size
870 insertions and 88 deletions across 12 files against the merge base, past the 500-line review threshold, so the split: 186 production Go (internal/format/maven/handler.go 116, cmd/artifact-registry/wire_maven.go 51, internal/config/virtualrepositories.go 19), 576 test (cmd/artifact-registry/wire_maven_test.go 398, cmd/artifact-registry/remote_audit_sink_test.go 94, internal/format/maven/handler_internal_test.go 60, internal/config/virtualrepositories_resolvetimeout_test.go 24; the wire_maven_usagedata_integration_test.go trim is (-10) net), and 106 run-recipe and plan (.claude/skills/run-artifact-registry/SKILL.md 59, docs/plans/2026-09-10-s30-maven-virtual.md 44, the two configuration documents 3).
The change does not split further. The composition root is one seam: the wire, the sink it needs, and the handler seams it composes cannot move to a later step without leaving a resolver that cannot boot. The test bulk is the wiring suite the plan's mirror duty requires plus the four tests review asked for — a shared-instance composition test, a loader-ordering test, and the pair pinning that a block the loader accepts composes while a block it cannot must fail the build — each of which fails when the property it pins is broken. The run-recipe lines are the boot-change guardrail's own requirement, and the plan lines are the correction the same guardrail's review round proved.
e2e scenarios
No docs/testing/ scenario is added or affected: no route serves a virtual read until Step 17 (the slot keeps the interim 501), so no conformance or e2e path reaches the composition. The e2e.maven.virtual.* group is Step 20's. The driver.sh smoke suite is green at 74 passed / 0 failed on this head, recorded in the run recipe.
User documentation
No docs/user/ surface changes. The one customer-facing behavior this MR alters is that a virtual_repositories.resolution block whose probe_timeout is at or above resolve_timeout now fails startup instead of loading: no virtual read is served until Step 17, the ordering was already refused wherever a resolver was built, and docs/user/ documents no virtual_repositories setting — that surface is the operator configuration reference under docs/dev/.
Related to #289 (closed)