docs(plans): add the S22 storage accounting plan

What

Adds the implementation plan for the S22 storage accounting platform, plus its docs/plans/README.md index row. 16 numbered steps across 17 MRs, since Step 2 splits into 2a and 2b. Plan MR only — no implementation.

Targets main. The S22 spec amendment (!1311 (merged)) merged on 2026-08-05, so this plan is written against the spec as it now stands on main and records no deviation from any criterion. Per the project guardrail, this plan lands on main before any step MR opens.

Decisions folded in

  • Format call sites are out of scope, moving to a separate S20-a spec and plan together with #464, #470, and #472 (closed). S22 publishes the emit API and lands with no production caller, so every path is test-driven.
  • The Redis primitives are their own package, internal/accounting/counterbuf, because the Lua scripts need the three-topology matrix while everything else in internal/accounting needs PostgreSQL — one package cannot be enrolled in both.
  • Registration is its own step (Step 9), so the two drain triggers and the reconciliation trigger register through one seam and stay sibling-parallel. The reconciliation_backlog collector is deliberately not on that seam: a prometheus.Collector is not a job kind, so Step 16 keeps its own wire file rather than serializing behind Steps 7, 8, and 9.
  • CI enrollment ships inside steps, including a new test:accounting-failpoints job and an accountingfaults build tag, because test:storage-failpoints runs untagged and service-less and cannot host these tests.
  • The shadow table splits across two migrations in two MRs (Steps 2a and 2b), reconciling the spec's one-transaction requirement with this repo's NO TRANSACTION partition convention. The boundary sits before the triggers and never between them and the seed.
  • drain_chunk_size is bounded at config load against jobs.MaxPayloadBytes, a defensive addition beyond the spec.
  • internal/accounting is added to no-reverse-dependency by Step 5, the step that lands the first file anywhere in the accounting tree. That rule is an enumerated per-prefix allow-list rather than a blanket internal/ rule, so the plan establishes the fence it relies on instead of inheriting it.

Research findings worth a reviewer's attention

  • ADR-007's prose contradicts its own example queries on what the repository-level size_bytes sum joins: prose says blob_storage_blobs, but all three example queries join the namespace shadow table and the section header agrees with the queries. The plan follows the example queries, which is why the size_bytes step depends on the shadow table. Not fixable here — docs/adr/ is synced from the handbook repo — so it went upstream as handbook!20663, which merged on 2026-08-07 and reaches docs/adr/ on the next sync:adrs run. Two follow-ups came out of it: !1358 (merged) corrects the S22 spec's own join target and the sentence scoping the shadow to namespace-level use, and handbook!20699 adds INCLUDE (size) to the shadow's primary key, which Step 2a's DDL is written against.
  • Criterion 19 over-states the OCI manifest cascade: ContainerManifestDeleter never touches container_blobs, and ADR-007 states the manifest-to-layer edge is deliberately not modeled as a foreign key, so reachability from a live manifest is not expressible in SQL. That wording travels to the S20-a spec with the call-site criteria.
  • Build-tagged files are linted by nothing. run.build-tags is deliberately unset, so //go:build integration files are outside the default build and their //nolint directives suppress nothing. Every integration and fault test this plan adds inherits that; reviewers are the only check on them.

Accepted code smells, named in the plan

  • Primitives crossing the emit boundary, chosen so S20-a's swap stays local at each call site rather than forced by Maven's signature, which nothing in the tree freezes.
  • Step 9 spans internal/accounting and the composition root, forced by the registration seam needing the concrete clients. Isolating it is what keeps Step 10 parallel with the Step 12/13/14 branch instead of chained behind a trigger step.
  • RegisterAsynqHandlers lands with an empty body, existing for its call site's timing until Step 15 fills it.

Coverage

All 36 acceptance criteria are owned by a step or explicitly declared out of scope.

  • Criteria 17–21 and 25 verify format call sites and travel to S20-a, with 25's contract half shipping here in Step 6.
  • Criterion 33 splits: this plan owns metric registration; alert wiring lands with #354.
  • No criterion is met by a deviation.

Per-step LOC estimates are in the Implementation Steps table, alongside nine recorded seam-interrogation outcomes: four steps split rather than bundled, and five kept bundled above the ~500 LOC ceiling with the negative outcome stated.

Related to #515

Edited by Pawel Rozlach

Merge request reports

Loading
Loading