docs(specs): reassign the hosted downloads_count writers off S18 (repository-column-writers plan: 1/5)
What this does
The specs assign repositories.downloads_count to S18, which has no spec. This amends them so the hosted writers belong to the repository-column-writers plan, and defines the npm hosted download event that no npm spec defined.
Documentation only: 9 files, +214 / -69. No code changes, no behaviour changes.
Plan steps
Step 1 of 5 of the repository-column-writers plan. Step 1 gates only the downloads_count half, because that half is the one the specs assign elsewhere. Step 3's MR is open as well (!2362 (merged)). Both target main directly and they share no file, so neither order is forced. !2363 (merged) is the plan's batch-fill MR and carries the step 1 and step 3 rows.
| # | Step | This MR |
|---|---|---|
| 1 | Reassign the hosted downloads_count writers from S18 to this work | this MR |
| 2 | Execute the Maven last_updated_at write after the upload commits | not open |
| 3 | Move container last_updated_at on all seven S12 events | !2362 (merged) |
| 4 | Add the third counter family, its drain, and npm's first increment | not open, blocked on the plan's Open Q1 |
| 5 | Move container downloads_count on the two S12 read events | not open, blocked via Step 4 |
What moves, per file
-
S10-maven-hosted.md: the S18 and S22 dependency rows, the owners sentence under "Interim implementation of buffered columns", the delivery-gate note, the S18 and S22 async-pipeline bullets, and the download-counter-propagation exclusion. Maven's hosted writer keeps its interim directUPDATE; only the assignment moves. -
S12-container-oci-hosted.md: the ownership sentence, the event-emission sentence under "Counter and Timestamp Updates", and the OQ-16 resolution. The split those sentences describe is four-way, not three-way. -
S11-npm-hosted.md: definesrepositories.downloads_count += 1on a delivered tarballGET. AGETdelivers on a full200body or a written redirect, per ADR-005's two delivery modes and ADR-012's rule that each mode reports the strongest signal it can observe. AHEAD, a304, a404, and a200ending short of the declared length book none. The event is defined, not live, and the item says so: nothing writes the column on an npm repository today (internal/format/npmholds no writer, andMavenRepositoryStore.BumpRepoDownloadCountatinternal/datastore/maven_repositories.go:339is the column's only production writer), and the plan step that adds npm's increment has no MR. The item names the condition and the event that lifts it, the shape S12 already uses for its two container read rows. The file had no occurrence ofdownloads_countbefore this. -
S15-npm-remote.md: states that the npm remote read path moves no counter and that the mechanism decision stays with #292, rather than defining an event nothing performs. -
S16-container-remote.md: re-points every passage assigning the counter to S18. Neither acceptance gate is removed: no step of this plan writes the container remote read path, so removing them would leave the criteria asserting downloads nothing books. -
S17-rest-management-api.md: the S18 dependency row and the three passages that restate the assignment. -
S14-maven-remote.mdandmonolith/S05-repository-detail.md: two more files carrying the same assignment. See Scope below. -
AGENTS.md: adds Two Adjacent Gates Are Read Against Each Other, the convention behind the S11 delivery-gate fix, requested by aremember:review note and shipped as its paired commit. Placed beside A Documented Distinction Owes a Test on the Case It Rests On and cross-referenced to it, since the two are the same hazard from opposite sides.
The owners sentences in S10 and S11 each go from four owners to five.
Corrections this makes beyond the reassignment
Each of these was false before this MR, and each is fixed here rather than left for a reader to rediscover.
- The ADR-023 justification was an inverted borrow. S16 said no shared buffered-write client exists, npm's being format-private and out of reach per ADR-023.
internal/accounting.Emitteroverinternal/accounting/counterbufis shared, and every hosted format and npm remote is wired to it (cmd/artifact-registry/wire_maven.go:122,wire_npm.go:160,wire_oci.go:850,wire_npm.go:707). What it carries no field for isdownloads_count, which the plan adds. ADR-023 bars format-to-format imports and prescribes exactly the sharedinternal/package, so it never put a client out of reach. S16 carried this at two sites; both are corrected. - S12's write endpoints already emit the S22 counters, through
internal/format/oci.CounterSinkoverinternal/accounting.Emitter, at five dispatch sites (store.go:288,store.go:496,blob.go:513,manifest_push.go:658,manifest_delete.go:395). Two sentences said the handlers emit nothing. - "The two remote read paths" described the set wrongly. Three remote read paths exist, and Maven remote already increments the column on a delivered archive-class
GET. npm and container remote are the two left without a writer. - S17's S18 row carried two claims already false at the plan's basis: that
MavenRepositoryStore.BumpRepoDownloadCountis a no-op stub, and thatdownloads_countis a column nothing maintains. It executes itsUPDATEatinternal/datastore/maven_repositories.go:318. #975 tracks the first, and this closes it. #975's second item is a different fact and is not fixed here: the Phase 8 Format-scope bullet sums three figures where the Format statistics acceptance criterion and theFormatStatisticsschema inapi/openapi/v1.yamlrequire four,repositories_countbeing the one it omits. Fixing that is not adownloads_countreassignment, so #975 stays open on it. - "A third repository-keyed counter family" over-counted.
internal/accounting/counterbuf/keys.go:12-26holds two families and onlyRepoScopedis repository-keyed, so the new one is the third family overall and the second keyed on a repository. Reworded in S10, S11, S12 and S16. - "Maven's equivalent store methods are still no-op stubs" was false, at six sites in
docs/specs/, five of them in S16.MavenPackageStore.BumpAccessTimestampsexecutes twoUPDATEs onmaven_packages.last_downloaded_atandmaven_versions.last_downloaded_at, throttled to one write per row per hour (internal/datastore/maven_packages.go:249), reached frominternal/format/maven/download.go:548and wired atcmd/artifact-registry/wire_maven.go:413; the remote twinMavenRemoteCacheStore.BumpLastDownloadedAtexecutes its own under the sameTODO(s18-buffered-counters)marker (internal/datastore/maven_remote_cache.go:648). S16's Download signals said the opposite, in the same subsection as a sentence saying Maven's handler "already calls its bump methods on every served read", and S17's S18 row said it in the row correction 4 rewrites. Correcting S16 also retires the reason it gave for following npm's bumper shape, which was that Maven's was inert, so the three S16 passages naming that shape now say both siblings' shapes are available. The same fact lived at four more S16 lines, which the first pass missed and which now read correctly, across three passages: the write-amplification paragraph of Download signals itself, its Follow-ups bullet, and the Resolutions bullet behind them, whose headline and Why: line each carried it. All three called the method inert and drew a conclusion from it, that the index cost onmaven_versionsandmaven_packagesis "latent rather than paid" and that their throttle "belongs in whatever lands that writer". The method executes and skips a row bumped within the hour, so those two tables carry an hour-bounded version of the same write amplification rather than none of it, and the deferral had nothing left to defer. The word the old Resolutions bullet got wrong was "unconditional", not the existence of the writer.MarkRepoLastUpdatedreally is still a discard stub (internal/datastore/maven_repositories.go:362), soS17:1645's "no-op stubs" sentence about it andBumpRepoCountersis correct and is deliberately left alone. The stale claim was specific toBumpAccessTimestampsandBumpRepoDownloadCount. - S11's blanket interim rule contradicted the event this MR adds. The rule under Interim implementation of buffered columns mapped every reference below it to the interim goroutine plus direct
UPDATE, while the new tarball event books into S22's pipeline. The rule now carries the carve-out, and names all three columns that take one rather than only the new event's.repositories.artifacts_countandrepositories.size_bytesalready skip the interim path: every npm site that moves them books throughCounterEmitter, whose production implementation isinternal/accounting.Emitter, and the interimbufferedUpdatehelper's five labels include neither. Both sit in the same paragraph's own column list, so a one-item exception list mapped them to the interim path by omission. The pre-existing passages that state the interim mapping for those emissions, in this file and in S10, are left alone: they are S22's fact to widen, and what earns the edit here is that this MR rewrote this sentence. - S12's replacement sentence over-claimed its own table. It read that the table "enumerates the events S12 endpoints source", while the table's Manifest PUT and Manifest DELETE rows both read
Noneand both endpoints emit repository deltas:emitPushDeltasbooks an artifact delta for a manifest new to the image and a byte delta for the payload blob's first attach (internal/format/oci/manifest_push.go:658), andemitDeleteDeltasbooks the matching decrements (internal/format/oci/manifest_delete.go:398). The sentence now scopes the live claim to the four emitting blob rows and records the manifest divergence. See What this MR does not reach. The replacement then over-claimed in the other direction, saying those four blob rows emit "repositories.size_bytesandrepositories.artifacts_count" deltas. Only the byte half ships, and it ships gated rather than unconditional: each delta is emitted on the repository's first attach of the digest and on its last detach (attachDeltaanddetachDeltaatinternal/format/oci/emit.go:264and:279, over the(namespace_id, container_repository_id, sha256)membershipreadBlobFootprintreads at:172), while the four rows state the delta unconditionally and the table's only carve-out is scoped to the cross-repository case. That divergence is recorded in the section too, above theartifacts_countparagraph so the two claims scoped toartifacts_countstay true. Every blob dispatch site passes a zero artifact delta, because a blob is neither an artifact nor a component (internal/format/oci/store.go:289and:497,internal/format/oci/blob.go:516), and the blobDELETErow specifies no artifact delta to begin with. So the table and the code disagree onartifacts_countin both directions: therepositories.artifacts_count += 1the two blob-upload rows and the201mount row specify on an auto-created image is never emitted, and the artifact delta ships on the two manifest rows the table marksNone. Both divergences are now recorded in the section, and settling them is S12's.
Scope: wider than the plan's Files entry
The plan's Step 1 Files entry names six spec files and, inside S16 and S10, a smaller set of sentences than the fact occupies. Re-pointing only the named sentences left each file contradicting itself, so this MR fixes every site instead.
S14-maven-remote.md and monolith/S05-repository-detail.md join for the same reason: the step's own acceptance criterion runs grep -rn 'downloads_count' docs/specs/, which is recursive and reaches both.
The plan's Files entry needs widening to match. That is a docs(plans) change, not this MR's to make, since a step MR does not edit the plan.
Two things in the plan text also need that same docs(plans) pass, neither actionable here:
- Step 1's Acceptance says "no sentence claims a remote read path books one", while its own
Filesentry orders S16's gates kept. Four S16 passages still claim the increment, by that instruction: the twodownloads_countrows of the Download signals mapping and the two acceptance criteria under the second### Download signals(S16:670-671and:980,:990, measured at0f1ba8e40). - The
Filesentry's site counts for S16 and S10. - Step 2's
Filesentry orders the:93clause calling the S10 interim bump a no-op stub rewritten there. Correction 6 fixes it here instead, because the row was already being rewritten whole and the clause is false onmaintoday, so that half of Step 2's entry has nothing left to do.
What this MR does not reach
repositories.last_updated_at's ownership.S10:153still reads "has no owner yet (#442)", which is true today and which Steps 2 and 3 falsify. Amending it belongs to those steps, not to this one'sdownloads_countscope, and S12's two claims of the same shape are left for the same reason. What this MR does reach is narrower and is its own new text: the two S12 sentences that stated as present-tense fact that nothing writes the column now name the condition and the event, so they read correctly on both sides of Step 3's merge. "Nothing writes it" and "no spec owns it" are different claims, and only the first is falsified by a writer landing. No order is forced by this: !2362 (merged) shares no file with this MR, and the condition-and-event wording is what makes the sentences survive either sequence.- The container remote and npm remote read paths. Neither gets a writer here or in any step of this plan; #292 owns the mechanism decision for both, and S15 and S16 now say so.
docs/dev/storage-accounting.md:1441-1442, which names no owner and so needs no edit. Steps 4 and 5 falsify it and own the fix.- Whether a container manifest payload counts toward
repositories.size_bytes, and which row should carry therepositories.artifacts_countdelta. Correction 8 records both divergences between S12's write rows and what the handlers emit, and leaves the accounting questions themselves to S12: deciding either changes what the counter means on a container repository, which is not this amendment's ownership move. The rows are left as they stand with the divergences named beside them. The unemitted auto-createartifacts_countincrement is a spec-versus-code gap rather than adownloads_countassignment, so no step of this plan closes it. - #975's second item, per correction 4. It is a
repositories_countomission in a statistics bullet, not adownloads_countassignment, so it does not travel with this fact. - The same "no-op stub" claim about
BumpAccessTimestampsandBumpRepoDownloadCountin four merged plan files (docs/plans/2026-05-12-maven-hosted.md:251,2026-07-27-maven-remote.md:981,2026-07-30-container-remote.md:203, and2026-08-10-maven-version-size-accounting.md:151, measured at0f1ba8e40). Correction 6 sweepsdocs/specs/only. A merged plan records what was true when it was written and a step MR does not edit plan files, so these are left as they stand rather than corrected here.
Merge order
Eleven open MRs edit at least one file this MR edits. Seven overlap on the spec files and are the table below; the other four arrived with AGENTS.md, which joined this MR at its last commit, and are under AGENTS.md overlaps. Of those seven, checked at this MR's head commit, none changes a line mentioning downloads_count or S18, so the overlap is file-level rather than a contested fact and no correctness order is forced. Whichever lands second rebases.
| MR | Overlapping file(s) | Its changed lines mentioning downloads_count or S18 |
|---|---|---|
| !2346 (merged) | S16-container-remote.md |
0 |
| !2332 | S11-npm-hosted.md, S12-container-oci-hosted.md, S17-rest-management-api.md |
0 |
| !2331 (merged) | S10-maven-hosted.md |
0 |
| !2328 (merged) | S10-maven-hosted.md, S17-rest-management-api.md |
0 |
| !2307 (merged) | S11-npm-hosted.md, S15-npm-remote.md |
0 |
| !2077 (merged) | S16-container-remote.md |
0 |
| !2055 (merged) | monolith/S05-repository-detail.md |
0 |
!2077 (merged) (rbatnag/s16-step-17-2-amendments, Draft) is the one to watch. It changes 46 lines of S16-container-remote.md (35 insertions, 11 deletions, measured at its head beec45adb), the largest of these overlaps, and of the two MRs touching that file it is the one amending it as documentation rather than as a doc note riding a code fix. This MR owns only the counter-ownership sentences there.
AGENTS.md overlaps
AGENTS.md arrived with this MR's last commit, so these were checked at
ce12234c0 rather than at the head the table above was measured at.
| MR | What it does to AGENTS.md |
Interaction |
|---|---|---|
| !2357 (merged) | Adds two paragraphs in the same gap this MR's new section goes in, between A Documented Distinction Owes a Test on the Case It Rests On and A Catch-All Arm Bounds Every Exclusion Claimed Above It | Direct textual collision. Both are additive and both blocks are kept, so the resolution is mechanical, but whichever lands second rebases. |
| !2020 (merged) | Routes AGENTS.md detail into lazily-loaded modules; its head carries 225 lines against this branch's 646, and none of the prose-convention sections | If it lands first, this section moves into whichever module inherits the conventions. Its base predates every section in that region, so the rebase it already owes has to place them all; this one is not a special case. Not imminent: last updated 2026-08-28, requested_changes. |
| !2346 (merged) | Three lines around the "Naming one file as the home of a list of values" paragraph | No conflict; different region. Already listed above for S16-container-remote.md. |
| !1859 (merged) | Four lines in the Guardrails list | No conflict; different region. |
No correctness order is forced by any of them. This MR's section states a documentation convention and nothing depends on it landing first or last.
Testing
Documentation only, so no tests are added. Run at the head commit against the pinned hook versions:
markdownlint-cli20.23.2: 0 issues across the 9 files.vale3.18.0 at--minAlertLevel=error: 0 errors.lychee --offline --include-fragmentsover the same 9 files: 610 links OK, 0 errors, 157 excluded.
No e2e scenario is added or affected: this MR changes specification text only and no request path, so nothing in docs/testing/ changes.
Related to #1046