carlos / platform Public
Plans 3a: Creation Caps Implementation Plan
> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: When plans are on, the console refuses to create what an account's plan does not allow: more running things (apps and instances) than the cap, more workers per app than the cap, a second free account for the same person, fleets and custom domains on plans without them.
Architecture: One file, internal/console/plancaps.go, holds the checks. Each takes the account's effective limits (Plan 1's EffectiveLimits; nil means plans are off and every check passes) and returns a *planCapError whose message is approved copy. Each capped door takes the account lock (lockAccount) around its count and its write so two concurrent creates cannot both pass. JSON doors answer 409 with "code":"cap"; browser doors flash the message, as they already do for their other refusals.
Tech Stack: Go, internal/console, internal/provision (record listing).
Spec: docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md §5.1 (running things, sidecars, creation has no grace), §8.1 (one free account per person, creation only), §8.4 (paid-only custom domains), table 4.2 (fleets). Builds on Plans 1 and 2 (merged f550afa, 0235046).
Global Constraints
- No catalog, no change. Every check returns nil when
EffectiveLimitsis nil. Each task pins this with a test. - Creation refusals have no grace (spec §5.1): refuse at the cap, touch nothing that already runs.
- Running things = live apps (including temporary apps and apps being restored:
deleted_at = 0 OR restoring = 1) plus customer instance records of those live apps. Trashed apps and their instances do not count. - Lock order is account before app (
applocks.go:50). A door takeslockAccountfirst, before any app lock. - Direct-mode creations never reach the console (
apps.go:546) and are out of scope; adopting such an app throughownerClaimis capped like any create. - All refusal text is approved copy (copy review before the strings go into files). Browser copy is sentence case; CLI and JSON copy is lower case. No em dashes.
- Tests: console tests run serially in practice; touched packages only, with
-run; CI runs the full suite. New routes: none. Claim fixtures: none expected (no new doors or workers). The #280 root-read overlay is re-pinned in the last task.
Review Focus
- Plans off. With no catalog, every door behaves exactly as today, including under concurrency. Pinned per task (
...WithoutACatalogtests). - Concurrent creates at the cap. Two creates racing for the last slot: exactly one succeeds. Pinned in Task 2 (
TestRunningThingsCapIsExactUnderConcurrentCreates). - Restore over the cap. Restoring a trashed app when the account is at its cap is refused, with nothing changed. Pinned in Task 2 (
TestRestoreRefusedAtTheRunningThingsCap). - A sponsored account above the catalog ceiling. Its recorded limit, not the plan's, is the cap. Pinned in Task 1 (
TestRunningThingsCapUsesEffectiveLimits). - Platform hostnames. Attaching a platform hostname stays allowed on free plans; only custom domains are paid-only. Pinned in Task 5 (
TestPlatformHostnamesStayFreeOnFreePlans).
---
Task 1: The checks
Files: Create internal/console/plancaps.go, internal/console/plancaps_test.go, internal/console/plancaps_copy.go (generated from approved copy); Modify internal/console/accountplans.go (one store query).
Interfaces — produces:
type planCapError struct{ msg string }
func (e *planCapError) Error() string
func (s *Store) LiveAppCount(accountID int64) (int, error) // deleted_at = 0 OR restoring = 1
func (s *Server) runningThings(ctx context.Context, acct *Account) (int, error) // live apps + their instance records (key-only bucket list of apps/<sqid>/)
func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding int) error
func (s *Server) checkSidecars(ctx context.Context, acct *Account, app string) error
func (s *Server) checkNewFreeAccount(addr string) error
func (s *Server) checkFleets(acct *Account) error
func (s *Server) checkCustomDomains(acct *Account) errorEach returns nil when plans are off, *planCapError at the cap, or an ordinary error when counting failed (doors answer that as they answer other internal errors).
- [ ] Step 1: Failing tests in
plancaps_test.go:TestPlanChecksPassWithoutACatalog(every check nil with no catalog, even at absurd counts);TestLiveAppCountCountsRestoringNotTrashed;TestRunningThingsCountsAppsAndInstances(two apps, three instance records, one trashed app with an instance: 5);TestRunningThingsCapAtAndBelow(free cap 3: at 2 → nil, at 3 → planCapError);TestRunningThingsCapUsesEffectiveLimits(sponsored running_things 300 above the 200 ceiling: 250 → nil);TestSidecarCap(free: second sidecar refused; personal: sixth refused);TestNewFreeAccountCap(owner of one free account: refused; owner of one paid account: allowed; member, not owner, of a free account: allowed);TestFleetsAndCustomDomainsFollowThePlan(free refused, personal allowed). - [ ] Step 2: Run
go test ./internal/console/ -run 'PlanChecks|LiveAppCount|RunningThings|SidecarCap|NewFreeAccountCap|FleetsAndCustomDomains'→ FAIL. - [ ] Step 3: Implement.
runningThingslists keys underapps/<sqid>/once and countsprovision.ParseRecordKeyhits whose app is in the live set fromAppsIn.checkNewFreeAccountusesAccountsFor(addr), owner role only, each account's plan fromAccountPlan(nil →DefaultPlan), counting accounts whose plan is notPaid, refusing atFreeAccountsPerPerson(0 means no cap). - [ ] Step 4: Run → PASS. Step 5: Commit
console: plan creation checks.
Task 2: App creation and restore
Files: Modify server.go (handleNewApp), apicli.go (handleAPICreateApp, handleAPIRestoreApp), apitemporary.go, restore.go, claim_restore.go, ownership.go (handleRestoreApp); tests beside each (apicli_test.go, restore_test.go, apitemporary_test.go).
- [ ] Step 1: Failing tests:
TestCreateAppRefusedAtTheRunningThingsCap(CLI: 409"code":"cap", no row; browser: flash with the approved message, no row);TestTemporaryAppCountsTowardRunningThings;TestRestoreRefusedAtTheRunningThingsCap(both restore doors, both claim modes; nothing changed);TestRunningThingsCapIsExactUnderConcurrentCreates(cap 3, two live, 8 concurrent CLI creates: exactly one 201);TestCreateAppWithoutACatalogUnchanged. - [ ] Step 2: Run → FAIL.
- [ ] Step 3: In
handleNewAppandhandleAPICreateApp, takedefer s.lockAccount(acct.Sqid)()beforeclaimAppand callcheckRunningThings(ctx, acct, 1); refuse with the door's existing shape. In/new-temporary, call it aftertemporaryCapCheck(the lock is already held). InrestoreAppandrestoreClaimApp, call it for every row, beside the temporary check, returning the*planCapError; both restore doors map it witherrors.As(CLI 409"code":"cap"; browserfail). - [ ] Step 4: Run → PASS. Step 5: Commit
console: the running-things cap on app creation and restore.
Task 3: Instances, first-route attach and sidecars
Files: Modify apiinstances.go (handleAPIInstanceCreate, before the seed branch at ~1580), domainattach.go (first-route record write ~237 and attachClaimFirstRoute), apisidecars.go (handleAPISidecarCreate); tests in apiinstancescanary_test.go, apiinstanceseed_test.go, apidomains_firstroute_test.go, apisidecars_test.go.
- [ ] Step 1: Failing tests:
TestInstanceCreateRefusedAtTheRunningThingsCap(plain, seeded and imported: 409"code":"cap", no record);TestFirstRouteAttachRefusedAtTheRunningThingsCap;TestSidecarCreateRefusedAtTheSidecarCap;TestInstanceAndSidecarCreateWithoutACatalogUnchanged. - [ ] Step 2: Run → FAIL.
- [ ] Step 3: Each door takes
lockAccountbefore its checks and calls the matching check; the instance door does so once, before the seed branch, so all three variants are covered. - [ ] Step 4: Run → PASS. Step 5: Commit
console: caps on instances, first routes and sidecars.
Task 4: One free account per person
Files: Modify server.go (handleNewAccount), authcli.go (handleCLINewAccount); tests in server_account_test.go, authcli_test.go.
- [ ] Step 1: Failing tests:
TestNewAccountRefusedForASecondFreeAccount(browser flash; CLI 409"code":"cap"; no account minted, no bucket manifest);TestFirstAccountIsNeverRefused(auto-mint on first sign-in with a catalog configured);TestNewAccountAllowedWhenTheOtherIsPaid;TestNewAccountWithoutACatalogUnchanged. - [ ] Step 2: Run → FAIL. Step 3: Call
checkNewFreeAccount(addr)in both doors beforemintAccount.currentAccount's auto-mint is untouched (spec §1: landing never refuses). Step 4: Run → PASS. Step 5: Commitconsole: one free account per person.
Task 5: Fleets and custom domains
Files: Modify fleets.go (handleAPIFleetNew), domainattach.go (attachDomainAs, after the platform-hostname check and above heldElsewhere, per the file's ordering rule), apidomains.go (alias branch above heldElsewhere ~525; map *planCapError to 409 "code":"cap"), appdomains.go (attachInstanceCatchall and the refusal render); tests in fleets_test.go, apidomains_test.go, appdomains_test.go.
- [ ] Step 1: Failing tests:
TestFleetCreateRefusedOnAPlanWithoutFleets;TestCustomDomainRefusedOnAFreePlan(CLI static, CLI alias, browser alias, browser catch-all: refused, nothing claimed);TestPlatformHostnamesStayFreeOnFreePlans;TestFleetsAndDomainsWithoutACatalogUnchanged. - [ ] Step 2: Run → FAIL. Step 3: Implement the gates. Step 4: Run → PASS. Step 5: Commit
console: fleets and custom domains follow the plan.
Task 6: Census and gates
- [ ] Re-pin the #280 root-read overlay for the changed console units (generator recipe from Plans 1–2), loaded last,
rev3=PROPOSED-plans-3a-creation-caps; check the record-write census; run the full claim invariant once; merge main; push; CI green; ask for the ruling on #280.
Done when
Every task's tests pass, Review Focus 1–5 are pinned, CI is green after merging current main, and Astra's whole-branch review has no open Critical or Important finding.