carlos / platform Public

A refused record with an unnamed bucket holds the name the deployment mints for it; the seventh review's small items

The sixth reading's fix resolved a bucketless grant only in a record a
walk could act on. In a record the reader refuses (a teardown note about
another bucket, or one that says deleted and not what) or under a name
that is not an app's, it added nothing to the holder inventory, and
another account's store on the minted bucket was sized as its only
holder (seventh review: Astra P1, Opus P3-1). A refused record now holds
the bucket it holds without naming as well as those it names; under a
name no rule mints for it is unresolved, which holds every store.

Three tests that assert holds are pinned to mid-day, where a failed
reading fails the pass and cannot stand in for the hold. Comments and
19i no longer say an older build wrote such grant blocks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ade000f1d232fc2fad28c1935aefd32c0178a400 parent 4ed6b71
6 files changed, +272 −57
  • docs/superpowers/specs/CODEX-FINDINGS-storage-sole-holder-review7.md +35 −0
  • docs/superpowers/specs/DISPOSITIONS-storage-sole-holder.md +17 −0
  • docs/superpowers/specs/OPUS-REVIEW-storage-sole-holder-review7.md +83 −0
  • implementation/carloku-paid-launch/OUTSTANDING.md +1 −1
  • internal/console/economicspass_test.go +98 −38
  • internal/objstore/walk.go +38 −18

CI passed — run details

diff --git a/docs/superpowers/specs/CODEX-FINDINGS-storage-sole-holder-review7.md b/docs/superpowers/specs/CODEX-FINDINGS-storage-sole-holder-review7.md
new file mode 100644
index 000000000..9d6b7a2fa
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-storage-sole-holder-review7.md
@@ -0,0 +1,35 @@
+1. **P1 — Refused bucketless grants still bypass the inventory fallback.**
+ `internal/objstore/walk.go:1782–1793` at `4ed6b71e`.
+
+ Consider `bcd/old` with a legacy bucketless grant, no provisioned block, and a teardown note naming another bucket, `Y`. Its earlier grant may have delivered credentials for minted bucket `X`. Another account’s provisioned store names `X`; its ledger contains no interval for `bcd/old`.
+
+ `Coherent` refuses the record because the teardown note disagrees with its unnamed estate. The inventory adds only `Y`, then executes `continue`. It never resolves the grant to `X` or adds it to `Unresolved`. The other account passes both generation checks and `SameStore`, and receives all of `X`’s bytes. Holding `bcd/old` during its own visit does not protect that other account.
+
+ A bucketless grant with `BucketDeletedAt` set but no `DeletedBucket` takes the same exit and contributes **no ownership evidence at all**.
+
+ **Smallest acceptable fix:** apply bucketless-estate resolution before leaving the refused-record branch. Preserve explicitly named bucket candidates; also count the minted candidate when applicable, or mark the record unresolved when it cannot be resolved. Add regressions combining a bucketless grant with each refused teardown shape and another account’s provisioned store on the minted bucket.
+
+ **Sixth-reading P1:** closed for the original coherent grant-only shape, **not closed for every required shape**. Coherent provisioned, explicitly granted and adopted records use the expected bucket; coherent bucketless grants use `MintedBucket`; retained teardowns still count; coherent records documenting their own bucket’s deletion are excluded. Refused bucketless records remain the gap.
+
+2. **P3 — The during-sizing race subtest still depends on the wall-clock fallback.**
+ `internal/console/economicspass_test.go:1923–1955`.
+
+ `"a holder appears while the bucket is being sized"` calls `storagePass` directly, bypassing `run`, where the new `storageMidDay` call lives. It therefore normally runs in last-chance mode for its historical date.
+
+ For example, a regression that returns a transient ledger-read error after the visitor’s interval closes would satisfy this subtest’s hold assertion without establishing that the generation comparison detected the visitor. Before 23:00 on the fixture date, that same error would instead fail the pass.
+
+ **Smallest acceptable fix:** pin this subtest to midday directly and assert that its sizing hook ran. The before-turn race test handles traversal order explicitly, and the new first-ledger-GET-only failure case now distinguishes `LedgerUnread` from a later read failure.
+
+Beyond P1, I found no additional attribution path or incorrectly scoped hold within the stated review boundaries. Outside the storage pass, `Get`/`Inspect` change the decode-error wrapper and text; both the sentinel and underlying error remain discoverable through `errors.Is`/`errors.As`. Existing callers retain their error-handling decisions. `Grant`’s take-back decision remains equivalent: same-account holders are ignored, departed coherent holders are filtered, and refused records and foreign open intervals still block.
+
+The day object remains version 1 with unchanged fields. `StoreUnswept` now includes ownership uncertainty, which the existing statement completeness calculation already handles per account. Statements and the economics view retain the same byte fold. Previous-build and new-build days decode together correctly; this does not retroactively correct previous shared-bucket measurements.
+
+**Claims the code contradicts**
+
+- `docs/superpowers/specs/DISPOSITIONS-storage-sole-holder.md:87` and `implementation/carloku-paid-launch/OUTSTANDING.md:68` overstate bucketless-grant coverage. Resolution and unresolved classification are bypassed by the refused-record branch described in P1.
+- `internal/objstore/walk.go:1708–1713` similarly promises minted-name resolution or `Unresolved` without stating that exception.
+- `docs/superpowers/specs/DISPOSITIONS-storage-sole-holder.md:89` says the sole-holder cases pin their clocks; the during-sizing subtest still does not.
+
+These conclusions come from source inspection only; no tests, mutations or backend checks were run.
+
+**Verdict: reject.**
\ No newline at end of file
diff --git a/docs/superpowers/specs/DISPOSITIONS-storage-sole-holder.md b/docs/superpowers/specs/DISPOSITIONS-storage-sole-holder.md
index 407a57a1f..282132b0c 100644
--- a/docs/superpowers/specs/DISPOSITIONS-storage-sole-holder.md
+++ b/docs/superpowers/specs/DISPOSITIONS-storage-sole-holder.md
@@ -94,3 +94,20 @@ Reports: `CODEX-FINDINGS-storage-sole-holder-review6.md`, `OPUS-REVIEW-storage-s
Mutation checks in a scratch copy, each failing its test: the unnamed grant not counted; the unread-baseline guard off.
Six readings. The whole-diff reading found one more P1, fixed in the commit that adds this section and not read by anyone. This branch is not accepted, and it is not merged.
+
+## Review seven (the whole diff `c68ecddd..4ed6b71e`; Astra rejected, Opus accepted with fixes)
+
+Reports: `CODEX-FINDINGS-storage-sole-holder-review7.md`, `OPUS-REVIEW-storage-sole-holder-review7.md`. Both read the whole change again. Both found the sixth reading's P1 closed for a record a walk could act on, and both found the same gap beside it, which Astra rated P1 and Opus P3: the fix did not reach a record no walk may act on. Both again found no path that records bytes against a store with another holder or holds the wrong account, no behaviour change for callers outside the storage pass, and the day object unchanged.
+
+| Finding | Disposition | Where | Reproduction |
+| --- | --- | --- | --- |
+| Astra P1, Opus P3-1: a grant block that names no bucket, in a record the reader refuses (a teardown note about another bucket, or one that says deleted and not what) or under a name that is not an account's or an app's, added nothing to the inventory. Another account's store on the name the deployment mints for it was sized as its only holder. | Fixed. A refused record now holds the bucket it holds without naming, as well as every bucket it names: its teardown note is not believed against its own grant. Under a name no rule mints for, the record is unresolved, which holds every store. | `objstore/walk.go` `ReadHolderInventory` (`unnamed`, `addUnnamed`) | `TestHolderInventoryRules` (the two refused shapes and the invalid name), `TestStoragePassCountsAGrantThatNamesNoBucket` (three shapes, each with another account's store provisioned on the minted bucket) |
+| Astra P3: the subtest for a holder that appears during the sizing ran in last-hour mode, so a failed reading would also have produced its hold | Fixed. It is pinned to mid-day, where a failed reading fails the pass, and it asserts the sizing ran once. | `economicspass_test.go` | `TestStoragePassSizesOnlyASoleHolderOfItsBucket/a holder appears while the bucket is being sized` |
+| Opus P3-3: the two tests for a size taken across a teardown and across a replacement of the store ran in last-hour mode | Fixed. Both are pinned to mid-day. | `economicspass_test.go` | the two tests named |
+| Opus P3-2: during a rolling deploy, a console on the previous build can grant an adopted bucket to another account without touching a ledger, and a pass on this build then sizes the first store as its only holder | 19i says so. No code: the previous build keeps no ledger, so this build cannot see its grants. | `OUTSTANDING.md` | none |
+| Opus, claims: the comments and 19i said an older build wrote grant blocks that name no bucket. Every grant walk since the first (`675e3eac`) records the bucket before anything else. | Corrected. Such a record is a hand edit as far as anyone knows; it is counted all the same. | `walk.go`, `economicspass_test.go`, `OUTSTANDING.md` | none |
+| Both, claims: the sixth reading's row said each sole-holder case pins its clock | It was true of the tests the sixth reading named and not of the three above. They are pinned now. | this file | none |
+
+Mutation checks in a scratch copy, each failing its test: a refused record's unnamed bucket not counted (the inventory test and both refused shapes of the storage pass test); a name minted for a name that is not an app's; the generation comparison before the sizing off; the comparison after the sizing off; a failed reading put in place of the comparison's hold (the during-sizing subtest now fails, where in last-hour mode it would have passed).
+
+Seven readings. Each of the last two whole-diff readings found one more shape of the same fault, a holder the inventory left out. This one is fixed in the commit that adds this section and has not been read by anyone. This branch is not accepted, and it is not merged.
diff --git a/docs/superpowers/specs/OPUS-REVIEW-storage-sole-holder-review7.md b/docs/superpowers/specs/OPUS-REVIEW-storage-sole-holder-review7.md
new file mode 100644
index 000000000..e67d3f20c
--- /dev/null
+++ b/docs/superpowers/specs/OPUS-REVIEW-storage-sole-holder-review7.md
@@ -0,0 +1,83 @@
+# Opus review 7: `feat/carloku-storage-sole-holder-20261001` at `4ed6b71e` (whole change against `c68ecddd`)
+
+## 0. Is the sixth reading's P1 closed?
+
+**Yes, for every shape that a record which can be acted on can take.** `ReadHolderInventory` (`internal/objstore/walk.go:1714`) picks the bucket in the same order as `bucketFor` (`walk.go:201`):
+
+| Shape | `bucketFor` | Inventory |
+|---|---|---|
+| Provisioned | `Provisioned.Bucket` | `Provisioned.Bucket` (first case) |
+| Granted with a bucket | `Grant.Bucket` | `Grant.Bucket` |
+| Granted without one, or provisioned with an empty name | `MintedBucket(d, …)` | `minted(a, name)`, which is the same function with the same `StoreDeployment` |
+| Adopted | the adopt name, written into `Grant.Bucket` (`walk.go:317`) and then provisioned | by `Grant.Bucket` or `Provisioned.Bucket` |
+| Torn down in part, no deletion noted | its own bucket | counted under its own bucket |
+| Torn down, own bucket noted as deleted | — | correctly holds nothing |
+| Incoherent, or under an invalid name | not walkable | every bucket it names, marked `refused` |
+
+`MintedBucket` never returns `""`, so the pass never reaches `Unresolved`. That branch is reachable only when `minted` is nil, i.e. in tests.
+
+One shape is still not in the inventory: a record that is incoherent or sits under an invalid name, has a grant block, and names no bucket anywhere. It is dropped silently: it is not added to `byBucket`, `Unresolved` or `Unparsed`. That is P3-1 below.
+
+## Findings
+
+**P3-1: the bucketless-grant fix covers only coherent records under valid names.**
+- **Where:** `walk.go:1783–1795`.
+- **Scenario:** two records are dropped from the inventory. The minted bucket `carlos-bcd-old` was given to another store as an adopted bucket, and that store is sized as its only holder.
+ - `apps/bcd/old/store.json` has `Grant{Bucket:""}` and `Teardown{BucketDeletedAt:T, DeletedBucket:""}`. `Coherent` refuses it with `ErrUnfinishedTeardown`, so the incoherent branch runs, and it adds nothing.
+ - The same grant block under an invalid app name is dropped the same way.
+- **Severity:** no build has ever written either shape. Every `GrantBlock{…}` on main, from `675e3eac` onwards, and on this branch sets `Bucket: bucket`. Only a hand edit makes one, which is why this is P3. It is still the same gap as the sixth reading's P1, in the neighbouring branch.
+- **Smallest fix:** in the incoherent or invalid-name branch, when `rec.Grant != nil || rec.Provisioned != nil` and neither names a bucket, apply the same minted-or-`Unresolved` rule. For an invalid name, prefer `Unresolved`.
+
+**P3-2: during a rolling deploy, a grant from a console still on the old build is invisible to the generation check.**
+- **Where:** `economicspass.go`, `sizeSoleHolder`.
+- **Scenario:**
+ 1. The new console's pass reads the inventory, with ledger generation `""` for bucket X, because main keeps no ledgers.
+ 2. A main-build console runs an operator adopt grant of X for account B. That writes B's record and delivers a credential, and touches no ledger.
+ 3. The new console sizes A's store on X. The generation is still `""`, B is not in the inventory, and A is charged for all of X.
+- 19i covers days written by the old build. It does not cover an old-build grant racing a new-build pass.
+- **Smallest fix:** one sentence in 19i, beside the mixed-fleet sentence: storage days taken while any console runs the previous build are not billable for adopted buckets. No code.
+
+**P3-3: two tests that assert holds still run in last-hour mode.**
+- **Where:** `economicspass_test.go:1639` (`TestStoragePassDiscardsASizeTakenAcrossATeardown`) and `:1682` (`…AcrossAReplacementOfTheStore`).
+- Neither pins `storageClock`, and the date `2026-08-12` is in the past, so `storageLastChance` is always true. They are not flaky.
+- **Scenario:** a regression turns the re-read after sizing into a transient error. Mid-day it would fail the pass, but in this mode it becomes a hold, so both tests still pass.
+- They do fail without the `SameStore` check, so they do test it.
+- **Fix:** add `storageMidDay(t, "2026-08-12")` to each.
+
+## 1. Bytes recorded against a possibly shared store, or a hold on the wrong account
+
+No path found.
+- **Ordering:** ledgers are read before records. Any grant after the ledger listing moves the generation, which is compared before sizing and after it. A grant before the listing leaves either an open interval, which `OpenOnLedger` sees, or a record, which the inventory sees. Both are true while its grant block stands.
+- **Same generation, different contents:** this cannot happen. `appendOnly` (`ownership.go:262`) never removes an interval, so a ledger's bytes never repeat. That matters because an S3 ETag is the MD5 of the body.
+- **Where holds land:** the fold (`economicspass.go:930`) holds the account in the day key, which is the record's own account. A hold on every store happens only for an unparsed record, an unresolved record, or an inventory that failed in the last hour. Each of those really is uncertain about any bucket.
+- **Ledger read failure:** an unread ledger holds only the stores on that bucket.
+
+## 2. Behaviour for callers outside the storage pass
+
+No change.
+- **`ErrRecordMalformed`:** it is an extra `%w` in front of the decode error. The decoder error is still wrapped, and the text keeps the key. No caller or test matches `json.SyntaxError` or the old message text.
+- **`OtherHolders` and Grant's take-back:** the result is the same as before.
+ - The new ledger read is guarded by `ValidBucketName`, which is always true at that point because `grant` validated the name at `walk.go:222`.
+ - Intervals are now filtered on account and app, not account alone. The caller still skips `h.Account == acct`, so nothing changes.
+ - The `left()` filter is the same, and duplicate entries do not change the first match.
+ - The error text is the same: `objstore: list the stores holding … / read ownership of …`.
+- **Other exports:** `OpenOnLedger`, `ErrLedgerMalformed`, `HolderInventory` and `SameStore` have no callers outside the pass.
+
+## 3. The day object
+
+The shape is unchanged. `usage/storage.go` changes only a comment. A held store is written as `StoreProvisioned:true, StoreUnswept:true`, which is the shape the teardown hold already wrote, and the fold and the economics view already exclude it for that account. A day written by the previous build decodes the same way. On a shared bucket its bytes may be double-counted and it reads as complete. 19i already says so.
+
+## 4. Tests
+
+- See P3-3.
+- Every other storage-pass test either pins the clock or does not depend on it.
+- The new `TestStoragePassCountsAGrantThatNamesNoBucket` and the `TestHolderInventoryRules` additions would fail without the `minted` branch. The dispositions record this as a mutation check.
+- No test depends on the order of map iteration. The subtest maps build a fresh fixture for each entry.
+
+## Claims the code contradicts
+
+- **`walk.go` `ReadHolderInventory` doc (about line 1707), the test comment at `economicspass_test.go:2526`, and 19i** ("An older grant block that names no bucket counts…"). They say "an older grant block names no bucket (the name was derived each time)" and describe a grant that died after delivering a credential. History says otherwise: every grant walk since `675e3eac` writes `Bucket` into the block before it does anything else. Such a record can only come from a hand edit. The defence is fine. The claim that an earlier build wrote this shape should go, and the dispositions' account of the sixth reading's P1 should say the shape is hand-made only.
+- **DISPOSITIONS, review six** ("Each pins the clock"). This is true for the tests the sixth reading named, but the two `Discards…` tests in P3-3 still run in last-hour mode.
+- **19i** ("which is fixed since and unread"). After this reading it is read and closed for every shape a build can write.
+
+**Verdict: accept with fixes.** The three findings are P3, and none blocks the merge.
diff --git a/implementation/carloku-paid-launch/OUTSTANDING.md b/implementation/carloku-paid-launch/OUTSTANDING.md
index 6397ba494..2a925ce08 100644
--- a/implementation/carloku-paid-launch/OUTSTANDING.md
+++ b/implementation/carloku-paid-launch/OUTSTANDING.md
@@ -65,7 +65,7 @@ Each is concrete. None is waived.
19. **No review of the whole branch has passed.** Astra rejected rounds one to sixteen; Opus accepted each with fixes. Round seventeen read only the diff since round sixteen: Opus accepted, Astra accepted with fixes (one P2, fixed since and unconfirmed). That is not a pass of the branch: the last whole-branch reading was round sixteen's, and it was rejected. Every round has closed the shapes the round before reported and found neighbouring ones, in the store walks since round seven. Some concern records no walk of this build writes; others need only this build's ordinary walks (a failed rotation, a retained teardown, a scanning door racing a teardown), and two were faults in the previous round's own fixes. Round eleven found one P1 and two P2 in the store walks (a grant could move an older record to another bucket name; a waiting rotation ignored a grant that ran; the user and its policy were written before the grant was counted). Its fixes are on the launch branch (`1395f7bf`). Round twelve read them: every round-eleven finding is closed for its reported shape, Astra rejected again (one P1, two P2) and Opus accepted with fixes (one P2, three P3). The new findings were about records no walk of this build writes (a teardown note for another bucket, a record naming two buckets) being acted on by teardown, rotation and the scanning doors; a rotation started after a grant that failed at its last write; and a take-back that returned access to a bucket another account holds, through the member's own door. Round thirteen read those fixes: closed for their reported shapes, Astra rejected again (two P1, four P2) and Opus accepted with fixes (one P2, five P3). The new findings were paths that read such a record and acted without the check (the holder inventory, the storage pass, the declare door), a rotation's own failed last write, and a retained record blocking another account's take-back. The check now sits in the record's reader and writer. Round fourteen read those: closed for their reported shapes; Astra rejected (two P1, two P2), Opus accepted with fixes (two P2, two P3). Round fifteen read those: all closed; Astra rejected (one P1, one P2), Opus accepted with fixes (one P2, two P3), all about readers of a bucket that a retained teardown left to another account (a sweep in flight, the scan status, the storage pass). Round sixteen read those: all closed, and one of them had made a single store under teardown hold storage for every account. Astra rejected (one P1, two P2), Opus accepted with fixes (one P1, one P2, two P3). The storage hold is now per account; two findings are left open as 19f and 19i. Those fixes are on the launch branch and unconfirmed until the next round reads them. Each round's fixes are on the launch branch, gated locally, and unconfirmed until the next round reads them.
19g. **Refusals whose doors say the wrong thing, and records only a person can repair.** A record that names two buckets (`ErrTwoBuckets`) or whose teardown note is about another bucket (`ErrUnfinishedTeardown`) is refused by the record's one reader and one writer, so nothing acts on it and nothing writes it; the settings page shows such a store as unreadable. No walk of this build writes either; whether an earlier build left such records in production has not been checked. There is no operator verb to repair one. The doors that meet these refusals answer with sentences written for other failures: the grant and provision doors say to run the grant again, the destroy door and the purge say to try again, the scanning doors say to try again or to re-run the grant. The same goes for a rotation refused because a grant must be run again first (`ErrGrantUnfinished`) and for a member's take-back refused because another account holds the bucket (`ErrBucketHeld`). Correct copy for each needs approval (A).
-19i. **A live bucket shared by two stores was sized in full for each of them.** Fixed on this branch (`feat/carloku-storage-sole-holder-20261001`), not on the launch branch: the daily storage pass sizes a store only when it is the sole holder of its bucket, by one inventory of the store records per pass and the bucket's ownership ledger, read before and after the sizing. Otherwise the store is held for its own account. Holds that last until a person acts: a store that left the bucket keeps its record, and holds the remaining store, until its retained teardown is finished, which nothing runs (19a); a record no walk can follow, until it is repaired by hand (19g); another store's grant that died after writing its grant block, until it is run again and the sharing it then creates is itself resolved, or, for a grant that was refused and whose undo write failed, until the block is removed by hand; a record under an account or app name no build writes, which no walk can touch, until it is removed by hand; an open ledger interval of a store whose record is gone, until the ledger is reconciled by hand; two apps of one account on one bucket, always; any store record that will not parse holds every store. Whose bytes a departed store's leftover objects are needs Paul's ruling (approval K). Not covered: one bucket adopted in two deployments is invisible to each and is sized in full in both. The walks' own holder scan still skips records under names no build writes. A store on a bucket name this build keeps no ledger for is held, for good: its ownership evidence cannot be checked, and nothing short of moving the store to a valid name changes that. A ledger that will not parse holds its bucket's stores until it is repaired by hand. A grant block written after the inventory's walk, by a grant whose ledger write then failed, is not seen; that store has no credential, so the bytes sized are the bucket's own store's. A reading the store fails to return (a record, a ledger, a bucket listing) fails the pass for a retry until the day's last hour (23:00 UTC), when the day is written with the affected stores held; a pass that keeps failing repeats every listing and sizing each hour until then, and a reading that never succeeds (a bucket deleted out of band, a ledger the console may not read) does that every day. Open: the pass runs hourly, so the last hour has about one tick, and a failure of the listings it starts with, of the day's own write, or a pass that runs past that tick, still leaves the day unwritten, which holds storage for every account; it needs a short retry timer after 23:00. An older grant block that names no bucket counts as holding the name the deployment mints for it. Storage days written before every console runs this build are not billable for any store on a shared bucket: the previous build sized such a bucket in full for each, and those days read as complete. Reviewed six times (`DISPOSITIONS-storage-sole-holder.md`): the fifth reading, of a diff, was accepted with fixes by both reviewers; the sixth, of the whole change, was accepted with fixes by Opus and rejected by Astra on one P1, which is fixed since and unread.
+19i. **A live bucket shared by two stores was sized in full for each of them.** Fixed on this branch (`feat/carloku-storage-sole-holder-20261001`), not on the launch branch: the daily storage pass sizes a store only when it is the sole holder of its bucket, by one inventory of the store records per pass and the bucket's ownership ledger, read before and after the sizing. Otherwise the store is held for its own account. Holds that last until a person acts: a store that left the bucket keeps its record, and holds the remaining store, until its retained teardown is finished, which nothing runs (19a); a record no walk can follow, until it is repaired by hand (19g); another store's grant that died after writing its grant block, until it is run again and the sharing it then creates is itself resolved, or, for a grant that was refused and whose undo write failed, until the block is removed by hand; a record under an account or app name no build writes, which no walk can touch, until it is removed by hand; an open ledger interval of a store whose record is gone, until the ledger is reconciled by hand; two apps of one account on one bucket, always; any store record that will not parse holds every store. Whose bytes a departed store's leftover objects are needs Paul's ruling (approval K). Not covered: one bucket adopted in two deployments is invisible to each and is sized in full in both. The walks' own holder scan still skips records under names no build writes. A store on a bucket name this build keeps no ledger for is held, for good: its ownership evidence cannot be checked, and nothing short of moving the store to a valid name changes that. A ledger that will not parse holds its bucket's stores until it is repaired by hand. A grant block written after the inventory's walk, by a grant whose ledger write then failed, is not seen; that store has no credential, so the bytes sized are the bucket's own store's. A reading the store fails to return (a record, a ledger, a bucket listing) fails the pass for a retry until the day's last hour (23:00 UTC), when the day is written with the affected stores held; a pass that keeps failing repeats every listing and sizing each hour until then, and a reading that never succeeds (a bucket deleted out of band, a ledger the console may not read) does that every day. Open: the pass runs hourly, so the last hour has about one tick, and a failure of the listings it starts with, of the day's own write, or a pass that runs past that tick, still leaves the day unwritten, which holds storage for every account; it needs a short retry timer after 23:00. A grant block that names no bucket counts as holding the name the deployment mints for it, in a record a walk could act on and in one it refuses; no build is known to have written one, so it is a hand edit. Under a name that is not an account's or an app's it cannot be resolved, and holds every store. Storage days written before every console runs this build are not billable for any store on a shared bucket: the previous build sized such a bucket in full for each, and those days read as complete. The same goes for days taken while any console still runs the previous build: it grants an adopted bucket without touching a ledger, so a pass on this build cannot see that grant. Reviewed seven times (`DISPOSITIONS-storage-sole-holder.md`): the fifth reading, of a diff, was accepted with fixes by both reviewers; the sixth and seventh, each of the whole change, were accepted with fixes by Opus and rejected by Astra on one P1 each time, both a holder the inventory left out. The seventh's is fixed since and unread.
19h. **A rotation whose delivery fails is repaired by a grant, which swaps the app's key with no overlap.** A failed delivery may still land, so the record stays marked, the next rotation is refused, and the grant run again replaces whichever key the app holds. Hosts hold a dead key until they adopt the new config. Before this branch such a rotation was resumed by running it again, which kept the overlap when the delivery had not landed and broke the app when it had. A repair that waits out the signing window and then reads which key was delivered would keep the overlap where the window is enforced (observed on AWS only, 19d); it is not built.
19f. **A store that leaves a bucket with an unsupported name does not hand on its scanner debt.** There is no ledger for such a name. The last holder would delete the bucket and leave a scanning plan behind. Today's scanner (ClamAV) has no plan to leave, so this is latent. Also latent for the same reason: the scan grant door checks that no teardown has begun and then enables scanning as two steps, so a retained teardown landing between them would leave scanning enabled on the other account's bucket with nothing recording that it is owed. And the scan queue's drain checks permission once per batch, so a drain admitted before a retained teardown could return the other account's object metadata; nothing calls the drain or enqueues today, and it must be fixed before the queue is wired.
19e. **A purge can end with a store that was granted during it.** A declare and a grant admitted while the app was live can land between the purge's last check and the removal of its marker. The purge now reports that as an error and never deletes the record, but it does not prevent it: the doors check liveness when they admit a request, not when they write. And after that error the estate is named only by its record and a log line. The app's console row is still trashed, so the settings page and the store-destroy door answer 404 and a second purge answers "not trashed", until the console restarts and restores the row from the record. The flash the member sees says "Nothing was deleted", which is false in this case; correct copy needs approval (A).
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go
index 0946f897f..8c7b9cfa8 100644
--- a/internal/console/economicspass_test.go
+++ b/internal/console/economicspass_test.go
@@ -1639,6 +1639,8 @@ func TestUnsweptStorageHoldsOnlyItsOwnAccount(t *testing.T) {
func TestStoragePassDiscardsASizeTakenAcrossATeardown(t *testing.T) {
fx := newConsoleFixture(t)
ctx := context.Background()
+ // Mid-day: a failed reading would fail the pass, not hold the store.
+ storageMidDay(t, "2026-08-12")
at := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
rec := &objstore.Record{
Grant: &objstore.GrantBlock{Backend: "aws", Bucket: "carlos-store-shared-x", Source: objstore.GrantSourceOperator, At: at, By: "op"},
@@ -1681,6 +1683,8 @@ func TestStoragePassDiscardsASizeTakenAcrossATeardown(t *testing.T) {
// The size is discarded and the day held.
func TestStoragePassDiscardsASizeTakenAcrossAReplacementOfTheStore(t *testing.T) {
at := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
+ // Mid-day: a failed reading would fail the pass, not hold the store.
+ storageMidDay(t, "2026-08-12")
for name, during := range map[string]func(t *testing.T, fx *consoleFixture){
"made again under the same name": func(t *testing.T, fx *consoleFixture) {
again := &objstore.Record{
@@ -1923,6 +1927,10 @@ func TestStoragePassSizesOnlyASoleHolderOfItsBucket(t *testing.T) {
t.Run("a holder appears while the bucket is being sized", func(t *testing.T) {
fx := newConsoleFixture(t)
ctx := context.Background()
+ // Mid-day: a reading that failed here would fail the pass, so the
+ // hold below can only be the generation comparison's.
+ storageMidDay(t, "2026-08-12")
+ sizings := 0
if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, provisioned(shared), ""); err != nil {
t.Fatal(err)
}
@@ -1930,6 +1938,7 @@ func TestStoragePassSizesOnlyASoleHolderOfItsBucket(t *testing.T) {
t.Fatal(err)
}
fx.srv.storeSizer = func(_ context.Context, p *objstore.Provisioned) (int64, error) {
+ sizings++
// An operator grants the bucket to another store, which
// writes to it and leaves again, all inside the listing.
if err := objstore.OpenBucketOwner(ctx, fx.rel, shared, other, "shop", at.Add(time.Hour), at.Add(time.Hour), false); err != nil {
@@ -1951,6 +1960,9 @@ func TestStoragePassSizesOnlyASoleHolderOfItsBucket(t *testing.T) {
if err := json.Unmarshal(raw, &day); err != nil {
t.Fatal(err)
}
+ if sizings != 1 {
+ t.Fatalf("the bucket was sized %d time(s); this case needs the visitor to arrive inside one sizing", sizings)
+ }
if app := day.Apps[mine]; !app.StoreUnswept || app.StoreBytes != 0 {
t.Errorf("%+v; want the size discarded and the store held", app)
}
@@ -2206,11 +2218,34 @@ func TestHolderInventoryRules(t *testing.T) {
// Its own bucket noted deleted: holds nothing.
put("bcd", "gone", &objstore.Record{Grant: grant("carlos-store-d"), Provisioned: prov("carlos-store-d"),
Teardown: &objstore.TeardownBlock{StartedAt: at, BucketDeletedAt: at, DeletedBucket: "carlos-store-d"}})
- // An older grant block that names no bucket: resolved by the minted
- // name when there is a rule, unresolved when there is none.
+ // A grant block that names no bucket: resolved by the minted name
+ // when there is a rule, unresolved when there is none.
put("bcd", "unnamed", &objstore.Record{Grant: &objstore.GrantBlock{Backend: "aws", Source: objstore.GrantSourceOperator, At: at, By: "op"}})
- if bare, err := objstore.ReadHolderInventory(ctx, fx.rel, nil); err != nil || len(bare.Unresolved) != 1 {
- t.Fatalf("with no minted-name rule: %+v %v; want the unnamed grant listed as unresolved", bare, err)
+ // The same unnamed grant in records no walk may act on. A teardown
+ // note about some other bucket, and one that says "deleted" and not
+ // what: neither shows the granted bucket is gone, so each still holds
+ // the minted name (and the bucket its note names).
+ unnamedGrant := func() *objstore.GrantBlock {
+ return &objstore.GrantBlock{Backend: "aws", Source: objstore.GrantSourceOperator, At: at, By: "op"}
+ }
+ refused := map[string]*objstore.Record{
+ "refnote": {Grant: unnamedGrant(), Teardown: &objstore.TeardownBlock{StartedAt: at, BucketDeletedAt: at, DeletedBucket: "carlos-store-elsewhere"}},
+ "refblank": {Grant: unnamedGrant(), Teardown: &objstore.TeardownBlock{StartedAt: at, BucketDeletedAt: at}},
+ }
+ for app, rec := range refused {
+ if objstore.Coherent(rec, "bcd", app) == nil {
+ t.Fatalf("fixture: %s is a record a walk could act on; this case needs one it refuses", app)
+ }
+ put("bcd", app, rec)
+ }
+ // And under a name that is not an app's: no rule mints for it.
+ const badApp = "Not_An_App"
+ if release.ValidApp(badApp) {
+ t.Fatalf("fixture: %q is a valid app name", badApp)
+ }
+ put("bcd", badApp, &objstore.Record{Grant: unnamedGrant()})
+ if bare, err := objstore.ReadHolderInventory(ctx, fx.rel, nil); err != nil || len(bare.Unresolved) != 4 {
+ t.Fatalf("with no minted-name rule: %+v %v; want every record with an unnamed bucket listed as unresolved", bare, err)
}
inv, err := objstore.ReadHolderInventory(ctx, fx.rel, func(acct, app string) string { return "carlos-store-minted-" + acct + "-" + app })
if err != nil {
@@ -2219,16 +2254,22 @@ func TestHolderInventoryRules(t *testing.T) {
for bucket, want := range map[string]int{
"carlos-store-x": 1, "carlos-store-y": 1,
"carlos-store-p": 1, "carlos-store-q": 1,
- "carlos-store-g": 1,
- "carlos-store-d": 0,
- "carlos-store-minted-bcd-unnamed": 1,
+ "carlos-store-g": 1,
+ "carlos-store-d": 0,
+ "carlos-store-minted-bcd-unnamed": 1,
+ "carlos-store-minted-bcd-refnote": 1,
+ "carlos-store-elsewhere": 1,
+ "carlos-store-minted-bcd-refblank": 1,
} {
if got := len(inv.Others(bucket, "bab", "asking")); got != want {
t.Errorf("%s: %d holder(s), want %d", bucket, got, want)
}
}
- if len(inv.Unresolved) != 0 {
- t.Errorf("with a minted-name rule the unnamed grant is still unresolved: %v", inv.Unresolved)
+ if len(inv.Unresolved) != 1 || inv.Unresolved[0] != objstore.Key("bcd", badApp) {
+ t.Errorf("with a minted-name rule: unresolved = %v; want only the record under a name no rule mints for", inv.Unresolved)
+ }
+ if len(inv.Others("carlos-store-minted-bcd-"+badApp, "bab", "asking")) != 0 {
+ t.Error("a bucket name was minted for a name that is not an app's")
}
}
@@ -2523,36 +2564,55 @@ func TestStoragePassAsksForTheLastHourWhenAReadingFails(t *testing.T) {
}
}
-// An older grant block names no bucket, and its grant may have delivered a
-// credential before it failed to record the store. The bucket it holds is
-// the one the deployment mints for it. Another store that names that bucket
-// is not its only holder, and is held.
+// A grant block that names no bucket (no build is known to have written
+// one; a hand edit) holds the bucket the deployment mints for it, whether
+// the record is one a walk could act on or one the reader refuses. Another
+// account's store that names that bucket is not its only holder, and is
+// held.
func TestStoragePassCountsAGrantThatNamesNoBucket(t *testing.T) {
at := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
- fx := newConsoleFixture(t)
- ctx := context.Background()
- storageMidDay(t, "2026-08-12")
- fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"}
- legacy := objstore.MintedBucket(fx.srv.cfg.StoreDeployment, "bcd", "old")
- if legacy == "" {
- t.Fatal("fixture: the deployment mints no name")
- }
- if _, err := fx.rel.Put(ctx, release.AppPrefix("bcd", "old")+"marker", []byte("x"), ""); err != nil {
- t.Fatal(err)
- }
- putRawStore(t, fx, "bcd", "old", &objstore.Record{
- Declared: &objstore.Declared{EnvPrefix: "S3", At: at, By: "m@example.com"},
- Grant: &objstore.GrantBlock{Backend: "mem", Source: objstore.GrantSourceOperator, At: at, By: "op"}})
- putRawStore(t, fx, fixtureAcct, fixtureApp, &objstore.Record{
- Grant: &objstore.GrantBlock{Backend: "mem", Bucket: legacy, Source: objstore.GrantSourceOperator, At: at, By: "op"},
- Provisioned: &objstore.Provisioned{Bucket: legacy, Region: "eu-west-1", IAMUser: "u", KeyID: "K", At: at}})
- sized := 0
- fx.srv.storeSizer = func(context.Context, *objstore.Provisioned) (int64, error) { sized++; return 5 << 30, nil }
- if err := fx.srv.storagePass(ctx, "2026-08-12"); err != nil {
- t.Fatal(err)
- }
- day, _ := storageDay(t, fx, "2026-08-12")
- if app := day.Apps[fixtureAcct+"/"+fixtureApp]; sized != 0 || !app.StoreUnswept || app.StoreBytes != 0 {
- t.Fatalf("sized %d time(s), %+v; want held: an older grant with no bucket named holds the name the deployment mints for it", sized, app)
+ unnamed := func() *objstore.GrantBlock {
+ return &objstore.GrantBlock{Backend: "mem", Source: objstore.GrantSourceOperator, At: at, By: "op"}
+ }
+ for name, legacyRec := range map[string]*objstore.Record{
+ "a record a walk could act on": {
+ Declared: &objstore.Declared{EnvPrefix: "S3", At: at, By: "m@example.com"}, Grant: unnamed()},
+ "with a teardown note about another bucket": {
+ Grant: unnamed(), Teardown: &objstore.TeardownBlock{StartedAt: at, BucketDeletedAt: at, DeletedBucket: "carlos-somewhere-else"}},
+ "with a teardown note that says deleted and not what": {
+ Grant: unnamed(), Teardown: &objstore.TeardownBlock{StartedAt: at, BucketDeletedAt: at}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ storageMidDay(t, "2026-08-12")
+ fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"}
+ legacy := objstore.MintedBucket(fx.srv.cfg.StoreDeployment, "bcd", "old")
+ if legacy == "" {
+ t.Fatal("fixture: the deployment mints no name")
+ }
+ if _, err := fx.rel.Put(ctx, release.AppPrefix("bcd", "old")+"marker", []byte("x"), ""); err != nil {
+ t.Fatal(err)
+ }
+ putRawStore(t, fx, "bcd", "old", legacyRec)
+ // Another account's store, provisioned on the very bucket the
+ // deployment mints for bcd/old. Its ledger has never heard of
+ // bcd/old.
+ putRawStore(t, fx, fixtureAcct, fixtureApp, &objstore.Record{
+ Grant: &objstore.GrantBlock{Backend: "mem", Bucket: legacy, Source: objstore.GrantSourceOperator, At: at, By: "op"},
+ Provisioned: &objstore.Provisioned{Bucket: legacy, Region: "eu-west-1", IAMUser: "u", KeyID: "K", At: at}})
+ if err := objstore.OpenBucketOwner(ctx, fx.rel, legacy, fixtureAcct, fixtureApp, at, at, false); err != nil {
+ t.Fatal(err)
+ }
+ sized := 0
+ fx.srv.storeSizer = func(context.Context, *objstore.Provisioned) (int64, error) { sized++; return 5 << 30, nil }
+ if err := fx.srv.storagePass(ctx, "2026-08-12"); err != nil {
+ t.Fatal(err)
+ }
+ day, _ := storageDay(t, fx, "2026-08-12")
+ if app := day.Apps[fixtureAcct+"/"+fixtureApp]; sized != 0 || !app.StoreUnswept || app.StoreBytes != 0 {
+ t.Fatalf("sized %d time(s), %+v; want held: a grant with no bucket named holds the name the deployment mints for it", sized, app)
+ }
+ })
}
}
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go
index 611462f2e..693394da6 100644
--- a/internal/objstore/walk.go
+++ b/internal/objstore/walk.go
@@ -1705,12 +1705,14 @@ func (inv *HolderInventory) LedgerUnread(bucket string) bool {
// ReadHolderInventory walks apps/<account>/<app>/store.json.
//
-// minted gives the bucket name the deployment mints for an app. An older
-// grant block names no bucket (the name was derived each time), and a grant
-// that died after delivering a credential and before recording the store
-// leaves exactly that: a record that holds a bucket and does not say
-// which. minted resolves it. With no rule (nil), or an empty answer, the
-// record goes in Unresolved.
+// minted gives the bucket name the deployment mints for an app. A grant or
+// provisioned block that names no bucket holds a bucket and does not say
+// which, and minted resolves it. No build is known to have written one
+// (every grant walk records the bucket before anything else), so such a
+// record is a hand edit; it is counted all the same, whether or not the
+// record is otherwise one a walk could act on. With no rule (nil), an
+// empty answer, or an account or app name that is not valid, the record
+// goes in Unresolved.
func ReadHolderInventory(ctx context.Context, s release.Store, minted func(acct, app string) string) (*HolderInventory, error) {
inv := &HolderInventory{byBucket: map[string][]OwnerRef{}, generation: map[string]string{}, ledgerUnread: map[string]bool{}}
// The ledgers first, every one there is, and only then the records.
@@ -1779,8 +1781,31 @@ func ReadHolderInventory(ctx context.Context, s release.Store, minted func(acct,
}
inv.byBucket[bucket] = append(inv.byBucket[bucket], OwnerRef{Account: a, App: name, Since: since, refused: refused})
}
- if !release.ValidAccount(a) || !release.ValidApp(name) || Coherent(&rec, a, name) != nil {
- // A record nothing may act on holds every bucket it names.
+ // A grant or provisioned block with no bucket named in either
+ // holds the name the deployment mints for the app. With no
+ // rule, no answer, or a name the rule was never meant for, the
+ // record holds a bucket nobody can name, and says so.
+ validName := release.ValidAccount(a) && release.ValidApp(name)
+ unnamed := (rec.Grant != nil || rec.Provisioned != nil) &&
+ (rec.Provisioned == nil || rec.Provisioned.Bucket == "") &&
+ (rec.Grant == nil || rec.Grant.Bucket == "")
+ addUnnamed := func(refused bool) {
+ bucket := ""
+ if minted != nil && validName {
+ bucket = minted(a, name)
+ }
+ if bucket == "" {
+ inv.Unresolved = append(inv.Unresolved, key)
+ return
+ }
+ add(bucket, refused)
+ }
+ if !validName || Coherent(&rec, a, name) != nil {
+ // A record nothing may act on holds every bucket it names,
+ // and the one it holds without naming. Its teardown note
+ // is not believed against its own grant: a note about some
+ // other bucket, or one that says "deleted" and not what,
+ // does not show the granted bucket is gone.
if rec.Provisioned != nil {
add(rec.Provisioned.Bucket, true)
}
@@ -1790,6 +1815,9 @@ func ReadHolderInventory(ctx context.Context, s release.Store, minted func(acct,
if rec.Teardown != nil {
add(rec.Teardown.DeletedBucket, true)
}
+ if unnamed {
+ addUnnamed(true)
+ }
continue
}
// A store whose own teardown noted its bucket deleted holds
@@ -1804,17 +1832,9 @@ func ReadHolderInventory(ctx context.Context, s release.Store, minted func(acct,
add(rec.Provisioned.Bucket, false)
case rec.Grant != nil && rec.Grant.Bucket != "":
add(rec.Grant.Bucket, false)
- case rec.Grant != nil || rec.Provisioned != nil:
+ case unnamed:
// Granted, and no bucket named anywhere in the record.
- name2 := ""
- if minted != nil {
- name2 = minted(a, name)
- }
- if name2 == "" {
- inv.Unresolved = append(inv.Unresolved, key)
- } else {
- add(name2, false)
- }
+ addUnnamed(false)
}
}
}