S22: wire the OCI storage-accounting counter sink in the composition root
## Why this item exists
The Container/OCI format package emits storage-accounting deltas from five sites: blob finalize, blob mount, manifest `PUT`, manifest `DELETE`, and blob `DELETE`.
Each site reaches the accounting pipeline through a `*CounterSink` field on its handler.
That field is nil unless the composition root passes `oci.WithUploadCounters`, `oci.WithManifestCounters`, or `oci.WithBlobCounters`.
A nil sink is inert by design, so the site skips its emit and the operation is otherwise unchanged.
While no file under `cmd/artifact-registry/` passes one of these three options, the OCI counters of a container repository stay at 0.
Once a composition root passes a sink, the same five sites move those counters on the next drain tick.
This item is the work that makes the second condition true.
The gap is not a regression.
OCI emitted no counter before the emit sites arrived, so a container repository reports `artifacts_count` 0 and `size_bytes` 0 on both sides of that merge.
Reconciliation does not cover for it.
`accounting.RegisterAsynqHandlers` in `internal/accounting/register.go` has an empty body, and the plan step that fills it is what schedules the reconciliation job.
## Deliverable 1: a production `RepositoryFootprint`
`internal/format/oci/emit.go` declares the `RepositoryFootprint` seam, and no datastore store implements it.
The only implementation is the integration suite's own double, `dbRepositoryFootprint` in `internal/format/oci/emit_integration_test.go`.
Its doc comment states the obligation: "It is declared here because no datastore store owns this query yet; the production wiring needs one, and this is the statement it owes."
That double carries the SQL a production store owes.
It reads the size from `blob_storage_blobs`, then answers membership with an `EXISTS` over `container_blobs UNION ALL container_manifests`.
Both arms join `container_images` on `container_repository_id`, and both arms are required.
A predicate over one arm alone counts wrong where the two populations diverge, for example on a manifest payload that is no image's layer.
Merge request https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/1754 adds `repoFreedBlobSizeTx`, which walks the same two container tables for a similar reason.
If that merge request lands, this deliverable stands, for three reasons.
The function is unexported, so `internal/format/oci` cannot reach it.
It takes a `*sql.Tx` and runs inside the caller's delete transaction, and this sink reads on the pool after the commit.
It returns a freed size with `referenced` collapsed into a zero, so it cannot answer the attach side, which needs `referenced` on its own.
## Deliverable 2: the sink construction in `wire_oci.go`, and its boot check
Build the sink in `cmd/artifact-registry/wire_oci.go` and pass it to the manifest, blob, and upload handlers.
`assertManifestOptionsWired` in the same file "fails boot when a ManifestOption that degrades silently was dropped".
It covers the usage tracker because "a nil tracker is inert by design, so it stops every OCI event", which is this failure shape exactly.
It checks the config reader and the usage tracker, and nothing else.
Add a check for the counters option beside `ConfigReaderWired` and `UsageTrackerWired`.
The sink also needs an emitter.
Merge request https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/1754 builds the first production `accounting.NewEmitter` of the tree.
It holds that emitter as a `counterEmitter` field on the `wiring` struct, and its `wire.go` diff does not touch `wireOCI`.
If that merge request lands first, the emitter is a value to pass.
If this item lands first, it builds the emitter too.
## Deliverable 3: the documentation section
`docs/dev/storage-accounting.md` has a section named `## What the composition root wires`.
The last paragraph of that section says "The emitter itself has no composition-root construction" and gives a `grep` command as the check.
The wiring makes that paragraph false.
The same change rewrites it and adds how a composition root builds the sink and passes the three options.
## Deliverable 4: a guard against a nil pointer boxed into `CounterEmitter`
`NewCounterSink(emitter CounterEmitter, footprint RepositoryFootprint) *CounterSink` panics when an argument is a nil interface value.
A nil pointer boxed into an interface is not a nil interface value.
`NewCounterSink(nilTypedEmitter, footprint)` therefore returns a non-nil `*CounterSink` that clears the constructor guard and every per-site nil check.
The panic then happens inside an emit, in an operation that already committed its rows.
While no composition root builds a sink, no code reaches this trap.
The wiring of deliverable 2 is the first code that can reach it, so the guard rides this item.
## Merge order
Plan step 18 adds the five OCI emit sites.
Once its merge request lands, the sites exist in the tree and stay inert until a composition root passes a sink.
That order is safe for OCI, because the counters read 0 on both sides of it.
Plan step 17 is the deadline, and this wiring must land before it.
Step 17 moves npm publish off `RepositoryStore.IncrementRepositoryPublishCounters` in `internal/datastore/repositories.go` onto the same emit API.
npm publish reaches that call in production through `npm.WithPublishCommitter(datastore.NewNpmPublishCommitter(client))` in `cmd/artifact-registry/wire_npm.go`, and the call moves the repository counters.
If step 17 lands while no composition root passes a sink, npm publish stops moving those counters, and the npm numbers regress.
The same gap only holds an OCI counter at 0, which is why step 18 can precede this item and step 17 cannot.
No merge request for plan step 17 existed when this item was filed.
## Notes for the implementer
The committed OCI tests drive the handlers through the exported options, so they do not change when the sink is wired.
`dbRepositoryFootprint` exercises the SQL of deliverable 1 against real Postgres rows, so the production store can copy a statement that already passes.
When this item was filed, neither the S22 spec nor the S22 plan named a wiring file for OCI or npm.
No ADR in the S22 dependency set said who constructs the sink.
Every code claim above was read from the branch that carries the five emit sites, on 2026-08-20.
Symbols are named without line numbers, because the line numbers move.
_This is a bot message 🤖 — /smurfit_
---
**🤖 Triage note**
This issue has been classified as **`type::feature`** and the following labels have been applied:
- `type::feature`
- `Category:Artifact Registry`
- `devops::package`
- `group::package registry`
**Reasoning:** This issue describes the addition of new production wiring — connecting the OCI storage-accounting counter sink to the composition root, implementing a production `RepositoryFootprint`, adding boot-time guards, and updating documentation. None of these deliverables fix a regression or broken behaviour (the description explicitly states "the gap is not a regression"); they introduce new, previously absent functionality. That makes this a feature addition rather than a bug fix or maintenance task.
If this classification doesn't look right to you, please update the `type::` label and let the team know — happy to be corrected! 🙂
issue
GitLab AI Context
Project: gitlab-org/ops/artifact-registry
Instance: https://gitlab.com
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/CONTRIBUTING.md — contribution guidelines
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/README.md — project overview and setup
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/AGENTS.md — AI agent instructions
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/CLAUDE.md — Claude Code instructions
Repository: https://gitlab.com/gitlab-org/ops/artifact-registry
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD