carlos / platform Public

spec: plans, caps and packs, round 1 builder-check answers

Astra's builder check raised nine questions and three citation errors.
Eleven are answered in the design; paid-only features after a downgrade
is open for Paul.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8afc8d645643812785bc8acf44c6260bc3c54a47 parent 5ff0e01
2 files changed, +171 −38
  • docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md +146 −38
  • docs/superpowers/specs/CODEX-FINDINGS-PLANS-CAPS-1.md +25 −0
diff --git a/docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md b/docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md
index 72cbeb8b3..e52a648b5 100644
--- a/docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md
+++ b/docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md
@@ -135,7 +135,8 @@ them as configuration, not constants.
| Idle window before sleep | 90 s (1 to 2 min **(Paul)**) | 15 min **(Paul)** | none | 15 min |
| Memory / CPU per app | 256 MB / 0.5 CPU | 512 MB / 1 CPU (today's default) | none | the existing 1 GB / 2 CPU children tier |
| Awake hours per month (account) | 100 | 1,500 | none in v1 | n/a |
-| Database + files on box (per app) | 1 GB | 10 GB | +10 GB (account pool, see 5.4) | 2x cap, and box safety |
+| Database + files on box (per running thing, 5.4) | 1 GB | 10 GB | +10 GB to every running thing | 2x cap, and box safety |
+| Sidecar workers per app | 1 | 5 | none in v1 | 5 |
| Object storage (per account) | operator-approved only, 1 GB if approved | 25 GB self-serve | +50 GB | 2x cap |
| Email per day (account) | 50 | 1,000 | +1,000 | 10,000 without an operator |
| Bandwidth per month (account) | 10 GB | 200 GB | +200 GB | 2x cap, then throttled |
@@ -156,9 +157,15 @@ cap, what is counted, where it is enforced, and what "refused" means.
**Counted:** apps, temporary apps, and customer instances (including
seeded and imported). Each counts as one. Sidecar workers do not count
-against the cap, but a free account may run at most one sidecar per app,
-and sidecars obey the plan's idle window (5.2). Fleets are customer-owned
-boxes, not apps; free accounts cannot create fleets.
+against the running-things cap; they have their own per-app cap (table
+4.2: 1 on free, 5 on Personal), enforced at `handleAPISidecarCreate`
+(`apisidecars.go:216`), and they obey the plan's idle window (5.2). Fleets
+are customer-owned boxes, not apps; free accounts cannot create fleets.
+
+**Grace and creation (codex 1.2).** Refusing to create something new is
+the cap itself, not a cut-off, so it has no grace: the warning at 80%
+is the notice. Grace protects what already runs. It applies whenever a
+lowered limit would take something away from something running.
**Enforced at:**
- `claimAppWith` (`internal/console/server.go:4052`), the single claim
@@ -167,9 +174,14 @@ boxes, not apps; free accounts cannot create fleets.
`lockAccount`, refused with 409 and `"code":"cap"`.
- `restoreApp` (`restore.go:55`) and the claims-mode restore
(`claim_restore.go:120`), which do not pass through the claim.
-- `provision.CreateRecord` (`internal/provision/provision.go:882`) for
- instances, reached from `apiinstances.go:1664`, `apiinstanceseed.go:341`,
- `apiinstanceimport.go:172` and `domainattach.go:237`.
+- Instance creation. `provision.CreateRecord`
+ (`internal/provision/provision.go:882`) is **not** a common choke point
+ (codex 1.10): claims-mode creation goes through `finishClaimCreate`
+ instead (`apiinstances.go:1658`). The check belongs in the console
+ handlers before either write: `handleAPIInstanceCreate`
+ (`apiinstances.go:987`, which the seeded and imported variants pass
+ through) and the first-route attach (`domainattach.go:237`), under the
+ same account lock as the app claim so the two counts cannot race.
- Direct mode skips the claim (`server.go:~4030`). The builder must either
count those apps when their rows are filled from the bucket and apply
the over-cap stages, or refuse direct-mode creation for plan-governed
@@ -215,10 +227,17 @@ runs. Paid keeps one minute.
pingers, open WebSockets and mining. The source is the per-account
`instance_hours` figure the hourly economics pass already writes to
`usage/accounts/<acct>/<month>.json` (`economicspass.go:378-397,835`). It
-is approximate and that is acceptable: it only gates. After grace, the
-account's apps get the shortest idle window the platform allows (and
-WebSocket upgrades are refused) until the month rolls or the account
-upgrades. They still wake and serve.
+is approximate and that is acceptable: it only gates.
+
+A shorter idle window cannot stop an app that is never idle: a steady
+pinger, a long stream or an open WebSocket keeps `inflight` above zero
+(`activator.go:4650`) (codex 1.1). So after grace the account is
+**throttled** through the same per-account throttle state as bandwidth
+(5.7), its open WebSockets are closed and new upgrades refused, and each
+app gets a continuous-awake ceiling (the activator's existing run ceiling,
+`MaxRunSeconds`, applied to instances): after 15 minutes awake the app is
+put to sleep and wakes on its next request. Apps still wake and serve,
+slowly, until the month rolls or the account upgrades.
### 5.3 Memory and CPU
@@ -229,8 +248,12 @@ The tier lists that hard-refuse unknown tiers need the new entry:
`strict_linux.go:216`, `strict_gen_linux.go:162`, `gen.go:65`, and
unit-backed apps' numbers in `caps/render.go`.
-There is no grace stage: a ceiling is a ceiling. What is new is the
-**explanation**: when an app is OOM-killed under the free tier, the
+A new app gets its plan's tier at once. An app that is already running
+when its account's limit drops (lapse, downgrade) keeps its old tier
+through the 7-day grace and moves to the lower tier at its next start
+after grace (codex 1.2). Lowering memory under a running process would
+kill it, which is the cut-off without notice the grace rule forbids. What
+is new is the **explanation**: when an app is OOM-killed under the free tier, the
console's app page and `carlos` CLI status say so in plain words and
point at the upgrade. A free app that cannot run in 256 MB should fail
loudly and once, not restart-loop silently.
@@ -247,16 +270,27 @@ the kernel, so writes fail at the hard limit with no polling and no race.
Filesystem quotas fit the policy exactly: a soft limit with a grace timer
and a hard limit. The builder chooses the mechanism (ext4 project quotas,
an XFS data volume with project quotas, or another), and the choice may
-mean new boxes get a data volume built for it. Existing boxes must keep
-working without a quota until they are replaced; a box without quota
-support falls back to a periodic size check that refuses wakes after grace.
-
-**Counting for warnings:** a periodic size check per app (the quota's own
-usage report where available) feeds the 80% warning and the grace clock.
-
-Per-app cap or account pool: the cap is per app, and a storage pack raises
-the cap of every app on the account. (Proposal; the simpler rule to
-enforce in the kernel.)
+mean new boxes get a data volume built for it.
+
+**The unit is the running thing (codex 1.5).** Each app or instance has
+its own data directory on exactly one box (`provision.go:2960`), so the
+quota is per data directory, and a storage pack raises the cap of every
+running thing on the account. This sells more total disk than an account
+pool would, but every running thing is already counted and paid for, and
+it needs no allocation across boxes.
+
+**Boxes without quota support (codex 1.6).** Existing boxes keep working
+without a quota until they are replaced. On those boxes the disk cap is
+**warn-only**: warnings and reminders, no after-grace stage, and never a
+stop or a refused wake for disk. The box-level disk alert and placement's
+10% free-disk floor (`boxchoice/policy.go:22`) remain the protection
+there. The disk cap's after-grace stage switches on per box, only where
+the quota exists. Replacing flagship boxes with quota-capable ones is a
+rollout prerequisite for that stage only, not for launch.
+
+**Counting for warnings:** a periodic size check per data directory (the
+quota's own usage report where available) feeds the 80% warning and the
+grace clock.
**After grace:** writes fail (SQLite sees a full disk). Reads keep
working. The app page says why.
@@ -282,11 +316,21 @@ v1 (8.6).
### 5.6 Email
-The mechanism exists: daily caps by tier (`internal/console/mailcounts.go:67`),
-counted from SES events, enforced by pausing the sender's key
-(`mail/walk.go:1120`) until the UTC day rolls. What is missing is the plan:
-`mailBillingTier` always answers `"free"` (`apimail.go:245`, #226). It
-reads the account's effective email limit instead.
+Half the mechanism exists: daily caps by tier
+(`internal/console/mailcounts.go:67`), counted from SES events, enforced by
+pausing a sender's key (`mail/walk.go:1120`) until the UTC day rolls. But
+it is **per sender, not per account** (codex 1.11): `enforceMailLimits`
+reads one sender's counts against that sender's persisted grant
+(`mailcounts.go:253`), and `mailBillingTier` (`apimail.go:245`, always
+`"free"`, #226) is only consulted when a grant is created
+(`apimail.go:1297`). v1 needs:
+
+- an account-wide daily count, summed over the account's senders, checked
+ against the account's effective email limit; at the limit every sender
+ of the account is paused;
+- the per-sender cap kept as it is (an operator's `DailyCap` still works);
+- existing grants re-derived when the account's limit changes (plan,
+ pack, lapse), not only at grant creation.
Email's cap resets daily, so a week-long grace does not fit. Its grace is
**within the day**: warn at 80%, allow up to 2x the cap, pause at 2x. The
@@ -329,15 +373,23 @@ for bandwidth and awake hours. Every grace has a hard ceiling behind it
(table 4.2): grace never means unlimited growth.
State lives in the console (one row per account per cap: first over,
-grace ends, last notified), so a reminder is sent once per period and a
-console restart does not reset a clock.
+grace ends, last notified, notice received), so a reminder is sent once
+per period and a console restart does not reset a clock.
+
+**What counts as notice (codex 1.4).** Notice is received when any of
+these is recorded for an owner of the account, after the cap went over:
+the warning email was accepted for delivery; the owner loaded a console
+page that showed the banner; or the owner ran a CLI command that printed
+the warning. **An after-grace stage starts only when grace has ended and
+notice was received at least 3 days earlier.** An unattended account
+whose owners were never reached stays in grace. Two things do not wait
+for notice: hard ceilings (they protect the box and every other tenant,
+not our economics) and refusing to create something new.
**Dependency:** the console cannot email account owners today; giving it
an SES sending grant is an open security ruling (the same one holding
console invite delivery, see memory `console-invite-delivery`). Until it
-is settled, warnings go to the console and CLI only, and v1 must not
-start an after-grace stage for a cap whose owners could only have been
-told by email.
+is settled, notice is only the console banner and CLI warning.
## 7. How limits reach the boxes
@@ -372,6 +424,10 @@ take customer apps down on a bucket hiccup.
accounts are created as paid (checkout first) or by an operator.
`carlos accounts create --owner` (operator, bucket-direct) is exempt.
Address farming is expected; detecting farms is later, not v1.
+ The rule applies at creation only (codex 1.8). A person who owns
+ several paid accounts that later lapse keeps them all, each on free
+ limits: each was paid for, and the alternative means deleting or
+ charging. Ownership transfers are not checked against it either.
2. **Never-sleep paths refused** (5.2).
3. **Everything that runs counts** (5.1).
4. **Phishing and malware.** v1 keeps one apps domain for free and paid
@@ -394,9 +450,20 @@ take customer apps down on a bucket hiccup.
reaped, amadan #122). Reported per account on the operator economics
view. No caps in v1.
7. **Sponsored accounts have limits too.** "Cover current usage" means a
- sponsored account's effective limits are the larger of Personal's caps
- and its usage on the day plans switch on, recorded once. Increases are
- reviewed separately (settled policy).
+ sponsored account's effective limits are, per limit, the larger of
+ Personal's cap and its measured usage, recorded once when plans switch
+ on (codex 1.9):
+ - counts and sizes (running things, disk, storage): as measured on
+ switch-on day;
+ - recurring figures (email, bandwidth, awake hours): the highest full
+ month in the three months before switch-on, from the existing
+ monthly usage statements (`usage/accounts/<acct>/<month>.json`);
+ email from the per-day counts, highest day.
+ A recorded sponsored limit may exceed a table 4.2 hard ceiling; it is
+ then an explicit operator-approved figure on that account, listed for
+ Paul before switch-on. Increases after that are reviewed separately
+ (settled policy). Nothing is invented: an account with no statement
+ history gets Personal's caps.
## 9. Checkout and packs
@@ -420,6 +487,11 @@ account keeps its paid limits. When the subscription ends, the account
drops to free limits and section 6 applies to every cap it is now over,
with its normal grace. Nothing is deleted.
+Paid-only features already in use when an account drops to free
+(attached custom domains, fleets, schedules under 15 minutes, a second
+free-plan-excess sidecar) (codex 1.7): **open, for Paul** (see the end of
+this document).
+
### 9.3 Bands
The original model scaled price by an affordability band (four bands per
@@ -449,8 +521,16 @@ Not a merge. Copy onto current main, adapt, and review as new code.
`resourcebudget`, `awsbilling`, `authority`, invoices, calculation, tax
split, statements, overage preferences, `month.go`, `lines.go`), bands
and countries, the legacy-EUR path, sponsored cohort enrollment and
- observation (the house flag covers sponsorship), and
- `billingretirement` unless the builder shows v1 needs it.
+ observation, and `billingretirement` unless the builder shows v1 needs
+ it.
+- **Sponsorship needs a new path (codex 1.12).** The harvested house flag
+ does not cover it on its own: at `165472b8` `queueEntitlementTx`
+ (`internal/console/entitlementstore.go:219`) publishes nothing for an
+ account with no subscription, and sponsored accounts never check out.
+ v1 needs a subscription-independent plan assignment (an operator sets
+ an account's plan, with its recorded sponsored limits from 8.7) that
+ writes the same limits record. Self-hosted deployments without a billing
+ provider use the same path (section 10).
- **Main rework:** the old code assumes one subscription item everywhere
(`billingservice.go:939`, `billingquantity.go:1015`). Packs mean several
items. Reconciliation, intent matching and price recognition must handle
@@ -530,3 +610,31 @@ All live steps need Paul. Dev before flagship (roll order rule).
- Delivery slicing, beyond the order: limits and records, then caps, then
notices and grace, then checkout, then the page.
- Copy for every refusal and warning (through copy review).
+
+## 14. Review record
+
+Round 1, Astra builder check at `5ff0e014`, findings committed unedited as
+`CODEX-FINDINGS-PLANS-CAPS-1.md`. Numbering here: questions 1 to 9 are
+codex 1.1 to 1.9; the three citation errors, in order, are codex 1.10 to
+1.12. All twelve are accepted:
+
+| Finding | Where answered |
+| --- | --- |
+| 1.1 awake-hours cap cannot stop a never-idle app | 5.2: throttle, close WebSockets, continuous-awake ceiling |
+| 1.2 grace vs creation refusal and memory reductions | 5.1, 5.3 |
+| 1.3 sidecars unbounded on paid | table 4.2, 5.1 |
+| 1.4 what counts as notice | section 6 |
+| 1.5 storage unit | 5.4: per running thing |
+| 1.6 boxes without quotas | 5.4: warn-only there |
+| 1.7 paid-only features after downgrade | open, for Paul |
+| 1.8 one free account per person after lapse | 8.1: creation only |
+| 1.9 sponsored "current usage" measurement | 8.7 |
+| 1.10 `CreateRecord` is not the instance choke point | 5.1 |
+| 1.11 email caps are per sender | 5.6 |
+| 1.12 house flag publishes no limits without a subscription | 9.4 |
+
+## Open, for Paul
+
+1. **Paid-only features after a downgrade** (codex 1.7). When a paid
+ account lapses to free, what happens to custom domains, fleets and
+ short schedules it already uses?
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-CAPS-1.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-CAPS-1.md
new file mode 100644
index 000000000..24d6abba2
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-CAPS-1.md
@@ -0,0 +1,25 @@
+Questions ranked by importance, referring to the [design at the reviewed commit](docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md):
+
+1. **What enforcement is permitted when an account exceeds awake hours but never becomes idle?** (§§3, 5.2, 5.7) Shortening the idle window cannot stop frequent HTTP requests, streaming responses or an existing WebSocket: the [idle check](internal/activator/activator.go:4650) requires no requests in flight and enough inactivity. Refusing new WebSocket upgrades does not close existing connections. The plan needs a permitted action that satisfies the required never-sleep abuse guard; the stated action does not.
+
+2. **How must the universal grace decision apply to creation refusals and reductions in memory/CPU limits?** (§§3, 5.1, 5.3, 6) Section 3 requires grace for every cap, but §5.1 refuses creation immediately and §5.3 explicitly excludes grace. In particular, lowering an existing app’s memory limit after a lapse can kill it immediately. The answer changes admission rules and whether resource-limit reductions require a delayed transition.
+
+3. **Are sidecars intentionally exempt from “counting everything that runs,” and what bounds their number on paid accounts?** (§§3, 5.1, 8.3) Section 5.1 excludes sidecars from the running-things cap and specifies a separate bound only for free accounts. Consequently, the advertised 200 ceiling does not bound paid workers. The answer determines whether worker creation consumes slots or needs another effective limit.
+
+4. **What counts as fair notice having reached owners, and can a hard ceiling take effect before that happens?** (§§6, 12) The design withholds after-grace enforcement where owners could only have been reached by email, but does not define whether rendering a banner, returning a CLI warning or explicit acknowledgement establishes receipt. An unattended account might receive none of these before reaching 2× usage. This determines the notice state to record, when enforcement becomes eligible, and whether hard ceilings override that eligibility.
+
+5. **What is the storage allowance’s unit: account pool, logical app across all instances, or individual instance?** (§§4.2, 5.4) The table calls the pack an account pool; §5.4 instead adds its increment to every app. Furthermore, the cited [data directory is per host](internal/provision/provision.go:2960), and an app can have instances on multiple boxes. One shared app allowance needs allocation across boxes; one allowance per instance multiplies the storage sold. This must be settled before choosing the quota mechanism.
+
+6. **What availability and disk-safety guarantees apply on existing boxes without kernel quotas?** (§§2, 5.4, 6) Refusing future wakes stops reads once an app sleeps, contrary to “reads keep working” and “apps keep serving.” It also does not stop an already-awake app filling the disk. Is that degraded protection an accepted launch condition, or must existing boxes gain quotas before enforcement starts? The answer changes rollout prerequisites and whether stopping an active app is permitted.
+
+7. **What happens to existing paid-only features after a downgrade?** (§§4.2, 5.1, 5.2, 8.4, 9.2) Examples include attached custom domains, customer fleets and one-minute schedules. Section 9.2 applies ordinary cap grace, but these features have no defined after-grace transition. Grandfathering existing configurations, disabling them and rejecting only subsequent changes produce different reconciliation work and different availability outcomes.
+
+8. **How does “one free account per person” apply to ownership changes and paid accounts that lapse?** (§§8.1, 9.2) A person can own several paid accounts, all of which must become free when subscriptions end. Checking only `mintAccount` cannot enforce the ownership rule after those transitions or after someone becomes an owner of another free account. The plan needs the policy for these cases without deleting data or automatically charging.
+
+9. **How is sponsored “current usage” measured for recurring limits, and does it override the stated hard ceilings?** (§§4.2, 8.7, 12) Counts and stored bytes can be captured at switch-on; email, bandwidth and awake hours need a reference period. Month-to-date usage can understate an unchanged account’s normal monthly needs. Existing usage might also exceed an operator-only ceiling. The answer determines the enrollment measurements and which limits can be assigned automatically.
+
+Separately, these code references would mislead a builder:
+
+- **§5.1 — `provision.CreateRecord` is not a common instance-creation enforcement point.** In [apiinstances.go:1658](internal/console/apiinstances.go:1658), claims-mode creation uses `finishClaimCreate` and bypasses it. A guard placed only in `CreateRecord` misses that path.
+- **§5.6 — the existing email mechanism is per sender, not per account.** [mailcounts.go:253](internal/console/mailcounts.go:253) reads one sender’s counts and persisted grant. `mailBillingTier` is used when [creating that grant](internal/console/apimail.go:1297). Replacing its answer alone neither establishes an account-wide cap nor updates existing grants.
+- **§9.4 — the harvested house flag does not by itself cover sponsorship.** At `165472b8`, `internal/console/entitlementstore.go:219–220` returns without publishing an entitlement when no subscription exists. Sponsoring existing accounts before checkout requires an additional subscription-independent assignment path.
\ No newline at end of file