feat(npm): swap the publish and unpublish counters onto the buffered pipeline (S22 plan: 17/21)
What this step delivers
npm's publish handler and both unpublish handlers now record their repository counter deltas on the buffered counter pipeline. Neither operation changes. Only what each one emits changes.
- Publish emits one repository-scoped event,
Δartifacts = +1andΔsize = the tarball's bytes, and one namespace-scoped event,Δcomponents = +1andΔdedup_size. The namespace size delta is gated on the CAS commit'sDeduplicatedverdict, so a blob the namespace already holds adds nothing to it. The directUPDATEthat movedrepositories.artifacts_countandsize_bytesis gone. One direct write stays on that row: thelast_updated_atstamp, narrowed to that single column and renamedMarkRepositoryLastUpdated. - Whole-package unpublish emits
Δartifacts = -(versions removed)andΔsize = 0, after the tombstone transaction commits. It replaces the post-commitDecrementRepositoryArtifactsCountwrite. The interimDecrementRepositorySizeByteswrite beside it is removed rather than swapped, becausesize_bytessettles when the purger reaps the rows. - Single-version unpublish emits
Δartifacts = -1andΔsize = 0. This path had no repository-scoped site before. The emit sits outsideafterVersionCommit. The handler skips that call on the arm where the removed version was the package's last active one. - Neither unpublish handler emits a namespace-scoped delta.
Δcomponentssettles at the reap, for the same reason asΔsize. - The seam is
npm.CounterEmitterininternal/format/npm/counter_emitter.go, an interface the consumer declares and*accounting.Emittersatisfies.wireAccountingbuilds the process's one emitter, and the format tiers read it off the wiring. An emitter over no buffer is inert by contract, which is what the database-less unit stub gets. SumNpmFileSizesByPackageand its one call go with the size decrement they fed. A whole-package unpublish no longer runs that pre-transaction sum. The deleted doc comment recorded its cost: the join to the hash-partitionedblob_storage_blobsprunes no partition.- Three specs, the storage-accounting developer document, and the npm e2e catalog are corrected to match the sites.
Spec coverage
Spec: docs/specs/S22-storage-accounting.md
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-1 | A repo-scoped increment is reflected in repositories.artifacts_count/size_bytes after the next drain tick |
Step 8's (internal/accounting/chunk_worker_integration_test.go). Exercised end to end from an npm call site by TestPublishEmitIntegration_AllFourCountersMoveAfterDrain, TestUnpublishEmitIntegration_ArtifactsFallsAndFootprintHolds |
| AC-2 | A namespace-scoped increment is reflected in namespace_statistics.components_count/deduplicated_size_bytes after the next drain tick |
Step 8's. Exercised end to end by TestPublishEmitIntegration_AllFourCountersMoveAfterDrain, TestPublishEmitIntegration_SecondAttachMovesComponentsNotDedupSize |
| AC-3 | Concurrent increments to one scope sum exactly, no lost updates | Step 5/8's. Not this MR |
| AC-4 | A scope re-incremented mid-claim is re-marked dirty and not double-counted | Step 5/8's. Not this MR |
| AC-5 | A chunk past drain_chunk_stale_timeout bails, re-adds its scopes, meters, and issues no UPDATE |
Step 8's. Not this MR |
| AC-6 | A chunk failing every attempt re-adds its scopes before the terminal error | Step 8's. Not this MR |
| AC-7 | Redis unavailability at increment time does not fail the underlying operation | Step 6's (internal/accounting/emit_faults_integration_test.go). Not this MR; the emit seam here returns no error, which is what makes the property reachable |
| AC-8 | Reconciliation clears buffered Redis state before its SQL scan | Step 13/14's. Not this MR |
| AC-9 | Reconciliation recomputes with the correct soft-delete visibility on every format | Step 11's. Not this MR |
| AC-10 | artifacts_count/components_count recomputes covered as a positive hit per version-type table and (format, kind) |
Step 11/13's. Not this MR |
| AC-11 | A discrepancy is recorded on the drift histogram before the counter is overwritten | Step 13's. Not this MR |
| AC-12 | A crash between HINCRBY and SADD leaves the delta recoverable |
Step 6's fault-injection suite. Not this MR |
| AC-13 | Hash TTLs refresh on every write; a normally drained scope never expires early | Step 5's. Not this MR |
| AC-14 | The S22 migrations apply cleanly and roundtrip through jet | Steps 1/2a. Not this MR |
| AC-15 | The blob_storage_blobs triggers keep the by-namespace shadow exactly consistent |
Step 2b's. Not this MR |
| AC-16 | Every namespace has a zero-valued namespace_statistics row by construction |
Step 1's. Relied on here: every fixture reads the trigger-created row rather than inserting one |
| AC-17 | npm publish swapped onto the pipeline (four counters, Δdedup_size first-attach gated); Δartifacts decrement on both unpublish handlers, after the tombstone commits, on all three arms; the interim size_bytes decrement removed |
TestPublishEmitIntegration_AllFourCountersMoveAfterDrain, TestPublishEmitIntegration_SecondAttachMovesComponentsNotDedupSize, TestPublishEmitIntegration_DeltaArgumentsAndSourceFirstOrdering, TestUnpublishEmitIntegration_ArtifactsFallsAndFootprintHolds (three arms), TestUnpublishEmitIntegration_DeltaArgumentsAndSourceFirstOrdering (three arms) |
| AC-18 | OCI emits increments at CompleteUpload, MountBlob, and manifest PUT |
Step 18's. Not this MR |
| AC-19 | OCI emits decrements at the delete handler, not the deleters | Step 18's. Not this MR |
| AC-20 | Maven emits all four increments from a post-commit site; BumpRepoCounters retired |
Step 19's. Not this MR |
| AC-21 | Repository cascade hard-delete emits through S20-A's purger | Gated on #464 (closed). Not this MR |
| AC-22 | Reconciliation fans out one task per namespace candidate | Step 14/15's. Not this MR |
| AC-23 | A namespace with no statistics row gets one on its first pass (UPSERT) | Step 13/14's. Not this MR |
| AC-24 | In-flight reconciliation tasks never exceed reconciliation_max_in_flight |
Step 14's. Not this MR |
| AC-25 | npm publish row: the npm_versions row is visible outside the transaction when the pipeline call is made; a rolled-back publish issues no delta |
TestPublishEmitIntegration_DeltaArgumentsAndSourceFirstOrdering (committed-before probe on both emits), TestPublishEmitIntegration_RolledBackPublishEmitsNothing (forced rollback plus a committed control) |
| AC-25 | npm unpublish, both handlers row: the soft_deleted_at write is committed; a rolled-back unpublish issues no delta |
TestUnpublishEmitIntegration_DeltaArgumentsAndSourceFirstOrdering (committed-before probe, three arms), TestUnpublishEmitIntegration_RolledBackWholePackageEmitsNothing, TestUnpublishEmitIntegration_StaleRevVersionUnpublishEmitsNothing, TestUnpublishEmitIntegration_FailedVersionTransactionEmitsNothing — each pairing the zero-emission assertion with a committed control. Partial: see "Coverage gaps" |
| AC-25 | OCI's four rows and Maven's row | Steps 18 and 19. Not this MR |
| AC-26 | last_reconciled_at stamped only after every repository is written back |
Step 13/14's. Not this MR |
| AC-27 | The trigger selects only namespaces stale beyond reconciliation_interval |
Step 15's. Not this MR |
| AC-28 | A namespace with an outstanding task is enqueued at most once | Step 15's. Not this MR |
| AC-29 | reconciliation_backlog exposed by a single-writer scrape-time collector |
Step 16's. Not this MR |
| AC-30 | A chunk whose scope was reconciled after its baseline read skips that scope | Step 7/8's. Not this MR |
| AC-31 | counter_dirty_set_size sampled once per tick before SPOP |
Step 10's. Not this MR |
| AC-32 | Config load rejects each invalid storage-accounting configuration | Steps 3/3b. Not this MR |
| AC-33 | The six S22-defined metrics are registered and scraped | Steps 8/13/16. Not this MR |
| AC-34 | The reconciliation task's saturation policy is enforced | Step 14's. Not this MR |
| AC-35 | A namespace-scoped chunk with no statistics row leaves the scope out and still deletes :flushed |
Step 8's. Not this MR |
| AC-36 | A failing recovery SADD does not lose the delta |
Step 8's fault-injection suite. Not this MR |
| AC-37 | Management-API deletes emit once their own writes commit | Gated on #313 (closed); deliberately outside the plan's numbered steps. Not this MR |
| AC-38 | A persistently failing reconciliation task leaves its namespace re-enqueueable | Step 14/15's. Not this MR |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | Redis unavailable at increment time | Step 6's emit_faults_integration_test.go. Not re-asserted here; the emit seam returns no error, so no npm arm can surface one |
| E-2 | Redis unavailable at drain-trigger time | Step 10's. Not this MR |
| E-3 | Chunk job's Postgres UPDATE fails |
Step 8's. Not this MR |
| E-4 | Chunk job's :flushed DEL fails after the UPDATE succeeded |
Step 8's. Not this MR |
| E-5 | Chunk job exhausts all retry attempts | Step 8's. Not this MR |
| E-6 | Recovery SADD itself fails |
Step 8's. Not this MR |
| E-7 | Chunk dequeued later than drain_chunk_stale_timeout |
Step 8's. Not this MR |
| E-8 | Worker dies mid-chunk after merging into :flushed |
Step 8's. Not this MR |
| E-9 | Two chunks run one scope concurrently | Step 8's. Not this MR |
| E-10 | Trigger's EnqueueTx fails while the process is alive |
Step 10's. Not this MR |
| E-11 | Crash between a trigger's SPOP and its EnqueueTx |
Step 10's. Not this MR |
| E-12 | Assigned repository or namespace row hard-deleted before its chunk drains | Step 8's. Not this MR |
| E-13 | Namespace-scoped chunk drains a namespace with no statistics row | Step 8's. Not this MR |
| E-14 | Crash between HINCRBY and SADD |
Step 6's. Not this MR |
| E-15 | Crash between reconciliation's clear and its SET |
Step 13/14's. Not this MR |
| E-16 | Reconciliation scan races a concurrent increment | Step 13/14's. Not this MR |
| E-17 | A chunk and a reconciliation process one scope concurrently | Step 7/8's. Not this MR |
| E-18 | Reconciliation finds a discrepancy | Step 13's. Not this MR |
| E-19 | Namespace has no statistics row when its reconciliation task runs | Step 13/14's. Not this MR |
| E-20 | Reconciliation task fails before its final UPSERT | Step 14's. Not this MR |
| E-21 | A namespace can never be reconciled | Step 14/15's. Not this MR |
No error case in the table is an npm call site's to answer: an emit reports no failure to its caller, so every arm the npm handlers can reach is the success arm. What the handlers do owe is that they emit nothing when their own transaction did not commit, which is criterion 25's zero-emission column above rather than a row here.
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Redis keys carry only internal UUIDs, so no key-injection or cross-slot risk | Step 5's counterbuf key tests own the grammar. Asserted from this side by the emit-argument cases, which pin that both call sites pass the resolved namespace_id/repository_id and no request-derived value: TestPublishEmitIntegration_DeltaArgumentsAndSourceFirstOrdering, TestUnpublishEmitIntegration_DeltaArgumentsAndSourceFirstOrdering |
| S-2 | Counter values are non-secret but feed billing, so a wrong value has financial impact | The exact-delta and unchanged-column assertions are this MR's share: TestUnpublishEmitIntegration_ArtifactsFallsAndFootprintHolds (size_bytes, components_count and deduplicated_size_bytes must not move at a tombstone), TestPublishEmitIntegration_SecondAttachMovesComponentsNotDedupSize (no double-count of a namespace-held blob). Reconciliation and the drift metrics are Steps 11-16's |
| S-3 | Redis and Postgres reuse the existing cache client and datastore pool; no new credential surface | No new credential surface in this MR either: the pipeline harness builds its client through redisclient.NewCacheClient and datastore.NewAppClient, the same constructors the composition root uses. Nothing to assert |
Coverage gaps
Criterion 25's zero-emission column is covered in full for the whole-package handler, through that deleter's own before-cascade fault hook.
For the single-version handler it rests on two adjacent routes.
datastore.NpmVersionUnpublishDeleter carries no such hook, so a genuine mid-transaction rollback needs new production code to reach.
The first route is a stale {rev}, which the real composer rejects before the transaction opens.
The second is a seam that returns the error a rollback surfaces as.
Both routes enter the handler's error guard, and the emit sits after that guard.
The residue is therefore the deleter's rollback-to-error mapping rather than the emit site.
e2e scenarios
docs/testing/e2e/npm.md gains e2e.npm.lifecycle.repository-storage-counters in three places.
- The lifecycle table gains the scenario row, at status
blocked. It reads the management API after a publish and after an unpublish. It polls until the drain lands, rather than reading straight after the response. - The note above that table says what blocks it: the periodic drain that moves the buffered deltas into
repositoriesis what makes the read assertable. - The usage-data table gains its row. The scenario emits no event of its own, because the publish and the unpublish it reads after are already listed with their events.
Diff size
The diff is 67 files, 3596 insertions and 805 deletions, which passes the 500 reviewable-LOC line in docs/dev/development-model.md.
The numbers come from git diff --numstat between main at af5027a60 and the branch tip 1670350b7.
| File group | Files | Added | Removed |
|---|---|---|---|
internal/format/npm/ production |
12 | 672 | 203 |
internal/format/npm/ tests |
16 | 2136 | 223 |
internal/datastore/ production |
7 | 179 | 223 |
internal/datastore/ tests |
8 | 95 | 42 |
cmd/artifact-registry/ production |
4 | 150 | 46 |
cmd/artifact-registry/ tests |
10 | 270 | 26 |
internal/managementapi/ tests |
1 | 10 | 0 |
internal/metrics/ |
2 | 19 | 19 |
docs/ |
6 | 45 | 13 |
.claude/skills/ |
1 | 20 | 10 |
The ten groups sum to 67 files, 3596 added and 805 removed.
Tests are 2515 of the 3596 added lines, and two new integration suites are 1279 of those 2515: publish_commit_emit_test.go at 743 lines and unpublish_emit_test.go at 536.
A third new test file, internal/format/npm/counter_emit_dispatch_internal_test.go, adds 288 lines.
Production Go is 987 added and 463 removed across 23 files.
A split does not help here, for three reasons.
Both unpublish handler constructors take the emitter as a required argument.
A split by emit site therefore leaves one merge request with a handler it cannot construct.
The composition root belongs with the sites, because it is what supplies the emitter they read.
And the two suites pin the code they ship with, so a test-only merge request fails against main.
Verification
A hand run of the branch against Postgres, MinIO and a live Redis drove all four paths, and read every delta back out of Redis.
A publish of a 189-byte tarball moved the repository hash to 1 artifact and 189 bytes.
The namespace hash moved to 1 component and 189 bytes.
The same publish left repositories.artifacts_count and size_bytes at 0, which is the point of the swap.
A second repository in the same namespace took the full 189 bytes, while the namespace's dedup size held at 189.
A whole-package unpublish moved artifacts from 3 to 0 with the size unmoved, and both single-version arms moved Δartifacts by -1 with the size unmoved.
No conformance suite applies: neither operation's request or response changes, and this MR adds no protocol behavior.
Metrics: the label values that retire, and the ones the emit path adds
Two metrics lose label values with the direct writes this MR removes. Every value in both label sets is a compile-time constant, so nothing request-derived is involved.
| Metric | Label | Retires | Appears |
|---|---|---|---|
gitlab_artifact_registry_npm_buffered_counter_updates_total |
column |
repository_publish_counters, artifacts_count |
last_updated_at, size_bytes |
database_query_duration_seconds |
name |
repositories_update_increment_publish_counters, npm_files_select_sum_sizes_by_package |
repositories_update_mark_last_updated |
size_bytes sits on both sides of the first row and is the one value whose meaning changes.
It stops labelling the unpublish path's buffered UPDATE and starts labelling the counter-emit dispatcher's lost deltas, which report no result="ok".
Nothing in-tree goes stale.
repository_publish_counters and both retired query names no longer appear anywhere in the tree; artifacts_count survives only as a response field and as a reconciliation counter name, neither of which is a metric label value.
internal/format/npm/metrics.md bounds the column label rather than enumerating it, and its budget of 10 is still met at the six values npm emits: last_downloaded_at, last_updated_at, npm_package_publish_counters, npm_remote_last_downloaded_at, tags_count and size_bytes.
An out-of-tree Grafana panel or alert selecting a retired value reads as "no data" rather than failing, which is why the values are listed here rather than left to be discovered.
The mechanism being replaced was metered per column, and the replacement is metered per lost delta.
The retired direct writes went through bufferedUpdate, which meters every dispatch on gitlab_artifact_registry_npm_buffered_counter_updates_total{column,result} with result in {ok, panic, dropped}, so dispatch rate and shed rate were visible per column.
dispatchCounterEmit now reports on that same vector rather than on a collector of its own, under one fixed column value of size_bytes: result="dropped" for a dispatch shed at a saturated cap, and result="panic" for a recovered seam panic.
Without those two, a shed ran no work, wrote no log line and left the operation answering 2xx, so "is npm losing counter deltas right now" had an answer in neither the logs nor /-/metrics.
Two things the reuse does not give back, both stated at the constant in internal/format/npm/counter_emit_dispatch.go.
The path reports no result="ok", because a dispatch whose work ran records nothing, so that column carries no denominator and each result is read as a rate on its own.
And one column value covers both arms, because dispatchCounterEmit is handed an opaque closure and never runs it on either metered arm: a shed unpublish retires an artifacts_count movement and no bytes, so a non-zero rate means "the emit path lost at least one delta" rather than a byte total.
The reuse costs no cardinality and adds no metric.
Both result values already exist on the vector, and size_bytes is a column value npm's own series carried before this swap. The accounting package is untouched. internal/metrics/ is not: retiring repository_publish_counters and artifacts_count and adding last_updated_at moves npm's column values from seven to six and the four packages' union from eleven to ten, which the budget, the pinned set and their ownership comments record.
The vector is npm's own interim buffered-write counter rather than a seventh S22 metric, so the set of six that docs/specs/S22-storage-accounting.md:816 delivers, and criterion 33 pins by name, is unchanged and no spec amendment follows.
Database Review Evidence
Query mode only. Migration mode did not run, because the diff changes no file under internal/datastore/migrations/sql/.
The full evidence, with the rendered SQL, the seed shapes, and the raw plans, is in this note.
- One query-producing record, out of the 59 changed Go files in the diff that
## Diff sizemeasures:RepositoryStore.MarkRepositoryLastUpdatedininternal/datastore/repositories.go.internal/datastore/reconcile_repository.gois a comment-only diff.internal/datastore/npm_files.goonly removesSumNpmFileSizesByPackage, so it adds no statement and changes none. That removal retires the only all-partition scan ofblob_storage_blobsin the tree. - Measurement: the plans come from an ephemeral PostgreSQL
17.10container with the full goose chain applied. Both plans ran insideBEGIN/ROLLBACKafterANALYZE. - Recipe run at 50 repositories: a Seq Scan on
repositories_p55, 1 partition of 64, execution1.444 ms. - Supplementary run at 5000 rows: an Index Scan on
repositories_p58usingrepositories_p58_pkey, 1 partition of 64, execution0.841 ms. The recipe sizes a write target at 50 rows, and that size cannot show whether an index covers the predicate. The larger run confirms that the primary key on(id, namespace_id)covers both predicate columns. It is supplementary to the recipe rather than part of it. - No anomalies and no flags.
Correction to the plan. Line 1260 of docs/plans/2026-08-04-s22-storage-accounting.md names internal/datastore/npm_publish_committer.go as the file this evidence covers. NpmPublishCommitter.MarkRepositoryLastUpdated delegates to RepositoryStore, and the jet chain is in internal/datastore/repositories.go. Guardrail 4 keeps a step merge request out of the plan file, so this merge request does not correct it. The section The plan's Status row hands the correction over together with step 17's Status row.
The repository size delta at publish is not first-attach gated
docs/adr/007_database_schema.md line 2092 states that the counter "increments only when a sha256 first becomes attached in the repository".
npm's publish site does not implement that gate.
emitPublishCounters emits the whole tarball's bytes on every publish, with no test of whether the repository already references that sha256.
A repeat publish of byte-identical content into one repository therefore over-counts repositories.size_bytes.
What takes the excess back off is a reconciliation pass recomputing the column from the repository's distinct blobs: the over-count stands until a pass reaches the repository, and the column returns to the recomputed value when one does.
The direct UPDATE this MR replaces carried the same arithmetic, so the branch moves the gap onto the pipeline rather than introducing it.
This deviation was escalated and accepted.
Work item #762 tracks it, together with the packument bytes that no npm emit site carries, on the repository column and on the namespace column.
That work item also records the handbook ADR amendment as one of its own deliverables.
This MR opens no handbook merge request, and it edits nothing under docs/adr/.
The developer document and the spec disagree in the interim
docs/dev/storage-accounting.md records npm publish's ungated repository size delta here, as an accepted exception to a rule that stands, and points the reader at #762.
docs/specs/S22-storage-accounting.md:266 still reads as if every emit site gates, so the two documents disagree while the spec correction is outstanding.
That correction is deliberately not in this MR. It belongs to a separate spec merge request, which is not scheduled by this run.
Its scope is line 266's accepted-transient paragraph, extended with the sequential repeat attach inside one repository.
It states the same in acceptance criterion 17, at line 1013.
It leaves line 244 alone, and it leaves line 266's definition of dedup-within-repository alone.
No merge order is owed between that merge request and this one.
The plan's Status row
This MR does not edit docs/plans/2026-08-04-s22-storage-accounting.md, and step 17's Status row is empty on main today.
Guardrail 4 keeps a step MR out of the plan file, Status table included, and gives that table one writer: a batch or standing docs(plans) merge request, filled in the same sitting the step MR opens.
No S22 batch or standing docs(plans) merge request exists today, so the row is handed to a vehicle that does not exist yet.
This is a deviation from that guardrail rather than compliance with it.
The row is recorded as owed and handed over, and a second correction to the same plan file travels with it: line 1260 points ## Database Review Evidence at internal/datastore/npm_publish_committer.go, where the statement that changed shape is in internal/datastore/repositories.go.
Notes for review
- The plan's
buffered.gobullet is stale, and the file is in this diff for another reason. The plan asks step 17 to correct aTODO(buffered)reference that names "S12 OQ-16 / S23 storage accounting". Merged commitc4189ecc3made that correction on 2026-08-14, and neither string exists underinternal/format/npm/onmain.internal/format/npm/buffered.gois here because itsbufferedUpdategodoc namedcolRepositoryPublishCounters, a constant this MR deletes. - The plan's Files list names no composition-root path, and 13 of the 56 changed paths sit under
cmd/artifact-registry/. Both unpublish handler constructors take the emitter as a required argument, so the tier that mounts them had to change. This is a gap in the plan's file list rather than scope beyond the step. - One retired symbol is named without a file path, on purpose.
docs/specs/S22-storage-accounting.md:26gives the pathinternal/format/npm/buffered.gofor thebufferedUpdatehelper, which still exists, and gives no path forIncrementRepositoryPublishCounters, which this MR deletes. A path is a claim that the symbol lives there, and a deleted symbol lives at no path. The asymmetry on that line is deliberate, not an oversight. - This MR falsifies a paragraph of another workstream's plan, and a step MR cannot carry the correction.
docs/plans/2026-08-12-s17-phase8-statistics.md:62describesNpmFileStore.SumNpmFileSizesByPackageas present in the tree and tells the Phase 8 author to leave it andSumDistinctNpmFileSizesByVersionalone, "a deliberate near-miss pair". This MR deletesSumNpmFileSizesByPackage, so once it merges there is no pair and the instruction names an object that is not in the tree. The same sentence routes ownership of both sums to work item #549 (closed), which closed on 2026-08-19.docs/plans/README.md:7reserves a substantive plan revision for a plan-amendment merge request rather than an in-place edit from a step branch, so this MR does not touch that file either. !1786 (merged) is the plan-amendment merge request for that file, and a factual note recording both stale clauses is on it at note 3712392542. Whether its author folds the correction in or not, the paragraph is falsified the moment this MR merges, and this bullet is where that stays on the record. - Merge history. This step was developed on top of step 14 while !1757 (merged) was open.
!1757 (merged) merged on 2026-08-20 as
fceabf70d, and this branch was then rebased ontomain. - Merge order. This merge request must merge after !1751 (merged) (S22 step 10).
The target branch enforces this order, because this merge request targets the branch of !1751 (merged) and not
main. !1751 (merged) supplies the drain trigger that applies the deltas this merge request emits. This merge request retires the direct npm writes torepositories.artifacts_countandrepositories.size_bytes. No other production path writes either column for an npm repository. Without the drain trigger, the deltas expire on a sliding TTL ofdrain_key_ttl_multiplier * drain_interval, which is 5 minutes at the default values. These deltas are then lost, and not deferred to a later drain. Reconciliation repairs drift in these columns, and step 15 dispatches it. This merge order does not depend on reconciliation. The retarget is temporary. The target branch returns tomainafter !1751 (merged) merges, and that change is still owed. While the target is the branch of !1751 (merged), the "Changes" view also shows the commits of !1751 (merged). This is expected, and not extra scope.
Review threads declined
One review thread on this merge request is declined rather than answered. This section records the decision, so a later pass does not re-open it.
The operator declines the confidential AppSec review thread on this merge request. That thread gets no reply, no resolve, and no calibration label.
Review feedback worked after the first review pass
Two findings from the review of 2026-08-21 changed the branch after the description above was first written, and both are recorded here so a reader of the diff is not surprised by them.
- The single-version unpublish emit is gated on its own committed result.
unpublishVersionTxre-reads the target withActiveNpmVersionExistsTxafter the packument rotation, andUnpublishVersionreturnsNpmVersionUnpublishOutcomerather than four positional values. Two concurrent unpublishes of one version both answer200, and exactly one of them movesartifacts_count. Acceptance criterion 17 stated the pre-gate premise and is corrected in this merge request; the plan's copy of it is not, because guardrail 4 keeps a step merge request out of the plan file. docs/dev/observability.mdgains npm's..._npm_buffered_counter_updates_totalrow. npm was the only one of the four packages declaring acolumnlabel on that counter with no catalog row. The row states the split reading and records thatsize_bytescarried anokdenominator until this swap moved the repository counters onto the pipeline.
Related to #515
This is a bot message