docs(plans): claim S22's format call sites and realign the plan with the amended criteria
What
Two things, both consequences of the S22 spec moving after this plan was written.
Claims S22's format call sites for npm, OCI and Maven as three parallel steps. The plan previously said every format-side change moves to S20-a and that criteria 17–21 travel with it; four of those are S22's under the operation split, so they land here — and so does criterion 25, whole.
Realigns the plan with the criteria as amended by !1350 (merged), so that every criterion the spec carries has a step and no step describes work the spec no longer specifies.
The ownership rule is the operation, not the format:
| Emit site | Operation | Owner |
|---|---|---|
npm publish increment, npm unpublish Δartifacts |
unchanged | S22 — Step 17 |
| OCI push increments, OCI client-delete decrements | unchanged | S22 — Step 18 |
| Maven upload increments, both scopes | unchanged | S22 — Step 19 |
Δartifacts at the repository tombstone, Δsize/Δcomponents at the purger |
new | S20-A |
Step 17 — npm
It named only internal/format/npm/unpublish_package.go, which holds the tree's only two repository-counter decrements. unpublish_version.go tombstones a single version and moves no repository counter, so a single-version unpublish under-counts today and reconciliation corrects it. Wiring only the package path would carry that forward under a rule the version path satisfies equally: Δartifacts settles at the tombstone, because that is when the version leaves the user's view.
The version-path emit must sit outside afterVersionCommit, which the handler skips when the cascade also tombstoned the package — exactly when the removed version was the last active one, and the commonest way a package leaves view. Acceptance covers three arms rather than two.
DecrementRepositorySizeBytes is removed rather than swapped: size_bytes settles at the reap, so leaving it would drop the column once at the tombstone and again at the purge.
Step 18 — OCI
Five sites across four files. Both decrements sit at the handler, never inside datastore.ContainerManifestDeleter or datastore.ContainerBlobUnlinker: those are called by the client delete, the artifact purge and the repository purge alike, and each owes a different delta set.
The repository-scoped last detach is format-layer logic. ContainerManifestDeleter drops the payload blob's attachment link only when no sibling manifest anywhere in the namespace references it — the NOT EXISTS guard keys on cm.namespace_id with no repository predicate — which is strictly stronger than the repository's last detach. The handler derives it from the repository's own container_manifests rows instead. Layer blobs are not the manifest delete's to release: ContainerManifestDeleter never touches container_blobs, so they go only at the blob DELETE.
Acceptance covers criterion 19's four sequential cases, its fifth concurrent case — which asserts a bound rather than a winner, since the handler is not required to win that race — the push-then-hard-delete round trip, whose second half is a reconciliation pass that must not restore the deleted bytes, and the caller contract: the emit comes from the handler's own committed affected-row result, asserted on both interleavings of a client delete racing a purge.
Step 19 — Maven
BumpRepoCounters is retired rather than swapped, which criterion 20 requires and the call site explains. The stub runs inside upsertAndCount's RunInTx closure, so an emit there records a delta a rollback can take away; and it is passed deltaArtifacts = 1 per inserted maven_files row while artifacts_count recomputes over maven_versions, so a JAR and a POM for one new version would emit +2 against a reconciled 1. The upload reports out of the transaction and a post-commit site turns that into the deltas. emitRepoCounters survives the retirement, because its second call is MarkRepoLastUpdated, whose stub is not S22's to move.
The namespace-scoped emit is a second site rather than two more arguments — not because the signature is frozen, which nothing in the tree makes it, but because widening a signature does not belong on a step whose job is wiring. Its two deltas differ by condition: Δcomponents fires on every new version row, only Δdedup_size is first-attach-gated.
Criterion 25 is owned here, whole
Its per-site table names seven sites, and every one is a site Steps 17–19 wire, so its committed-before probes and zero-emission cases have no other step to sit in: npm publish and npm unpublish in Step 17, manifest PUT, CompleteUpload, MountBlob and both DELETEs in Step 18, Maven upload in Step 19. The contract half — the source-first ordering obligation stated once — still ships in Step 6.
Maven's row is the one whose committed-before assertion fails against the tree today, since the stub emits from inside RunInTx. Its 409 arm is named separately from the rollback, because a byte-differing release re-upload aborts from inside upsertAndCount and is the arm a post-commit site could most easily emit on by mistake.
Criterion 21 is the only criterion this plan hands over, which is what the spec's closing says: criteria 21 and 37 are the two verified in dependent work, and nothing else is.
The call-site steps are leaves off the drain, not off Step 6
Their acceptance asserts Postgres row values after a drain, so each needs Step 8's chunk worker merged to write it; Step 18 additionally needs Step 14's task, because three of criterion 19's cases assert what a reconciliation pass does after the deltas settle. Recorded in the step table, the graph, and each step's own Depends on line, the way Step 14's row records Step 8. Their tests drive the chunk worker directly rather than waiting for Step 10's trigger.
Steps 17 and 19 are therefore siblings of everything from Step 9 down, and Step 18 a sibling of Step 15 below Step 14.
Step 14 — criterion 38
!1350 (merged) adds a criterion with no home in the plan: a reconciliation task must never reach asynq's archived state. Step 14 builds that handler, so it lands there rather than on a call-site step.
UniqueByArgs derives a deterministic asynq TaskID, and an archived task keeps its hash for archivedExpirationInDays (90, unexported), so a task that spends its retry budget blocks its namespace from re-enqueue for a quarter — and staleness selection, the outer retry loop this design relies on, stops reaching it. The handler returns success on its final attempt with last_reconciled_at unstamped, on asynq.GetRetryCount(ctx) >= asynq.GetMaxRetry(ctx), which is asynq's own archive condition.
The saturation path reaches the same end and is the worse route: a semaphore error is not transient under the foundation's classifier, so it consumes retry budget, and saturation peaks when reconciliation is furthest behind. Criterion 34's policy is unchanged for every attempt but the last. Both routes are asserted.
Criterion 37 is recorded rather than stepped, and covers both halves
Its operation does not exist yet and #313 (closed) has no milestone, so a numbered step nobody can start would misreport this plan's parallelism. It is recorded after Step 19 with a step's detail, and ## Dependencies says the same from its side.
The section now mirrors the spec's dispatch table across both halves — the targets #313 (closed) tombstones and the ones it hard-deletes in the request — because the container-manifest target reuses the deleter Step 18 deliberately keeps emit-free, so nothing Step 18 lands reaches it. It also states that the row count comes out of the tombstone transaction rather than a post-commit recount: closed beta's retention window is zero, so a recount can find the rows already reaped, emit Δartifacts = 0, and lose the decrement for good, with reconciliation then confirming the un-decremented value. That interleaving is asserted explicitly.
Step 11's forward-note
The plan's Step 11 realignment landed with !1350 (merged), so this branch no longer carries a note about the OCI branch gaining a parent predicate only once #313 (closed) lands. What it keeps from its own round is the RepositoryStore.List hazard's framing: it breaks in Closed Beta, not at GA, because S20-A's destructive repository DELETE writes repositories.soft_deleted_at and the purge can run for as long as the repository's contents take. The earlier GA-only framing rested on nothing carrying soft_deleted_at in CB, which was never true and which the plan's own criterion-9 paragraph contradicts.
Coverage
Every acceptance criterion from 1 to 38 has a step, with two exceptions, both matching the spec's closing: 21 travels to S20-a, and 37 has the deferred section above. Criterion 9's rows are all S22's own, per the spec's rule that a criterion travels only when it waits on an operation its owner alone can write, never on a column.
Counts
Nineteen numbered steps across twenty MRs, since Step 2 splits into 2a and 2b. The ## Status table's row count is the MR one, and it now has rows for Steps 17–19 for their MRs to fill at open. Verified against the step entries: 8 feat, 12 chore, 20 rows.
Rebase and current state
Rebased onto main now that !1350 (merged) has merged, so the previous round's "merge !1350 (merged) first" ordering no longer applies. The rebase conflicted on Step 11: main's parent-predicate bullet and acceptance line are kept, and this branch's corrected CB-vs-GA framing for the List-reuse hazard.
Two commits, two files.
Testing
Documentation only. markdownlint-cli2, vale and lychee pass.
Related to #515