carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Plans 3a: Creation Caps Implementation Plan
2
3> **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.
4
5**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.
6
7**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.
8
9**Tech Stack:** Go, `internal/console`, `internal/provision` (record listing).
10
11**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).
12
13## Global Constraints
14
15- **No catalog, no change.** Every check returns nil when `EffectiveLimits` is nil. Each task pins this with a test.
16- Creation refusals have no grace (spec §5.1): refuse at the cap, touch nothing that already runs.
17- **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.
18- Lock order is account before app (`applocks.go:50`). A door takes `lockAccount` first, before any app lock.
19- 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.
20- 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.
21- 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.
22
23## Review Focus
24
251. **Plans off.** With no catalog, every door behaves exactly as today, including under concurrency. Pinned per task (`...WithoutACatalog` tests).
262. **Concurrent creates at the cap.** Two creates racing for the last slot: exactly one succeeds. Pinned in Task 2 (`TestRunningThingsCapIsExactUnderConcurrentCreates`).
273. **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`).
284. **A sponsored account above the catalog ceiling.** Its recorded limit, not the plan's, is the cap. Pinned in Task 1 (`TestRunningThingsCapUsesEffectiveLimits`).
295. **Platform hostnames.** Attaching a platform hostname stays allowed on free plans; only custom domains are paid-only. Pinned in Task 5 (`TestPlatformHostnamesStayFreeOnFreePlans`).
30
31---
32
33### Task 1: The checks
34
35**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).
36
37**Interfaces — produces:**
38```go
39type planCapError struct{ msg string }
40func (e *planCapError) Error() string
41func (s *Store) LiveAppCount(accountID int64) (int, error) // deleted_at = 0 OR restoring = 1
42func (s *Server) runningThings(ctx context.Context, acct *Account) (int, error) // live apps + their instance records (key-only bucket list of apps/<sqid>/)
43func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding int) error
44func (s *Server) checkSidecars(ctx context.Context, acct *Account, app string) error
45func (s *Server) checkNewFreeAccount(addr string) error
46func (s *Server) checkFleets(acct *Account) error
47func (s *Server) checkCustomDomains(acct *Account) error
48```
49Each 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).
50
51- [ ] **Step 1: Failing tests** in `plancaps_test.go`:
52 `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).
53- [ ] **Step 2:** Run `go test ./internal/console/ -run 'PlanChecks|LiveAppCount|RunningThings|SidecarCap|NewFreeAccountCap|FleetsAndCustomDomains'` → FAIL.
54- [ ] **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).
55- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: plan creation checks`.
56
57### Task 2: App creation and restore
58
59**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`).
60
61- [ ] **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`.
62- [ ] **Step 2:** Run → FAIL.
63- [ ] **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`).
64- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: the running-things cap on app creation and restore`.
65
66### Task 3: Instances, first-route attach and sidecars
67
68**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`.
69
70- [ ] **Step 1: Failing tests:** `TestInstanceCreateRefusedAtTheRunningThingsCap` (plain, seeded and imported: 409 `"code":"cap"`, no record); `TestFirstRouteAttachRefusedAtTheRunningThingsCap`; `TestSidecarCreateRefusedAtTheSidecarCap`; `TestInstanceAndSidecarCreateWithoutACatalogUnchanged`.
71- [ ] **Step 2:** Run → FAIL.
72- [ ] **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.
73- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: caps on instances, first routes and sidecars`.
74
75### Task 4: One free account per person
76
77**Files:** Modify `server.go` (`handleNewAccount`), `authcli.go` (`handleCLINewAccount`); tests in `server_account_test.go`, `authcli_test.go`.
78
79- [ ] **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`.
80- [ ] **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`.
81
82### Task 5: Fleets and custom domains
83
84**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`.
85
86- [ ] **Step 1: Failing tests:** `TestFleetCreateRefusedOnAPlanWithoutFleets`; `TestCustomDomainRefusedOnAFreePlan` (CLI static, CLI alias, browser alias, browser catch-all: refused, nothing claimed); `TestPlatformHostnamesStayFreeOnFreePlans`; `TestFleetsAndDomainsWithoutACatalogUnchanged`.
87- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement the gates. **Step 4:** Run → PASS. **Step 5:** Commit `console: fleets and custom domains follow the plan`.
88
89### Task 6: Census and gates
90
91- [ ] 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.
92
93## Done when
94
95Every 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.
96