carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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 EffectiveLimits is 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 takes lockAccount first, before any app lock.
  • Direct-mode creations never reach the console (apps.go:546) and are out of scope; adopting such an app through ownerClaim is 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

  1. Plans off. With no catalog, every door behaves exactly as today, including under concurrency. Pinned per task (...WithoutACatalog tests).
  2. Concurrent creates at the cap. Two creates racing for the last slot: exactly one succeeds. Pinned in Task 2 (TestRunningThingsCapIsExactUnderConcurrentCreates).
  3. 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).
  4. A sponsored account above the catalog ceiling. Its recorded limit, not the plan's, is the cap. Pinned in Task 1 (TestRunningThingsCapUsesEffectiveLimits).
  5. 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) error

Each 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. runningThings lists keys under apps/<sqid>/ once and counts provision.ParseRecordKey hits whose app is in the live set from AppsIn. checkNewFreeAccount uses AccountsFor(addr), owner role only, each account's plan from AccountPlan (nil → DefaultPlan), counting accounts whose plan is not Paid, refusing at FreeAccountsPerPerson (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 handleNewApp and handleAPICreateApp, take defer s.lockAccount(acct.Sqid)() before claimApp and call checkRunningThings(ctx, acct, 1); refuse with the door's existing shape. In /new-temporary, call it after temporaryCapCheck (the lock is already held). In restoreApp and restoreClaimApp, call it for every row, beside the temporary check, returning the *planCapError; both restore doors map it with errors.As (CLI 409 "code":"cap"; browser fail).
  • [ ] 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 lockAccount before 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 before mintAccount. currentAccount's auto-mint is untouched (spec §1: landing never refuses). Step 4: Run → PASS. Step 5: Commit console: 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.