chore(managementapi): shared write helpers (S17 phase 4 plan: 9/38)

Why

The artifact write surface needs four helpers before any handler or bulk worker can land: a digest path parser, the 202 writer, the delete-event emitter, and a buffered counter dispatcher. Landing them alone is what lets the eleven handler steps and the three bulk tracks merge in any order within their dependency edges, rather than fusing shared machinery to its first consumer the way the previous phase measured.

Step 9 of the merged plan, docs/plans/2026-08-10-s17-phase4-artifact-writes.md. No route is registered and no consumer exists yet, so nothing here is reachable from a request.

What

Three choices the diff cannot explain on its own.

parsePathDigest is canonical-only, and oci.ParseDigest is not. The OCI parser accepts mixed-case hex and canonicalizes it, because a pushed digest is a submitted value. A path digest is an address, so this mirrors parsePathID instead: one 404 for missing-or-invalid, denying a caller the syntax oracle, and the logged 500 for an unbound path value, which is a registration defect rather than caller input. managementapi also must not import internal/format/*.

The counter dispatcher is a deliberate second variant of npm's unexported bufferedUpdate, named as such in the plan: npm's is format-local and metric-labeled for its own package, and a shared home is worth revisiting once a third consumer appears. One departure from the original. The column label is a defined type whose label() method maps any value outside the declared set to unknown, which is what bounds the metric's label domain. The type by itself does not bound it: an untyped constant satisfies counterColumn with no conversion, so a misspelled literal compiles.

The metric is not on /-/metrics yet. Nothing registers the collector, because the composition root is not in this step's file list, and the collector's doc comment says so. Two follow-ups belong with the step that wires the first consumer: the RegisterMetrics call in cmd/artifact-registry/main.go, which no step's Files list currently names, and the docs/dev/observability.md catalog row.

Test plan

CI. Unit only, 17 test functions: no route, query, or migration is touched, so no integration or conformance coverage applies and no e2e scenario changes.

Locally go test -count=1 -race ./internal/managementapi/ and go-lint-ci ./internal/managementapi/... --max-same-issues=0 --max-issues-per-linter=0 are both clean. The dispatcher families also passed -race -count=20, and three properties no signature can protect were mutation-checked by hand: the hex width check (deleting it lets sha256:<canonical>ab alias onto the canonical digest), the two worker log levels, and the slot-release ordering.

Spec coverage

Requirement Source Tests
A path digest not in canonical sha256:<hex> form is missing-or-invalid, so it answers 404 S17 Artifact write routes TestParsePathDigest_CanonicalDigest_Parses, _RejectedSpellings_Returns404, _RejectionIsIndistinguishableFromNotFound
An unbound {digest} path value is a registration defect, so it answers the logged 500 Mirrors parsePathID TestParsePathDigest_UnboundPathValue_Returns500
A successful delete answers 202 with an empty body S17 Delete semantics TestWriteAccepted
The event helper carries deletion_type=manual and every anchored-event field Plan Step 9 acceptance TestTrackArtifactDeleted_EmitsOneAnchoredEvent
The helper requires a non-nil tracker, matching NewHandler's assertion Implementation contract TestTrackArtifactDeleted_NilTrackerPanics
The dispatcher sheds over-cap dispatches and meters them Plan Step 9 acceptance TestBufferedCounterUpdate_ShedsOverCapDispatches, _AdmitsExactlyMaxInFlight, _CapIsSharedAcrossColumns
A failed or panicking update is contained, logged, and metered, and releases its slot Plan Approach, shed-and-meter TestBufferedCounterUpdate_PanicIsContainedAndReleasesItsSlot, _FnError_MetersOK, _LogLinesNameTheSurface
The dispatch outlives the request context it was started from Plan Approach, context.WithoutCancel TestBufferedCounterUpdate_RunsFnOnceAndMetersOK, _StripsCallerCancellation
The metered series reaches a caller's registerer Plan Step 9 acceptance TestRegisterMetrics_ExposesTheSeriesViaGather, _DuplicateRegistration_ReturnsError
Context for LLM agents

Commit shape. Test-first per the project's authorship contract: b131d629 (tests plus a zero-value skeleton) is the branch's single --no-verify commit, the documented carve-out, because the pre-commit go-test hook runs the full suite and a test floor fails it by design. Every later commit ran the full hook chain. The skeleton is zero-value rather than panicking because staticcheck folds an unconditional panic into unreachable code and then reports SA4006 across the test file.

Rejected alternatives.

  • Importing oci.ParseDigest. Blocked by the layering the plan states (managementapi does not import internal/format/*), and wrong on the merits: that parser canonicalizes mixed-case hex, which is correct for a submitted digest and wrong for an address.
  • Exporting npm's bufferedUpdate or extracting a shared package. The plan decides against both and says why. A third consumer is the trigger to revisit.
  • Adding a Props parameter to trackRepositoryEvent. It would widen a signature whose existing caller emits before the response, leaving both callers reading the looser contract. trackArtifactDeleted restates the projection instead.
  • Reordering parsePathDigest to test the prefix before decoding the hex. Declined twice under review. It saves a bounded reject-path allocation and costs a duplicated 404 branch, and a pre-decode length check would sit redundantly beside the load-bearing len(raw) guard.

Load-bearing details a future edit can break silently. The hex width check must stay before the slice-to-array conversion: a short but even-length body round-trips cleanly and would otherwise truncate into a valid-looking digest. Inside bufferedCounterUpdate, the slot drain precedes wg.Done() as statement order inside one deferred function, which is deliberate: the previous nested-defer shape depended on LIFO registration order, and swapping it survived 80 race runs undetected.

Non-goals. No route registration, no composition-root wiring, no docs/dev/observability.md row, and no plan Status-table edit. Sibling steps own each of those, and the Status table is filled once for the whole wave. The repositories counters (artifacts_count, size_bytes, last_updated_at) are deliberately absent: management deletes move those through the S22 accounting emit, which a later step wires.

Routed for the wave conductor. The bulk worker steps turn this per-request cap into a per-batch one, so they should aggregate per batch or take a sub-quota rather than shedding their own tail plus every concurrent handler's update. internal/metrics/cardinality.go's column and result budget comments do not yet name this dispatcher as a contributor. And internal/format/npm/buffered.go carries the same LIFO defer subtlety fixed here, worth the same structural fuse at its next touch.

Related to #313 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading