docs(specs): wire S22's emit sites by operation, not by deleter

What

Assigns every S22 emit site exactly one owner, and corrects the three sites whose specified wiring does not match the code they wire into.

S20-A (docs/specs/S20-a-lifecycle-closed-beta.md) settles which delete paths change and which do not, which is what makes the assignment possible. The rule: S22 wires every site on an operation S20-A does not change; S20-A wires the two it adds — the destructive repository DELETE and the purge.

Ownership

Emit site Operation Owner
npm publish increment unchanged S22 (17)
npm unpublish Δartifacts unchanged S22 (17) — was unowned
OCI push increments unchanged S22 (18)
OCI manifest-delete decrements unchanged S22 (19)
OCI blob-delete Δsize unchanged S22 (19)
OCI tag delete unchanged nothing — was assigned decrements
Maven upload increments, both scopes unchanged S22 (20)
#313 (closed) management tombstones new S22 defines, #313 (closed) calls (37)
#313 (closed) synchronous hard deletes new S22 defines, #313 (closed) calls (37) — was unowned
Δartifacts at the repository tombstone new S20-A
Δsize/Δcomponents at the purger new S20-A

Three corrections with implementation impact

Maven's stub cannot be swapped in place (20). BumpRepoCounters is called inside upsertAndCount's RunInTx closure and is passed deltaArtifacts = 1 per inserted maven_files row. artifacts_count recomputes over maven_versions, so a JAR and a POM for one new version emit +2 against a reconciled 1; and an in-transaction emit records a delta a rollback can still take away. The stub is retired for a post-commit site.

OCI's three client deletes remove different rows (19, counter model). DeleteManifestByTag deletes only container_tags; a blob DELETE removes repository blob membership. Neither removes a container_manifests row, so neither can decrement artifacts_count or components_count. The counter model now carries a per-operation table.

#313 (closed) hard-deletes as well as tombstones (37). Its bounded targets — container manifests and tags, Maven and npm files, npm dist-tags — run the S12 and S11 deleters directly, not the protocol handlers criterion 19 puts OCI's emit on. A management manifest delete would otherwise have no owner for any of its three deltas.

Two contracts added

Emit from what your own transaction changed. Per-operation ownership says which caller should count a row; it does not stop a client delete and a purge racing for it. Each caller emits from its own committed affected-row result, so the loser contributes nothing. Binds S20-A's purger to report rows removed, not rows found.

Criterion 37's row count comes out of the tombstone transaction. Closed beta's retention window is zero, so the purger can reap on the sweep right after the commit; a post-commit recount then emits zero, and reconciliation confirms that rather than correcting it.

Also

Source-first ordering is now asserted per site rather than for a named subset — the old list omitted Maven, which is the one site that currently violates it. Criterion 10's positive-hit coverage extends to kind = remote. The repository-scoped last-detach race is stated and accepted on the same reasoning as the first-attach race it mirrors, rather than closed with a lock. The soft-delete predicate is described by tombstone location instead of by format, since closed beta gave every format a tombstone.

Review round

Nineteen threads, all resolved. Eight changed the spec's substance rather than its wording.

The rule the emit sites follow, stated once. Every emit-site question this MR was asked — which operation owns a delta, whether an untag emits, where npm's footprint decrements fire — has one answer the document never wrote down: the reconciliation recompute defines each counter and the buffered pipeline caches it, so a site is legitimate exactly where the operation changes what its recompute returns. Both existing contracts follow from it rather than standing beside it. At the head of The counter model, where it governs the emit tables and the attribution section alike.

A failing reconciliation task no longer locks its namespace out for 90 days. UniqueByArgs derives a deterministic asynq TaskID, and an archived task keeps its hash for archivedExpirationInDays (90) — both unexported constants — so a task that spends its retry budget blocks re-enqueue for a quarter. Criterion 34's saturation path reaches the same end and is worse, since a saturation error is not transient under the foundation's classifier and saturation peaks when reconciliation is furthest behind. One contract closes both: the handler never reaches archived, returning success on its final attempt with last_reconciled_at unstamped. New criterion 38, appended rather than inserted.

The correction bound is conditional, and says so. The ~2× worst-case correction latency was asserted unconditionally while Follow-ups deferred confirming what makes it true. It now names its precondition — reconciliation draining a stale generation within one interval — and its only symptom, reconciliation_backlog failing to trend toward zero. No capacity model: that stays deferred to measurement, now as a stated decision.

Billing gets both halves of what it needs from this side. Liveness (the alerts gate consumption, not delivery) and freshness (last_reconciled_at qualifies reads, with the bound left to policy). The prediction that a remote-cache exclusion would be "a filter S24 applies to these columns" is withdrawn — deduplicated_size_bytes deduplicates namespace-wide, so the aggregate does not preserve per-blob origin. Closed beta bills cached footprint like any other.

Acceptance separates S22's own set from its integration contracts. Criteria 37 and 21 are verified in the dependent work that ships the call, each naming its MR. Numbering unchanged, deliberately — the plan MRs reference these by number.

Two forward claims narrowed to what holds. S18 reuses this pipeline for downloads_count, which is additive; last_downloaded_at merges max-wins and is not claimed. BumpRepoCounters is not frozen — the S20-A side of that lands in !1392 (merged).

The withdrawn remote-cache prediction survived in a second copy. Fairness / billing scope withdrew the "a filter S24 applies to these columns" prediction; the billing follow-up bullet still asserted it verbatim — unchanged main text that appears in the diff only as context, which is how it escaped the commit that made the withdrawal. It now stops at "the columns are built the same either way" and points at the section for what an exclusion would have to work with: the aggregate does not preserve per-blob origin, so the shape of any exclusion is S24's to settle.

#313 (closed) gates neither of criterion 9's disputed rows, and the plan follows. Criterion 9's Maven row had been moved to the integration contracts and its OCI row given a merge order, both on the reading that #313 (closed) delivers what they need. The criterion asserts the recompute's soft-delete predicate, and a test seeds soft_deleted_at directly, so a row needs the column to exist and never a production writer — which is all the Maven deferral rested on. container_images.soft_deleted_at is the one column genuinely absent from main, and it is a nullable timestamp on a table S22 already reconciles, so whichever of #313 (closed) and S22's recompute step lands first carries the migration and the other drops it. Both rows return to S22's own set and the integration contracts drop to two. The closing paragraph now states the rule the membership comes off — a criterion waits on an operation only its owner can write, never on a column — rather than re-ranking the two rows each round, which is how they moved twice. Step 11 loses the merge order from its Depends on line and keeps the half that binds: the container branches must not ship without the parent predicate, whoever added the column. Its test list now seeds a live and a tombstoned row on every positive hit rather than the two container cells — criterion 9 asks for that per format and criterion 10 on both remote tables, which between them is all six — so a dropped soft_deleted_at IS NULL no longer ships green on the hosted cells. The merged plan's Step 11 realignment that this MR already carried is unaffected; no S22 step MR is recorded in the plan's Status table, so nothing was built on any of it.

Three threads were answered by citation rather than change: the shadow table and the drain pipeline against ADR-007's measured comparison and its buffered-writes decision, and management-API authorization against the #313 (closed) boundary. Two were declined — a sharding threshold before production traffic, and thematic grouping of the criteria, which cannot preserve identifiers under MD029 style: "one".

Testing

Documentation only. markdownlint-cli2, vale and lychee pass.

The S22 plan joins the spec in this change set, because Step 11's artifacts_count recompute specified the container predicate the spec has since moved to the parent column. The title keeps its docs(specs): prefix — the spec change is the one that forced the other — which means the squash commit carries docs(specs): over a diff that also touches docs/plans/**.

Rebased onto main after S20-A (!1331 (merged)) and the S22 plan (!1296 (merged)) merged; no conflicts, since neither touched this file. S20-A's dependency row and the S20 follow-up lose their conditional hedging with that merge, and link by file path. S17 Phase 4 keeps its hedging and its MR reference: !1269 (merged) is still open.

The five places where S20-A's own text disagrees with this spec are corrected separately in !1392 (merged), not here.

Related to #533 (closed)

Edited by Pawel Rozlach

Merge request reports

Loading
Loading