carlos / platform Public

Fix the ten findings of the independent review

Round 1 (GPT-6 Astra, docs/superpowers/specs/CODEX-FINDINGS-carloku-paid-
launch-overnight.md) rejected the branch with nine P1 and one P2. Each was
checked against the code and accepted; the dispositions file lists the fix
and the test that reproduces each scenario.

- Report deliveries: the overwrite layout is refused; only a versioned
  delivery directory is read and every file must lie inside it. A row's
  period end and a usage line's hour must belong to the month being read.
  CloudFront or inbound transfer with a cost above zero holds.
- Bucket ownership: a ledger resolves an owner only if it was created by
  the grant that minted the bucket, before any credential existed.
  Backfilled ledgers and adopted buckets record and resolve nothing, so a
  bucket-mate's or a pre-adoption transfer can no longer land on whoever
  happens to be recorded. The month evidence projection carries the claim.
- Replication writes: only answered 2xx PutObject, UploadPart and
  CreateMultipartUpload under the replicate purpose. Checkpoints (the move
  worker forces one) and the two operations S3 can answer 200 with an
  embedded error are not billed.
- A box known to metrics or edge that has no durability ledger holds the
  day.
- A month the store-transfer report holds open is revisited for up to six
  months, so a late repair can still close it.
- The plan chooser prices the quantity the checkout door computes and
  disables a plan the door would refuse.

Verified: go build, go vet, gofmt and go test ./... plus the vendored
Litestream S3 package pass outside the sandbox (60 packages ok). The
catch-up and active-box reproductions fail with their fixes removed.
Round 2 of the review has not run yet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8e7499ae50a2177085274b79fb899cb9898e4793 parent 1fde082
23 files changed, +954 −144
  • docs/superpowers/plans/2026-10-01-durability-attestation-path.md +3 −2
  • docs/superpowers/plans/2026-10-01-store-transfer-cur-collector.md +4 −4
  • docs/superpowers/specs/CODEX-DISPOSITIONS-carloku-paid-launch-overnight.md +26 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-carloku-paid-launch-overnight-round2.md +30 −0
  • implementation/carloku-paid-launch/AGENT_HANDOFF.md +11 −8
  • implementation/carloku-paid-launch/copy-review/billing-page/strings.json +286 −39
  • implementation/carloku-paid-launch/stripe-provisioning.md +11 −8
  • internal/awsbilling/classify.go +22 −7
  • internal/awsbilling/classify_test.go +5 −3
  • internal/awsbilling/delivery.go +33 −3
  • internal/awsbilling/delivery_test.go +88 −11
  • internal/console/billingpage.go +13 −5
  • internal/console/billingpage_test.go +26 −0
  • internal/console/durabilityfold.go +21 −1
  • internal/console/economicspass.go +40 −1
  • internal/console/meterfolds_test.go +96 −5
  • internal/console/templates/billing.html +2 −2
  • internal/objstore/ownership.go +49 −14
  • internal/objstore/ownership_test.go +149 −12
  • internal/objstore/walk.go +8 −2
  • internal/usage/billable_test.go +5 −2
  • internal/usage/durability.go +15 −8
  • internal/usage/evidence_month.go +11 −7

CI cancelled — superseded by a newer push — run details

diff --git a/docs/superpowers/plans/2026-10-01-durability-attestation-path.md b/docs/superpowers/plans/2026-10-01-durability-attestation-path.md
index 70255e2d3..b360df866 100644
--- a/docs/superpowers/plans/2026-10-01-durability-attestation-path.md
+++ b/docs/superpowers/plans/2026-10-01-durability-attestation-path.md
@@ -4,7 +4,7 @@ Date: 2026-10-01. Status: the meter folds and holds; no production box attests;
## Where the meter stands
-`durability_writes_1k` is folded from each box's published replication ledger (`usage/durability/<box>/days/<date>.json`) by `internal/console/durabilityfold.go`. A day counts toward the source only when every expected box published a day that is closed, has one ledger generation, no gap, no conflict, no recording failure, no unknown outcome, and coverage attested for every account. The billable count is `usage.BillableReplicationWrites`: answered 2xx write operations (PutObject, UploadPart, CreateMultipartUpload, CompleteMultipartUpload, CopyObject) made for the tenant's own replication or checkpoint. Leases, restores, moves, the verify drill, platform buckets, reads, lists, deletes, failed and unanswered attempts are never billed.
+`durability_writes_1k` is folded from each box's published replication ledger (`usage/durability/<box>/days/<date>.json`) by `internal/console/durabilityfold.go`. A day counts toward the source only when every expected box published a day that is closed, has one ledger generation, no gap, no conflict, no recording failure, no unknown outcome, and coverage attested for every account. The billable count is `usage.BillableReplicationWrites`: answered 2xx `PutObject`, `UploadPart` and `CreateMultipartUpload` requests made for continuous replication. Checkpoints (the move worker forces one under the same purpose), `CompleteMultipartUpload` and `CopyObject` (S3 can answer them 200 with an error in the body, and the ledger records the status before the body is read), leases, restores, moves, the verify drill, platform buckets, reads, lists, deletes, failed and unanswered attempts are never billed. A box that reports metrics or edge traffic and has no ledger directory holds the day.
The only production open is `usage.OpenDurabilityLedger(dir, box, now, usage.DurabilityLedgerOptions{})` in `internal/host/durability.go`: not attested. So the source reads zero days on every production box and the monthly freeze is refused. That is correct today, because the statement below is not yet true.
@@ -34,4 +34,5 @@ The flag may be set only by code that checks its own premise at startup: no `car
## Open decisions
- Whether LIST requests, which S3 prices at the write tier, should count. They do not today.
-- Whether a checkpoint forced by a platform-initiated move is the tenant's write. It counts today when recorded under the checkpoint purpose; the move's own copies do not.
+- Whether the tenant's own checkpoints should be billed. They are not today, because the ledger cannot tell them from a checkpoint the move worker forces. Billing them needs a distinct purpose for the forced one.
+- Whether `CompleteMultipartUpload` and `CopyObject` should be billed once the ledger records the decoded outcome instead of the raw status.
diff --git a/docs/superpowers/plans/2026-10-01-store-transfer-cur-collector.md b/docs/superpowers/plans/2026-10-01-store-transfer-cur-collector.md
index 50accd7ce..9f3e397cd 100644
--- a/docs/superpowers/plans/2026-10-01-store-transfer-cur-collector.md
+++ b/docs/superpowers/plans/2026-10-01-store-transfer-cur-collector.md
@@ -8,8 +8,8 @@ Object-storage transfer counts only transfer that AWS charges Team Tito for. S3
## What is built (branch feat/carloku-paid-launch-20260916)
-- `internal/awsbilling`: reads a Cost and Usage Report delivery (CUR 2.0 Data Export or legacy CUR, gzip or plain CSV, header driven), picks the newest delivery for a billing period, refuses a delivery that disagrees with itself or changes while it is read, and pins the manifest and every file by digest. `ChargedS3Transfer` counts a line only when it is AmazonS3, a usage line, a transfer-out usage type, priced above zero, names a bucket and covers one whole UTC hour. Zero-cost lines (free tier, CloudFront origin, inbound) are named and never counted. A charged line it cannot classify is a hold.
-- `internal/objstore/ownership.go`: `usage/buckets/<bucket>.json`, an append-only, effective-dated ownership ledger written with compare-and-swap. `Grant` opens an interval before any credential is delivered. `Teardown` closes it after the bucket is deleted. `OwnerAt` answers for an hour only when every interval touching it names one account and app.
+- `internal/awsbilling`: reads a Cost and Usage Report delivery (CUR 2.0 Data Export or legacy CUR, gzip or plain CSV, header driven), picks the newest delivery for a billing period, refuses a delivery that disagrees with itself or changes while it is read, and pins the manifest and every file by digest. `ChargedS3Transfer` counts a line only when it is AmazonS3, a usage line, a transfer-out usage type, priced above zero, names a bucket and covers one whole UTC hour. Zero-cost lines (free tier, CloudFront origin, inbound) are named and never counted. A charged line it cannot classify is a hold, and so is CloudFront or inbound transfer that carries a cost. A row must belong to the month being read: its period end and its usage hour are both checked.
+- `internal/objstore/ownership.go`: `usage/buckets/<bucket>.json`, an append-only, effective-dated ownership ledger written with compare-and-swap. `Grant` opens an interval before any credential is delivered. `Teardown` closes it after the bucket is deleted. `OwnerAt` answers for an hour only when the ledger was created by the grant that minted the bucket (so it cannot be missing a grantee) and every interval touching the hour names one account and app. A ledger for an adopted bucket, or one written for a store that already existed, records what is known and resolves nothing.
- `internal/console/storeegressfold.go`: for a month, folds each account's charged, owned bytes into `store_egress_gb`. The source `store_egress` reads complete (the month's full day count) only when a delivery exists, AWS has invoiced it (every S3 line carries `bill/InvoiceId`), no charged line was unclassifiable, and every charged bucket-hour has exactly one owner or belongs to a declared platform bucket. Otherwise the attributable bytes fold as a lower bound for the modelling views, the source stays at zero days and the monthly freeze is refused.
- A configured report that is not yet provable holds the month open. A closed statement is never rewritten, so closing on the fourth of the month, before AWS invoices, would make the month unbillable for good.
- Evidence: the report is read through the month's evidence store like every other source. Ownership ledgers are pinned by their projection onto the month (`usage/evidence_month.go`), so a teardown in October does not invalidate September, and an interval closed inside September does.
@@ -21,7 +21,7 @@ Tests: `go test ./internal/awsbilling ./internal/objstore`, and in `internal/con
Account 271376211898, region eu-west-1. The console reads the report from a prefix of the deployment bucket so that report objects pass through statement evidence and the console needs no new read grant.
-1. One Data Export (AWS Billing and Cost Management, Data Exports): type CUR 2.0, time granularity hourly, "include resource IDs" on, format gzip CSV, file versioning "overwrite existing", delivered to bucket `carlos-flagship-271376211898` under prefix `billing/cur/` with export name `carloku-transfer`. Column selection: the default set is enough; the reader needs `identity_line_item_id`, `identity_time_interval`, `bill_invoice_id`, `bill_billing_period_start_date`, `bill_billing_period_end_date`, `line_item_line_item_type`, `line_item_usage_start_date`, `line_item_usage_end_date`, `line_item_usage_type`, `line_item_resource_id`, `line_item_usage_amount`, `line_item_unblended_rate`, `line_item_unblended_cost`, `product_servicecode`.
+1. One Data Export (AWS Billing and Cost Management, Data Exports): type CUR 2.0, time granularity hourly, "include resource IDs" on, format gzip CSV, file versioning "create new export file" (one immutable directory per delivery; the reader refuses the overwrite layout, because AWS replaces its files one at a time before rewriting the manifest), delivered to bucket `carlos-flagship-271376211898` under prefix `billing/cur/` with export name `carloku-transfer`. Column selection: the default set is enough; the reader needs `identity_line_item_id`, `identity_time_interval`, `bill_invoice_id`, `bill_billing_period_start_date`, `bill_billing_period_end_date`, `line_item_line_item_type`, `line_item_usage_start_date`, `line_item_usage_end_date`, `line_item_usage_type`, `line_item_resource_id`, `line_item_usage_amount`, `line_item_unblended_rate`, `line_item_unblended_cost`, `product_servicecode`.
2. One bucket policy statement on the deployment bucket allowing `bcm-data-exports.amazonaws.com` and `billingreports.amazonaws.com` to `s3:PutObject` and `s3:GetBucketPolicy` under `billing/cur/*`, conditioned on `aws:SourceAccount` 271376211898 and the export's `aws:SourceArn`.
3. Host IAM: confirm the box role cannot read `billing/cur/*` (tenant boxes have no business reading the account's bill); add an explicit Deny if the current grant is broader than its own prefixes.
4. Console environment: `CONSOLE_STORE_EGRESS_REPORT_PREFIX=billing/cur/carloku-transfer/` and, if the platform owns buckets beyond the deployment bucket, `CONSOLE_STORE_EGRESS_PLATFORM_BUCKETS`.
@@ -35,7 +35,7 @@ Verification after apply: within 24 hours `billing/cur/carloku-transfer/metadata
## Before a first invoice can include store transfer
- Scoped approval for the four changes above, then a first real delivery read end to end.
-- Backfill: the four stores already provisioned on the flagship have no ownership ledger. A re-grant opens one from the grant block's own time. Until then their charged transfer holds the month, by design. An operator verb for the backfill does not exist yet.
+- Existing stores: the four stores already provisioned on the flagship have no ownership ledger, and a re-grant writes one that does not claim completeness, because credentials existed before the ledger did. Their charged transfer holds the month until an operator attests each bucket's history (who has ever been granted it). That door does not exist yet and must be designed: it grants billing authority.
- Confirm against the first real report that usage types, resource ids and the invoice id behave as the fixtures assume, in particular how the 100 GB free allowance is written across buckets. Anything unexpected arrives as `store_egress_unclassified_charge`, never as a bill.
- The per-month rebuild door for a statement closed before this existed is still missing (requirements audit step 1).
diff --git a/docs/superpowers/specs/CODEX-DISPOSITIONS-carloku-paid-launch-overnight.md b/docs/superpowers/specs/CODEX-DISPOSITIONS-carloku-paid-launch-overnight.md
new file mode 100644
index 000000000..1e91bd0c5
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-DISPOSITIONS-carloku-paid-launch-overnight.md
@@ -0,0 +1,26 @@
+# Dispositions: independent review of 2026-10-01 (round 1)
+
+Findings are in `CODEX-FINDINGS-carloku-paid-launch-overnight.md`, unedited. Reviewer: GPT-6 Astra via codex, high reasoning effort, against `aed42815..70bb5114`. Verdict: reject. Every finding was checked against the code before it was accepted. All ten were accepted.
+
+| # | Finding | Disposition | Where | Reproduction |
+| --- | --- | --- | --- | --- |
+| 1 | Partially overwritten report accepted as one final delivery | Accepted. The overwrite layout is refused outright (`ErrMutableDelivery`); only a versioned delivery directory is read, and every data file must lie inside that directory. | `internal/awsbilling/delivery.go` | `TestReadDeliveryRefusesTheOverwriteLayout`, "outside the delivery directory" in `TestReadDeliveryManifestRefusals` |
+| 2 | Partial ownership backfill bills one tenant for a bucket-mate | Accepted. A ledger resolves an owner only if it claims completeness, and it can claim it only when created by the grant that mints the bucket, before any credential exists. Backfilled ledgers record and resolve nothing. | `internal/objstore/ownership.go`, `walk.go` | `TestGrantBackfillsLedgerForHealthyStore`, `TestCompletenessCannotBeClaimedLater`, `TestStoreEgressFoldHoldsOnAnUnprovenLedger` |
+| 3 | Adoption assigns pre-adoption transfer to the adopter | Accepted, same mechanism: an adopted bucket's ledger never claims completeness, so its transfer holds. | same | `TestAdoptedBucketResolvesNoOwner` |
+| 4 | HTTP 200 with an embedded error counted as a write | Accepted. `CompleteMultipartUpload` and `CopyObject` are no longer billable. | `internal/usage/durability.go` | `TestBillableReplicationWritesPolicy` |
+| 5 | Platform-forced move checkpoints billed to the tenant | Accepted. The checkpoint purpose is no longer billable; only `replicate` is. | same | same |
+| 6 | A box that never registered durability is left out of completeness | Accepted. A box known to the metrics or edge source and expected that day, with no ledger directory, holds the day. | `internal/console/durabilityfold.go` | `TestDurabilityFoldHoldsForAnActiveBoxWithNoLedger` (fails with the fix removed) |
+| 7 | Positive-cost CloudFront transfer silently discarded | Accepted. CloudFront or inbound transfer with a cost above zero is a hold (`unexpected_transfer_charge`). | `internal/awsbilling/classify.go` | classifier table, `TestStoreEgressFoldHoldsOnAChargedCloudFrontLine` |
+| 8 | A row can bill a later month's usage to an earlier month | Accepted. A row's period end must equal the month's end, and a usage line's window must lie inside the month being read. | `internal/awsbilling/delivery.go` | `TestReadDeliveryRefusesUsageOutsideTheMonth` |
+| 9 | A transfer hold becomes permanent after the following month ends | Accepted. With a report configured, the statements pass revisits months still open up to six months back. | `internal/console/economicspass.go` | `TestStoreEgressHeldMonthIsRevisitedAfterTheNextMonthEnds` (fails with the fix removed) |
+| 10 | The chooser offers a plan checkout refuses | Accepted. The chooser prices the quantity the door computes; a plan that cannot take it is shown disabled with a line saying why. | `internal/console/billingpage.go`, `templates/billing.html` | `TestBillingPageDoesNotOfferAPlanCheckoutWouldRefuse` |
+
+Contradicted handoff claim ("every unmetered fallback files an escape"): accepted. A failed ledger open leaves no publisher and files nothing the console can see. Finding 6's fix is what holds that box-day. The handoff now says so.
+
+Tests that asserted less than their names: the delivery digest test now compares digests with the bytes read; the teardown test now checks the interval is still open when the bucket is deleted; the never-registered box has its own test. Not addressed in this round: `TestDurabilityHostRecorderAfterStopVoidsCleanStop` (reopen with the handoff token) and `TestReadLinesBothGenerations` (equivalent rows).
+
+Consequences worth knowing:
+
+- The Data Export must use versioned delivery ("create new export file"), not overwrite. The collector plan says so now.
+- Stores provisioned before ownership ledgers, and every adopted bucket, hold their month's store transfer until an operator attests their history. No such door exists yet. That is a launch blocker for billing store transfer on the four existing flagship stores.
+- Replication writes bill only `PutObject`, `UploadPart` and `CreateMultipartUpload` made for continuous replication. That undercounts, by design.
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-carloku-paid-launch-overnight-round2.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-carloku-paid-launch-overnight-round2.md
new file mode 100644
index 000000000..b7c03ff51
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-carloku-paid-launch-overnight-round2.md
@@ -0,0 +1,30 @@
+# Review brief, round 2: Carloku paid launch, overnight changes of 2026-10-01
+
+Read `CODEX-REVIEW-BRIEF-carloku-paid-launch-overnight.md` first: its fixed rulings and its instructions still apply. Then read `CODEX-FINDINGS-carloku-paid-launch-overnight.md` (round 1, unedited) and `CODEX-DISPOSITIONS-carloku-paid-launch-overnight.md` (what was done about each finding).
+
+Review the branch between `aed42815` and the commit named in the prompt.
+
+## First: a disposition table
+
+For each of the ten round 1 findings, say whether the fix closes it (closed, partly closed, not closed), citing the file and line of the fix and of the test that reproduces the original scenario. Say if a reproduction would pass without the fix.
+
+## Then: new findings only
+
+Attack what is new since round 1, and what the fixes changed:
+
+- `internal/awsbilling/delivery.go`: versioned deliveries only. Can a delivery still be read while AWS is writing it? Can the newest-delivery choice be steered? Is the period and usage-window check complete for non-usage S3 lines?
+- `internal/awsbilling/classify.go`: is there any positive-cost S3 transfer row that is neither counted nor held?
+- `internal/objstore/ownership.go`, `walk.go`: `CompleteSince`. Can a ledger claim completeness when someone else could have held access? Is `minted` true in any walk where a credential already exists, or where the bucket was not minted by this grant? Can a complete ledger lose a grantee (a second app granted the same bucket name, a crash between steps, a record deleted and re-declared)?
+- `internal/usage/evidence_month.go`: the ownership projection now carries completeness.
+- `internal/usage/durability.go` `BillableReplicationWrites`: replicate purpose, three operations, 2xx.
+- `internal/console/durabilityfold.go`: boxes known to metrics or edge with no ledger hold the day.
+- `internal/console/economicspass.go`: the catch-up loop for months held open. Can it close a month it should not, or wedge the pass?
+- `internal/console/billingpage.go`, `templates/billing.html`: the unavailable plan row.
+- NEW, not reviewed before: `internal/console/billingoperator.go` (operator API doors for billing review holds and the house marker) and `internal/console/billingquantity.go` with `internal/billingstripe/client.go` `UpdateQuantityWith` and `cmd/console/billing.go` (`CONSOLE_BILLING_PRORATION`). These grant authority and move money. For the doors: can a non-operator learn anything or change anything; can a resolution or a house change be made without its audit row; can the house marker change under a live subscription or an open checkout, including by a race with reconcile or checkout? For the worker: with the policy unset, is there any provider call; can an addition be marked processed without the billed quantity covering it; can two passes or a crash double-charge; can it raise a subscription that is not the account's, not active or in the wrong mode; is the idempotency key stable and specific; what happens when membership changes between the read and the mark; does the peak rule ever exceed what the spec allows ("additions prorate; people who leave come off at renewal")?
+
+## What to return
+
+1. The disposition table.
+2. New findings, most severe first, in the round 1 format (severity, file and line, concrete scenario, smallest acceptable fix).
+3. Handoff claims the code contradicts.
+4. A one-line verdict: accept, accept with fixes, or reject.
diff --git a/implementation/carloku-paid-launch/AGENT_HANDOFF.md b/implementation/carloku-paid-launch/AGENT_HANDOFF.md
index d5ed3adbc..ac2c48992 100644
--- a/implementation/carloku-paid-launch/AGENT_HANDOFF.md
+++ b/implementation/carloku-paid-launch/AGENT_HANDOFF.md
@@ -30,12 +30,14 @@ When a review is approved, apply each string byte for byte: the site by replacin
1. Plans and calculations, Stripe boundary, entitlements and membership, quantity history, invoice engine: as described in the September handoff (kept in git history at `aed42815`). Unchanged in this run.
2. Console GET `/a/{sqid}/billing` (`billingpage.go`, `templates/billing.html`): current plan, owner-only chooser posting to the existing checkout door, `?country=` price preview computed from the plans terms, error and pending banners, portal button once a customer exists, read-only for members, byte-identical 404 for non-members, closed state when billing is off. `plans.Countries()` exposes the embedded country list.
-3. Durability ledger: the epoch read race is fixed (epoch and created-at are immutable after recover) with a `-race` regression. Every unmetered replication fallback now files an escape (`DurabilityLedger.Escaped`): the box-day is incomplete and the owner is excluded for the run. Attestation is still false everywhere in production.
-4. `internal/awsbilling`: Cost and Usage Report reader, newest-delivery selection, digests, charged S3 transfer classifier.
-5. `internal/objstore/ownership.go`: append-only bucket ownership ledger, opened at grant, closed at teardown.
+3. Durability ledger: the epoch read race is fixed (epoch and created-at are immutable after recover) with a `-race` regression. An unmetered replication fallback on a box whose ledger opened now files an escape (`DurabilityLedger.Escaped`): the box-day is incomplete and the owner is excluded for the run. A box whose ledger failed to open has no publisher and files nothing; the console fold holds any day on which a box known to metrics or edge has no ledger. Attestation is still false everywhere in production.
+4. `internal/awsbilling`: Cost and Usage Report reader (versioned deliveries only), newest-delivery selection, digests, charged S3 transfer classifier that holds on any charged line it does not count.
+5. `internal/objstore/ownership.go`: append-only bucket ownership ledger, opened at grant, closed at teardown. It resolves an owner only when created by the grant that minted the bucket; adopted and pre-existing stores hold.
6. Statement folds: `console/durabilityfold.go` (`durability_writes_1k`, policy in `usage.BillableReplicationWrites`) and `console/storeegressfold.go` (`store_egress_gb`). `usage.MeterSource` now names a source for both meters. Month evidence pins ownership ledgers by their month projection and knows the durability box directory.
-7. Stripe provisioning plan `stripe-provisioning.md` and print-only `stripe-provision.sh` (verified never to invoke the CLI).
-8. Plans: `docs/superpowers/plans/2026-10-01-store-transfer-cur-collector.md` and `2026-10-01-durability-attestation-path.md`.
+7. Operator API doors (`billingoperator.go`): list and resolve billing review holds, list and set the house marker. No CLI verb yet.
+8. Team quantity worker (`billingquantity.go`) behind `CONSOLE_BILLING_PRORATION` (`immediate` or `next_invoice`). Unset, which is how it must stay until Paul decides, it makes no provider call. It does not yet lower the quantity at renewal.
+9. Stripe provisioning plan `stripe-provisioning.md` and print-only `stripe-provision.sh` (verified never to invoke the CLI).
+10. Plans: `docs/superpowers/plans/2026-10-01-store-transfer-cur-collector.md` and `2026-10-01-durability-attestation-path.md`.
## Why no overage can be invoiced yet, precisely
@@ -46,9 +48,10 @@ The monthly freeze requires all five billed meters complete. Two cannot be, in p
## Still missing from the full requirements audit
-- Quantity and proration worker: nothing processes subscription-bound member additions, `billingstripe.UpdateQuantity` has no console caller, removals at renewal are not applied, and an account that adds a member holds its own monthly freeze forever. The collection policy is Paul's decision; the pricing review proposes "charged straight away, prorated".
+- Quantity at renewal: the worker raises a subscription for added members but nothing lowers it at renewal for members who left, so a renewal bills the old quantity. Must be built before team plans are sold. The proration policy itself is Paul's decision and the switch is unset.
- Partial first month and free-to-paid months hold permanently (`plans.ErrAssignmentHistory` is quiet and files no review). Months spanning two tax codes hold with a review; per-line tax codes are not implemented.
-- House marker door and billing review resolution door: store functions only, no HTTP or CLI caller.
+- The operator doors have no `carlos` verb and no documentation page, and have not had their own independent review.
+- An operator door to attest a bucket's ownership history (existing and adopted stores): missing, and it holds store-transfer billing for the four existing flagship stores.
- Free tier: no Free entitlement, no app cap, no storage refusal, no per-plan hibernation idle, no flush-only replication class, nothing at runtime reads entitlements.
- Dormancy archive and warnings, recognised-revenue pool accounting at 15 percent, the books site: not started.
- Statement rebuild door for a closed month: missing.
@@ -68,7 +71,7 @@ Merchant `acct_1UD7LXI4Nrr6AEsO`, Team Tito Limited, Ireland, EUR; Irish standar
## Next actions, in order
1. Apply approved copy from both reviews when `result.json` arrives.
-2. Get the independent whole-branch review (Astra via codex, brief committed beside the spec) and resolve findings.
+2. Independent review: round 1 by Astra rejected with ten findings, all accepted and fixed (`docs/superpowers/specs/CODEX-DISPOSITIONS-carloku-paid-launch-overnight.md`). Round 2 and the Opus production review must pass on the exact tip before anything lands.
3. Scoped approvals: Cost and Usage Report export; Stripe catalog, portal, webhook and the `rk_live` key; then a test-mode rehearsal and a real purchase.
4. Build the quantity and proration worker behind an explicit policy switch, the two operator doors, then Free-tier enforcement.
5. Choose and build the attestation path. Until then replication writes hold every month.
diff --git a/implementation/carloku-paid-launch/copy-review/billing-page/strings.json b/implementation/carloku-paid-launch/copy-review/billing-page/strings.json
index 27580d44b..f4390823c 100644
--- a/implementation/carloku-paid-launch/copy-review/billing-page/strings.json
+++ b/implementation/carloku-paid-launch/copy-review/billing-page/strings.json
@@ -1,41 +1,288 @@
[
- {"id":"billing.title","section":"Billing page","label":"Page title","text":"Billing","context":"The browser tab title and the page heading of the account's billing page in the console. The page sits beside Apps, Fleet, Team and Settings in the account navigation.","notes":"One word if possible"},
- {"id":"billing.nav","section":"Billing page","label":"Navigation label","text":"Billing","context":"The link to this page in the account navigation bar, next to Apps, Fleet, Team and Settings."},
- {"id":"billing.lede","section":"Billing page","label":"Lede","text":"Your plan, what it includes, and how to change it.","context":"Sits under the page heading. One sentence saying what the page is for."},
- {"id":"billing.closed","section":"Billing page","label":"Paid plans not open","text":"Paid plans are not open on this console yet. Every account is on the Free plan.","context":"Shown instead of the plan chooser when billing is switched off on this deployment. This is what production shows until launch day."},
- {"id":"billing.current.heading","section":"Current plan","label":"Section heading","text":"Your plan","context":"Heading over the block that names the account's current plan and its state."},
- {"id":"billing.current.free","section":"Current plan","label":"On the Free plan","text":"Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.","context":"The line shown when the account has no paid subscription. It states the plan and its limits so the reader knows what they are on before they compare."},
- {"id":"billing.current.active","section":"Current plan","label":"Active subscription","text":"{{.PlanName}}, {{.Quantity}} members, renews on {{.Renews}}.","context":"The line shown when a paid subscription is active. The placeholders are filled in: plan name, member count paid for, renewal date. Keep the three placeholders.","notes":"Keep {{.PlanName}}, {{.Quantity}} and {{.Renews}} exactly"},
- {"id":"billing.current.pastdue","section":"Current plan","label":"Payment failed","text":"The last payment did not go through. Update your card below to keep the plan.","context":"Shown under the plan line when Stripe reports the subscription as past due or unpaid. It tells the reader what to do without alarm."},
- {"id":"billing.current.ending","section":"Current plan","label":"Cancelled, still running","text":"This plan ends on {{.Renews}} and will not renew.","context":"Shown when the subscriber has cancelled and the plan is running out its paid year. The placeholder is the end date.","notes":"Keep {{.Renews}}"},
- {"id":"billing.current.pending","section":"Current plan","label":"Checkout completing","text":"Thanks. Stripe is confirming your payment. Your plan turns on within a few minutes, and this page will show it.","context":"Shown at the top of the page when the reader has just come back from paying at Stripe, before the confirmation has arrived. Reassures them nothing else is needed."},
- {"id":"billing.current.review","section":"Current plan","label":"Under review","text":"Something about this account's billing needs a person to look at it. Nothing is charged while that happens. We will be in touch.","context":"Shown when an operator review hold is open on the account, which stops any charge until a person clears it."},
- {"id":"billing.members.note","section":"Current plan","label":"Member count","text":"Community and Business are priced per member. You have {{.Members}} members; the minimum is 10.","context":"Sits under the plan chooser for team plans. Explains why the price is for at least ten members even if the account has fewer people.","notes":"Keep {{.Members}}"},
- {"id":"billing.choose.heading","section":"Choose a plan","label":"Section heading","text":"Choose a plan","context":"Heading over the form where an account owner picks a paid plan, sets the billing country and goes to pay."},
- {"id":"billing.choose.lede","section":"Choose a plan","label":"Section lede","text":"Prices are per year, before VAT, and depend on your billing country. The exact price is shown before you pay.","context":"One or two sentences under the Choose a plan heading. Prepares the reader for the country field and for the price being confirmed at checkout."},
- {"id":"billing.plan.personal.name","section":"Choose a plan","label":"Personal plan name","text":"Personal","context":"The name of the single-person paid plan, used on the radio button, on Stripe's checkout page and on invoices."},
- {"id":"billing.plan.personal.blurb","section":"Choose a plan","label":"Personal plan line","text":"One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.","context":"The one-line description under the Personal radio button."},
- {"id":"billing.plan.community.name","section":"Choose a plan","label":"Community plan name","text":"Community","context":"The name of the low-cost team plan for clubs, classes and open-source groups. Used on the radio button, on Stripe's checkout page and on invoices."},
- {"id":"billing.plan.community.blurb","section":"Choose a plan","label":"Community plan line","text":"Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.","context":"The one-line description under the Community radio button."},
- {"id":"billing.plan.business.name","section":"Choose a plan","label":"Business plan name","text":"Business","context":"The name of the team plan for companies. Used on the radio button, on Stripe's checkout page and on invoices."},
- {"id":"billing.plan.business.blurb","section":"Choose a plan","label":"Business plan line","text":"Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.","context":"The one-line description under the Business radio button."},
- {"id":"billing.price.peryear","section":"Choose a plan","label":"Price suffix, whole account","text":"a year","context":"Follows the euro amount next to the Personal plan, as in €24 a year."},
- {"id":"billing.price.permember","section":"Choose a plan","label":"Price suffix, per member","text":"per member a year","context":"Follows the euro amount next to Community and Business, as in €5 per member a year."},
- {"id":"billing.country.label","section":"Choose a plan","label":"Country field label","text":"Billing country","context":"Label of the drop-down where the owner picks the country they are paying from. It sets the price band and the VAT."},
- {"id":"billing.country.placeholder","section":"Choose a plan","label":"Country drop-down first option","text":"Choose a country","context":"The unselected first option in the country drop-down."},
- {"id":"billing.postal.label","section":"Choose a plan","label":"Postal code label","text":"Postal code","context":"Label of the text field for a postal or ZIP code. Only required for the United States, Canada and India, where tax depends on it."},
- {"id":"billing.postal.help","section":"Choose a plan","label":"Postal code help","text":"Needed in the United States, Canada and India, where tax depends on it. Leave it empty elsewhere. We do not keep it.","context":"Small print under the postal code field."},
- {"id":"billing.quote.button","section":"Choose a plan","label":"Show prices button","text":"Show prices for this country","context":"A secondary button beside the country drop-down. It reloads the page with the exact yearly prices for the chosen country, without going to Stripe."},
- {"id":"billing.quote.band","section":"Choose a plan","label":"Band explanation","text":"Prices for {{.Country}} are {{.Percent}}% of the base price, and so are the monthly inclusions.","context":"Shown after the owner asks for prices for a country. Explains the affordability band applied. Placeholders are the country name and the percentage (100, 75, 50 or 35).","notes":"Keep {{.Country}} and {{.Percent}}"},
- {"id":"billing.consent.label","section":"Choose a plan","label":"Terms checkbox","text":"I have read the pricing page and agree to be charged yearly at the price shown at checkout, plus usage beyond the plan's inclusions billed monthly.","context":"The checkbox the owner must tick before the Pay button works. It is the consent record for the terms version in force. It should name what they agree to in one sentence."},
- {"id":"billing.consent.link","section":"Choose a plan","label":"Pricing page link text","text":"the pricing page","context":"The words inside the consent sentence that link to carloku.com/pricing/. If the consent sentence changes, this must remain a phrase inside it."},
- {"id":"billing.pay.button","section":"Choose a plan","label":"Pay button","text":"Continue to payment","context":"The main button of the form. It takes the owner to Stripe's hosted checkout page, where the final price with VAT is shown before any card details are entered."},
- {"id":"billing.pay.note","section":"Choose a plan","label":"Under the pay button","text":"You pay by card at Stripe. Nothing is charged until you confirm there.","context":"Small print directly under the Continue to payment button."},
- {"id":"billing.manage.heading","section":"Manage","label":"Section heading","text":"Cards, invoices and cancelling","context":"Heading over the button that opens Stripe's customer portal for an account that has paid before."},
- {"id":"billing.manage.body","section":"Manage","label":"Section text","text":"Change your card, download invoices or cancel at Stripe. Cancelling keeps the plan until the end of the year you paid for.","context":"One or two sentences over the portal button."},
- {"id":"billing.manage.button","section":"Manage","label":"Portal button","text":"Open billing at Stripe","context":"The button that opens Stripe's customer portal in the same tab."},
- {"id":"billing.member.readonly","section":"Members","label":"Non-owner note","text":"Only an account owner can change the plan.","context":"Shown to a member who is not an owner, in place of the plan chooser and the portal button."},
- {"id":"billing.error.unavailable","section":"Errors","label":"Unavailable","text":"Billing is not available right now. Nothing was charged. Try again in a few minutes.","context":"Red banner at the top of the page after a checkout or portal attempt failed for a reason on our side or Stripe's."},
- {"id":"billing.error.pending","section":"Errors","label":"Previous checkout pending","text":"An earlier checkout for this account is still completing. Wait a minute and reload this page before trying again.","context":"Red banner when the owner tried to start a second checkout while an earlier one has not settled. Prevents a double purchase."},
- {"id":"billing.error.conflict","section":"Errors","label":"Account changed","text":"The account changed while you were on this page, for example a member was added. Check the plan and try again.","context":"Red banner when the member count or plan state moved between loading the page and pressing pay."}
+ {
+ "id": "billing.title",
+ "section": "Billing page",
+ "label": "Page title",
+ "text": "Billing",
+ "context": "The browser tab title and the page heading of the account's billing page in the console. The page sits beside Apps, Fleet, Team and Settings in the account navigation.",
+ "notes": "One word if possible"
+ },
+ {
+ "id": "billing.nav",
+ "section": "Billing page",
+ "label": "Navigation label",
+ "text": "Billing",
+ "context": "The link to this page in the account navigation bar, next to Apps, Fleet, Team and Settings."
+ },
+ {
+ "id": "billing.lede",
+ "section": "Billing page",
+ "label": "Lede",
+ "text": "Your plan, what it includes, and how to change it.",
+ "context": "Sits under the page heading. One sentence saying what the page is for."
+ },
+ {
+ "id": "billing.closed",
+ "section": "Billing page",
+ "label": "Paid plans not open",
+ "text": "Paid plans are not open on this console yet. Every account is on the Free plan.",
+ "context": "Shown instead of the plan chooser when billing is switched off on this deployment. This is what production shows until launch day."
+ },
+ {
+ "id": "billing.current.heading",
+ "section": "Current plan",
+ "label": "Section heading",
+ "text": "Your plan",
+ "context": "Heading over the block that names the account's current plan and its state."
+ },
+ {
+ "id": "billing.current.free",
+ "section": "Current plan",
+ "label": "On the Free plan",
+ "text": "Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.",
+ "context": "The line shown when the account has no paid subscription. It states the plan and its limits so the reader knows what they are on before they compare."
+ },
+ {
+ "id": "billing.current.active",
+ "section": "Current plan",
+ "label": "Active subscription",
+ "text": "{{.PlanName}}, {{.Quantity}} members, renews on {{.Renews}}.",
+ "context": "The line shown when a paid subscription is active. The placeholders are filled in: plan name, member count paid for, renewal date. Keep the three placeholders.",
+ "notes": "Keep {{.PlanName}}, {{.Quantity}} and {{.Renews}} exactly"
+ },
+ {
+ "id": "billing.current.pastdue",
+ "section": "Current plan",
+ "label": "Payment failed",
+ "text": "The last payment did not go through. Update your card below to keep the plan.",
+ "context": "Shown under the plan line when Stripe reports the subscription as past due or unpaid. It tells the reader what to do without alarm."
+ },
+ {
+ "id": "billing.current.ending",
+ "section": "Current plan",
+ "label": "Cancelled, still running",
+ "text": "This plan ends on {{.Renews}} and will not renew.",
+ "context": "Shown when the subscriber has cancelled and the plan is running out its paid year. The placeholder is the end date.",
+ "notes": "Keep {{.Renews}}"
+ },
+ {
+ "id": "billing.current.pending",
+ "section": "Current plan",
+ "label": "Checkout completing",
+ "text": "Thanks. Stripe is confirming your payment. Your plan turns on within a few minutes, and this page will show it.",
+ "context": "Shown at the top of the page when the reader has just come back from paying at Stripe, before the confirmation has arrived. Reassures them nothing else is needed."
+ },
+ {
+ "id": "billing.current.review",
+ "section": "Current plan",
+ "label": "Under review",
+ "text": "Something about this account's billing needs a person to look at it. Nothing is charged while that happens. We will be in touch.",
+ "context": "Shown when an operator review hold is open on the account, which stops any charge until a person clears it."
+ },
+ {
+ "id": "billing.members.note",
+ "section": "Current plan",
+ "label": "Member count",
+ "text": "Community and Business are priced per member. You have {{.Members}} members; the minimum is 10.",
+ "context": "Sits under the plan chooser for team plans. Explains why the price is for at least ten members even if the account has fewer people.",
+ "notes": "Keep {{.Members}}"
+ },
+ {
+ "id": "billing.choose.heading",
+ "section": "Choose a plan",
+ "label": "Section heading",
+ "text": "Choose a plan",
+ "context": "Heading over the form where an account owner picks a paid plan, sets the billing country and goes to pay."
+ },
+ {
+ "id": "billing.choose.lede",
+ "section": "Choose a plan",
+ "label": "Section lede",
+ "text": "Prices are per year, before VAT, and depend on your billing country. The exact price is shown before you pay.",
+ "context": "One or two sentences under the Choose a plan heading. Prepares the reader for the country field and for the price being confirmed at checkout."
+ },
+ {
+ "id": "billing.plan.personal.name",
+ "section": "Choose a plan",
+ "label": "Personal plan name",
+ "text": "Personal",
+ "context": "The name of the single-person paid plan, used on the radio button, on Stripe's checkout page and on invoices."
+ },
+ {
+ "id": "billing.plan.personal.blurb",
+ "section": "Choose a plan",
+ "label": "Personal plan line",
+ "text": "One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.",
+ "context": "The one-line description under the Personal radio button."
+ },
+ {
+ "id": "billing.plan.personal.unavailable",
+ "section": "Choose a plan",
+ "label": "Personal not available",
+ "text": "Personal is for one member. This account has {{.Members}}.",
+ "context": "Shown under the Personal plan line when the account has more than one member. The Personal option is greyed out, because checkout would refuse it. The placeholder is the member count.",
+ "notes": "Keep {{.Members}}"
+ },
+ {
+ "id": "billing.plan.community.name",
+ "section": "Choose a plan",
+ "label": "Community plan name",
+ "text": "Community",
+ "context": "The name of the low-cost team plan for clubs, classes and open-source groups. Used on the radio button, on Stripe's checkout page and on invoices."
+ },
+ {
+ "id": "billing.plan.community.blurb",
+ "section": "Choose a plan",
+ "label": "Community plan line",
+ "text": "Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.",
+ "context": "The one-line description under the Community radio button."
+ },
+ {
+ "id": "billing.plan.business.name",
+ "section": "Choose a plan",
+ "label": "Business plan name",
+ "text": "Business",
+ "context": "The name of the team plan for companies. Used on the radio button, on Stripe's checkout page and on invoices."
+ },
+ {
+ "id": "billing.plan.business.blurb",
+ "section": "Choose a plan",
+ "label": "Business plan line",
+ "text": "Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.",
+ "context": "The one-line description under the Business radio button."
+ },
+ {
+ "id": "billing.price.peryear",
+ "section": "Choose a plan",
+ "label": "Price suffix, whole account",
+ "text": "a year",
+ "context": "Follows the euro amount next to the Personal plan, as in €24 a year."
+ },
+ {
+ "id": "billing.price.permember",
+ "section": "Choose a plan",
+ "label": "Price suffix, per member",
+ "text": "per member a year",
+ "context": "Follows the euro amount next to Community and Business, as in €5 per member a year."
+ },
+ {
+ "id": "billing.country.label",
+ "section": "Choose a plan",
+ "label": "Country field label",
+ "text": "Billing country",
+ "context": "Label of the drop-down where the owner picks the country they are paying from. It sets the price band and the VAT."
+ },
+ {
+ "id": "billing.country.placeholder",
+ "section": "Choose a plan",
+ "label": "Country drop-down first option",
+ "text": "Choose a country",
+ "context": "The unselected first option in the country drop-down."
+ },
+ {
+ "id": "billing.postal.label",
+ "section": "Choose a plan",
+ "label": "Postal code label",
+ "text": "Postal code",
+ "context": "Label of the text field for a postal or ZIP code. Only required for the United States, Canada and India, where tax depends on it."
+ },
+ {
+ "id": "billing.postal.help",
+ "section": "Choose a plan",
+ "label": "Postal code help",
+ "text": "Needed in the United States, Canada and India, where tax depends on it. Leave it empty elsewhere. We do not keep it.",
+ "context": "Small print under the postal code field."
+ },
+ {
+ "id": "billing.quote.button",
+ "section": "Choose a plan",
+ "label": "Show prices button",
+ "text": "Show prices for this country",
+ "context": "A secondary button beside the country drop-down. It reloads the page with the exact yearly prices for the chosen country, without going to Stripe."
+ },
+ {
+ "id": "billing.quote.band",
+ "section": "Choose a plan",
+ "label": "Band explanation",
+ "text": "Prices for {{.Country}} are {{.Percent}}% of the base price, and so are the monthly inclusions.",
+ "context": "Shown after the owner asks for prices for a country. Explains the affordability band applied. Placeholders are the country name and the percentage (100, 75, 50 or 35).",
+ "notes": "Keep {{.Country}} and {{.Percent}}"
+ },
+ {
+ "id": "billing.consent.label",
+ "section": "Choose a plan",
+ "label": "Terms checkbox",
+ "text": "I have read the pricing page and agree to be charged yearly at the price shown at checkout, plus usage beyond the plan's inclusions billed monthly.",
+ "context": "The checkbox the owner must tick before the Pay button works. It is the consent record for the terms version in force. It should name what they agree to in one sentence."
+ },
+ {
+ "id": "billing.consent.link",
+ "section": "Choose a plan",
+ "label": "Pricing page link text",
+ "text": "the pricing page",
+ "context": "The words inside the consent sentence that link to carloku.com/pricing/. If the consent sentence changes, this must remain a phrase inside it."
+ },
+ {
+ "id": "billing.pay.button",
+ "section": "Choose a plan",
+ "label": "Pay button",
+ "text": "Continue to payment",
+ "context": "The main button of the form. It takes the owner to Stripe's hosted checkout page, where the final price with VAT is shown before any card details are entered."
+ },
+ {
+ "id": "billing.pay.note",
+ "section": "Choose a plan",
+ "label": "Under the pay button",
+ "text": "You pay by card at Stripe. Nothing is charged until you confirm there.",
+ "context": "Small print directly under the Continue to payment button."
+ },
+ {
+ "id": "billing.manage.heading",
+ "section": "Manage",
+ "label": "Section heading",
+ "text": "Cards, invoices and cancelling",
+ "context": "Heading over the button that opens Stripe's customer portal for an account that has paid before."
+ },
+ {
+ "id": "billing.manage.body",
+ "section": "Manage",
+ "label": "Section text",
+ "text": "Change your card, download invoices or cancel at Stripe. Cancelling keeps the plan until the end of the year you paid for.",
+ "context": "One or two sentences over the portal button."
+ },
+ {
+ "id": "billing.manage.button",
+ "section": "Manage",
+ "label": "Portal button",
+ "text": "Open billing at Stripe",
+ "context": "The button that opens Stripe's customer portal in the same tab."
+ },
+ {
+ "id": "billing.member.readonly",
+ "section": "Members",
+ "label": "Non-owner note",
+ "text": "Only an account owner can change the plan.",
+ "context": "Shown to a member who is not an owner, in place of the plan chooser and the portal button."
+ },
+ {
+ "id": "billing.error.unavailable",
+ "section": "Errors",
+ "label": "Unavailable",
+ "text": "Billing is not available right now. Nothing was charged. Try again in a few minutes.",
+ "context": "Red banner at the top of the page after a checkout or portal attempt failed for a reason on our side or Stripe's."
+ },
+ {
+ "id": "billing.error.pending",
+ "section": "Errors",
+ "label": "Previous checkout pending",
+ "text": "An earlier checkout for this account is still completing. Wait a minute and reload this page before trying again.",
+ "context": "Red banner when the owner tried to start a second checkout while an earlier one has not settled. Prevents a double purchase."
+ },
+ {
+ "id": "billing.error.conflict",
+ "section": "Errors",
+ "label": "Account changed",
+ "text": "The account changed while you were on this page, for example a member was added. Check the plan and try again.",
+ "context": "Red banner when the member count or plan state moved between loading the page and pressing pay."
+ }
]
diff --git a/implementation/carloku-paid-launch/stripe-provisioning.md b/implementation/carloku-paid-launch/stripe-provisioning.md
index e8328b32f..d1c257a76 100644
--- a/implementation/carloku-paid-launch/stripe-provisioning.md
+++ b/implementation/carloku-paid-launch/stripe-provisioning.md
@@ -280,10 +280,12 @@ covers `GET /v1/account`.
Deliberately not granted:
-- Subscription items write. `UpdateQuantity`
- (`POST /v1/subscription_items/{id}`) exists in `internal/billingstripe` but
- nothing in the console calls it at `9acaf6f6`. Add the permission in the same
- change that wires the call, not before.
+- Subscription items write, for now. The team quantity worker
+ (`internal/console/billingquantity.go`) calls
+ `POST /v1/subscription_items/{id}`, but only once `CONSOLE_BILLING_PRORATION`
+ is set, and that policy is undecided. Grant this permission in the same
+ change that sets the variable, not before: until then the key cannot raise a
+ quantity even by mistake.
- Events. Nothing calls `/v1/events`. The webhook handler authenticates by
signature and reconciles by reading the subscription.
- Promotion codes. Checkout resolves `allow_promotion_codes` on Stripe's side.
@@ -501,9 +503,10 @@ Found while deriving this plan. None blocks it, each should be decided.
read by `billingConfig` in `cmd/console/billing.go`, which `serve()` calls. The spec
names two variables; the console needs nine, and any invalid one is fatal at startup.
2. The spec says member changes update the subscription quantity, with additions
- prorated. `billingstripe.UpdateQuantity` exists, but no non-test code in `internal`
- or `cmd` calls it at `9acaf6f6`. So the plan leaves the Subscriptions write and
- Subscription items permissions out, and the spec's behaviour is not yet wired.
+ prorated. A worker now does this for additions, behind `CONSOLE_BILLING_PRORATION`
+ (unset: no provider call). Nothing lowers the quantity at renewal for members who
+ left. The plan leaves the Subscription items write permission out until the policy
+ is decided.
3. The spec says `automatic_tax` is enabled once registrations exist. The code always
sends it and, before creating any session, refuses unless both Irish registrations are
active and already started. The effect is the same and the code is stricter.
@@ -524,5 +527,5 @@ Found while deriving this plan. None blocks it, each should be decided.
and legal URLs.
- Whether `customer_update` should allow `name` and `tax_id`, or be off entirely.
- Whether `phone` should be allowed in the portal.
-- Whether to wire `UpdateQuantity` before launch (finding 2).
+- The proration policy: `immediate` or `next_invoice` (finding 2). The pricing page draft says "charged straight away".
- Whether the first live proof includes a real purchase and refund.
diff --git a/internal/awsbilling/classify.go b/internal/awsbilling/classify.go
index 7d3cb5b46..d7822e4cf 100644
--- a/internal/awsbilling/classify.go
+++ b/internal/awsbilling/classify.go
@@ -30,9 +30,15 @@ const (
// cannot classify. Never counted, and IsHold is true: the month's
// store-transfer meter must not be marked complete over them.
ReasonUnknownTransfer = "unknown_transfer_type"
- ReasonNoResource = "no_bucket_resource_id"
- ReasonNotHourly = "not_an_hourly_line"
- ReasonBadAmount = "non_positive_amount"
+ // A transfer type AWS normally does not charge the bucket owner for
+ // (CloudFront origin, inbound) that carries a cost above zero. It is
+ // not counted, because nothing says it is tenant transfer out, and it
+ // is a hold, because AWS charged something nobody has been shown to
+ // owe.
+ ReasonUnexpectedCharge = "unexpected_transfer_charge"
+ ReasonNoResource = "no_bucket_resource_id"
+ ReasonNotHourly = "not_an_hourly_line"
+ ReasonBadAmount = "non_positive_amount"
)
// IsHold reports whether a reason describes a CHARGED line the pipeline
@@ -42,7 +48,7 @@ const (
// absorbing it.
func IsHold(reason string) bool {
switch reason {
- case ReasonUnknownTransfer, ReasonNoResource, ReasonNotHourly, ReasonBadAmount:
+ case ReasonUnknownTransfer, ReasonUnexpectedCharge, ReasonNoResource, ReasonNotHourly, ReasonBadAmount:
return true
}
return false
@@ -110,14 +116,23 @@ func ChargedS3Transfer(l Line) (bucket string, bytes int64, charged bool, reason
return "", 0, false, ReasonNotUsage
}
out, why := classifyUsageType(l.UsageType)
- if !out && why != ReasonUnknownTransfer {
+ if !out && why == ReasonNotTransfer {
return "", 0, false, why
}
if l.Cost == nil || l.Cost.Sign() <= 0 {
- return "", 0, false, ReasonFree
+ // Uncharged transfer of any kind: never counted, never a hold.
+ if out || why == ReasonUnknownTransfer {
+ return "", 0, false, ReasonFree
+ }
+ return "", 0, false, why
}
- if !out {
+ switch {
+ case out:
+ case why == ReasonUnknownTransfer:
return "", 0, false, ReasonUnknownTransfer
+ default:
+ // CloudFront or inbound transfer with a cost above zero.
+ return "", 0, false, ReasonUnexpectedCharge
}
bucket, ok := bucketFromResource(l.ResourceID)
if !ok {
diff --git a/internal/awsbilling/classify_test.go b/internal/awsbilling/classify_test.go
index 4aa888121..2cc5e5a1d 100644
--- a/internal/awsbilling/classify_test.go
+++ b/internal/awsbilling/classify_test.go
@@ -55,10 +55,12 @@ func TestChargedS3TransferTable(t *testing.T) {
{"exponent amount", func() Line { return s3Line(t, "EUW1-DataTransfer-Out-Bytes", "1.5E-1", "0.0135") }, want{true, 150_000_000, ReasonCharged}},
{"free tier is zero cost", func() Line { return s3Line(t, "EUW1-DataTransfer-Out-Bytes", "40", "0") }, want{false, 0, ReasonFree}},
{"negative cost is not a charge", func() Line { return s3Line(t, "EUW1-DataTransfer-Out-Bytes", "1", "-0.09") }, want{false, 0, ReasonFree}},
- {"cloudfront origin", func() Line { return s3Line(t, "EUW1-CloudFront-Out-Bytes", "7", "0.02") }, want{false, 0, ReasonCloudFront}},
+ {"cloudfront origin", func() Line { return s3Line(t, "EUW1-CloudFront-Out-Bytes", "7", "0") }, want{false, 0, ReasonCloudFront}},
{"cloudfront in", func() Line { return s3Line(t, "EUW1-CloudFront-In-Bytes", "7", "0") }, want{false, 0, ReasonCloudFront}},
- {"transfer in", func() Line { return s3Line(t, "EUW1-DataTransfer-In-Bytes", "9", "0.01") }, want{false, 0, ReasonInbound}},
- {"inter-region in", func() Line { return s3Line(t, "EUW1-USE1-AWS-In-Bytes", "9", "0.01") }, want{false, 0, ReasonInbound}},
+ {"transfer in", func() Line { return s3Line(t, "EUW1-DataTransfer-In-Bytes", "9", "0") }, want{false, 0, ReasonInbound}},
+ {"inter-region in", func() Line { return s3Line(t, "EUW1-USE1-AWS-In-Bytes", "9", "0") }, want{false, 0, ReasonInbound}},
+ {"charged inbound transfer holds", func() Line { return s3Line(t, "EUW1-DataTransfer-In-Bytes", "9", "0.01") }, want{false, 0, ReasonUnexpectedCharge}},
+ {"charged cloudfront transfer holds", func() Line { return s3Line(t, "EUW1-CloudFront-Out-Bytes", "7", "0.07") }, want{false, 0, ReasonUnexpectedCharge}},
{"requests", func() Line { return s3Line(t, "EUW1-Requests-Tier2", "1000", "0.0004") }, want{false, 0, ReasonNotTransfer}},
{"storage", func() Line { return s3Line(t, "EUW1-TimedStorage-ByteHrs", "0.1", "0.0023") }, want{false, 0, ReasonNotTransfer}},
{"not s3", func() Line {
diff --git a/internal/awsbilling/delivery.go b/internal/awsbilling/delivery.go
index a041ce0e3..247dfe7aa 100644
--- a/internal/awsbilling/delivery.go
+++ b/internal/awsbilling/delivery.go
@@ -30,6 +30,14 @@ var ErrNoDelivery = errors.New("awsbilling: no report delivery for the period")
// guess, and summing them would count restatements twice.
var ErrAmbiguousDelivery = errors.New("awsbilling: cannot tell which delivery is newest")
+// ErrMutableDelivery means the delivery is in the overwrite layout: AWS
+// replaces its data files in place, one at a time, before it rewrites the
+// manifest, so a reader can see files from two deliveries under one
+// unchanged manifest and nothing in the bucket can prove otherwise. Only
+// the versioned layout, one immutable directory per delivery, is billing
+// authority.
+var ErrMutableDelivery = errors.New("awsbilling: delivery files can be overwritten in place")
+
// ErrDeliveryChanged means the manifest changed while its files were being
// read, so the files may belong to two different deliveries.
var ErrDeliveryChanged = errors.New("awsbilling: delivery changed during the read")
@@ -107,8 +115,9 @@ const deliveryStampLen = len("20060102T150405Z")
// newestManifest picks the authoritative manifest among a period's
// metadata keys.
//
-// One manifest (the overwrite layout) is the delivery. Several (the
-// versioned layout, one directory per delivery) are ordered by the UTC
+// One manifest is the delivery (ReadDelivery then refuses it unless it
+// sits in a versioned directory). Several (the versioned layout, one
+// directory per delivery) are ordered by the UTC
// stamp their directory name starts with. A key whose directory carries no
// parseable stamp, or two deliveries with the same stamp, make the order
// unknowable, and the answer is ErrAmbiguousDelivery rather than a guess.
@@ -243,6 +252,15 @@ func ReadDelivery(ctx context.Context, src Source, prefix string, period time.Ti
if err != nil {
return nil, err
}
+ // The delivery's own directory: "<stamp>-<id>/". A manifest that sits
+ // directly under the period's metadata prefix is the overwrite layout.
+ deliveryDir, _, versioned := strings.Cut(strings.TrimPrefix(manifestKey, metaPrefix), "/")
+ if !versioned || len(deliveryDir) < deliveryStampLen {
+ return nil, fmt.Errorf("%w: %s", ErrMutableDelivery, manifestKey)
+ }
+ if _, err := time.Parse("20060102T150405Z", deliveryDir[:deliveryStampLen]); err != nil {
+ return nil, fmt.Errorf("%w: %s", ErrMutableDelivery, manifestKey)
+ }
rawManifest, manifestETag, err := src.Get(ctx, manifestKey)
if err != nil {
return nil, fmt.Errorf("awsbilling: read %s: %w", manifestKey, err)
@@ -271,7 +289,9 @@ func ReadDelivery(ctx context.Context, src Source, prefix string, period time.Ti
if len(refs) == 0 {
return nil, fmt.Errorf("%w: manifest %s lists no data files", ErrMalformed, manifestKey)
}
- dataPrefix := DataPrefix(prefix, period)
+ // Files must lie in the delivery's own directory, which AWS never
+ // rewrites: a file anywhere else could change under the manifest.
+ dataPrefix := DataPrefix(prefix, period) + deliveryDir + "/"
d := &Delivery{
Period: period,
AssemblyID: id,
@@ -302,9 +322,19 @@ func ReadDelivery(ctx context.Context, src Source, prefix string, period time.Ti
return fmt.Errorf("%w: %s: row %d: billing period %s is not %s", ErrMalformed, key, l.Row,
l.PeriodStart.Format(time.RFC3339), PeriodLabel(period))
}
+ if !l.PeriodEnd.IsZero() && !l.PeriodEnd.Equal(periodEnd) {
+ return fmt.Errorf("%w: %s: row %d: billing period ends %s, not %s", ErrMalformed, key, l.Row,
+ l.PeriodEnd.Format(time.RFC3339), periodEnd.Format(time.RFC3339))
+ }
if l.ServiceCode != ServiceS3 {
return nil
}
+ // The usage window is checked against the month being read,
+ // not against whatever period the row claims for itself.
+ if l.IsUsage() && (l.UsageStart.Before(period) || l.UsageEnd.After(periodEnd)) {
+ return fmt.Errorf("%w: %s: row %d: usage %s..%s lies outside %s", ErrMalformed, key, l.Row,
+ l.UsageStart.Format(time.RFC3339), l.UsageEnd.Format(time.RFC3339), PeriodLabel(period))
+ }
if l.LineItemID == "" {
return fmt.Errorf("%w: %s: row %d: S3 line has no line item id", ErrMalformed, key, l.Row)
}
diff --git a/internal/awsbilling/delivery_test.go b/internal/awsbilling/delivery_test.go
index 8405e8415..e8494a9ca 100644
--- a/internal/awsbilling/delivery_test.go
+++ b/internal/awsbilling/delivery_test.go
@@ -11,15 +11,24 @@ import (
const testPrefix = "cur/carloku-transfer/"
+// overwriteLayout makes putDelivery write the manifest and files directly
+// under the period prefixes, as AWS's "overwrite" versioning does.
+const overwriteLayout = "overwrite"
+
var sept = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
// putDelivery writes a CUR 2.0 manifest and its data files. dir is "" for
// the overwrite layout, or a "<stamp>-<id>" directory for the versioned one.
func putDelivery(t *testing.T, src *MemSource, dir, id string, files map[string]string) string {
t.Helper()
- sub := ""
- if dir != "" {
- sub = dir + "/"
+ if dir == "" {
+ // The default is a versioned delivery: the only layout the reader
+ // accepts. overwriteLayout asks for the refused one.
+ dir = "20261003T000000Z-" + id
+ }
+ sub := dir + "/"
+ if dir == overwriteLayout {
+ sub = ""
}
var refs []string
for name, body := range files {
@@ -51,7 +60,7 @@ func readAll(t *testing.T, src Source) (*Delivery, []Line, error) {
func TestReadDeliveryFinalAndPinned(t *testing.T) {
src := NewMemSource()
body := string(readFixture(t, "cur2-2026-09.csv"))
- putDelivery(t, src, "", "exec-1", map[string]string{"part-00001.csv": body})
+ manifestKey := putDelivery(t, src, "", "exec-1", map[string]string{"part-00001.csv": body})
d, lines, err := readAll(t, src)
if err != nil {
t.Fatal(err)
@@ -59,6 +68,11 @@ func TestReadDeliveryFinalAndPinned(t *testing.T) {
if !d.Final || d.NotFinal != "" {
t.Fatalf("every S3 line carries an invoice id: Final=%v NotFinal=%q", d.Final, d.NotFinal)
}
+ // Pinned means the digests are those of the bytes that were read.
+ rawManifest, _, _ := src.Get(context.Background(), manifestKey)
+ if d.ManifestKey != manifestKey || d.ManifestSHA256 != digest(rawManifest) || d.Files[0].SHA256 != digest([]byte(body)) {
+ t.Fatalf("digests do not match the bytes read: %+v", d)
+ }
if d.AssemblyID != "exec-1" || len(d.Files) != 1 || len(d.Files[0].SHA256) != 64 || d.Files[0].Rows != 10 || len(d.ManifestSHA256) != 64 {
t.Fatalf("evidence not pinned: %+v", d)
}
@@ -184,29 +198,91 @@ func TestReadDeliveryDedup(t *testing.T) {
func TestReadDeliveryManifestRefusals(t *testing.T) {
body := string(readFixture(t, "cur2-2026-09.csv"))
+ const dir = "20261003T000000Z-e/"
mk := func(m map[string]any) *MemSource {
src := NewMemSource()
- src.Put(DataPrefix(testPrefix, sept)+"p.csv", []byte(body))
+ src.Put(DataPrefix(testPrefix, sept)+dir+"p.csv", []byte(body))
+ src.Put(DataPrefix(testPrefix, sept)+"stray.csv", []byte(body))
raw, _ := json.Marshal(m)
- src.Put(MetadataPrefix(testPrefix, sept)+"x-Manifest.json", raw)
+ src.Put(MetadataPrefix(testPrefix, sept)+dir+"x-Manifest.json", raw)
return src
}
period := map[string]string{"start": "2026-09-01T00:00:00.000Z", "end": "2026-10-01T00:00:00.000Z"}
- good := "s3://b/" + DataPrefix(testPrefix, sept) + "p.csv"
+ good := "s3://b/" + DataPrefix(testPrefix, sept) + dir + "p.csv"
cases := map[string]map[string]any{
"no id": {"billingPeriod": period, "dataFiles": []string{good}},
"wrong period": {"executionId": "e", "billingPeriod": map[string]string{"start": "2026-08-01T00:00:00.000Z", "end": "2026-09-01T00:00:00.000Z"}, "dataFiles": []string{good}},
"no files": {"executionId": "e", "billingPeriod": period},
"outside data": {"executionId": "e", "billingPeriod": period, "dataFiles": []string{"s3://b/cur/other/secret.csv"}},
- "dot-dot": {"executionId": "e", "billingPeriod": period, "dataFiles": []string{"s3://b/" + DataPrefix(testPrefix, sept) + "../../x.csv"}},
+ "dot-dot": {"executionId": "e", "billingPeriod": period, "dataFiles": []string{"s3://b/" + DataPrefix(testPrefix, sept) + dir + "../../x.csv"}},
"listed twice": {"executionId": "e", "billingPeriod": period, "dataFiles": []string{good, good}},
"partial month": {"executionId": "e", "billingPeriod": map[string]string{"start": "2026-09-01T00:00:00.000Z", "end": "2026-09-15T00:00:00.000Z"}, "dataFiles": []string{good}},
+ // A file of the right period but outside the delivery's own
+ // directory could be replaced under the manifest.
+ "outside the delivery directory": {"executionId": "e", "billingPeriod": period, "dataFiles": []string{"s3://b/" + DataPrefix(testPrefix, sept) + "stray.csv"}},
}
for name, m := range cases {
if _, _, err := readAll(t, mk(m)); !errors.Is(err, ErrMalformed) {
t.Errorf("%s: err = %v, want ErrMalformed", name, err)
}
}
+ // Control: the same fixture with the good manifest reads.
+ if _, _, err := readAll(t, mk(map[string]any{"executionId": "e", "billingPeriod": period, "dataFiles": []string{good}})); err != nil {
+ t.Fatalf("control: %v", err)
+ }
+}
+
+// The overwrite layout is never billing authority. AWS replaces its files
+// one at a time and rewrites the manifest last, so new file one, old file
+// two and the old manifest can sit together, every line invoiced, and
+// nothing in the bucket can show the mix. The reader refuses the layout.
+func TestReadDeliveryRefusesTheOverwriteLayout(t *testing.T) {
+ src := NewMemSource()
+ body := string(readFixture(t, "cur2-2026-09.csv"))
+ putDelivery(t, src, overwriteLayout, "exec-1", map[string]string{"part-00001.csv": body})
+ if _, _, err := readAll(t, src); !errors.Is(err, ErrMutableDelivery) {
+ t.Fatalf("err = %v, want ErrMutableDelivery", err)
+ }
+ // A manifest in a directory with no delivery stamp is no better.
+ src = NewMemSource()
+ putDelivery(t, src, "latest", "exec-1", map[string]string{"part-00001.csv": body})
+ if _, _, err := readAll(t, src); !errors.Is(err, ErrMutableDelivery) {
+ t.Fatalf("unstamped directory: err = %v, want ErrMutableDelivery", err)
+ }
+}
+
+// A row may not carry usage from outside the month being read, whatever
+// billing period it claims for itself: an invoiced row stamped September
+// to November with an October hour would bill October's transfer to
+// September.
+func TestReadDeliveryRefusesUsageOutsideTheMonth(t *testing.T) {
+ fixture := string(readFixture(t, "cur2-2026-09.csv"))
+ header, rows, _ := strings.Cut(fixture, "\n")
+ first, _, _ := strings.Cut(rows, "\n")
+ stretched := strings.NewReplacer(
+ "2026-09-01T00:00:00Z,2026-10-01T00:00:00Z", "2026-09-01T00:00:00Z,2026-11-01T00:00:00Z",
+ "2026-09-03T10:00:00Z", "2026-10-01T00:00:00Z",
+ "2026-09-03T11:00:00Z", "2026-10-01T01:00:00Z",
+ ).Replace(first)
+ if stretched == first {
+ t.Fatal("fixture row did not take the replacement")
+ }
+ src := NewMemSource()
+ putDelivery(t, src, "", "e", map[string]string{"p.csv": header + "\n" + stretched + "\n"})
+ if _, _, err := readAll(t, src); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("err = %v, want ErrMalformed", err)
+ }
+ // The same hour with the honest period end is refused too: it is not
+ // September usage.
+ late := strings.NewReplacer(
+ "2026-09-03T10:00:00Z", "2026-10-01T00:00:00Z",
+ "2026-09-03T11:00:00Z", "2026-10-01T01:00:00Z",
+ ).Replace(first)
+ src = NewMemSource()
+ putDelivery(t, src, "", "e", map[string]string{"p.csv": header + "\n" + late + "\n"})
+ if _, _, err := readAll(t, src); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("october hour: err = %v, want ErrMalformed", err)
+ }
}
// A line from another billing period inside this period's delivery
@@ -227,8 +303,8 @@ func TestReadDeliveryForeignPeriodLine(t *testing.T) {
}
}
-// The overwrite layout replaces files in place: a manifest that changes
-// while its files are read means the files may straddle two deliveries.
+// A manifest that changes while its files are read means the files may
+// belong to two deliveries.
func TestReadDeliveryManifestChangedDuringRead(t *testing.T) {
src := NewMemSource()
body := string(readFixture(t, "cur2-2026-09.csv"))
@@ -236,7 +312,8 @@ func TestReadDeliveryManifestChangedDuringRead(t *testing.T) {
src.OnGet = func(key string) {
if strings.HasSuffix(key, "p.csv") {
src.OnGet = nil
- putDelivery(t, src, "", "exec-2", map[string]string{"p.csv": body})
+ raw, _, _ := src.Get(context.Background(), manifestKey)
+ src.Put(manifestKey, append(raw, '\n'))
}
}
_, _, err := readAll(t, src)
diff --git a/internal/console/billingpage.go b/internal/console/billingpage.go
index 9beeef99a..dfb5c53ce 100644
--- a/internal/console/billingpage.go
+++ b/internal/console/billingpage.go
@@ -84,6 +84,9 @@ type billingPlanRow struct {
// checkout door would charge; PerMember is the per-member yearly price
// for team plans. Both are formatted euro amounts ("€24", "€8.40").
Price, PerMember string
+ // Unavailable: the checkout door would refuse this plan for the
+ // account's member count. The row is shown and cannot be chosen.
+ Unavailable bool
}
type billingQuote struct {
@@ -256,15 +259,20 @@ func billingPlanRows(band string, members int64) []billingPlanRow {
if err != nil {
continue
}
+ // Exactly the quantity the checkout door computes. A plan that
+ // cannot take it (Personal with more than one member) is shown
+ // as unavailable, priced at its own quantity, never offered at a
+ // price the door would then refuse.
qty := max(members, def.MinQuantity)
- if qty > def.MaxQuantity {
- qty = def.MaxQuantity
- }
+ row := billingPlanRow{Plan: plan, Name: billingPlanNames[plan], Team: def.MinQuantity > 1}
q, err := plans.Quote(plans.CurrentTermsVersion, plan, band, qty)
if err != nil {
- continue
+ row.Unavailable = true
+ if q, err = plans.Quote(plans.CurrentTermsVersion, plan, band, def.MinQuantity); err != nil {
+ continue
+ }
}
- row := billingPlanRow{Plan: plan, Name: billingPlanNames[plan], Team: def.MinQuantity > 1, Price: formatEuroMicro(q.AnnualPriceMicroEUR)}
+ row.Price = formatEuroMicro(q.AnnualPriceMicroEUR)
if row.Team {
row.PerMember = formatEuroMicro(q.AnnualPriceMicroEUR / q.Quantity)
}
diff --git a/internal/console/billingpage_test.go b/internal/console/billingpage_test.go
index 564df819b..848130a5d 100644
--- a/internal/console/billingpage_test.go
+++ b/internal/console/billingpage_test.go
@@ -341,3 +341,29 @@ func TestBillingPageCountriesSource(t *testing.T) {
}
}
}
+
+// The chooser prices what the checkout door would charge. With two members
+// Personal cannot be bought, so it is shown and cannot be chosen, rather
+// than offered at a price the door then refuses.
+func TestBillingPageDoesNotOfferAPlanCheckoutWouldRefuse(t *testing.T) {
+ fx, _, acct := newBillingFixture(t)
+ if err := fx.st.AddMember(acct.ID, "second@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/billing?country=IE"))
+ m := mainOf(t, body)
+ for _, want := range []string{
+ `name="plan" value="personal" required disabled`,
+ "Personal is for one member. This account has 2.",
+ `name="plan" value="community" required>`,
+ } {
+ if !strings.Contains(m, want) {
+ t.Fatalf("missing %q in:\n%s", want, m)
+ }
+ }
+ // The door agrees: Personal for two members is refused.
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", time.Now()); err == nil {
+ t.Fatal("checkout accepted Personal for two members; the page and the door disagree")
+ }
+}
diff --git a/internal/console/durabilityfold.go b/internal/console/durabilityfold.go
index 440d50d82..7767a7d51 100644
--- a/internal/console/durabilityfold.go
+++ b/internal/console/durabilityfold.go
@@ -35,10 +35,30 @@ import (
// ledger into the sweep's per-account meters and reports whether the day is
// complete for billing. It never sets the sweep's covered-day flag: that
// legacy number is metrics, edge and storage only.
-func (s *Server) foldDurabilityDay(ctx context.Context, cands []sourceCandidate, d time.Time, date string, lifecycleOK bool, sweep *usageSweep, touch func(string) map[string]float64) bool {
+//
+// active is every box another per-box source knows (metrics, edge), keyed
+// by that source. A box that was alive on the day, by its own metrics or
+// edge expectation, and has no ledger directory at all is a box whose
+// ledger never opened: its replication ran unmetered and it must not be
+// left out of the question. It holds the day.
+func (s *Server) foldDurabilityDay(ctx context.Context, cands []sourceCandidate, active map[string][]sourceCandidate, d time.Time, date string, lifecycleOK bool, sweep *usageSweep, touch func(string) map[string]float64) bool {
// No candidate proves nothing: a deployment that has never published a
// ledger has no evidence that nothing replicated.
complete := lifecycleOK && len(cands) > 0
+ ledgered := map[string]bool{}
+ for _, cand := range cands {
+ ledgered[cand.Box] = true
+ }
+ for source, others := range active {
+ for _, other := range others {
+ if ledgered[other.Box] {
+ continue
+ }
+ if _, _, expected := other.window(source, d); expected {
+ complete = false
+ }
+ }
+ }
for _, cand := range cands {
box := cand.Box
wFrom, wTo, expected := cand.window(usage.SourceDurability, d)
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go
index a94f447b3..54983b465 100644
--- a/internal/console/economicspass.go
+++ b/internal/console/economicspass.go
@@ -813,7 +813,7 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re
// Replication writes (durabilityfold.go): folded as a lower bound
// whatever the proof says; the day counts only when every expected
// box's ledger attests it box-wide.
- durabilityOK := s.foldDurabilityDay(ctx, durabilityCands, d, date, lifecycleOK, sweep, touch)
+ durabilityOK := s.foldDurabilityDay(ctx, durabilityCands, map[string][]sourceCandidate{usage.SourceMetrics: metricsCands, usage.SourceEdge: edgeCands}, d, date, lifecycleOK, sweep, touch)
// Storage is read ONCE here and cached (sweep.Storage), the same way
// census is, so foldStorageAndCensus folds the EXACT bytes this
@@ -1248,9 +1248,48 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
log.Printf("console: statements pass: %s: %v", p.month, err)
}
}
+ // Catch-up for a month the store-transfer report held open
+ // (storeEgressResult.HoldClose) past the two periods above. Without it
+ // a month still waiting on an invoice or an ownership repair when the
+ // following month ends would never be swept again and could never
+ // close. Only a deployment with a report configured can be in that
+ // state, so only it pays for the extra reads.
+ if s.cfg.StoreEgressReportPrefix != "" {
+ for back := 2; back <= statementCatchUpMonths; back++ {
+ start := firstOfThisMonth.AddDate(0, -back, 0)
+ month := usage.MonthOf(start)
+ if !s.statementStillOpen(ctx, month) {
+ continue
+ }
+ if err := s.statementsForMonth(ctx, month, start, start.AddDate(0, 1, -1), nowUTC, true, routes); err != nil {
+ log.Printf("console: statements pass: %s (catch-up): %v", month, err)
+ }
+ }
+ }
return nil
}
+// statementCatchUpMonths bounds how far back statementsPass looks for a
+// month held open. Six months is far longer than AWS takes to invoice and
+// long enough for an ownership hole to be noticed and repaired.
+const statementCatchUpMonths = 6
+
+// statementStillOpen reports whether the platform account's statement for
+// month exists and is not closed. Every pass writes that account's
+// statement with the rest, so it stands for the month. A month with no
+// statement at all predates the deployment, or metering, and is left alone.
+func (s *Server) statementStillOpen(ctx context.Context, month string) bool {
+ raw, _, err := s.cfg.Releases.Get(ctx, usage.StatementKey(release.OpsAccount, month))
+ if err != nil {
+ return false
+ }
+ var st usage.Statement
+ if json.Unmarshal(raw, &st) != nil {
+ return false
+ }
+ return !st.Closed
+}
+
// statementsForMonth folds one month's metrics, edge and storage days (plus
// census, for app_months, and fleet box-hours, for fleet_box_hours_server/
// device) into one usage.Statement per account, create-or-replace via
diff --git a/internal/console/meterfolds_test.go b/internal/console/meterfolds_test.go
index faad6e6cb..de5b4b77d 100644
--- a/internal/console/meterfolds_test.go
+++ b/internal/console/meterfolds_test.go
@@ -35,12 +35,15 @@ func curLine(id, invoice, usageType, bucket, hour, gb, rate, cost string) string
return strings.Join([]string{id, hour + "/" + end, invoice, "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z", "271376211898", "271376211898", "Usage", hour, end, "AmazonS3", usageType, bucket, gb, rate, cost, "test line", "AmazonS3"}, ",") + "\n"
}
-// putCURDelivery writes an overwrite-layout CUR 2.0 delivery for September
-// into the deployment bucket under foldReportPrefix.
+// putCURDelivery writes a versioned CUR 2.0 delivery for September into the
+// deployment bucket under foldReportPrefix.
func putCURDelivery(t *testing.T, fx *consoleFixture, id, body string) {
t.Helper()
ctx := context.Background()
- dataKey := awsbilling.DataPrefix(foldReportPrefix, foldSept) + "part-00001.csv"
+ // The versioned layout, one immutable directory per delivery: the only
+ // one the reader accepts. Later ids sort later.
+ dir := "20261003T00000" + id[len(id)-1:] + "Z-" + id + "/"
+ dataKey := awsbilling.DataPrefix(foldReportPrefix, foldSept) + dir + "part-00001.csv"
put := func(key string, raw []byte) {
_, etag, err := fx.rel.Get(ctx, key)
if err != nil {
@@ -56,7 +59,7 @@ func putCURDelivery(t *testing.T, fx *consoleFixture, id, body string) {
"billingPeriod": map[string]string{"start": "2026-09-01T00:00:00.000Z", "end": "2026-10-01T00:00:00.000Z"},
"dataFiles": []string{"s3://deployment-bucket/" + dataKey},
})
- put(awsbilling.MetadataPrefix(foldReportPrefix, foldSept)+"carloku-transfer-Manifest.json", manifest)
+ put(awsbilling.MetadataPrefix(foldReportPrefix, foldSept)+dir+"carloku-transfer-Manifest.json", manifest)
}
func septemberStatement(t *testing.T, fx *consoleFixture, acct string) usage.Statement {
@@ -76,7 +79,8 @@ func near(a, b float64) bool { d := a - b; return d < 1e-9 && d > -1e-9 }
func openOwner(t *testing.T, fx *consoleFixture, bucket, acct, app string) {
t.Helper()
- if err := objstore.OpenBucketOwner(context.Background(), fx.rel, bucket, acct, app, time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC)); err != nil {
+ // minted: the ledger is created by the grant that mints the bucket.
+ if err := objstore.OpenBucketOwner(context.Background(), fx.rel, bucket, acct, app, time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC), true); err != nil {
t.Fatal(err)
}
}
@@ -366,3 +370,90 @@ func TestDurabilityFoldNeedsEveryBox(t *testing.T) {
t.Errorf("durability source days = %d, want 0: box2 was silent", got)
}
}
+
+// A ledger written for a bucket that already existed (an adopted bucket, or
+// a store re-granted after ledgers were introduced) names an owner without
+// being able to vouch that it names everyone. Its charged transfer holds
+// the month instead of landing on whoever happens to be recorded.
+func TestStoreEgressFoldHoldsOnAnUnprovenLedger(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ fx.srv.cfg.StoreEgressReportPrefix = foldReportPrefix
+ if err := objstore.OpenBucketOwner(ctx, fx.rel, foldBucketA, foldAcctA, "shop", time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC), false); err != nil {
+ t.Fatal(err)
+ }
+ putCURDelivery(t, fx, "exec-1", curLine("l1", "INV-1", "EUW1-DataTransfer-Out-Bytes", foldBucketA, "2026-09-03T10:00:00Z", "2", "0.09", "0.18"))
+ meters := map[string]map[string]float64{}
+ res := fx.srv.foldStoreEgress(ctx, foldSept, meters)
+ if res.Complete || res.Reason != storeEgressUnowned || len(res.Unowned) != 1 {
+ t.Fatalf("result %+v", res)
+ }
+ if len(meters) != 0 {
+ t.Fatalf("an unproven ledger's owner was billed: %v", meters)
+ }
+}
+
+// A charged CloudFront line is a charge nobody has been shown to owe: the
+// month holds rather than completing with that charge counted as zero.
+func TestStoreEgressFoldHoldsOnAChargedCloudFrontLine(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.StoreEgressReportPrefix = foldReportPrefix
+ openOwner(t, fx, foldBucketA, foldAcctA, "shop")
+ putCURDelivery(t, fx, "exec-1", curLine("l1", "INV-1", "EUW1-CloudFront-Out-Bytes", foldBucketA, "2026-09-03T10:00:00Z", "7", "0.01", "0.07"))
+ res := fx.srv.foldStoreEgress(context.Background(), foldSept, map[string]map[string]float64{})
+ if res.Complete || res.Reason != storeEgressUnclassified || res.Holds[awsbilling.ReasonUnexpectedCharge] != 1 {
+ t.Fatalf("result %+v", res)
+ }
+}
+
+// A box that reports metrics or edge traffic but has no durability ledger
+// at all is a box whose ledger never opened. It must hold the day: an
+// attested neighbour cannot vouch for replication it never saw.
+func TestDurabilityFoldHoldsForAnActiveBoxWithNoLedger(t *testing.T) {
+ fx := newConsoleFixture(t)
+ publishDurabilityDay(t, fx, "box1", true, tenantWrites(t))
+ if err := fx.srv.statementsPass(context.Background(), time.Date(2026, 9, 20, 6, 0, 0, 0, time.UTC)); err != nil {
+ t.Fatal(err)
+ }
+ if got := septemberStatement(t, fx, foldAcctA).SourceDays[usage.SourceDurability]; got != 1 {
+ t.Fatalf("control: durability source days = %d, want 1 with box1 alone", got)
+ }
+ // box2 is known to the edge source and has never published a ledger.
+ fx2 := newConsoleFixture(t)
+ publishDurabilityDay(t, fx2, "box1", true, tenantWrites(t))
+ putStoreJSON(t, fx2.rel, usage.EdgeDayKey("box2", "2026-09-17"), watchedEdgeDay("box2", "2026-09-17"))
+ if err := fx2.srv.statementsPass(context.Background(), time.Date(2026, 9, 20, 6, 0, 0, 0, time.UTC)); err != nil {
+ t.Fatal(err)
+ }
+ if got := septemberStatement(t, fx2, foldAcctA).SourceDays[usage.SourceDurability]; got != 0 {
+ t.Errorf("durability source days = %d, want 0: box2 is active and has no ledger", got)
+ }
+}
+
+// A month the report holds open past the following month is still swept,
+// so an ownership repair in November closes September.
+func TestStoreEgressHeldMonthIsRevisitedAfterTheNextMonthEnds(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ fx.srv.cfg.StoreEgressReportPrefix = foldReportPrefix
+ openOwner(t, fx, foldBucketA, foldAcctA, "shop")
+ putCURDelivery(t, fx, "exec-1",
+ curLine("l1", "INV-1", "EUW1-DataTransfer-Out-Bytes", foldBucketA, "2026-09-03T10:00:00Z", "2", "0.09", "0.18")+
+ curLine("l3", "INV-1", "EUW1-DataTransfer-Out-Bytes", foldBucketB, "2026-09-04T00:00:00Z", "5", "0.09", "0.45"))
+ for _, now := range []time.Time{time.Date(2026, 10, 5, 6, 0, 0, 0, time.UTC), time.Date(2026, 11, 5, 6, 0, 0, 0, time.UTC)} {
+ if err := fx.srv.statementsPass(ctx, now); err != nil {
+ t.Fatal(err)
+ }
+ if septemberStatement(t, fx, foldAcctA).Closed {
+ t.Fatalf("September closed over an ownership hole at %s", now.Format("2006-01-02"))
+ }
+ }
+ openOwner(t, fx, foldBucketB, foldAcctB, "blog")
+ if err := fx.srv.statementsPass(ctx, time.Date(2026, 11, 5, 7, 0, 0, 0, time.UTC)); err != nil {
+ t.Fatal(err)
+ }
+ a := septemberStatement(t, fx, foldAcctA)
+ if !a.Closed || !a.MeterComplete(usage.MeterStoreEgressGB) {
+ t.Fatalf("September after a November repair: closed=%v days=%d", a.Closed, a.SourceDays[usage.SourceStoreEgress])
+ }
+}
diff --git a/internal/console/templates/billing.html b/internal/console/templates/billing.html
index 3eec5d1dc..f3167979f 100644
--- a/internal/console/templates/billing.html
+++ b/internal/console/templates/billing.html
@@ -57,11 +57,11 @@
<table>
{{range .Plans}}
<tr>
- <td><label><input type="radio" name="plan" value="{{.Plan}}" required>
+ <td><label><input type="radio" name="plan" value="{{.Plan}}" required{{if .Unavailable}} disabled{{end}}>
{{if eq .Plan "personal"}}<!-- billing.plan.personal.name -->Personal{{else if eq .Plan "community"}}<!-- billing.plan.community.name -->Community{{else if eq .Plan "business"}}<!-- billing.plan.business.name -->Business{{end}}</label></td>
<td>{{if .Team}}<strong>{{.PerMember}}</strong> <!-- billing.price.permember -->per member a year
<br><span class="small">{{.Price}} <!-- billing.price.peryear -->a year</span>{{else}}<strong>{{.Price}}</strong> <!-- billing.price.peryear -->a year{{end}}</td>
- <td class="small">{{if eq .Plan "personal"}}<!-- billing.plan.personal.blurb -->One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.{{else if eq .Plan "community"}}<!-- billing.plan.community.blurb -->Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.{{else if eq .Plan "business"}}<!-- billing.plan.business.blurb -->Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.{{end}}</td>
+ <td class="small">{{if eq .Plan "personal"}}<!-- billing.plan.personal.blurb -->One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.{{else if eq .Plan "community"}}<!-- billing.plan.community.blurb -->Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.{{else if eq .Plan "business"}}<!-- billing.plan.business.blurb -->Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.{{end}}{{if .Unavailable}}<br><!-- billing.plan.personal.unavailable -->Personal is for one member. This account has {{$.Billing.Members}}.{{end}}</td>
</tr>
{{end}}
</table>
diff --git a/internal/objstore/ownership.go b/internal/objstore/ownership.go
index 4e8666f7b..43c74e8b5 100644
--- a/internal/objstore/ownership.go
+++ b/internal/objstore/ownership.go
@@ -41,9 +41,11 @@ import (
// block's own time, so no tenant can move a byte out of a bucket before
// the ledger names them. Closed AFTER the bucket is deleted (Teardown),
// so no byte can leave it after the ledger stops naming them.
-// - OwnerAt answers only when every interval touching the hour names the
-// same account and app. Two owners in one hour, or none, is not an
-// answer, and the caller must hold rather than choose.
+// - OwnerAt answers only for a ledger created by the grant that minted
+// the bucket (CompleteSince), and only when every interval touching
+// the hour names the same account and app. Two owners in one hour,
+// none, or a bucket that existed before its ledger, is not an answer,
+// and the caller must hold rather than choose.
// BucketOwnershipPrefix is where ownership ledgers live.
const BucketOwnershipPrefix = "usage/buckets/"
@@ -72,9 +74,19 @@ func (iv OwnerInterval) overlaps(from, to time.Time) bool {
// BucketOwnership is one bucket's ownership ledger.
type BucketOwnership struct {
- V int `json:"v"` // 1
- Bucket string `json:"bucket"`
- Intervals []OwnerInterval `json:"intervals"`
+ V int `json:"v"` // 1
+ Bucket string `json:"bucket"`
+ // CompleteSince is the instant from which this ledger is known to name
+ // EVERYONE the platform gave access to the bucket. It is set only when
+ // the ledger is created by the grant that mints the bucket, before any
+ // credential for it exists: from then on every grantee has an interval
+ // opened before its credentials, so the ledger cannot be missing one.
+ // A ledger created for a bucket that already existed (an adopted
+ // bucket, or a store provisioned before ledgers) has none: someone may
+ // have held access the ledger never saw, and OwnerAt answers nothing
+ // for it. Write-once.
+ CompleteSince *time.Time `json:"complete_since,omitempty"`
+ Intervals []OwnerInterval `json:"intervals"`
}
func validOwnershipTime(t time.Time) error {
@@ -151,6 +163,12 @@ func appendOnly(prev, next *BucketOwnership) error {
if prev == nil {
return nil
}
+ switch {
+ case prev.CompleteSince == nil && next.CompleteSince != nil:
+ return fmt.Errorf("objstore: ownership ledger %s: completeness may be claimed only when the ledger is created", prev.Bucket)
+ case prev.CompleteSince != nil && (next.CompleteSince == nil || !next.CompleteSince.Equal(*prev.CompleteSince)):
+ return fmt.Errorf("objstore: ownership ledger %s: completeness was rewritten", prev.Bucket)
+ }
if len(next.Intervals) < len(prev.Intervals) {
return fmt.Errorf("objstore: ownership ledger %s: a write may not remove an interval", prev.Bucket)
}
@@ -223,7 +241,12 @@ func writeBucketOwnership(ctx context.Context, s release.Store, bucket string, c
// stand, OwnerAt reports every hour they share as unresolved, and the
// store-transfer meter holds the month with the bucket named, which is
// the outcome a person needs to see.
-func OpenBucketOwner(ctx context.Context, s release.Store, bucket, acct, app string, from time.Time) error {
+//
+// minted says this grant creates the bucket and no credential for it exists
+// yet. Only then, and only when this call creates the ledger, is the ledger
+// marked complete from `from` (BucketOwnership.CompleteSince). Every other
+// ledger records what is known and resolves no owner.
+func OpenBucketOwner(ctx context.Context, s release.Store, bucket, acct, app string, from time.Time, minted bool) error {
if !release.ValidAccount(acct) || !release.ValidApp(app) {
return fmt.Errorf("objstore: open bucket owner: invalid owner %q/%q", acct, app)
}
@@ -234,6 +257,10 @@ func OpenBucketOwner(ctx context.Context, s release.Store, bucket, acct, app str
return writeBucketOwnership(ctx, s, bucket, func(b *BucketOwnership) (*BucketOwnership, error) {
if b == nil {
b = &BucketOwnership{V: 1, Bucket: bucket}
+ if minted {
+ since := from
+ b.CompleteSince = &since
+ }
}
for _, iv := range b.Intervals {
if iv.open() && iv.Account == acct && iv.App == app {
@@ -279,19 +306,27 @@ func CloseBucketOwner(ctx context.Context, s release.Store, bucket, acct, app st
}
// OwnerAt answers who owned the bucket for the whole UTC hour containing
-// hour. ok is true only when at least one interval touches the hour and
-// every interval that does names the same account and app.
+// hour. ok is true only when the ledger is complete (CompleteSince), at
+// least one interval touches the hour, and every interval that does names
+// the same account and app.
//
-// An hour only partly covered by its one owner (the store was provisioned
-// at 10:20, or deleted at 10:40) still resolves to that owner: the ledger
-// is opened before credentials exist and closed after the bucket is gone,
-// so no one else could have moved bytes out of it in the rest of that hour.
+// An hour only partly covered by its one owner (the bucket was minted at
+// 10:20, or deleted at 10:40) still resolves to that owner, and only
+// because the ledger is complete: the bucket did not exist before it was
+// minted, the ledger was opened before any credential existed and closed
+// after the bucket was gone, so no one else could have moved bytes out of
+// it in the rest of that hour. For an adopted or backfilled bucket none of
+// that holds, which is why such a ledger resolves nothing.
func (b *BucketOwnership) OwnerAt(hour time.Time) (acct, app string, ok bool) {
- if b == nil {
+ if b == nil || b.CompleteSince == nil {
+ // No ledger, or one that cannot vouch for having seen everyone.
return "", "", false
}
from := hour.UTC().Truncate(time.Hour)
to := from.Add(time.Hour)
+ if !b.CompleteSince.Before(to) {
+ return "", "", false
+ }
for _, iv := range b.Intervals {
if !iv.overlaps(from, to) {
continue
diff --git a/internal/objstore/ownership_test.go b/internal/objstore/ownership_test.go
index 0c6ff5bce..e7112f684 100644
--- a/internal/objstore/ownership_test.go
+++ b/internal/objstore/ownership_test.go
@@ -29,12 +29,12 @@ func mustLedger(t *testing.T, s release.Store, bucket string) *BucketOwnership {
func TestOpenBucketOwnerIsIdempotentAndNeverMovesFrom(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0); err != nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0, true); err != nil {
t.Fatal(err)
}
// Earlier and later opens for the same owner change nothing.
for _, at := range []time.Time{t0.Add(-48 * time.Hour), t0.Add(48 * time.Hour)} {
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", at); err != nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", at, true); err != nil {
t.Fatal(err)
}
}
@@ -54,7 +54,7 @@ func TestCloseBucketOwner(t *testing.T) {
if b, _, _ := ReadBucketOwnership(ctx, s, ledgerBucket); b != nil {
t.Fatal("closing an absent ledger must not create one")
}
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0); err != nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0, true); err != nil {
t.Fatal(err)
}
end := t0.Add(5 * time.Hour)
@@ -72,7 +72,7 @@ func TestCloseBucketOwner(t *testing.T) {
}
// Reopening appends; the closed interval stands.
again := t0.Add(72 * time.Hour)
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", again); err != nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", again, true); err != nil {
t.Fatal(err)
}
b = mustLedger(t, s, ledgerBucket)
@@ -84,7 +84,7 @@ func TestCloseBucketOwner(t *testing.T) {
func TestCloseBeforeFromClampsToEmpty(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0); err != nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0, true); err != nil {
t.Fatal(err)
}
if err := CloseBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0.Add(-time.Hour)); err != nil {
@@ -101,9 +101,15 @@ func TestCloseBeforeFromClampsToEmpty(t *testing.T) {
func TestOwnerAt(t *testing.T) {
end := time.Date(2026, 9, 3, 14, 40, 0, 0, time.UTC)
- b := &BucketOwnership{V: 1, Bucket: ledgerBucket, Intervals: []OwnerInterval{
+ since := t0
+ b := &BucketOwnership{V: 1, Bucket: ledgerBucket, CompleteSince: &since, Intervals: []OwnerInterval{
{Account: "bab", App: "titogo", From: t0, Until: &end},
}}
+ // The same intervals without the completeness claim resolve nothing.
+ unproven := &BucketOwnership{V: 1, Bucket: ledgerBucket, Intervals: b.Intervals}
+ if _, _, ok := unproven.OwnerAt(time.Date(2026, 9, 3, 12, 0, 0, 0, time.UTC)); ok {
+ t.Fatal("a ledger that does not claim completeness resolved an owner")
+ }
cases := []struct {
hour time.Time
ok bool
@@ -136,14 +142,14 @@ func TestOwnerAt(t *testing.T) {
func TestOwnerAtRefusesSharedHour(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0); err != nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0, true); err != nil {
t.Fatal(err)
}
if err := CloseBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0.Add(time.Hour)); err != nil {
t.Fatal(err)
}
// Another app picks the bucket up in the same hour the first let go.
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bac", "other", t0.Add(90*time.Minute)); err != nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bac", "other", t0.Add(90*time.Minute), true); err != nil {
t.Fatal(err)
}
owners := BucketOwners{ledgerBucket: mustLedger(t, s, ledgerBucket)}
@@ -161,7 +167,7 @@ func TestOwnerAtRefusesSharedHour(t *testing.T) {
}
// Two apps opened concurrently on one adopted bucket: both stand, and
// every shared hour is unresolved.
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0.Add(3*time.Hour)); err != nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0.Add(3*time.Hour), true); err != nil {
t.Fatal(err)
}
b := mustLedger(t, s, ledgerBucket)
@@ -230,7 +236,7 @@ func TestReadBucketOwnershipRefusesBadLedgers(t *testing.T) {
}
// A writer must refuse to build on a ledger it cannot read, never
// overwrite it.
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0); err == nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0, true); err == nil {
t.Errorf("%s: open wrote over an unreadable ledger", name)
}
}
@@ -249,7 +255,7 @@ type racingOwnershipStore struct {
func (r *racingOwnershipStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
if key == BucketOwnershipKey(ledgerBucket) && !r.raced {
r.raced = true
- if err := OpenBucketOwner(ctx, r.Store, ledgerBucket, "bac", "other", t0.Add(-time.Hour)); err != nil {
+ if err := OpenBucketOwner(ctx, r.Store, ledgerBucket, "bac", "other", t0.Add(-time.Hour), true); err != nil {
return "", err
}
}
@@ -259,7 +265,7 @@ func (r *racingOwnershipStore) Put(ctx context.Context, key string, body []byte,
func TestOpenBucketOwnerRetriesLostCAS(t *testing.T) {
ctx := context.Background()
s := &racingOwnershipStore{Store: release.NewDirStore(t.TempDir())}
- if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0); err != nil {
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0, true); err != nil {
t.Fatal(err)
}
b := mustLedger(t, s, ledgerBucket)
@@ -332,6 +338,137 @@ func TestGrantBackfillsLedgerForHealthyStore(t *testing.T) {
if len(b.Intervals) != 1 || !b.Intervals[0].From.Equal(grantAt) {
t.Fatalf("backfill: %+v, want from the grant block's time %s", b.Intervals, grantAt)
}
+ // Recorded, not vouched for: credentials existed before this ledger
+ // did, so another grantee of the bucket may be missing from it. If it
+ // resolved an owner, a store re-granted alone would be billed for a
+ // bucket-mate's transfer.
+ if b.CompleteSince != nil {
+ t.Fatalf("a backfilled ledger claimed completeness: %v", b.CompleteSince)
+ }
+ if acct, _, ok := b.OwnerAt(grantAt.AddDate(0, 1, 1)); ok {
+ t.Fatalf("a backfilled ledger resolved an owner: %q", acct)
+ }
+}
+
+// The grant that mints the bucket creates a complete ledger: nobody could
+// have held access before it, so it resolves its owner, including for the
+// hour the bucket was minted in.
+func TestMintingGrantCreatesACompleteLedger(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+ grantAt := time.Date(2026, 9, 1, 8, 20, 0, 0, time.UTC)
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", grantAt); err != nil {
+ t.Fatal(err)
+ }
+ b := mustLedger(t, s, ledgerBucket)
+ if b.CompleteSince == nil || !b.CompleteSince.Equal(grantAt) {
+ t.Fatalf("complete_since = %v, want the grant time", b.CompleteSince)
+ }
+ if acct, app, ok := b.OwnerAt(time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)); !ok || acct != "bab" || app != "titogo" {
+ t.Fatalf("mint hour: %q %q %v", acct, app, ok)
+ }
+ if _, _, ok := b.OwnerAt(time.Date(2026, 9, 1, 7, 0, 0, 0, time.UTC)); ok {
+ t.Fatal("an hour before the bucket existed resolved an owner")
+ }
+ // A re-grant of the now healthy store does not disturb the claim.
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", grantAt.Add(48*time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if b := mustLedger(t, s, ledgerBucket); b.CompleteSince == nil || !b.CompleteSince.Equal(grantAt) {
+ t.Fatalf("re-grant changed completeness: %v", b.CompleteSince)
+ }
+}
+
+// An adopted bucket existed, and may have served bytes to anyone, before
+// its first ledger interval. Transfer at 10:05 must not become the bill of
+// a tenant who adopted the bucket at 10:20, so its ledger resolves nothing.
+func TestAdoptedBucketResolvesNoOwner(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+ adoptedAt := time.Date(2026, 9, 3, 10, 20, 0, 0, time.UTC)
+ const adopted = "carlos-store-legacy-archive"
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, adopted, adoptedAt); err != nil {
+ t.Fatal(err)
+ }
+ b := mustLedger(t, s, adopted)
+ if b.CompleteSince != nil {
+ t.Fatalf("an adopted bucket's ledger claimed completeness: %v", b.CompleteSince)
+ }
+ for _, hour := range []time.Time{adoptedAt.Truncate(time.Hour), adoptedAt.Add(24 * time.Hour)} {
+ if acct, _, ok := b.OwnerAt(hour); ok {
+ t.Fatalf("adopted bucket resolved %q at %s", acct, hour.Format(time.RFC3339))
+ }
+ }
+}
+
+// Completeness is claimed at creation or never: no later write can add it.
+func TestCompletenessCannotBeClaimedLater(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "titogo", t0, false); err != nil {
+ t.Fatal(err)
+ }
+ // A minted=true call on an existing ledger changes nothing.
+ if err := OpenBucketOwner(ctx, s, ledgerBucket, "bac", "other", t0.Add(time.Hour), true); err != nil {
+ t.Fatal(err)
+ }
+ if b := mustLedger(t, s, ledgerBucket); b.CompleteSince != nil {
+ t.Fatalf("completeness appeared on an existing ledger: %v", b.CompleteSince)
+ }
+ err := writeBucketOwnership(ctx, s, ledgerBucket, func(b *BucketOwnership) (*BucketOwnership, error) {
+ since := t0
+ b.CompleteSince = &since
+ return b, nil
+ })
+ if err == nil {
+ t.Fatal("a write claimed completeness after creation")
+ }
+}
+
+// deleteWatcher fails the test if the ownership interval is already closed
+// when the bucket is deleted: closing first would let bytes leave a bucket
+// the ledger no longer names anyone for.
+type deleteWatcher struct {
+ *MemProvisioner
+ t *testing.T
+ s release.Store
+ calls int
+}
+
+func (d *deleteWatcher) DeleteBucket(ctx context.Context, bucket string) error {
+ d.calls++
+ b, _, err := ReadBucketOwnership(ctx, d.s, bucket)
+ if err != nil || b == nil || len(b.Intervals) != 1 || b.Intervals[0].Until != nil {
+ d.t.Errorf("at DeleteBucket the interval must still be open: %+v %v", b, err)
+ }
+ return d.MemProvisioner.DeleteBucket(ctx, bucket)
+}
+
+func TestTeardownClosesOwnershipOnlyAfterTheBucketIsGone(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0); err != nil {
+ t.Fatal(err)
+ }
+ w := &deleteWatcher{MemProvisioner: p, t: t, s: s}
+ if err := Teardown(ctx, s, w, nil, "bab", "titogo"); err != nil {
+ t.Fatal(err)
+ }
+ if w.calls != 1 {
+ t.Fatalf("DeleteBucket calls = %d, want 1", w.calls)
+ }
+ if b := mustLedger(t, s, ledgerBucket); b.Intervals[0].Until == nil {
+ t.Fatal("the interval was not closed after the bucket was deleted")
+ }
}
// A ledger write failure stops Grant before any credential is minted.
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go
index c50f6c8c0..d606b216d 100644
--- a/internal/objstore/walk.go
+++ b/internal/objstore/walk.go
@@ -176,10 +176,16 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
// idempotence short-circuit and before any credential exists, so that
// no tenant can move a byte out of this bucket while the ledger names
// nobody, and so that a healthy store re-granted by an operator is
- // backfilled from its own grant time. Idempotent: an open interval for
+ // recorded from its own grant time (recorded, not vouched for: see
+ // minted below). Idempotent: an open interval for
// this app is left untouched. A failure stops the walk before anything
// is created; re-running it repairs.
- if err := OpenBucketOwner(ctx, s, bucket, acct, app, grantOwnershipFrom(rec.Grant, now)); err != nil {
+ // The ledger may claim completeness only when this grant mints the
+ // bucket and nothing has been delivered yet: an adopted bucket, or a
+ // store that already held credentials before its ledger existed, may
+ // have been reachable by someone the ledger never saw.
+ minted := bucket == MintedBucket(d, acct, app) && adoptBucket == "" && rec.Provisioned == nil
+ if err := OpenBucketOwner(ctx, s, bucket, acct, app, grantOwnershipFrom(rec.Grant, now), minted); err != nil {
return nil, fmt.Errorf("objstore: record ownership of %s: %w", bucket, err)
}
// 2. the principal and its policy. EnsureUser runs UNCONDITIONALLY, before
diff --git a/internal/usage/billable_test.go b/internal/usage/billable_test.go
index 0cb14cabf..4972b6efd 100644
--- a/internal/usage/billable_test.go
+++ b/internal/usage/billable_test.go
@@ -20,8 +20,11 @@ func TestBillableReplicationWritesPolicy(t *testing.T) {
want int64
}{
{"tenant replicate put, 2xx only", row("acme", "replicate", "PutObject", map[string]int64{"200": 3, "500": 2, "403": 1}, 4, 1), 3},
- {"checkpoint multipart part", row("acme", "checkpoint", "UploadPart", map[string]int64{"200": 5}, 0, 0), 5},
- {"copy counts as a write", row("acme", "replicate", "CopyObject", map[string]int64{"200": 1}, 0, 0), 1},
+ {"multipart part", row("acme", "replicate", "UploadPart", map[string]int64{"200": 5}, 0, 0), 5},
+ {"multipart create", row("acme", "replicate", "CreateMultipartUpload", map[string]int64{"200": 1}, 0, 0), 1},
+ {"a checkpoint may be the platform's move, so it is not billed", row("acme", "checkpoint", "PutObject", map[string]int64{"200": 5}, 0, 0), 0},
+ {"complete can answer 200 with an error in the body", row("acme", "replicate", "CompleteMultipartUpload", map[string]int64{"200": 1}, 0, 0), 0},
+ {"copy can answer 200 with an error in the body", row("acme", "replicate", "CopyObject", map[string]int64{"200": 1}, 0, 0), 0},
{"reads never count", row("acme", "replicate", "GetObject", map[string]int64{"200": 9}, 0, 0), 0},
{"lists never count", row("acme", "replicate", "ListObjectsV2", map[string]int64{"200": 9}, 0, 0), 0},
{"deletes never count", row("acme", "replicate", "DeleteObject", map[string]int64{"204": 9}, 0, 0), 0},
diff --git a/internal/usage/durability.go b/internal/usage/durability.go
index 2baea67a1..ab115c2f9 100644
--- a/internal/usage/durability.go
+++ b/internal/usage/durability.go
@@ -220,12 +220,19 @@ type DurabilityRow struct {
//
// A write counts when all three hold:
//
-// - the purpose is the tenant's own replication: replicate or checkpoint.
-// Lease housekeeping, restores, moves, the verify drill and the
-// platform's own buckets are the platform's cost, never the tenant's;
-// - the operation is one S3 prices as a write: PutObject, UploadPart,
-// CreateMultipartUpload, CompleteMultipartUpload, CopyObject. Reads,
-// lists, heads and deletes never count;
+// - the purpose is replicate: the continuous replication of the
+// tenant's own database. A checkpoint is not counted, because the move
+// worker forces one under the same purpose and the ledger cannot tell
+// a platform-forced checkpoint from the tenant's own. Lease
+// housekeeping, restores, moves, the verify drill and the platform's
+// own buckets are the platform's cost, never the tenant's;
+// - the operation is PutObject, UploadPart or CreateMultipartUpload.
+// CompleteMultipartUpload and CopyObject are S3 writes too, but S3 can
+// answer them 200 with an error in the body, and the ledger records the
+// status before the SDK reads the body, so a failed attempt and its
+// successful retry would both look answered. Until the ledger records
+// the decoded outcome they are not billed. Reads, lists, heads and
+// deletes never count;
// - the server answered it with a 2xx. A retry that failed, an attempt
// that got no reply and an attempt whose outcome is unknown are not
// billed: every ambiguity resolves in the customer's favour.
@@ -236,12 +243,12 @@ func BillableReplicationWrites(r DurabilityRow) int64 {
return 0
}
switch ReplicationPurpose(r.Purpose) {
- case PurposeReplicate, PurposeCheckpoint:
+ case PurposeReplicate:
default:
return 0
}
switch r.Operation {
- case "PutObject", "UploadPart", "CreateMultipartUpload", "CompleteMultipartUpload", "CopyObject":
+ case "PutObject", "UploadPart", "CreateMultipartUpload":
default:
return 0
}
diff --git a/internal/usage/evidence_month.go b/internal/usage/evidence_month.go
index 151f9b88f..687decdde 100644
--- a/internal/usage/evidence_month.go
+++ b/internal/usage/evidence_month.go
@@ -63,9 +63,10 @@ func projectBucketOwnershipMonth(key string, raw []byte, month string) ([]byte,
}
bucket := strings.TrimSuffix(strings.TrimPrefix(key, BucketOwnershipPrefix), ".json")
var rec struct {
- V int `json:"v"`
- Bucket string `json:"bucket"`
- Intervals []struct {
+ V int `json:"v"`
+ Bucket string `json:"bucket"`
+ CompleteSince *time.Time `json:"complete_since,omitempty"`
+ Intervals []struct {
Account string `json:"account_sqid"`
App string `json:"app"`
From time.Time `json:"from"`
@@ -98,11 +99,14 @@ func projectBucketOwnershipMonth(key string, raw []byte, month string) ([]byte,
spans = append(spans, span{iv.Account, iv.App, lo, hi})
}
}
+ // Whether the ledger vouched for the month is part of what it proved.
+ complete := rec.CompleteSince != nil && rec.CompleteSince.Before(end)
return json.Marshal(struct {
- Bucket string `json:"bucket"`
- Month string `json:"month"`
- Spans []span `json:"spans"`
- }{bucket, month, spans})
+ Bucket string `json:"bucket"`
+ Month string `json:"month"`
+ Complete bool `json:"complete"`
+ Spans []span `json:"spans"`
+ }{bucket, month, complete, spans})
}
func projectLifecycleMonth(key string, raw []byte, month string) ([]byte, error) {