design-plans-and-caps
Squashed from design-plans-and-caps into main.
46 files changed,
+3928
−4
accounts.go+12 −1accounts_plan.go+293 −0accounts_plan_copy.go+43 −0accounts_plan_test.go+142 −0cmd/console/claimworkers_started_test.go+35 −0cmd/console/main.go+20 −0cmd/console/plancatalog_test.go+28 −0docs/site/cli.md+12 −0docs/site/nav.json+5 −0docs/site/plans.md+55 −0docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md+451 −0docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md+681 −0docs/superpowers/specs/CODEX-FINDINGS-PLANS-1-CODE.md+28 −0docs/superpowers/specs/CODEX-FINDINGS-PLANS-CAPS-1.md+25 −0docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-1-CODE.md+52 −0docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-CAPS.md+30 −0internal/console/accountplans.go+151 −0internal/console/accountplans_test.go+167 −0internal/console/apiaccountplan.go+192 −0internal/console/apiaccountplan_copy.go+14 −0internal/console/apiaccountplan_test.go+202 −0internal/console/authcli.go+2 −0internal/console/claim_invariant_fixtures_test.go+3 −0internal/console/claim_invariant_probes_nonapp_test.go+13 −0internal/console/claim_invariant_probes_read_test.go+2 −0internal/console/claim_invariant_probes_worker_test.go+15 −0internal/console/claim_workers.go+1 −0internal/console/planlimits.go+137 −0internal/console/planlimits_test.go+240 −0internal/console/server.go+5 −0internal/console/store.go+17 −0internal/console/store_gate.go+1 −0internal/console/svcroutes_test.go+7 −2internal/console/testdata/claim-routes.txt+3 −0internal/domains/reconcile_test.go+8 −0internal/hostops/limits_classifier_test.go+45 −0internal/hostops/mutation_classifier.go+6 −0internal/plans/catalog.go+263 −0internal/plans/catalog_test.go+204 −0internal/plans/record.go+86 −0internal/plans/record_test.go+106 −0internal/plans/testdata/example-catalog.json+53 −0internal/provision/plan14_root_reads_test.go+14 −0internal/provision/plan9_b8_census_test.go+1 −1internal/provision/plans_caps_census_test.go+33 −0internal/provision/testdata/plan14-root-reads-design-plans-and-caps.tsv+25 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 108.670s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.876s
ok github.com/carlosframework/platform/cmd/moveprobe 92.360s
ok github.com/carlosframework/platform/cmd/zddprobe 0.708s
ok github.com/carlosframework/platform/internal/activator 250.628s
ok github.com/carlosframework/platform/internal/agentsock 0.019s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.454s
ok github.com/carlosframework/platform/internal/apperr 0.072s
ok github.com/carlosframework/platform/internal/applog 0.126s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.761s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.013s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.698s
ok github.com/carlosframework/platform/internal/changelog 0.354s
ok github.com/carlosframework/platform/internal/claimactivation 0.807s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 443.317s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.839s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.006s
ok github.com/carlosframework/platform/internal/domains 2.400s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 45.042s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.133s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.154s
ok github.com/carlosframework/platform/internal/host 192.096s
ok github.com/carlosframework/platform/internal/hostactors 6.054s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.005s
ok github.com/carlosframework/platform/internal/hostbox 2.801s
ok github.com/carlosframework/platform/internal/hostclaim 0.597s
ok github.com/carlosframework/platform/internal/hostenv 0.006s
ok github.com/carlosframework/platform/internal/hostevidence 0.007s
ok github.com/carlosframework/platform/internal/hostexec 5.045s
ok github.com/carlosframework/platform/internal/hostfence 0.031s
ok github.com/carlosframework/platform/internal/hostfiles 10.491s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 0.997s
ok github.com/carlosframework/platform/internal/hostops 1.638s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.475s
ok github.com/carlosframework/platform/internal/hostresolve 0.153s
ok github.com/carlosframework/platform/internal/hostwrite 0.004s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 5.230s
ok github.com/carlosframework/platform/internal/instanceexec 2.769s
ok github.com/carlosframework/platform/internal/instancefs 0.008s
ok github.com/carlosframework/platform/internal/instancekey 0.002s
ok github.com/carlosframework/platform/internal/instanceupdates 0.006s
ok github.com/carlosframework/platform/internal/layout 1.572s
ok github.com/carlosframework/platform/internal/lbhealth 0.006s
ok github.com/carlosframework/platform/internal/lease 9.899s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.408s
ok github.com/carlosframework/platform/internal/ledger 0.012s
ok github.com/carlosframework/platform/internal/mail 0.173s
ok github.com/carlosframework/platform/internal/maintenance 0.101s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.509s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.079s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.141s
ok github.com/carlosframework/platform/internal/moves/archive 0.824s
ok github.com/carlosframework/platform/internal/objstore 0.228s
ok github.com/carlosframework/platform/internal/ops 0.203s
ok github.com/carlosframework/platform/internal/park 0.227s
ok github.com/carlosframework/platform/internal/plans 0.009s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 39.332s
ok github.com/carlosframework/platform/internal/publicrelease 0.012s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.551s
ok github.com/carlosframework/platform/internal/relay 7.235s
ok github.com/carlosframework/platform/internal/release 24.277s
ok github.com/carlosframework/platform/internal/rollout 3.847s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.059s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/runs 0.582s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.585s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.279s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.457s
ok github.com/carlosframework/platform/internal/status 0.062s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.199s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.589s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49807522 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.024s
=== RUN TestT4StorageProbeMinio
2026/10/21 13:34:14 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E08ECB23BD9192, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.025s
minio contract: PASS
diff --git a/accounts.go b/accounts.go| index b36beccf9..acc557932 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -29,7 +29,7 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -const accountsUsage = `usage: carlos accounts <create|list|migrate> |
| +const accountsUsage = `usage: carlos accounts <create|list|migrate|plan> |
| create --name <name> mint an account: the next free id, its |
| frozen sqid, and the bucket manifest every |
| @@ -52,6 +52,15 @@ const accountsUsage = `usage: carlos accounts <create|list|migrate> |
| [--from <sqid>] source account (default: the legacy, |
| account-less keys) |
| [--dry-run] print the plan and touch nothing |
| + plan --account <sqid> show or change an account's plan (deployment operators) |
| + [--set <plan>] put the account on this plan. needs --reason |
| + [--pack <name=quantity>] add a pack. repeat it for more than one |
| + [--sponsored <file.json>] limits for a sponsored account, used where |
| + they are more generous than the plan's |
| + [--reason <text>] why the plan is changing |
| + [--console <url>] which logged-in console to use |
| + |
| +plan only works where the console has a plan catalog (CARLOS_PLAN_CATALOG). |
| create always names an owner, one way or the other: log this terminal in with |
| "carlos auth login" and you become the owner, or pass --owner. It refuses |
| @@ -76,6 +85,8 @@ func RunAccounts(args []string) { |
| runAccountsList(args[1:]) |
| case "migrate": |
| runAccountsMigrate(args[1:]) |
| + case "plan": |
| + runAccountsPlan(args[1:]) |
| default: |
| fmt.Fprint(os.Stderr, accountsUsage) |
| os.Exit(1) |
diff --git a/accounts_plan.go b/accounts_plan.go| new file mode 100644 |
| index 000000000..30796de9b |
| --- /dev/null |
| +++ b/accounts_plan.go |
| @@ -0,0 +1,293 @@ |
| +package main |
| + |
| +// `carlos accounts plan`: a deployment operator reads or sets an account's |
| +// plan, packs and sponsored limits through the console's plan door |
| +// (internal/console/apiaccountplan.go; plans-caps-packs spec §4, §8.7). |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// accountPlanReply and setAccountPlanRequest mirror the console door's wire |
| +// shapes. |
| +type accountPlanReply struct { |
| + Account string `json:"account"` |
| + CatalogID string `json:"catalog_id"` |
| + Plan string `json:"plan"` |
| + Packs map[string]int `json:"packs"` |
| + Source string `json:"source"` |
| + Sponsored *plans.Limits `json:"sponsored,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + UpdatedBy string `json:"updated_by,omitempty"` |
| + Revision int64 `json:"revision"` |
| + PublishedRevision int64 `json:"published_revision"` |
| + Limits *plans.Limits `json:"limits,omitempty"` |
| + LimitsError string `json:"limits_error,omitempty"` |
| +} |
| + |
| +type setAccountPlanRequest struct { |
| + Plan string `json:"plan"` |
| + Packs map[string]int `json:"packs,omitempty"` |
| + Sponsored *plans.Limits `json:"sponsored,omitempty"` |
| + Reason string `json:"reason"` |
| +} |
| + |
| +// packFlags collects repeated --pack name=quantity values. |
| +type packFlags map[string]int |
| + |
| +func (p *packFlags) String() string { |
| + if p == nil { |
| + return "" |
| + } |
| + return formatPacks(*p) |
| +} |
| + |
| +func (p *packFlags) Set(v string) error { |
| + name, qty, ok := strings.Cut(v, "=") |
| + n, err := strconv.Atoi(qty) |
| + if !ok || name == "" || err != nil { |
| + return fmt.Errorf(cliPlanCopyPackFormat, v) |
| + } |
| + if *p == nil { |
| + *p = packFlags{} |
| + } |
| + (*p)[name] = n |
| + return nil |
| +} |
| + |
| +func formatPacks(p map[string]int) string { |
| + names := make([]string, 0, len(p)) |
| + for name := range p { |
| + names = append(names, name) |
| + } |
| + slices.Sort(names) |
| + parts := make([]string, 0, len(names)) |
| + for _, name := range names { |
| + parts = append(parts, fmt.Sprintf("%s %d", name, p[name])) |
| + } |
| + return strings.Join(parts, ", ") |
| +} |
| + |
| +func runAccountsPlan(args []string) { |
| + if err := runAccountsPlanWithSession(context.Background(), nil, args, stdout); err != nil { |
| + if errors.Is(err, flag.ErrHelp) { |
| + return |
| + } |
| + log.Fatalf("accounts plan: %v", err) |
| + } |
| +} |
| + |
| +func runAccountsPlanWithSession(ctx context.Context, sess *apiSession, args []string, out io.Writer) error { |
| + fs := flag.NewFlagSet("accounts plan", flag.ContinueOnError) |
| + fs.SetOutput(out) |
| + account := fs.String("account", "", cliPlanCopyFlagAccount) |
| + set := fs.String("set", "", cliPlanCopyFlagSet) |
| + var packs packFlags |
| + fs.Var(&packs, "pack", cliPlanCopyFlagPack) |
| + sponsored := fs.String("sponsored", "", cliPlanCopyFlagSponsored) |
| + reason := fs.String("reason", "", cliPlanCopyFlagReason) |
| + consoleFlag := consoleOption(fs, "console origin to use") |
| + fs.Usage = flagUsage(fs, "") |
| + if err := fs.Parse(args); err != nil { |
| + return err |
| + } |
| + if fs.NArg() != 0 { |
| + return fmt.Errorf("unexpected argument %q", fs.Arg(0)) |
| + } |
| + acct, err := explicitStoreAccount(*account) |
| + if err != nil { |
| + return err |
| + } |
| + var req *setAccountPlanRequest |
| + if *set == "" { |
| + if len(packs) > 0 || *sponsored != "" || *reason != "" { |
| + return errors.New(cliPlanCopyNeedsSet) |
| + } |
| + } else { |
| + if strings.TrimSpace(*reason) == "" { |
| + return errors.New(cliPlanCopyReasonRequired) |
| + } |
| + req = &setAccountPlanRequest{Plan: *set, Packs: packs, Reason: *reason} |
| + if *sponsored != "" { |
| + b, err := os.ReadFile(*sponsored) |
| + if err == nil { |
| + // Strict: a misspelt limit would otherwise vanish and lower |
| + // the account's limits. |
| + req.Sponsored = &plans.Limits{} |
| + dec := json.NewDecoder(bytes.NewReader(b)) |
| + dec.DisallowUnknownFields() |
| + err = dec.Decode(req.Sponsored) |
| + } |
| + if err != nil { |
| + return fmt.Errorf(cliPlanCopySponsoredFile, *sponsored, err) |
| + } |
| + } |
| + } |
| + if sess == nil { |
| + sess = requirePlanAPISession(*consoleFlag) |
| + printUsing(out, sess) |
| + } |
| + var res accountPlanReply |
| + if req == nil { |
| + res, err = sess.accountPlan(ctx, acct) |
| + } else { |
| + res, err = sess.setAccountPlan(ctx, acct, *req) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + printAccountPlan(out, res) |
| + return nil |
| +} |
| + |
| +func (s *apiSession) accountPlan(ctx context.Context, account string) (accountPlanReply, error) { |
| + status, body, err := s.getJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/plan") |
| + return decodeAccountPlanReply(status, body, err) |
| +} |
| + |
| +func (s *apiSession) setAccountPlan(ctx context.Context, account string, req setAccountPlanRequest) (accountPlanReply, error) { |
| + status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/plan", req) |
| + return decodeAccountPlanReply(status, body, err) |
| +} |
| + |
| +func decodeAccountPlanReply(status int, body []byte, err error) (accountPlanReply, error) { |
| + if err != nil { |
| + return accountPlanReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return accountPlanReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out accountPlanReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return accountPlanReply{}, fmt.Errorf("accounts plan: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +func printAccountPlan(w io.Writer, r accountPlanReply) { |
| + fmt.Fprintln(w, colorFor(w).ok(fmt.Sprintf(cliPlanCopyHeading, r.Account, r.Plan))) |
| + if len(r.Packs) > 0 { |
| + fmt.Fprintf(w, cliPlanCopyPacks+"\n", formatPacks(r.Packs)) |
| + } |
| + switch r.Source { |
| + case "operator": |
| + fmt.Fprintf(w, cliPlanCopySourceOperator+"\n", r.UpdatedBy, r.Reason) |
| + case "sponsored": |
| + fmt.Fprintf(w, cliPlanCopySourceSponsored+"\n", r.UpdatedBy, r.Reason) |
| + case "billing": |
| + fmt.Fprintln(w, cliPlanCopySourceBilling) |
| + default: |
| + fmt.Fprintln(w, cliPlanCopySourceDefault) |
| + } |
| + if r.Revision > 0 && r.PublishedRevision < r.Revision { |
| + mutedf(w, "%s", cliPlanCopyUnpublished) |
| + } |
| + if r.Limits == nil { |
| + fmt.Fprintf(w, cliPlanCopyLimitsError+"\n", r.LimitsError) |
| + return |
| + } |
| + l := r.Limits |
| + fmt.Fprintln(w) |
| + fmt.Fprintln(w, cliPlanCopyLimitsHeading) |
| + storage := planBytes(l.StorageBytes) |
| + if !l.StorageSelfServe { |
| + storage += ", " + cliPlanCopyStorageOperator |
| + } |
| + history := cliPlanCopyLatestOnly |
| + if l.HistoryDays > 0 { |
| + history = plural(l.HistoryDays, "day") |
| + } |
| + rows := [][2]string{ |
| + {cliPlanCopyLimitRunning, strconv.Itoa(l.RunningThings)}, |
| + {cliPlanCopyLimitSidecars, strconv.Itoa(l.SidecarsPerApp)}, |
| + {cliPlanCopyLimitIdle, humanSeconds(l.IdleSeconds)}, |
| + {cliPlanCopyLimitMemory, l.MemoryTier}, |
| + {cliPlanCopyLimitAwake, strconv.Itoa(l.AwakeHoursMonth)}, |
| + {cliPlanCopyLimitDisk, planBytes(l.DiskBytes)}, |
| + {cliPlanCopyLimitStorage, storage}, |
| + {cliPlanCopyLimitEmail, strconv.Itoa(l.EmailPerDay)}, |
| + {cliPlanCopyLimitBandwidth, planBytes(l.BandwidthBytesMonth)}, |
| + {cliPlanCopyLimitDomains, allowed(l.CustomDomains)}, |
| + {cliPlanCopyLimitFleets, allowed(l.Fleets)}, |
| + {cliPlanCopyLimitSchedule, humanSeconds(l.MinScheduleSeconds)}, |
| + {cliPlanCopyLimitHistory, history}, |
| + } |
| + width := 0 |
| + for _, row := range rows { |
| + width = max(width, len(row[0])) |
| + } |
| + for _, row := range rows { |
| + fmt.Fprintf(w, " %-*s %s\n", width, row[0], row[1]) |
| + } |
| +} |
| + |
| +func allowed(b bool) string { |
| + if b { |
| + return cliPlanCopyAllowed |
| + } |
| + return cliPlanCopyNotAllowed |
| +} |
| + |
| +func plural(n int, unit string) string { |
| + if n == 1 { |
| + return "1 " + unit |
| + } |
| + return strconv.Itoa(n) + " " + unit + "s" |
| +} |
| + |
| +// humanSeconds renders a duration in the largest whole unit. |
| +func humanSeconds(s int) string { |
| + d := time.Duration(s) * time.Second |
| + switch { |
| + case s == 0: |
| + return "0 seconds" |
| + case d%time.Hour == 0: |
| + return plural(int(d/time.Hour), "hour") |
| + case d%time.Minute == 0: |
| + return plural(int(d/time.Minute), "minute") |
| + default: |
| + return plural(s, "second") |
| + } |
| +} |
| + |
| +// planBytes renders a size in GB, or MB below one GB (binary units, as |
| +// the catalog's numbers are). |
| +func planBytes(b int64) string { |
| + const mb, gb = 1 << 20, 1 << 30 |
| + if b >= gb { |
| + if b%gb == 0 { |
| + return strconv.FormatInt(b/gb, 10) + " GB" |
| + } |
| + return strconv.FormatFloat(float64(b)/gb, 'f', 1, 64) + " GB" |
| + } |
| + return strconv.FormatInt(b/mb, 10) + " MB" |
| +} |
| + |
| +// requirePlanAPISession is the console session the plan door needs. Plans |
| +// live in the console's database, so direct bucket mode has no equivalent. |
| +func requirePlanAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal(cliPlanCopyNeedsConsole) |
| + } |
| + return sess |
| +} |
diff --git a/accounts_plan_copy.go b/accounts_plan_copy.go| new file mode 100644 |
| index 000000000..9873643cf |
| --- /dev/null |
| +++ b/accounts_plan_copy.go |
| @@ -0,0 +1,43 @@ |
| +// Code generated from the approved copy review of 2026-10-20 (plans 1). Apply |
| +// later edits through copy review, not by hand. |
| + |
| +package main |
| + |
| +const ( |
| + cliPlanCopyFlagAccount = "account sqid to show or change" |
| + cliPlanCopyFlagSet = "plan to put the account on. leave it out to show the current plan" |
| + cliPlanCopyFlagPack = "a pack and how many, as name=quantity. repeat it for more than one pack" |
| + cliPlanCopyFlagSponsored = "a JSON file of limits for a sponsored account. each one replaces the plan's where it is more generous" |
| + cliPlanCopyFlagReason = "why the plan is changing. it is kept with the change" |
| + cliPlanCopyPackFormat = "--pack %q: write it as name=quantity, for example apps=2" |
| + cliPlanCopyReasonRequired = "--reason is required when you change a plan" |
| + cliPlanCopyNeedsSet = "--pack, --sponsored and --reason change a plan. add --set with the plan to put the account on" |
| + cliPlanCopySponsoredFile = "--sponsored %s: %v" |
| + cliPlanCopyHeading = "account %s is on the %s plan" |
| + cliPlanCopyPacks = "packs: %s" |
| + cliPlanCopySourceDefault = "nobody has assigned a plan, so it is on the default plan" |
| + cliPlanCopySourceOperator = "set by %s: %s" |
| + cliPlanCopySourceSponsored = "sponsored by %s: %s" |
| + cliPlanCopySourceBilling = "set by its subscription" |
| + cliPlanCopyUnpublished = "the boxes do not have this change yet. it is retried every minute" |
| + cliPlanCopyLimitsHeading = "limits" |
| + cliPlanCopyLimitRunning = "apps and instances" |
| + cliPlanCopyLimitSidecars = "workers per app" |
| + cliPlanCopyLimitIdle = "sleeps after" |
| + cliPlanCopyLimitMemory = "memory per app" |
| + cliPlanCopyLimitAwake = "awake hours a month" |
| + cliPlanCopyLimitDisk = "disk per app" |
| + cliPlanCopyLimitStorage = "object storage" |
| + cliPlanCopyStorageOperator = "if an operator approves it" |
| + cliPlanCopyLimitEmail = "emails a day" |
| + cliPlanCopyLimitBandwidth = "bandwidth a month" |
| + cliPlanCopyLimitDomains = "custom domains" |
| + cliPlanCopyLimitFleets = "own boxes (fleets)" |
| + cliPlanCopyLimitSchedule = "schedules at most every" |
| + cliPlanCopyLimitHistory = "database history" |
| + cliPlanCopyLatestOnly = "latest copy only" |
| + cliPlanCopyAllowed = "allowed" |
| + cliPlanCopyNotAllowed = "not allowed" |
| + cliPlanCopyLimitsError = "these limits cannot be worked out: %s. the boxes keep the last limits they were given until the plan is fixed" |
| + cliPlanCopyNeedsConsole = "carlos accounts plan works through a console, and this shell is in direct bucket mode\n(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). plans are kept by the console.\nunset the store env and run: carlos auth login --console <url>" |
| +) |
diff --git a/accounts_plan_test.go b/accounts_plan_test.go| new file mode 100644 |
| index 000000000..5463acbb3 |
| --- /dev/null |
| +++ b/accounts_plan_test.go |
| @@ -0,0 +1,142 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +func TestAccountsPlanParsesPacks(t *testing.T) { |
| + var p packFlags |
| + for _, v := range []string{"apps=2", "email=1"} { |
| + if err := p.Set(v); err != nil { |
| + t.Fatalf("Set(%q): %v", v, err) |
| + } |
| + } |
| + if !reflect.DeepEqual(map[string]int(p), map[string]int{"apps": 2, "email": 1}) { |
| + t.Fatalf("packs %v", p) |
| + } |
| + for _, bad := range []string{"apps", "apps=x", "=2", "apps=2=3"} { |
| + var q packFlags |
| + if err := q.Set(bad); err == nil { |
| + t.Errorf("Set(%q) accepted", bad) |
| + } |
| + } |
| +} |
| + |
| +// planServer answers the plan door with reply and records each request. |
| +func planServer(t *testing.T, reply accountPlanReply) (*httptest.Server, *[]string, *[]setAccountPlanRequest) { |
| + t.Helper() |
| + var paths []string |
| + var bodies []setAccountPlanRequest |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + paths = append(paths, r.Method+" "+r.URL.RequestURI()) |
| + if r.Method == http.MethodPost { |
| + var req setAccountPlanRequest |
| + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| + t.Errorf("decode request: %v", err) |
| + } |
| + bodies = append(bodies, req) |
| + } |
| + json.NewEncoder(w).Encode(reply) |
| + })) |
| + t.Cleanup(ts.Close) |
| + return ts, &paths, &bodies |
| +} |
| + |
| +func TestAccountsPlanShowAndSet(t *testing.T) { |
| + reply := accountPlanReply{ |
| + Account: "bac", Plan: "personal", Packs: map[string]int{"apps": 2}, Source: "operator", |
| + Reason: "upgraded", UpdatedBy: "ops@example.com", Revision: 2, PublishedRevision: 2, |
| + Limits: &plans.Limits{RunningThings: 35, IdleSeconds: 900, MemoryTier: "default", DiskBytes: 10 << 30, |
| + EmailPerDay: 1000, CustomDomains: true, HistoryDays: 7}, |
| + } |
| + ts, paths, bodies := planServer(t, reply) |
| + s := newTestSession(ts.URL) |
| + |
| + var out bytes.Buffer |
| + if err := runAccountsPlanWithSession(context.Background(), s, []string{"--account", "bac"}, &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + text := out.String() |
| + for _, want := range []string{"bac", "personal", "apps 2", "35", "15 minutes", "10 GB", "1000", "7 days", "ops@example.com", "upgraded"} { |
| + if !strings.Contains(text, want) { |
| + t.Errorf("show output lacks %q:\n%s", want, text) |
| + } |
| + } |
| + |
| + sponsored := filepath.Join(t.TempDir(), "limits.json") |
| + if err := os.WriteFile(sponsored, []byte(`{"running_things": 40}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + args := []string{"--account", "bac", "--set", "personal", "--pack", "apps=2", "--sponsored", sponsored, "--reason", "existing"} |
| + if err := runAccountsPlanWithSession(context.Background(), s, args, io.Discard); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{"GET /api/cli/accounts/bac/plan", "POST /api/cli/accounts/bac/plan"} |
| + if !reflect.DeepEqual(*paths, want) { |
| + t.Fatalf("paths %v, want %v", *paths, want) |
| + } |
| + got := (*bodies)[0] |
| + if got.Plan != "personal" || got.Reason != "existing" || got.Packs["apps"] != 2 || |
| + got.Sponsored == nil || got.Sponsored.RunningThings != 40 { |
| + t.Fatalf("request %+v", got) |
| + } |
| +} |
| + |
| +func TestAccountsPlanValidationStopsBeforeNetwork(t *testing.T) { |
| + hits := 0 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + missing := filepath.Join(t.TempDir(), "missing.json") |
| + for _, args := range [][]string{ |
| + {}, // no account |
| + {"--account", "bac", "--set", "free"}, // no reason |
| + {"--account", "bac", "--pack", "apps=1"}, // pack without set |
| + {"--account", "bac", "--reason", "r"}, // reason without set |
| + {"--account", "bac", "--set", "free", "--pack", "apps", "--reason", "r"}, |
| + {"--account", "bac", "--set", "free", "--sponsored", missing, "--reason", "r"}, |
| + {"--account", "bac", "extra"}, |
| + } { |
| + if err := runAccountsPlanWithSession(context.Background(), s, args, io.Discard); err == nil { |
| + t.Errorf("%v: accepted", args) |
| + } |
| + } |
| + var help bytes.Buffer |
| + if err := runAccountsPlanWithSession(context.Background(), s, []string{"--help"}, &help); !errors.Is(err, flag.ErrHelp) { |
| + t.Fatalf("--help: %v", err) |
| + } |
| + if hits != 0 { |
| + t.Fatalf("%d requests reached the console", hits) |
| + } |
| +} |
| + |
| +func TestAccountsPlanRefusesAMisspeltSponsoredFile(t *testing.T) { |
| + hits := 0 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ })) |
| + defer ts.Close() |
| + f := filepath.Join(t.TempDir(), "limits.json") |
| + if err := os.WriteFile(f, []byte(`{"running_thing": 40}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + args := []string{"--account", "bac", "--set", "free", "--sponsored", f, "--reason", "r"} |
| + if err := runAccountsPlanWithSession(context.Background(), newTestSession(ts.URL), args, io.Discard); err == nil { |
| + t.Fatal("accepted a sponsored file with an unknown field") |
| + } |
| + if hits != 0 { |
| + t.Fatalf("%d requests reached the console", hits) |
| + } |
| +} |
diff --git a/cmd/console/claimworkers_started_test.go b/cmd/console/claimworkers_started_test.go| new file mode 100644 |
| index 000000000..8e44fd76c |
| --- /dev/null |
| +++ b/cmd/console/claimworkers_started_test.go |
| @@ -0,0 +1,35 @@ |
| +package main |
| + |
| +import ( |
| + "os" |
| + "regexp" |
| + "testing" |
| +) |
| + |
| +// TestEveryRegisteredClaimWorkerIsStarted: registerClaimWorkers |
| +// (internal/console/claim_workers.go) names each background worker, but only |
| +// serve's explicit RunClaimWorker list starts one. A worker registered and |
| +// never started passes every console test and does nothing in production. |
| +func TestEveryRegisteredClaimWorkerIsStarted(t *testing.T) { |
| + reg, err := os.ReadFile("../../internal/console/claim_workers.go") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + main, err := os.ReadFile("main.go") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + registered := regexp.MustCompile(`(?m)^\t\t"([A-Za-z]+)":\s+s\.`).FindAllSubmatch(reg, -1) |
| + if len(registered) < 10 { |
| + t.Fatalf("found only %d registered workers; the pattern no longer matches claim_workers.go", len(registered)) |
| + } |
| + started := map[string]bool{} |
| + for _, m := range regexp.MustCompile(`RunClaimWorker\(context\.Background\(\), "([A-Za-z]+)"\)`).FindAllSubmatch(main, -1) { |
| + started[string(m[1])] = true |
| + } |
| + for _, m := range registered { |
| + if name := string(m[1]); !started[name] { |
| + t.Errorf("claim worker %s is registered but serve never starts it", name) |
| + } |
| + } |
| +} |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index fb2759fbf..bdee22837 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -111,6 +111,7 @@ import ( |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/rootaws" |
| @@ -292,6 +293,11 @@ func serve() { |
| st := openServeDB(hostClaim) |
| storeProvisioner, storeDeployment := storeBackend() |
| storeScanner, seedAntivirus := storeScannerFor(storeDeployment) |
| + catalog, err := planCatalog() |
| + if err != nil { |
| + // A broken catalog must never quietly mean "no limits". |
| + log.Fatalf("console: CARLOS_PLAN_CATALOG: %v", err) |
| + } |
| var callbackSigner *console.CallbackSigner |
| if os.Getenv("CARLOS_SCAN_WEBHOOK_KEY") == "" && os.Getenv("CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS") != "" { |
| log.Fatal("console: CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS is set without CARLOS_SCAN_WEBHOOK_KEY") |
| @@ -402,6 +408,7 @@ func serve() { |
| StoreDeployment: storeDeployment, |
| StoreMaxPerApp: envCount("CARLOS_STORE_MAX_PER_APP", 0), |
| StoreOperators: storeOperators(), |
| + PlanCatalog: catalog, |
| TemporaryApps: os.Getenv("CARLOS_TEMPORARY_APPS") == "1", |
| // CARLOS_ACME_DELEGATION_ZONE — the same variable the edge reads |
| // (internal/edge/wildcard.go's newWildcardFromEnv) for the same |
| @@ -516,6 +523,9 @@ func serve() { |
| // The temporary-app expiry pass (internal/console/expirypass.go). Same |
| // placement rule as every pass above: started here, never in NewServer. |
| go srv.RunClaimWorker(context.Background(), "StartExpiryPass") |
| + // The plan limits pass (internal/console/planlimits.go): publishes each |
| + // account's limits record. A no-op with CARLOS_PLAN_CATALOG unset. |
| + go srv.RunClaimWorker(context.Background(), "StartPlanLimitsPass") |
| // The run sweep (internal/console/runsweep.go): expires queued runs, |
| // ends lost ones, deletes a day-old run's objects. Same placement rule. |
| go srv.RunClaimWorker(context.Background(), "StartRunSweep") |
| @@ -1519,3 +1529,13 @@ func regionHAEnabled(getenv func(string) string) bool { |
| on, err := strconv.ParseBool(strings.TrimSpace(getenv("CARLOS_REGION_HA"))) |
| return err == nil && on |
| } |
| + |
| +// planCatalog loads the plan catalog named by CARLOS_PLAN_CATALOG |
| +// (plans-caps-packs spec §4.1). Unset means plans are off: nil, nil. |
| +func planCatalog() (*plans.Catalog, error) { |
| + path := strings.TrimSpace(os.Getenv("CARLOS_PLAN_CATALOG")) |
| + if path == "" { |
| + return nil, nil |
| + } |
| + return plans.LoadCatalog(path) |
| +} |
diff --git a/cmd/console/plancatalog_test.go b/cmd/console/plancatalog_test.go| new file mode 100644 |
| index 000000000..e3a2a0ec7 |
| --- /dev/null |
| +++ b/cmd/console/plancatalog_test.go |
| @@ -0,0 +1,28 @@ |
| +package main |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestLoadPlanCatalogEnv(t *testing.T) { |
| + t.Setenv("CARLOS_PLAN_CATALOG", "") |
| + if c, err := planCatalog(); c != nil || err != nil { |
| + t.Fatalf("unset: %v, %v", c, err) |
| + } |
| + t.Setenv("CARLOS_PLAN_CATALOG", "../../internal/plans/testdata/example-catalog.json") |
| + if c, err := planCatalog(); c == nil || err != nil { |
| + t.Fatalf("example: %v, %v", c, err) |
| + } |
| + bad := filepath.Join(t.TempDir(), "bad.json") |
| + if err := os.WriteFile(bad, []byte(`{"version": ""}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, p := range []string{bad, filepath.Join(t.TempDir(), "missing.json")} { |
| + t.Setenv("CARLOS_PLAN_CATALOG", p) |
| + if c, err := planCatalog(); c != nil || err == nil { |
| + t.Fatalf("%s: %v, %v; want an error", p, c, err) |
| + } |
| + } |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 782888cec..918eb7768 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1775,6 +1775,18 @@ logged-in one. |
| prefix and re-stamps its routes. Run it with `--dry-run` first; it prints the |
| plan and touches nothing. |
| +### carlos accounts plan |
| + |
| +Shows or changes an account's plan. It is a deployment-operator command, and it only works where the console has a plan catalog (see [Plans](plans)). |
| + |
| +```sh |
| +carlos accounts plan --account bac |
| +carlos accounts plan --account bac --set personal --pack apps=2 --reason "needs more apps" |
| +carlos accounts plan --account bac --set free --sponsored limits.json --reason "existing account, sponsored" |
| +``` |
| + |
| +Without `--set` it prints the plan, who set it and why, and every limit the account has. With `--set` it changes the plan and prints the result. `--reason` is required for a change. `--pack` can be repeated. `--sponsored` names a JSON file of limits; each one replaces the plan's limit where it is more generous. |
| + |
| ### carlos fleets |
| A fleet is a group of remote boxes an account owns, dialing in to this |
diff --git a/docs/site/nav.json b/docs/site/nav.json| index 495c0168d..3ead9a5b3 100644 |
| --- a/docs/site/nav.json |
| +++ b/docs/site/nav.json |
| @@ -12,6 +12,11 @@ |
| "slug": "scan-callbacks", |
| "label": "Receiving scan results", |
| "blurb": "Ask for one object to be scanned, read the verdict, and verify the callback that tells you it is done." |
| + }, |
| + { |
| + "slug": "plans", |
| + "label": "Plans", |
| + "blurb": "Give accounts limits, packs and sponsorship. Plans stay off until you configure a catalog." |
| } |
| ] |
| } |
diff --git a/docs/site/plans.md b/docs/site/plans.md| new file mode 100644 |
| index 000000000..006392789 |
| --- /dev/null |
| +++ b/docs/site/plans.md |
| @@ -0,0 +1,55 @@ |
| +# Plans |
| + |
| +A deployment can give each account a plan. A plan sets limits: how many apps and instances an account runs, how soon its apps sleep, how much disk, storage, email and bandwidth it gets, and whether it can use custom domains or its own boxes. Packs raise some of those limits. |
| + |
| +Plans are off unless you turn them on. A deployment with no plan catalog has no limits, which is how every deployment has run until now. |
| + |
| +## Turning plans on |
| + |
| +Write a catalog file and point the console at it: |
| + |
| +```sh |
| +CARLOS_PLAN_CATALOG=/etc/carlos/plans.json |
| +``` |
| + |
| +The console reads the file when it starts. If the file is missing or wrong, the console refuses to start rather than run with no limits. |
| + |
| +## The catalog |
| + |
| +```json |
| +{ |
| + "version": "2026-10-20-v1", |
| + "default_plan": "free", |
| + "free_accounts_per_person": 1, |
| + "plans": { |
| + "free": {"paid": false, "limits": {"running_things": 3, "idle_seconds": 90, "memory_tier": "small"}}, |
| + "personal": {"paid": true, "limits": {"running_things": 25, "idle_seconds": 900}, "packs": ["apps"]} |
| + }, |
| + "packs": { |
| + "apps": {"adds": {"running_things": 5}, "max_quantity": 20} |
| + }, |
| + "ceilings": {"running_things": 200} |
| +} |
| +``` |
| + |
| +Every account without a plan of its own is on `default_plan`. Packs may only raise counts and sizes: apps and instances, disk, storage, email and bandwidth. A ceiling is the most any plan with packs can reach. |
| + |
| +Change `version` whenever you change the file. The console notices an edit that kept the old version and treats it as a new catalog anyway, so every account's limits are written again. |
| + |
| +## Assigning a plan |
| + |
| +```sh |
| +carlos accounts plan --account bac --set personal --reason "upgraded" |
| +``` |
| + |
| +The console writes each account's limits to `accounts/<sqid>/limits.json` in the deployment bucket, and the boxes read them from there. A change reaches the bucket straight away, or within a minute if the bucket was unavailable. |
| + |
| +## Sponsored accounts |
| + |
| +A sponsored account keeps its plan but gets limits of its own where those are more generous, for example the usage it already had when plans were turned on: |
| + |
| +```sh |
| +carlos accounts plan --account bac --set free --sponsored limits.json --reason "existing account" |
| +``` |
| + |
| +`limits.json` holds any of the limit names from the catalog. A sponsored limit can go above a ceiling: that is a decision you made for that account. |
diff --git a/docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md b/docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md| new file mode 100644 |
| index 000000000..09c1119fd |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md |
| @@ -0,0 +1,451 @@ |
| +# Plans 1: Catalog and Account Limits 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:** A deployment can configure a plan catalog; the console records each account's plan and packs, computes its effective limits, and publishes them to the bucket at `accounts/<sqid>/limits.json`. Nothing enforces them yet. |
| + |
| +**Architecture:** A small stdlib-only package `internal/plans` holds the catalog, the limits arithmetic and the wire record (boxes will import it in Plans 2). The console stores assignments in a new `account_plans` table, publishes records from a claim worker (`StartPlanLimitsPass`) and immediately after an operator change, and exposes an operator-only CLI door. With `CARLOS_PLAN_CATALOG` unset, the worker and the door do nothing and no record is written: every existing deployment is unchanged. |
| + |
| +**Tech Stack:** Go (repo `go.mod` version), SQLite via the console's existing `Store`, the deployment bucket via `release.Store`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md` (sections 4, 7, 8.7, 9.4 "Sponsorship needs a new path", 10). |
| + |
| +## Where this plan sits |
| + |
| +The spec ships as a sequence of plans, each working software on its own: |
| + |
| +1. **Catalog and limits (this plan).** Dormant foundation. |
| +2. Box-side limits: a record reader with last-known-good caching and fail-open; per-route idle window for instances; the small memory tier; the continuous-awake ceiling; the throttle state at the edge. |
| +3. Creation caps: running things, sidecars per app, one free account per person, fleets, paid-only custom domains, schedule minimum, never-sleep refusals. |
| +4. Notices and grace: the warning/grace/after-grace engine, notice receipt, measured caps (awake hours, storage, email account-wide, bandwidth), the lapse lifecycle (disable, delete after 90 days via the Trash), and the sponsored-usage measurement tool. |
| +5. Disk quotas on boxes. |
| +6. Checkout and packs: harvested Stripe client, multi-item subscription, webhook, portal, slim price book. |
| +7. Pricing page and public price export. |
| +8. Abuse tooling: takedown command, outbound port 25 block, cost monitoring. |
| + |
| +Plans 2 to 8 are written when their predecessor merges, against the code as it then stands. |
| + |
| +## Global Constraints |
| + |
| +- No new module dependencies. `internal/plans` imports only the standard library and `internal/release`. |
| +- Cross-compile gate: CI builds darwin and windows; nothing in this plan is Linux-only. |
| +- No catalog configured means no caps, no records, no behaviour change (spec §2 Goals, §4.1). |
| +- carloku.com gets no special case anywhere in code (spec §2 Anti-goals). Examples and tests use neutral names (`example.com`, a plan called `free`, a pack called `apps`); never tenant hosts. |
| +- The console is the single writer of limits records (spec §7). |
| +- Every user-facing string (CLI output, error messages, docs prose) goes through the copy-review skill before merge. Copy voice: no em dashes; a full stop and a new sentence instead. |
| +- Local testing: run only the touched packages with `-run`, e.g. `go test ./internal/plans/` and `go test ./internal/console/ -run 'Plan|Limits'`. Leave `GOCACHE` at its default. CI runs the full suite and gates the merge. |
| +- New console routes and claim workers must be added to `internal/console/testdata/claim-routes.txt`, or `claim_routes_test.go` fails. |
| +- Console code outside `store.go`/`store_gate.go` reaches SQL only through `s.db` (`claim_sql_census_test.go`). |
| + |
| +## Review Focus |
| + |
| +1. **A catalog edited without a version bump.** Expect the console to refuse to start, naming the file, rather than serve records whose `catalog_version` lies about their content. Pinned in Task 1 (`TestCatalogIDChangesWithContent`) and Task 4 (`TestLoadPlanCatalogEnv`). |
| +2. **A catalog change that orphans an assignment** (plan or pack removed while accounts hold it). Expect the pass to skip that account, log it, keep the last published record, and keep publishing everyone else. Pinned in Task 4 (`TestPlanLimitsPassSkipsAnOrphanedAssignment`). |
| +3. **A deleted (tombstoned) account.** Expect no row ensured and no record written for it. Pinned in Task 2 (`TestEnsureDefaultPlansSkipsDeletedAccounts`). |
| +4. **An operator change racing the pass.** Expect the newer revision to win in the bucket and in `published_revision`; a stale publish never marks a newer revision published. Pinned in Task 2 (`TestMarkPlanPublishedIgnoresAStaleRevision`) and Task 4 (`TestPublishNeverOverwritesANewerRecord`). |
| +5. **Partial sponsored limits.** A sponsored override that sets only some fields must leave the rest at the plan's value; in particular `min_schedule_seconds: 0` must not mean "no minimum". Pinned in Task 1 (`TestEffectiveSponsoredZeroIsUnset`). |
| + |
| +--- |
| + |
| +### Task 1: The catalog and the limits arithmetic |
| + |
| +**Files:** |
| +- Create: `internal/plans/catalog.go` |
| +- Create: `internal/plans/catalog_test.go` |
| +- Create: `internal/plans/testdata/example-catalog.json` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + ```go |
| + type Limits struct { |
| + RunningThings int `json:"running_things,omitempty"` |
| + SidecarsPerApp int `json:"sidecars_per_app,omitempty"` |
| + IdleSeconds int `json:"idle_seconds,omitempty"` |
| + MemoryTier string `json:"memory_tier,omitempty"` // "small" | "default" |
| + AwakeHoursMonth int `json:"awake_hours_month,omitempty"` |
| + DiskBytes int64 `json:"disk_bytes,omitempty"` // per running thing |
| + StorageBytes int64 `json:"storage_bytes,omitempty"` // per account |
| + StorageSelfServe bool `json:"storage_self_serve,omitempty"` |
| + EmailPerDay int `json:"email_per_day,omitempty"` |
| + BandwidthBytesMonth int64 `json:"bandwidth_bytes_month,omitempty"` |
| + CustomDomains bool `json:"custom_domains,omitempty"` |
| + Fleets bool `json:"fleets,omitempty"` |
| + MinScheduleSeconds int `json:"min_schedule_seconds,omitempty"` |
| + HistoryDays int `json:"history_days,omitempty"` |
| + } |
| + type Plan struct { |
| + Paid bool `json:"paid"` |
| + Limits Limits `json:"limits"` |
| + Packs []string `json:"packs,omitempty"` // pack names this plan may buy |
| + } |
| + type Pack struct { |
| + Adds Limits `json:"adds"` |
| + MaxQuantity int `json:"max_quantity"` |
| + } |
| + type Catalog struct { |
| + Version string `json:"version"` |
| + DefaultPlan string `json:"default_plan"` |
| + FreeAccountsPerPerson int `json:"free_accounts_per_person"` |
| + Plans map[string]Plan `json:"plans"` |
| + Packs map[string]Pack `json:"packs"` |
| + Ceilings Limits `json:"ceilings"` |
| + } |
| + func ParseCatalog(b []byte) (*Catalog, error) |
| + func LoadCatalog(path string) (*Catalog, error) |
| + func (c *Catalog) ID() string // "<version>@<first 12 hex of sha256 of the canonical JSON>" |
| + func (c *Catalog) Effective(plan string, packs map[string]int, sponsored *Limits) (Limits, error) |
| + var ErrUnknownPlan, ErrUnknownPack, ErrPackNotOffered, ErrPackQuantity error |
| + ``` |
| + |
| +- [ ] **Step 1: Write the example catalog** `internal/plans/testdata/example-catalog.json` with the spec's table 4.2 values: version `"2026-10-20-v1"`, default plan `free`, `free_accounts_per_person: 1`; plan `free` (`paid:false`, running_things 3, sidecars_per_app 1, idle_seconds 90, memory_tier "small", awake_hours_month 100, disk_bytes 1073741824, storage_bytes 1073741824, email_per_day 50, bandwidth_bytes_month 10737418240, min_schedule_seconds 900, no packs); plan `personal` (`paid:true`, running_things 25, sidecars_per_app 5, idle_seconds 900, memory_tier "default", awake_hours_month 1500, disk_bytes 10737418240, storage_bytes 26843545600, storage_self_serve true, email_per_day 1000, bandwidth_bytes_month 214748364800, custom_domains true, fleets true, min_schedule_seconds 60, history_days 7, packs `["apps","storage","email","bandwidth"]`); packs `apps` (+5 running_things), `storage` (+10737418240 disk_bytes, +53687091200 storage_bytes), `email` (+1000 email_per_day), `bandwidth` (+214748364800 bandwidth_bytes_month), each `max_quantity: 20`; ceilings running_things 200, email_per_day 10000, idle_seconds 900. |
| + |
| +- [ ] **Step 2: Write the failing tests** in `catalog_test.go`: |
| + |
| +```go |
| +func TestParseExampleCatalog(t *testing.T) // LoadCatalog("testdata/example-catalog.json") succeeds; Plans["free"].Limits.RunningThings == 3 |
| +func TestParseCatalogRefuses(t *testing.T) // table: empty version; default_plan not in plans; pack listed by a plan but not defined; |
| + // pack adds a non-additive field (idle_seconds, memory_tier, any bool); max_quantity < 1; |
| + // memory_tier not "small"/"default"; unknown JSON field (DisallowUnknownFields). Each returns a non-nil error. |
| +func TestCatalogIDChangesWithContent(t *testing.T) // same version, running_things 3 -> 4: IDs differ; byte-identical re-parse: IDs equal; |
| + // key order / whitespace changes alone: IDs equal |
| +func TestEffectivePlanOnly(t *testing.T) // Effective("free", nil, nil) == Plans["free"].Limits |
| +func TestEffectiveAddsPacks(t *testing.T) // Effective("personal", {"apps":2,"email":1}, nil): RunningThings 35, EmailPerDay 2000 |
| +func TestEffectiveClampsToCeilings(t *testing.T) // Effective("personal", {"apps":20,"email":20}, nil): RunningThings 125 (25+100, under 200); |
| + // with ceilings.running_things 100: 100; EmailPerDay 10000 (1000+20000 clamped) |
| +func TestEffectiveRefuses(t *testing.T) // unknown plan -> ErrUnknownPlan; unknown pack -> ErrUnknownPack; |
| + // {"apps":1} on free -> ErrPackNotOffered; {"apps":21} or {"apps":-1} -> ErrPackQuantity |
| +func TestEffectiveSponsoredTakesTheMoreGenerous(t *testing.T) // sponsored {RunningThings: 300, EmailPerDay: 20} on personal: |
| + // RunningThings 300 (above the 200 ceiling: sponsored is not clamped), EmailPerDay 1000 (plan wins); |
| + // sponsored {CustomDomains:true} on free: true; {MemoryTier:"default"} on free: "default" |
| +func TestEffectiveSponsoredZeroIsUnset(t *testing.T) // sponsored {RunningThings: 40} on free: MinScheduleSeconds stays 900; |
| + // sponsored {MinScheduleSeconds: 60} on free: 60 (smaller is more generous for this field only) |
| +``` |
| + |
| +- [ ] **Step 3: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/plans/` |
| +Expected: FAIL (package does not compile: undefined `LoadCatalog`). |
| + |
| +- [ ] **Step 4: Implement `catalog.go`** |
| + |
| +`ParseCatalog` uses a `json.Decoder` with `DisallowUnknownFields`, then validates. Additive fields (the only ones a pack may set, and the only ones ceilings clamp): `RunningThings`, `DiskBytes`, `StorageBytes`, `EmailPerDay`, `BandwidthBytesMonth`. A ceiling of 0 means no ceiling for that field; `IdleSeconds` ceiling clamps the plan value too. `ID()` hashes `json.Marshal(c)` (map keys marshal sorted, so it is canonical). `Effective` order: plan limits, plus `qty * adds` per pack, clamp additive fields and idle to ceilings, then merge `sponsored` field by field taking the more generous value: larger for numbers, `true` for bools, `"default"` over `"small"`, and for `MinScheduleSeconds` the smaller non-zero value. Zero in `sponsored` means unset for every field. Errors wrap the sentinels with the offending name (`fmt.Errorf("%w: %q", ErrUnknownPack, name)`). |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/plans/` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/plans/ |
| +git commit -m "plans: the catalog and the limits arithmetic" |
| +``` |
| + |
| +### Task 2: Assignments in the console store |
| + |
| +**Files:** |
| +- Modify: `internal/console/store.go` (the `CREATE TABLE` list in `migrate()`, before line ~385) |
| +- Create: `internal/console/accountplans.go` |
| +- Create: `internal/console/accountplans_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `plans.Limits` (Task 1). |
| +- Produces: |
| + ```go |
| + type AccountPlanRow struct { |
| + AccountID int64 |
| + Sqid string |
| + Plan string |
| + Packs map[string]int |
| + Source string // "default" | "operator" | "sponsored" | "billing" |
| + Sponsored *plans.Limits |
| + Reason string |
| + Revision int64 |
| + PublishedRevision int64 |
| + PublishedCatalog string |
| + UpdatedBy string |
| + UpdatedAt time.Time |
| + } |
| + func (s *Store) AccountPlan(accountID int64) (*AccountPlanRow, error) // nil, nil when no row |
| + func (s *Store) SetAccountPlan(accountID int64, plan string, packs map[string]int, source string, |
| + sponsored *plans.Limits, reason, by string, now time.Time) (int64, error) // returns the new revision |
| + func (s *Store) EnsureDefaultPlans(defaultPlan string, now time.Time) (int, error) // rows inserted |
| + func (s *Store) PlansToPublish(catalogID string, limit int) ([]AccountPlanRow, error) |
| + func (s *Store) MarkPlanPublished(accountID, revision int64, catalogID string) error |
| + ``` |
| + |
| +- [ ] **Step 1: Write the failing tests** in `accountplans_test.go`, opening a store with `Open(filepath.Join(t.TempDir(), "console.db"))` and creating accounts with `CreateAccount`: |
| + |
| +```go |
| +func TestAccountPlanAbsentIsNil(t *testing.T) // AccountPlan on a fresh account: nil, nil |
| +func TestSetAccountPlanBumpsRevision(t *testing.T) // first Set returns 1, second 2; AccountPlan round-trips Packs, Sponsored, Source, Reason, UpdatedBy |
| +func TestEnsureDefaultPlansInsertsOnlyMissing(t *testing.T) // 3 accounts, one already set to "personal": returns 2; the set one keeps "personal" and its revision; |
| + // inserted rows have Source "default", Revision 1, UpdatedBy "plans" |
| +func TestEnsureDefaultPlansSkipsDeletedAccounts(t *testing.T) // an account with deleted_at set gets no row |
| +func TestPlansToPublish(t *testing.T) // returns rows where published_revision < revision OR published_catalog != catalogID; respects limit; |
| + // after MarkPlanPublished(id, rev, "v1@abc") the row is not returned for "v1@abc" but is for "v2@def" |
| +func TestMarkPlanPublishedIgnoresAStaleRevision(t *testing.T) // Set (rev 1), Set (rev 2), MarkPlanPublished(id, 1, c): PublishedRevision stays 0; |
| + // MarkPlanPublished(id, 2, c): becomes 2 |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console/ -run 'AccountPlan|DefaultPlans|PlansToPublish|MarkPlanPublished'` |
| +Expected: FAIL (undefined `AccountPlanRow`). |
| + |
| +- [ ] **Step 3: Add the table** to the `CREATE TABLE IF NOT EXISTS` list in `migrate()`: |
| + |
| +```sql |
| +CREATE TABLE IF NOT EXISTS account_plans ( |
| + account_id INTEGER PRIMARY KEY, |
| + plan TEXT NOT NULL, |
| + packs TEXT NOT NULL DEFAULT '{}', |
| + source TEXT NOT NULL, |
| + sponsored TEXT NOT NULL DEFAULT '', |
| + reason TEXT NOT NULL DEFAULT '', |
| + revision INTEGER NOT NULL, |
| + published_revision INTEGER NOT NULL DEFAULT 0, |
| + published_catalog TEXT NOT NULL DEFAULT '', |
| + updated_by TEXT NOT NULL, |
| + updated_at INTEGER NOT NULL |
| +); |
| +``` |
| + |
| +- [ ] **Step 4: Implement the five methods in `accountplans.go`** through `s.db`. `packs` and `sponsored` are JSON text (`sponsored` empty for nil). `SetAccountPlan` is one `INSERT ... ON CONFLICT(account_id) DO UPDATE SET ..., revision = revision + 1 RETURNING revision`. `EnsureDefaultPlans` is one `INSERT ... SELECT id ... FROM accounts WHERE deleted_at = 0 AND id NOT IN (SELECT account_id FROM account_plans)`. `PlansToPublish` joins `accounts` for `Sqid` and excludes deleted accounts. `MarkPlanPublished` updates only `WHERE account_id = ? AND revision = ?`. |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console/ -run 'AccountPlan|DefaultPlans|PlansToPublish|MarkPlanPublished'` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/console/store.go internal/console/accountplans.go internal/console/accountplans_test.go |
| +git commit -m "console: account plan assignments" |
| +``` |
| + |
| +### Task 3: The limits record on the wire |
| + |
| +**Files:** |
| +- Create: `internal/plans/record.go` |
| +- Create: `internal/plans/record_test.go` |
| +- Modify: `internal/domains/reconcile_test.go` (one test) |
| + |
| +**Interfaces:** |
| +- Consumes: `plans.Limits` (Task 1), `release.Store`. |
| +- Produces: |
| + ```go |
| + const RecordSchema = 1 |
| + type Record struct { |
| + Schema int `json:"schema"` |
| + Account string `json:"account"` // sqid |
| + CatalogID string `json:"catalog_id"` |
| + Plan string `json:"plan"` |
| + Source string `json:"source"` |
| + Revision int64 `json:"revision"` |
| + Limits Limits `json:"limits"` |
| + IssuedAt time.Time `json:"issued_at"` |
| + } |
| + func RecordKey(sqid string) string // "accounts/" + sqid + "/limits.json" |
| + func ParseRecord(b []byte, sqid string) (*Record, error) |
| + func GetRecord(ctx context.Context, s release.Store, sqid string) (*Record, string, error) // record, etag; os.ErrNotExist-wrapped when absent |
| + func PutRecord(ctx context.Context, s release.Store, r *Record, ifMatch string) (string, error) |
| + ``` |
| + Later plans add fields (throttle state, suspended routes) additively with `omitempty`; readers ignore unknown fields so an older box reads a newer record. |
| + |
| +- [ ] **Step 1: Write the failing tests** in `record_test.go`, using `release.NewDirStore(t.TempDir())` as the bucket: |
| + |
| +```go |
| +func TestRecordKey(t *testing.T) // RecordKey("abc") == "accounts/abc/limits.json" |
| +func TestRecordRoundTrip(t *testing.T) // PutRecord(create-only) then GetRecord returns an equal Record and a non-empty etag |
| +func TestParseRecordRefuses(t *testing.T) // schema 2; account "other" when asked for "abc"; malformed JSON: each errors |
| +func TestParseRecordIgnoresUnknownFields(t *testing.T) // a record with an extra "throttled": true parses |
| +func TestPutRecordIsCAS(t *testing.T) // second create-only Put returns release.ErrCASConflict; Put with a stale etag returns ErrCASConflict |
| +``` |
| + |
| +And in `internal/domains/reconcile_test.go`: |
| + |
| +```go |
| +func TestAccountManifestSqidIgnoresLimitsRecord(t *testing.T) // AccountManifestSqid("accounts/abc/limits.json") returns ok == false |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/plans/ ./internal/domains/ -run 'Record|IgnoresLimits'` |
| +Expected: `internal/plans` FAILS (undefined `RecordKey`); the domains test PASSES already (it pins existing behaviour so a later change cannot break it). |
| + |
| +- [ ] **Step 3: Implement `record.go`**, mirroring `objstore.GetEntitlement`/`PutEntitlement` (`internal/objstore/entitlement.go`): `ParseRecord` uses plain `json.Unmarshal` (unknown fields allowed), then checks `Schema == RecordSchema` and `Account == sqid`. |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/plans/ ./internal/domains/ -run 'Record|IgnoresLimits'` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/plans/record.go internal/plans/record_test.go internal/domains/reconcile_test.go |
| +git commit -m "plans: the per-account limits record" |
| +``` |
| + |
| +### Task 4: Publishing, and the catalog in the console's config |
| + |
| +**Files:** |
| +- Modify: `internal/console/server.go` (the `Config` struct near `StoreMaxPerApp`, line ~717) |
| +- Modify: `cmd/console/main.go` (config literal near line 392; a `planCatalog()` helper beside `storeOperators()`) |
| +- Modify: `internal/console/claim_workers.go` (register `"StartPlanLimitsPass"`) |
| +- Modify: `internal/console/testdata/claim-routes.txt` (add `worker StartPlanLimitsPass`) |
| +- Create: `internal/console/planlimits.go` |
| +- Create: `internal/console/planlimits_test.go` |
| +- Create: `cmd/console/plancatalog_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Tasks 1 to 3. |
| +- Produces: |
| + ```go |
| + // Config |
| + PlanCatalog *plans.Catalog // nil: plans are off on this deployment |
| + func (s *Server) StartPlanLimitsPass(ctx context.Context) |
| + func (s *Server) planLimitsPass(ctx context.Context, now time.Time) |
| + func (s *Server) publishAccountLimits(ctx context.Context, row AccountPlanRow, now time.Time) error |
| + func (s *Server) EffectiveLimits(acct *Account) (*plans.Limits, error) // nil, nil when PlanCatalog is nil; default plan when no row |
| + // cmd/console |
| + func planCatalog() (*plans.Catalog, error) // reads CARLOS_PLAN_CATALOG; "" -> nil, nil |
| + ``` |
| + |
| +- [ ] **Step 1: Write the failing tests** in `planlimits_test.go`, using `newConsoleFixture(t)` and setting `fx.srv.cfg.PlanCatalog` from `plans.LoadCatalog("../plans/testdata/example-catalog.json")`: |
| + |
| +```go |
| +func TestPlanLimitsPassOffWithoutACatalog(t *testing.T) // PlanCatalog nil: planLimitsPass writes nothing under accounts/*/limits.json and inserts no account_plans rows |
| +func TestPlanLimitsPassPublishesDefaults(t *testing.T) // with the catalog: after one pass the fixture account has a record with Plan "free", |
| + // Source "default", Limits.RunningThings 3, CatalogID == catalog.ID(); its row is marked published |
| +func TestPlanLimitsPassRepublishesOnCatalogChange(t *testing.T) // pass with catalog A, then with A' (running_things 4, new version): the record's CatalogID |
| + // and RunningThings update; a third pass with A' writes nothing (etag unchanged) |
| +func TestPlanLimitsPassSkipsAnOrphanedAssignment(t *testing.T) // two accounts; one set to plan "gold" (not in the catalog): the pass publishes the other, |
| + // leaves the orphan's existing record byte-identical, and does not mark it published |
| +func TestPublishNeverOverwritesANewerRecord(t *testing.T) // seed the bucket with Revision 5 for the same CatalogID; publish a row at revision 3: |
| + // the bucket keeps 5, and the row is marked published (the newer write already covers it) |
| +func TestPublishRetriesAfterABucketFailure(t *testing.T) // wrap fx.rel so Put fails once: the row stays unpublished; the next pass publishes it |
| +func TestEffectiveLimits(t *testing.T) // nil catalog: nil, nil; catalog + no row: free limits; row "personal" + {"apps":1}: RunningThings 30 |
| +``` |
| + |
| +And in `cmd/console/plancatalog_test.go`: |
| + |
| +```go |
| +func TestLoadPlanCatalogEnv(t *testing.T) // unset: nil, nil; path to the example: non-nil; path to an invalid file or a missing file: error |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console/ -run 'PlanLimits|Publish|EffectiveLimits' && go test ./cmd/console/ -run PlanCatalog` |
| +Expected: FAIL (undefined `PlanCatalog`). |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +- `planCatalog()` returns `plans.LoadCatalog(path)`; `main` calls `log.Fatalf("CARLOS_PLAN_CATALOG: %v", err)` on error: a misconfigured catalog must never silently mean unlimited. |
| +- `StartPlanLimitsPass` follows `StartExpiryPass` (`expirypass.go:27`): return at once when `s.cfg.Releases == nil || s.cfg.PlanCatalog == nil`; otherwise run now and every minute. |
| +- `planLimitsPass`: `EnsureDefaultPlans(catalog.DefaultPlan, now)`, then `PlansToPublish(catalog.ID(), 200)`, then `publishAccountLimits` for each, logging and continuing on error. |
| +- `publishAccountLimits`: compute `Effective`; on error log `console: plan limits: <sqid>: <err>` and return it (record untouched). `GetRecord`; if it exists with the same `CatalogID` and `Revision >= row.Revision`, mark published and return. Otherwise `PutRecord` with the read etag (create-only if absent); on `ErrCASConflict` re-read once and repeat the comparison, then give up until the next pass. On success `MarkPlanPublished(row.AccountID, row.Revision, catalog.ID())`. |
| +- `EffectiveLimits` reads `AccountPlan`; no row means the catalog's default plan with no packs. |
| + |
| +- [ ] **Step 4: Register the worker** in `registerClaimWorkers` and add `worker StartPlanLimitsPass` to `testdata/claim-routes.txt` in sorted position. |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console/ -run 'PlanLimits|Publish|EffectiveLimits|ClaimRoutes' && go test ./cmd/console/ -run PlanCatalog` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/console/ cmd/console/ |
| +git commit -m "console: publish account limits from the plan catalog" |
| +``` |
| + |
| +### Task 5: The operator door and `carlos accounts plan` |
| + |
| +**Files:** |
| +- Create: `internal/console/apiaccountplan.go` |
| +- Create: `internal/console/apiaccountplan_test.go` |
| +- Modify: `internal/console/authcli.go` (register two `cliRoute`s beside `store/approve`, line ~931) |
| +- Modify: `internal/console/testdata/claim-routes.txt` |
| +- Modify: `apimode.go` (two session methods beside `storeApprove`, line ~2195) |
| +- Modify: `accounts.go` (add `case "plan":` to the dispatch at line ~73) |
| +- Create: `accounts_plan_test.go` |
| +- Modify: `docs/site/cli.md` (document `carlos accounts plan`) |
| +- Create: `docs/site/plans.md` (operator guide: the catalog file, `CARLOS_PLAN_CATALOG`, assigning and sponsoring) |
| +- Modify: `docs/site/nav.json` (a "Plans" page under Reference) |
| + |
| +**Interfaces:** |
| +- Consumes: Tasks 1, 2, 4. |
| +- Produces: |
| + - `GET /api/cli/accounts/{account}/plan` and `POST /api/cli/accounts/{account}/plan`, fleet operators only (`s.store.IsOperator`), everyone else 404 (the store-approval doors' convention, `apistore_entitlement.go:92`). |
| + - Reply body: |
| + ```go |
| + type accountPlanReply struct { |
| + Account string `json:"account"` |
| + CatalogID string `json:"catalog_id"` |
| + Plan string `json:"plan"` |
| + Packs map[string]int `json:"packs"` |
| + Source string `json:"source"` |
| + Sponsored *plans.Limits `json:"sponsored,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + Revision int64 `json:"revision"` |
| + PublishedRevision int64 `json:"published_revision"` |
| + Limits *plans.Limits `json:"limits,omitempty"` // effective; omitted when Effective errors |
| + LimitsError string `json:"limits_error,omitempty"` |
| + } |
| + ``` |
| + - POST body: `{"plan": "personal", "packs": {"apps": 2}, "sponsored": {...} | null, "reason": "..."}`. Source is `"sponsored"` when `sponsored` is non-null, else `"operator"`. `reason` is required. |
| + - CLI: `carlos accounts plan --account <sqid>` prints the reply; `carlos accounts plan --account <sqid> --set <plan> [--pack name=qty ...] [--sponsored <file.json>] --reason <text>` sets it. |
| + - Session: `func (s *apiSession) accountPlan(ctx context.Context, account string) (accountPlanReply, error)` and `func (s *apiSession) setAccountPlan(ctx context.Context, account string, req setAccountPlanRequest) (accountPlanReply, error)` (`setAccountPlanRequest` mirrors the POST body). |
| + |
| +- [ ] **Step 1: Write the failing console tests** in `apiaccountplan_test.go` with `newConsoleFixture`, an operator token from `fx.st.SetOperator(addr, true)` plus `cliToken(t, fx, addr, code)` (as `entitlementOperatorToken` does, without the store-operator list): |
| + |
| +```go |
| +func TestAccountPlanDoorIsOperatorOnly(t *testing.T) // a non-operator owner of the account: GET and POST both 404 |
| +func TestAccountPlanDoorWithoutACatalog(t *testing.T) // PlanCatalog nil: 409, error names CARLOS_PLAN_CATALOG |
| +func TestAccountPlanGetDefaults(t *testing.T) // fresh account: plan "free", source "default", revision 0, limits.running_things 3 |
| +func TestAccountPlanSetAndPublish(t *testing.T) // POST personal + {"apps":1}: reply revision 1, limits.running_things 30; |
| + // the bucket record at accounts/<sqid>/limits.json has Revision 1 and Plan "personal" (published inline) |
| +func TestAccountPlanSetSponsored(t *testing.T) // POST free with sponsored {"running_things": 12}: source "sponsored", limits.running_things 12 |
| +func TestAccountPlanSetRefuses(t *testing.T) // 400 for: unknown plan; unknown pack; pack not offered by the plan; quantity 0, -1 or above max; |
| + // missing reason. Nothing is written (revision unchanged) |
| +func TestAccountPlanSetSurvivesABucketFailure(t *testing.T) // Put fails: POST still 200 with published_revision < revision; the next planLimitsPass publishes |
| +``` |
| + |
| +- [ ] **Step 2: Write the failing CLI test** in `accounts_plan_test.go`, following the `runStoreApproveWithSession` pattern (`store.go:405`) against a test console session: |
| + |
| +```go |
| +func TestAccountsPlanParsesPacks(t *testing.T) // "--pack apps=2 --pack email=1" -> {"apps":2,"email":1}; "--pack apps" and "--pack apps=x" are errors |
| +func TestAccountsPlanShowAndSet(t *testing.T) // show prints the plan, source and each effective limit; set sends the request body above |
| +``` |
| + |
| +- [ ] **Step 3: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console/ -run AccountPlan && go test . -run AccountsPlan` |
| +Expected: FAIL (404 / undefined). |
| + |
| +- [ ] **Step 4: Implement the door, the routes, the session methods and the CLI command.** POST validates with `catalog.Effective(plan, packs, sponsored)` before writing, maps the `plans.Err*` sentinels to 400 with a plain message, then `SetAccountPlan`, then calls `publishAccountLimits` inline, logging (not failing) a publish error. The CLI prints limits in human units (GB, per day, per month) in the field order of spec table 4.2. Add both routes to `testdata/claim-routes.txt`. |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console/ -run 'AccountPlan|ClaimRoutes' && go test . -run AccountsPlan` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Write the docs** `docs/site/plans.md` (what a catalog is, the example catalog, that no catalog means no limits, how to assign and sponsor) and the `cli.md` entry. Then run the copy-review skill on every new human-facing string from Tasks 4 and 5 (CLI output, error messages, docs prose) and apply the approved text verbatim. Internal log lines and JSON keys are not reviewed. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/console/ apimode.go accounts.go accounts_plan_test.go docs/site/ |
| +git commit -m "console, cli: the operator door for account plans" |
| +``` |
| + |
| +## Done when |
| + |
| +- `go test ./internal/plans/ ./internal/domains/ -run 'Catalog|Effective|Record|IgnoresLimits'`, `go test ./internal/console/ -run 'AccountPlan|DefaultPlans|PlansToPublish|MarkPlanPublished|PlanLimits|Publish|EffectiveLimits|ClaimRoutes'`, `go test ./cmd/console/ -run PlanCatalog` and `go test . -run AccountsPlan` pass locally. |
| +- CI is green on the branch tip, after merging current main. |
| +- With `CARLOS_PLAN_CATALOG` unset, no console behaviour changes (pinned by `TestPlanLimitsPassOffWithoutACatalog` and `TestAccountPlanDoorWithoutACatalog`). |
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| new file mode 100644 |
| index 000000000..d96c54559 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md |
| @@ -0,0 +1,681 @@ |
| +# Plans, caps and packs: getting paid plans live on carloku.com |
| + |
| +Status: draft for Paul's review, 2026-10-20. Supersedes the scope (not the |
| +settled policies) of the billing handoff in amadan discussion #321 for the |
| +first paid launch. |
| + |
| +## 1. Why |
| + |
| +carloku.com charges nobody today. The previous billing effort set out to |
| +bill metered usage (CPU hours, egress, durability writes, storage |
| +byte-months) and to prove every number on an invoice. That grew to 370 |
| +unmerged commits on `feat/carloku-backend-integration-20261003` |
| +(`165472b8`): about 54k lines of product code, 34 files conflicting with |
| +main, and a blocked unit with an unexplained test failure. None of it is |
| +live. |
| + |
| +Paul's direction (2026-10-20): get plans live first, worry about overages |
| +later. The shape that makes that possible: |
| + |
| +- **Charge for counts the console already knows, never for measured |
| + usage.** An app count, a pack quantity, a plan. Nothing on an invoice |
| + needs proving because nothing on an invoice was measured. |
| +- **Measure only to enforce.** Sizes, sends, bytes and awake hours are |
| + still measured, but only to warn and to stop. A measurement that is an |
| + hour late means someone hits a wall an hour late. Nobody is |
| + overcharged. |
| + |
| +The moment we are building for: someone has tried Carloku, deployed a free |
| +app, and wants another. They hit the free limit, and they pay to lift it, |
| +in the console, there and then. |
| + |
| +## 2. Scope |
| + |
| +Two deliveries, in this order: |
| + |
| +1. **Pricing page** on carloku.com showing Free and Personal, rendered from |
| + an approved price book. Can ship before checkout works. |
| +2. **Plans, caps, packs and checkout** in the platform: a free plan, one |
| + paid annual plan (Personal) bought through Stripe Checkout, packs sold |
| + as quantities on the same subscription, caps with warnings and grace |
| + periods, and the abuse guards in section 8. |
| + |
| +### Goals |
| + |
| +- A new account gets the free plan and its caps, with no card. |
| +- A free account that hits a cap can buy Personal (and packs) in the |
| + console and is lifted immediately on payment. |
| +- Every cap warns at 80%, allows a grace period, then refuses only the |
| + capped action. Apps keep serving. Data is never deleted by a cap, |
| + except what a lapsed or reduced paid account no longer pays for, and |
| + only after grace, a 90-day disabled period and the Trash (9.2). |
| +- Existing finite Carloku accounts stay sponsored: they act as paid, at |
| + no charge (settled policy, #321). |
| +- Plans are a general platform feature. A deployment with no plan catalog |
| + configured (Tito, self-hosted boxes, dev unless switched on) behaves |
| + exactly as today: no caps, no billing. |
| + |
| +### Non-goals (later, not never) |
| + |
| +- Metered usage billing and overages of any kind. |
| +- Team plans priced per member (Community, Business). |
| +- Affordability bands (see 9.3). |
| +- Seats as a sellable pack. |
| +- An always-on option on any plan. |
| +- Billing storage requests, replication writes or egress by volume. |
| + |
| +### Anti-goals (would make this a failure even if it works) |
| + |
| +- A surprise bill. Nothing is charged that the customer did not choose. |
| +- Losing a customer's data without warning, or while they are still |
| + paying for it. |
| +- A free tier that can be farmed without limit, which makes every cap |
| + meaningless. |
| +- Carloku-specific code paths in the platform. carloku.com is a |
| + deployment that configures plans; it gets no special case. |
| +- Re-importing the old branch wholesale, or its review process. |
| + |
| +## 3. Decisions already made |
| + |
| +By Paul in this conversation (2026-10-20), not to be reopened by review: |
| + |
| +- Scope is (1) the pricing page and (2) flat plans plus packs. Overages |
| + wait. |
| +- Free: **3 apps**. Free apps **sleep after 1 to 2 minutes idle**. |
| +- Paid apps **stay awake up to 15 minutes** after the last request. |
| +- **All apps hibernate.** That is what makes the economics work. No plan |
| + offers always-on. |
| +- **Packs** raise caps: extra apps, storage, email, bandwidth. They are |
| + bought, not metered. |
| +- **Every cap has a grace period with fair notice** before anything is cut |
| + off. |
| +- **Bandwidth** follows the same cap pattern. |
| +- **After a lapse, paid-only features get a grace period, are then |
| + disabled, and are eventually deleted.** Agreed details: 7 days grace, |
| + 90 days disabled with reminders, then the Trash's 30 days (9.2). |
| +- Abuse guards: account farming, things that never sleep, and counting |
| + everything that runs are in v1. Phishing defences are in v1 if free and |
| + paid apps share a domain. Outbound abuse starts with an audit. Hidden |
| + costs start as monitoring. |
| + |
| +Settled earlier and still binding (#321 and the 2026-10-01 rulings): |
| + |
| +- Existing finite accounts are sponsored; sponsorship stays with the |
| + account through ownership transfer; once limits apply, exhaustion pauses |
| + safely and data is kept at Carloku's expense. No automatic charging. |
| +- `console.carloku.com` is the paid deployment. Tito and other |
| + self-managed deployments stay billing OFF. No hostname-triggered |
| + activation. |
| +- Three currencies, lower case: usd, eur, gbp. Default by billing country |
| + (EU member states eur, GB gbp, everything else usd), with a visible |
| + override. An explicit currency we do not sell is refused, never |
| + defaulted. Nothing is converted. |
| +- A live console refuses a draft price book; approval is a new book |
| + version, never an edit. |
| +- Stripe keys are restricted `rk_` keys in the console's EnvironmentFile, |
| + never the bucket, never `carlos secrets set`. |
| + |
| +## 4. Plans and limits |
| + |
| +### 4.1 The catalog |
| + |
| +A deployment opts in by configuring a **plan catalog**: a versioned file |
| +(shape left to the builder; JSON beside `internal/plans` is the obvious |
| +home) that names each plan's caps and each pack's increment. No catalog |
| +configured means no caps and no billing. |
| + |
| +An account's **effective limits** are its plan's caps plus, for each pack, |
| +quantity times increment, never above the pack's ceiling. The console |
| +computes them. Every enforcement point reads effective limits, never a |
| +plan name. |
| + |
| +### 4.2 Numbers |
| + |
| +Paul's numbers are marked **(Paul)**. Everything else is a proposal for |
| +Paul to approve or change before the book is approved; the builder treats |
| +them as configuration, not constants. |
| + |
| +| Limit | Free | Personal | Pack adds | Hard ceiling | |
| +| --- | --- | --- | --- | --- | |
| +| Running things (apps + instances, 5.1) | 3 **(Paul)** | 25 | +5 | 200 without an operator | |
| +| 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 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 | |
| +| Free accounts per person | 1 | n/a | n/a | n/a | |
| +| Custom domains | none | allowed | n/a | n/a | |
| +| Point-in-time database history | latest copy only | 7 days | none | n/a | |
| + |
| +Prices: Personal at the draft book's annual bases (usd 28, eur 24, gbp 22). |
| +Pack prices are Paul's to set; the book needs one price per pack per |
| +currency. |
| + |
| +## 5. The caps |
| + |
| +Every cap has the same three stages (section 6). This section says, per |
| +cap, what is counted, where it is enforced, and what "refused" means. |
| + |
| +### 5.1 Running things (the app cap) |
| + |
| +**Counted:** apps, temporary apps, and customer instances (including |
| +seeded and imported). Each counts as one. Sidecar workers do not count |
| +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 |
| + check both create doors and temporary apps already use. The temporary |
| + app cap (`apitemporary.go:24,66,167`) is the template: checked under |
| + `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. |
| +- 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 |
| + accounts. Leaving it open is not an option. |
| + |
| +**Over the cap:** creation is refused. Nothing that already runs is |
| +touched. An account can be over the cap only by losing a plan or a pack |
| +(lapse, downgrade, reduction at renewal). Then: grace (section 6), and |
| +after grace the **newest** running things beyond the cap are disabled |
| +(stop serving, show a clear page), and, if still over, deleted after the |
| +retention period (9.2). This is the one case where a cap leads to |
| +deletion. |
| + |
| +### 5.2 Idle window, and things that never sleep |
| + |
| +**Idle window.** Today `cfg.Idle` is box-wide (`CARLOS_HIBERNATE_IDLE`, |
| +default 15 min, `internal/activator/activator.go:101,123`), and only |
| +sidecar workers carry a per-route window (`rt.IdleSeconds`, |
| +`activator.go:3114`, used at `:4646`). The plan's idle window reaches the |
| +box as a per-route `IdleSeconds` set by the provision reconcile pass |
| +(`internal/provision/reconcile.go:441` for instances, |
| +`sidecar_reconcile.go:144` for sidecars) from the account's effective |
| +limits, and the activator honours a per-route window for every entry, not |
| +only workers. A route's window is the smaller of its own and the plan's. |
| + |
| +**Never sleeping is refused for plan-governed accounts:** |
| +- the `daemon` capability (`internal/caps/caps.go:106`), refused at |
| + release promote/ship time with a message naming the plan, not just at |
| + converge; |
| +- `SidecarManifest.Hibernate = false` (`provision/sidecar.go:56`, |
| + member-settable at `apisidecars.go:614`); |
| +- any member-reachable path that yields an always-on route. Address-proxy |
| + routes default to always-on (`provision.go:444`); the builder confirms |
| + whether a member can create one and closes it if so. |
| + |
| +Operators keep `carlos route --hibernate=false` for their own needs. |
| + |
| +**Schedules.** `MinEvery` is one minute (`internal/schedule/spec.go:21`) |
| +and a due tick holds the host awake (`activator.go:~4655`). Free accounts |
| +get a minimum schedule interval of 15 minutes, validated where `ValidEvery` |
| +runs. Paid keeps one minute. |
| + |
| +**Awake hours.** A monthly awake-hours cap per account backstops keep-alive |
| +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. |
| + |
| +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 |
| + |
| +Add a third tier beside `TierDefault` and `TierChildren` |
| +(`internal/cgroup/cgroup.go:92`): 256 MB, CPU quota 50%. `memTier` |
| +(`internal/host/memtier.go`) chooses it from the route's account limits. |
| +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`. |
| + |
| +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. |
| + |
| +### 5.4 Database and files on the box |
| + |
| +Today nothing stops one app filling a box's disk (single gp3 root volume, |
| +`infra/modules/region-host/main.tf:493`; data at `/data/<host>.db` and |
| +`/data/instances/<id>/`, `provision.go:2912`, `bootstrap.go:75`). This is |
| +a live risk whether or not anyone pays, so it is in v1. |
| + |
| +**Enforcement:** a per-app quota on the app's data directory, enforced by |
| +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. |
| + |
| +**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. |
| + |
| +### 5.5 Object storage |
| + |
| +Free accounts keep today's rule: an operator approves storage |
| +(`internal/console/apistore_entitlement.go`, `objstore/entitlement.go`). |
| +Paid accounts get it self-serve up to their cap. |
| + |
| +**Counting:** `sizeProvisionedStore` (`economicspass.go:1023`) already |
| +lists each store bucket's bytes daily into `usage/storage/<date>.json`. |
| +Daily is enough. |
| + |
| +**After grace:** a `Deny s3:PutObject` statement in the store's policy |
| +(`storePolicyJSON`, `internal/objstore/aws.go:180`; `EnsureUser` rewrites |
| +the whole inline policy). Reads and deletes keep working, so the customer |
| +can clean up. The MinIO adapter needs the equivalent (section 10). |
| + |
| +Downloads straight from a store do not pass the edge, so they are outside |
| +the bandwidth cap. Storage request counts are monitored, not capped, in |
| +v1 (8.6). |
| + |
| +### 5.6 Email |
| + |
| +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 |
| +10,000 a day hard ceiling is not raised by packs; only an operator raises |
| +it, because sending reputation is shared by every tenant. |
| + |
| +### 5.7 Bandwidth |
| + |
| +**Counting:** the edge already counts response bytes per route |
| +(`internal/edge/usagemeter.go:36`), flushed every 5 minutes into |
| +`usage/edge/<box>/days/<date>.json` and summed per account per month as |
| +`egress_gb` by the economics pass (`economicspass.go:409-427`). Approximate |
| +and late by up to an hour: acceptable for a gate. |
| + |
| +**Gap:** WebSocket traffic after the upgrade is not counted |
| +(`edge.go:1704`). v1 accepts that and relies on the awake-hours cap. |
| + |
| +**After grace:** throttle, do not refuse. The account's routes are served |
| +at a reduced rate until the month rolls or the account upgrades. This |
| +needs a per-account throttle state that every edge can see; it travels in |
| +the account's limits record (section 7). Grace for a monthly cap is "the |
| +rest of the month, up to 2x the cap". |
| + |
| +## 6. Warnings, grace and notice |
| + |
| +One engine, used by every cap except memory: |
| + |
| +1. **Warning at 80%.** Console banner on the account and the affected |
| + app, a warning line in `carlos` CLI output for commands touching the |
| + account, and an email to the account's owners. |
| +2. **Over the cap, in grace.** Everything keeps working. A daily reminder |
| + says what is over, by how much, when grace ends, and the three ways |
| + out: buy a pack, upgrade, or clean up. |
| +3. **After grace.** Only the capped action is refused (per cap, above). |
| + The app keeps serving. Data is untouched. |
| + |
| +Grace periods: 7 days for counts and sizes (apps, disk, storage); |
| +"the rest of the day up to 2x" for email; "the rest of the month up to 2x" |
| +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, 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, notice is only the console banner and CLI warning. |
| + |
| +## 7. How limits reach the boxes |
| + |
| +The console is the single writer of an account's effective limits. It |
| +writes them to one record in the bucket per account; boxes read it the |
| +same way they already read `accounts/<sqid>/account.json` |
| +(`domains/reconcile.go:573`, exposed to the account's own boxes by |
| +`edge/storedoor.go:376`). |
| + |
| +The integration branch's `internal/entitlement` record |
| +(`entitlements/accounts/<sqid>.json`, with |
| +`Runtime{IdleSeconds, MaxApps, MaxMembers, MemoryTier, ...}`) is the shape |
| +to start from. Harvest the record; leave its writer-lineage ("authority") |
| +machinery behind unless the builder finds a second writer, which v1 should |
| +not have. |
| + |
| +Boxes use the record for: idle window, memory tier, throttle state, |
| +suspended routes, and the disk quota size. The console uses its own |
| +database for everything it enforces itself (creation caps, email, |
| +storage policy, custom domains, schedules). |
| + |
| +A box that cannot read the record keeps the last one it read. A box that |
| +has never read one for an account applies **no** caps (fail open), and |
| +reports it. Caps protect economics, not security; failing closed would |
| +take customer apps down on a bucket hiccup. |
| + |
| +## 8. Abuse guards |
| + |
| +1. **One free account per person.** `mintAccount` (`server.go:2226`) is the |
| + path for every console account creation (auto-mint on first sign-in, |
| + `/accounts/new`, the CLI). A person may own one free account. More |
| + 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 |
| + (a separate free domain would rename apps on every upgrade; see 9.2). |
| + Because they share it, v1 ships: custom domains for paid accounts only |
| + (gate in `attachDomainAs`, `domainattach.go:116`, above the uniqueness |
| + checks per that file's ordering rule); an abuse-report address on the |
| + site and in each app's footer page; and an operator takedown command |
| + that suspends an app and its account's other apps with a reason. |
| +5. **Outbound abuse.** Units already deny private ranges and metadata |
| + (`IPAddressDeny`), but the public internet is open and nothing blocks |
| + outbound port 25. v1: block outbound SMTP (25) from tenant units on |
| + plan-governed deployments, and write down what else is open. Further |
| + egress policy is later. |
| +6. **Hidden costs, monitored.** Per-app wakes per day (a 90-second window |
| + can turn steady light traffic into a sleep/wake cycle every few |
| + minutes; wakes reuse the warm local database, `backend_exec.go:784`, |
| + but still take a lease and a checkpoint), replication request rates, |
| + store request counts, and ship frequency (the staging disk is never |
| + 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, 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 |
| + |
| +### 9.1 What gets sold |
| + |
| +One subscription per account, annual, in the account's currency: |
| + |
| +- one item: Personal, quantity 1; |
| +- zero or more items: one per pack type, quantity = number of packs. |
| + |
| +Adding a pack mid-year updates the item's quantity with Stripe proration. |
| +Reducing a pack takes effect at renewal. Upgrading from free is Stripe |
| +Checkout in subscription mode. Managing payment method and invoices is |
| +the Stripe customer portal, locked to those functions. The portal does not |
| +change plans or quantities; the console does. |
| + |
| +### 9.2 Lapse and downgrade |
| + |
| +A failed renewal follows Stripe's retry schedule. While Stripe retries, the |
| +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. |
| + |
| +**What the account no longer pays for is disabled, then deleted (Paul, |
| +2026-10-20, codex 1.7).** This covers everything a lapse, downgrade or |
| +pack reduction leaves beyond free (or the remaining) limits: running |
| +things beyond the cap and their data, sidecars beyond the cap, attached |
| +custom domains, and fleets. The steps: |
| + |
| +1. **Grace, 7 days** (section 6, including its notice rule). Everything |
| + keeps working. |
| +2. **Disabled.** Stops serving; a custom domain stops routing; a fleet |
| + takes no new work. Configuration and data are kept. Paying again (or |
| + buying the pack back) re-enables it at once. |
| +3. **Deleted, 90 days after being disabled**, with reminders 30, 7 and 1 |
| + days before. Deletion goes through the existing Trash |
| + (`release.TrashCooloff`, 30 days, `internal/release/trash.go:33`), so |
| + it can still be restored for a month. Data is gone for good about four |
| + months after the lapse at the earliest. |
| + |
| +Which running things are kept is the account's choice where it makes |
| +one before grace ends (pick which three stay); otherwise the newest go |
| +first. Schedules faster than the free minimum are not deleted: after |
| +grace they run at the free minimum interval. |
| + |
| +**Sponsored accounts are never on this path.** They keep the settled |
| +policy: paused at exhaustion, data kept at Carloku's expense. |
| + |
| +### 9.3 Bands |
| + |
| +The original model scaled price by an affordability band (four bands per |
| +plan). v1 sells at one price per currency (band A). Bands multiply the |
| +catalog by four and thread through checkout metadata, intents and |
| +records; they can be added later as more prices without changing the |
| +model. (My call; Paul to confirm.) |
| + |
| +### 9.4 Code to harvest from `165472b8` |
| + |
| +Not a merge. Copy onto current main, adapt, and review as new code. |
| + |
| +- **Copy:** `internal/billingstripe` without `invoices.go` (stdlib only, |
| + idempotency on every write, webhook signature verification with |
| + rotation, portal, customer, catalog checks); `internal/entitlement` |
| + (minus authority lineage). |
| +- **Adapt:** `internal/plans` down to one plan plus packs per currency, no |
| + bands or countries, keeping the draft/approved gate and |
| + `PublicPriceBook`; `cmd/console/billing.go` env handling with new price |
| + keys (`<book>/personal/<cur>`, `<book>/pack/<name>/<cur>`); the core of |
| + `billingstore` (accounts, checkout intents, book assignments, event |
| + dedupe, rejected events, reviews), `billingservice`, `billinghandlers`, |
| + `billingpage`, `billingoperator` (the house flag), `entitlementstore` |
| + (fill limits from plan plus packs); the quantity update and fence from |
| + `billingquantity`, aimed at pack items. |
| +- **Leave behind:** everything metered (`usage` durability, |
| + `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, 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 |
| + a set of items, each recognised against the book. |
| + |
| +Tests are rewritten for v1, not copied. |
| + |
| +### 9.5 Open decisions from the old branch that still apply |
| + |
| +From `DECISIONS-FOR-PAUL.md` on the integration branch: 1 to 5 (money on |
| +a refused purchase, what "settled" requires, Stripe contradicting itself, |
| +an unbound checkout, what the buyer sees during review), 6 to 8 recast |
| +for pack reductions, 9 (who is free on switch-on day: answered here, |
| +existing accounts are sponsored, new ones are free), and 15 |
| +(`incomplete_expired`). The builder proposes an answer for each in the |
| +plan; Paul rules. |
| + |
| +### 9.6 Stripe live setup |
| + |
| +Following the old `ENABLEMENT.md` procedure, reshaped: products (Personal, |
| +one per pack type), prices (one per product per currency, yearly, |
| +licensed, tax-exclusive), the 100% house coupon, a locked portal |
| +configuration, the webhook (`checkout.session.completed`, |
| +`customer.subscription.{created,updated,deleted}`, `invoice.paid`, |
| +`invoice.payment_failed`), the restricted key, and the console variables. |
| +IE and OSS tax registrations already exist. Every live step is Paul's to |
| +approve; a test-mode rehearsal on dev comes first. |
| + |
| +## 10. Provider boundary |
| + |
| +- Caps, grace, notices, plan catalog and limits records are platform core: |
| + no AWS or Stripe dependency. A self-hosted deployment can configure |
| + plans with no billing provider at all (an operator assigns plans). |
| +- Stripe is a billing adapter behind the console's billing service. |
| +- Store write blocking is an objstore adapter operation: AWS writes a |
| + Deny statement; MinIO writes its own policy equivalent. |
| +- Disk quotas are a host (Linux) mechanism; non-Linux builds get a stub |
| + (cross-compile gate). |
| +- Outbound port blocking is a host mechanism, Linux only. |
| + |
| +## 11. Pricing page (delivery 1) |
| + |
| +The page lives in the carloku.com site (deployed from `bes/website`, see |
| +memory `platform-hostnames-209`; earlier drafts in the |
| +`carloku-paid-pricing` worktree, rendered from |
| +`copy-review/pricing/strings.json` and `public-pricebook.json`). It shows |
| +Free and Personal from the approved book's public export, the caps table |
| +in human terms, and the packs. The upgrade button goes to the console; |
| +until checkout is live it goes to sign-up. |
| + |
| +Gates: an approved price book (a new version, not an edit of the draft); |
| +copy review of every string (the 2026-09-22 approval is lost and does not |
| +count); the site refuses to publish from a draft book. |
| + |
| +## 12. Rollout |
| + |
| +All live steps need Paul. Dev before flagship (roll order rule). |
| + |
| +1. Ship the plan catalog, limits records and caps **dormant** (no catalog |
| + configured). Nothing changes anywhere. |
| +2. Dev: configure a catalog; mark every existing dev account sponsored; |
| + create fresh free accounts and exercise every cap through grace and |
| + after grace; Stripe test mode end to end, including a lapse. |
| +3. Flagship: mark every existing account sponsored (house) with limits |
| + recorded from current usage. Configure the catalog. New accounts are |
| + free. Watch a week of warnings with after-grace stages disabled. |
| +4. Enable after-grace stages (only for caps whose notice reached owners, |
| + section 6). |
| +5. Stripe live provisioning (9.6), then switch checkout on. Publish the |
| + pricing page's upgrade button. |
| + |
| +## 13. Left to the builder |
| + |
| +- Catalog and limits record file shapes. |
| +- The disk quota mechanism and the data-volume change it needs. |
| +- How throttling is applied at the edge. |
| +- 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 | 9.2, Paul's ruling: grace, disable, delete after 90 days via the Trash | |
| +| 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 | |
| + |
| +## Calls made by the author, for Paul to check |
| + |
| +Not discussed with Paul; each is a proposal in the document: |
| + |
| +- All numbers in table 4.2 other than those marked (Paul), and pack |
| + prices left blank. |
| +- Free gets 256 MB / 0.5 CPU (5.3). |
| +- No affordability bands in v1 (9.3). |
| +- One apps domain for free and paid, with paid-only custom domains, |
| + abuse reports and takedown instead of a separate free domain (8.4). |
| +- Storage cap per running thing, not an account pool (5.4). |
| +- Notice rule: after-grace waits for received notice plus 3 days (6). |
| +- Awake-hours enforcement: throttle, close WebSockets, 15-minute |
| + continuous-awake ceiling (5.2). |
| +- Sponsored limits from measured usage, busiest of the last three months |
| + for recurring figures (8.7). |
| +- One free account per person checked at creation only (8.1). |
| +- Limits fail open on a box that has never read an account's record (7). |
| +- Outbound port 25 blocked for tenants on plan-governed deployments (8.5). |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-1-CODE.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-1-CODE.md| new file mode 100644 |
| index 000000000..1f1a1a1aa |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-1-CODE.md |
| @@ -0,0 +1,28 @@ |
| +1. **Important — Publishing worker never starts.** [claim_workers.go:17](internal/console/claim_workers.go:17) registers `StartPlanLimitsPass`, but the explicit worker startup list in `cmd/console/main.go` never invokes it. Configuring a catalog therefore publishes no default records; catalog changes never propagate; failed inline publishes never retry. Start it through `RunClaimWorker` alongside the other workers, and test the startup wiring. |
| + |
| +2. **Important — Orphaned assignments can permanently block other accounts.** [accountplans.go:126](internal/console/accountplans.go:126) always selects the first 200 outstanding accounts. If a catalog change orphans those assignments, each publish fails and they occupy every subsequent batch. Later valid accounts never publish. I reproduced this selection behavior with an in-memory SQLite database. Use pagination or a rotating cursor that advances past failures while retaining them for retry. |
| + |
| +3. **Minor — An in-flight request can publish after account deletion.** [apiaccountplan.go:120](internal/console/apiaccountplan.go:120) relies on the account lookup performed before reading the request body. An operator request can pass that lookup, pause while the owner deletes the account, then resume. `SetAccountPlan` accepts the tombstoned account, `AccountPlan` returns its row, and the handler publishes its limits. The SQL behavior reproduced in memory. Make assignment writes conditional on a live account and synchronize publishing with deletion; cover both inline and background publishing races. |
| + |
| +4. **Minor — Misspelled sponsored fields silently disappear.** [accounts_plan.go:130](accounts_plan.go:130) accepts `{"running_thing":40}` as an empty override. The command succeeds and publishes the base plan’s lower limit; updating an existing sponsorship can also remove its previous allowance. Direct API requests have the same permissive decoding. Reject unknown fields in sponsored files and assignment requests before changing the row. |
| + |
| +5. **Minor — Pack arithmetic can overflow before ceiling enforcement.** [catalog.go:214](internal/plans/catalog.go:214) multiplies and adds unchecked integers. An accepted catalog with base storage `1`, pack increment `4611686018427387904`, quantity `2`, and ceiling `100` produces `-9223372036854775807`; the ceiling does not correct it. Use checked multiplication and addition, rejecting overflow or saturating safely before clamping. |
| + |
| +The executor’s rulings are reasonable: additional catalog validation, SQL classification, the absent-record convention, the corrected census test name, the exact put-only host-claim family and probes, and request validation alongside `Effective`. The copy organization and review rulings introduce no functional objection. |
| + |
| +The remaining deliberate checks look sound statically: content changes alter catalog identity without a version bump; partial sponsorship preserves unset fields and schedule minimums; same-catalog CAS retries protect newer revisions; stale publication cannot mark a newer SQL revision published; authenticated non-operators receive the stdlib 404. |
| + |
| +**Declined to judge** |
| + |
| +- Enforcement, box caching, grace periods, checkout, and sponsorship measurement: assigned to later plans. |
| +- Approved human-facing wording: expressly excluded. |
| +- Inherited unauthenticated/service-credential responses: existing authentication policy, matching the referenced store-door convention. |
| +- Concurrent consoles using different catalogs: beyond the specified single-writer model. |
| +- Catalog identity after programmatic mutation: no production path mutates the loaded catalog. |
| +- Unsupported nonadditive ceilings: the implementation plan explicitly limits which ceilings apply. |
| +- Ignored negative sponsored values or unknown memory tiers: no demonstrated harmful effective-limit change. |
| +- Repeated `--pack` flags replacing earlier values: conventional map behavior without a contrary requirement. |
| + |
| +Go tests could not start because the read-only sandbox prevented creation of Go’s build directory. Darwin/Windows portability was inspected but not compiled. HEAD remains `80672fad599ca72aba577f78d783d8c6107599f1`; the working tree is unchanged. |
| + |
| +**Verdict: not ready.** The missing worker startup and batch starvation block the core publishing behavior. |
| \ No newline at end of file |
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 |
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-1-CODE.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-1-CODE.md| new file mode 100644 |
| index 000000000..424d1e978 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-1-CODE.md |
| @@ -0,0 +1,52 @@ |
| +# Whole-branch code review brief: Plans 1 (catalog and account limits) |
| + |
| +You are a senior Go reviewer on the carlos platform. Review the |
| +implementation of Plan 1 on branch `design-plans-and-caps`, range |
| +`3d444a1b..HEAD` (`git diff 3d444a1b..HEAD`; ignore the docs under |
| +docs/superpowers/ except as requirements). The review is read-only: do not |
| +modify the working tree, index, HEAD or branches, and do not build into the |
| +repo. You may run `go test` for packages you want to probe. |
| + |
| +Requirements, in order of authority: |
| +1. Spec: docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md |
| + (sections 4, 7, 8.7, 9.4, 10 are what Plan 1 implements; section 3 |
| + lists product decisions that are not to be argued with). |
| +2. Plan: docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md. |
| +3. The executor's rulings (deviations from the plan, each with its cost): |
| + `.superpowers/sdd/2026-10-20-plans-1-catalog-and-limits/progress.md`, |
| + lines containing `Ruling:`. Weigh each; say if any is wrong. |
| + |
| +The spec is a vision document. For behaviour it is silent on, judge by what |
| +a reasonable operator or customer would expect, and grade by effect. |
| + |
| +Check these deliberately (the plan's Review Focus, inputs its tests may not |
| +exercise): |
| +1. A catalog edited without a version bump: the console must refuse to |
| + start or treat it as new, never publish records whose catalog_id lies. |
| +2. A catalog change that orphans an assignment (plan or pack removed): skip |
| + that account, keep its last record, keep publishing everyone else. |
| +3. A deleted (tombstoned) account: no row ensured, no record written. |
| +4. An operator change racing the publish pass: the newer revision wins in |
| + the bucket and in published_revision; a stale publish never marks a |
| + newer revision published. |
| +5. Partial sponsored limits: unset fields leave the plan's value; |
| + min_schedule_seconds 0 must not mean "no minimum". |
| + |
| +Also look hard at: the host-claim boundary (hostops classifier entry for |
| +accounts/<sqid>/limits.json, claim fixtures and probes), behaviour with |
| +CARLOS_PLAN_CATALOG unset (must be exactly today's behaviour), the operator |
| +door's authorization (fleet operators only; everyone else gets the stdlib |
| +404), CAS and retry correctness in publishAccountLimits, SQL in |
| +accountplans.go, and cross-compilation (darwin/windows). |
| + |
| +Do not review the wording of human-facing strings: they passed Paul's copy |
| +review and are applied verbatim (accounts_plan_copy.go, |
| +internal/console/apiaccountplan_copy.go, docs/site/plans.md, the cli.md |
| +entry, nav.json, the accounts usage block). |
| + |
| +Output format: |
| +- Findings, most severe first, each labelled Critical / Important / Minor, |
| + with file:line, the concrete failure scenario, and the fix you suggest. |
| +- "Declined to judge": every behaviour you considered and set aside as |
| + outside the plan or spec, one line each with the reason. |
| +- A verdict: ready to merge, ready with fixes, or not ready. |
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-CAPS.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-CAPS.md| new file mode 100644 |
| index 000000000..3982379fd |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-CAPS.md |
| @@ -0,0 +1,30 @@ |
| +# Builder check brief: plans, caps and packs |
| + |
| +You are a talented senior Go engineer on the carlos platform. You've been |
| +handed `docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md` (at |
| +the commit you are reviewing) and asked to put together an implementation |
| +plan for what it describes. You will not be able to talk to the person who |
| +wrote it. |
| + |
| +Read the design. You may read the code on `origin/main` (the worktree is |
| +main plus this spec) to check what it cites, and the unmerged branch |
| +`origin/feat/carloku-backend-integration-20261003` via `git show` for the |
| +harvest list. Do not build, test or modify anything. |
| + |
| +Report only the questions you'd still need answered before you could plan |
| +this well: places where you'd have to guess and a wrong guess would matter. |
| +For each, give the question and why the answer changes the plan. Rank them |
| +by importance. |
| + |
| +Also report, separately and briefly, any code citation in the design that |
| +is wrong (file or line does not say what the design claims) where the |
| +error would mislead a builder. |
| + |
| +Section 3 lists decisions already made by the product owner. Do not argue |
| +with them; report only places where the design fails to honour them or |
| +where they conflict with each other or with the code. |
| + |
| +Skip anything the document explicitly leaves to the builder (section 13), |
| +anything you could reasonably decide yourself, and wording nitpicks. |
| + |
| +If nothing important is left, say "nothing important left". |
diff --git a/internal/console/accountplans.go b/internal/console/accountplans.go| new file mode 100644 |
| index 000000000..2f5a5ce7c |
| --- /dev/null |
| +++ b/internal/console/accountplans.go |
| @@ -0,0 +1,151 @@ |
| +package console |
| + |
| +import ( |
| + "database/sql" |
| + "encoding/json" |
| + "errors" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// The sources an account's plan row can have. "default" rows are the ones |
| +// EnsureDefaultPlans inserts for accounts nobody has assigned. |
| +const ( |
| + PlanSourceDefault = "default" |
| + PlanSourceOperator = "operator" |
| + PlanSourceSponsored = "sponsored" |
| + PlanSourceBilling = "billing" |
| +) |
| + |
| +// planDefaultActor is who a default row names as its writer. |
| +const planDefaultActor = "plans" |
| + |
| +// AccountPlanRow is one account's plan assignment. |
| +type AccountPlanRow struct { |
| + AccountID int64 |
| + Sqid string |
| + Plan string |
| + Packs map[string]int |
| + Source string |
| + Sponsored *plans.Limits |
| + Reason string |
| + Revision int64 |
| + PublishedRevision int64 |
| + PublishedCatalog string |
| + UpdatedBy string |
| + UpdatedAt time.Time |
| +} |
| + |
| +const accountPlanColumns = `p.account_id, a.sqid, p.plan, p.packs, p.source, p.sponsored, p.reason, |
| + p.revision, p.published_revision, p.published_catalog, p.updated_by, p.updated_at` |
| + |
| +func scanAccountPlan(sc interface{ Scan(...any) error }) (*AccountPlanRow, error) { |
| + var r AccountPlanRow |
| + var packs, sponsored string |
| + var updated int64 |
| + if err := sc.Scan(&r.AccountID, &r.Sqid, &r.Plan, &packs, &r.Source, &sponsored, &r.Reason, |
| + &r.Revision, &r.PublishedRevision, &r.PublishedCatalog, &r.UpdatedBy, &updated); err != nil { |
| + return nil, err |
| + } |
| + if err := json.Unmarshal([]byte(packs), &r.Packs); err != nil { |
| + return nil, err |
| + } |
| + if sponsored != "" { |
| + r.Sponsored = &plans.Limits{} |
| + if err := json.Unmarshal([]byte(sponsored), r.Sponsored); err != nil { |
| + return nil, err |
| + } |
| + } |
| + r.UpdatedAt = time.Unix(updated, 0) |
| + return &r, nil |
| +} |
| + |
| +// AccountPlan is the account's plan row, or nil when it has none. |
| +func (s *Store) AccountPlan(accountID int64) (*AccountPlanRow, error) { |
| + r, err := scanAccountPlan(s.db.QueryRow(`SELECT `+accountPlanColumns+` |
| + FROM account_plans p JOIN accounts a ON a.id = p.account_id WHERE p.account_id = ?`, accountID)) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return nil, nil |
| + } |
| + return r, err |
| +} |
| + |
| +// SetAccountPlan writes the account's plan and returns its new revision. |
| +// It validates nothing: callers check the plan against the catalog first. |
| +func (s *Store) SetAccountPlan(accountID int64, plan string, packs map[string]int, source string, |
| + sponsored *plans.Limits, reason, by string, now time.Time) (int64, error) { |
| + if packs == nil { |
| + packs = map[string]int{} |
| + } |
| + pj, err := json.Marshal(packs) |
| + if err != nil { |
| + return 0, err |
| + } |
| + sj := "" |
| + if sponsored != nil { |
| + b, err := json.Marshal(sponsored) |
| + if err != nil { |
| + return 0, err |
| + } |
| + sj = string(b) |
| + } |
| + var rev int64 |
| + err = s.db.QueryRow(`INSERT INTO account_plans |
| + (account_id, plan, packs, source, sponsored, reason, revision, updated_by, updated_at) |
| + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) |
| + ON CONFLICT(account_id) DO UPDATE SET plan = excluded.plan, packs = excluded.packs, |
| + source = excluded.source, sponsored = excluded.sponsored, reason = excluded.reason, |
| + updated_by = excluded.updated_by, updated_at = excluded.updated_at, revision = revision + 1 |
| + RETURNING revision`, |
| + accountID, plan, string(pj), source, sj, reason, by, now.Unix()).Scan(&rev) |
| + return rev, err |
| +} |
| + |
| +// EnsureDefaultPlans gives every live account without a plan row the |
| +// default plan, and reports how many rows it inserted. |
| +func (s *Store) EnsureDefaultPlans(defaultPlan string, now time.Time) (int, error) { |
| + res, err := s.db.Exec(`INSERT INTO account_plans |
| + (account_id, plan, packs, source, revision, updated_by, updated_at) |
| + SELECT id, ?, '{}', ?, 1, ?, ? FROM accounts |
| + WHERE deleted_at = 0 AND id NOT IN (SELECT account_id FROM account_plans)`, |
| + defaultPlan, PlanSourceDefault, planDefaultActor, now.Unix()) |
| + if err != nil { |
| + return 0, err |
| + } |
| + n, err := res.RowsAffected() |
| + return int(n), err |
| +} |
| + |
| +// PlansToPublish lists live accounts whose bucket record is behind their |
| +// row (an unpublished revision, or a record from another catalog), in |
| +// account order after afterAccountID, so a caller can page past rows it |
| +// cannot publish. |
| +func (s *Store) PlansToPublish(catalogID string, afterAccountID int64, limit int) ([]AccountPlanRow, error) { |
| + rows, err := s.db.Query(`SELECT `+accountPlanColumns+` |
| + FROM account_plans p JOIN accounts a ON a.id = p.account_id |
| + WHERE a.deleted_at = 0 AND p.account_id > ? AND (p.published_revision < p.revision OR p.published_catalog != ?) |
| + ORDER BY p.account_id LIMIT ?`, afterAccountID, catalogID, limit) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []AccountPlanRow |
| + for rows.Next() { |
| + r, err := scanAccountPlan(rows) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, *r) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// MarkPlanPublished records that the bucket carries revision under |
| +// catalogID. A revision that is no longer current marks nothing: the newer |
| +// one still needs publishing. |
| +func (s *Store) MarkPlanPublished(accountID, revision int64, catalogID string) error { |
| + _, err := s.db.Exec(`UPDATE account_plans SET published_revision = ?, published_catalog = ? |
| + WHERE account_id = ? AND revision = ?`, revision, catalogID, accountID, revision) |
| + return err |
| +} |
diff --git a/internal/console/accountplans_test.go b/internal/console/accountplans_test.go| new file mode 100644 |
| index 000000000..bd099ba56 |
| --- /dev/null |
| +++ b/internal/console/accountplans_test.go |
| @@ -0,0 +1,167 @@ |
| +package console |
| + |
| +import ( |
| + "path/filepath" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +func planStore(t *testing.T, accounts ...string) (*Store, map[string]int64) { |
| + t.Helper() |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + ids := map[string]int64{} |
| + for i, sqid := range accounts { |
| + id := int64(100 + i) |
| + if err := st.CreateAccount(id, sqid, sqid, sqid+"@example.com", time.Unix(1_700_000_000, 0)); err != nil { |
| + t.Fatal(err) |
| + } |
| + ids[sqid] = id |
| + } |
| + return st, ids |
| +} |
| + |
| +var planNow = time.Unix(1_800_000_000, 0) |
| + |
| +func TestAccountPlanAbsentIsNil(t *testing.T) { |
| + st, ids := planStore(t, "aaa") |
| + row, err := st.AccountPlan(ids["aaa"]) |
| + if err != nil || row != nil { |
| + t.Fatalf("got %+v, %v; want nil, nil", row, err) |
| + } |
| +} |
| + |
| +func TestSetAccountPlanBumpsRevision(t *testing.T) { |
| + st, ids := planStore(t, "aaa") |
| + id := ids["aaa"] |
| + sp := &plans.Limits{RunningThings: 12} |
| + rev, err := st.SetAccountPlan(id, "personal", map[string]int{"apps": 2}, "sponsored", sp, "existing user", "ops@example.com", planNow) |
| + if err != nil || rev != 1 { |
| + t.Fatalf("first set: rev %d, %v", rev, err) |
| + } |
| + rev, err = st.SetAccountPlan(id, "personal", map[string]int{"apps": 3}, "sponsored", sp, "more apps", "ops@example.com", planNow) |
| + if err != nil || rev != 2 { |
| + t.Fatalf("second set: rev %d, %v", rev, err) |
| + } |
| + row, err := st.AccountPlan(id) |
| + if err != nil || row == nil { |
| + t.Fatalf("read back: %+v, %v", row, err) |
| + } |
| + if row.Sqid != "aaa" || row.Plan != "personal" || row.Source != "sponsored" || row.Reason != "more apps" || |
| + row.UpdatedBy != "ops@example.com" || row.Revision != 2 || !row.UpdatedAt.Equal(planNow) { |
| + t.Fatalf("row %+v", row) |
| + } |
| + if !reflect.DeepEqual(row.Packs, map[string]int{"apps": 3}) { |
| + t.Fatalf("packs %v", row.Packs) |
| + } |
| + if row.Sponsored == nil || *row.Sponsored != *sp { |
| + t.Fatalf("sponsored %+v", row.Sponsored) |
| + } |
| + if _, err := st.SetAccountPlan(id, "free", nil, "operator", nil, "back to free", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + row, _ = st.AccountPlan(id) |
| + if row.Sponsored != nil || len(row.Packs) != 0 { |
| + t.Fatalf("cleared row kept sponsored %+v / packs %v", row.Sponsored, row.Packs) |
| + } |
| +} |
| + |
| +func TestEnsureDefaultPlansInsertsOnlyMissing(t *testing.T) { |
| + st, ids := planStore(t, "aaa", "bbb", "ccc") |
| + if _, err := st.SetAccountPlan(ids["bbb"], "personal", nil, "operator", nil, "paid", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + n, err := st.EnsureDefaultPlans("free", planNow) |
| + if err != nil || n != 2 { |
| + t.Fatalf("inserted %d, %v; want 2", n, err) |
| + } |
| + b, _ := st.AccountPlan(ids["bbb"]) |
| + if b.Plan != "personal" || b.Revision != 1 { |
| + t.Fatalf("existing row changed: %+v", b) |
| + } |
| + a, _ := st.AccountPlan(ids["aaa"]) |
| + if a == nil || a.Plan != "free" || a.Source != "default" || a.Revision != 1 || a.UpdatedBy != "plans" { |
| + t.Fatalf("default row %+v", a) |
| + } |
| + if n, err := st.EnsureDefaultPlans("free", planNow); err != nil || n != 0 { |
| + t.Fatalf("second ensure inserted %d, %v", n, err) |
| + } |
| +} |
| + |
| +func TestEnsureDefaultPlansSkipsDeletedAccounts(t *testing.T) { |
| + st, ids := planStore(t, "aaa", "gone") |
| + if _, err := st.db.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ?`, planNow.Unix(), ids["gone"]); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := st.EnsureDefaultPlans("free", planNow); err != nil || n != 1 { |
| + t.Fatalf("inserted %d, %v; want 1", n, err) |
| + } |
| + if row, _ := st.AccountPlan(ids["gone"]); row != nil { |
| + t.Fatalf("deleted account got a row: %+v", row) |
| + } |
| +} |
| + |
| +func TestPlansToPublish(t *testing.T) { |
| + st, ids := planStore(t, "aaa", "bbb", "ccc") |
| + if _, err := st.EnsureDefaultPlans("free", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + rows, err := st.PlansToPublish("v1@abc", 0, 10) |
| + if err != nil || len(rows) != 3 { |
| + t.Fatalf("got %d rows, %v; want 3", len(rows), err) |
| + } |
| + if rows, _ := st.PlansToPublish("v1@abc", 0, 2); len(rows) != 2 { |
| + t.Fatalf("limit ignored: %d rows", len(rows)) |
| + } |
| + if err := st.MarkPlanPublished(ids["aaa"], 1, "v1@abc"); err != nil { |
| + t.Fatal(err) |
| + } |
| + rows, _ = st.PlansToPublish("v1@abc", 0, 10) |
| + for _, r := range rows { |
| + if r.Sqid == "aaa" { |
| + t.Fatal("published row still due for the same catalog") |
| + } |
| + } |
| + if len(rows) != 2 { |
| + t.Fatalf("got %d rows, want 2", len(rows)) |
| + } |
| + rows, _ = st.PlansToPublish("v2@def", 0, 10) |
| + if len(rows) != 3 { |
| + t.Fatalf("catalog change: got %d rows, want 3", len(rows)) |
| + } |
| + if _, err := st.db.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ?`, planNow.Unix(), ids["bbb"]); err != nil { |
| + t.Fatal(err) |
| + } |
| + rows, _ = st.PlansToPublish("v2@def", 0, 10) |
| + if len(rows) != 2 { |
| + t.Fatalf("deleted account still due: got %d rows, want 2", len(rows)) |
| + } |
| +} |
| + |
| +func TestMarkPlanPublishedIgnoresAStaleRevision(t *testing.T) { |
| + st, ids := planStore(t, "aaa") |
| + id := ids["aaa"] |
| + for range 2 { |
| + if _, err := st.SetAccountPlan(id, "free", nil, "operator", nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := st.MarkPlanPublished(id, 1, "c"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if row, _ := st.AccountPlan(id); row.PublishedRevision != 0 { |
| + t.Fatalf("stale publish marked: %d", row.PublishedRevision) |
| + } |
| + if err := st.MarkPlanPublished(id, 2, "c"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if row, _ := st.AccountPlan(id); row.PublishedRevision != 2 || row.PublishedCatalog != "c" { |
| + t.Fatalf("published %d / %q", row.PublishedRevision, row.PublishedCatalog) |
| + } |
| +} |
diff --git a/internal/console/apiaccountplan.go b/internal/console/apiaccountplan.go| new file mode 100644 |
| index 000000000..88c5cc853 |
| --- /dev/null |
| +++ b/internal/console/apiaccountplan.go |
| @@ -0,0 +1,192 @@ |
| +package console |
| + |
| +import ( |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "slices" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// The account plan door (plans-caps-packs spec §4, §8.7, §9.4): a fleet |
| +// operator reads or sets an account's plan, packs and sponsored limits. |
| +// Everyone else gets 404, the store-approval doors' convention. With no |
| +// catalog configured it answers 409: plans are off. |
| + |
| +type accountPlanReply struct { |
| + Account string `json:"account"` |
| + CatalogID string `json:"catalog_id"` |
| + Plan string `json:"plan"` |
| + Packs map[string]int `json:"packs"` |
| + Source string `json:"source"` |
| + Sponsored *plans.Limits `json:"sponsored,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + UpdatedBy string `json:"updated_by,omitempty"` |
| + Revision int64 `json:"revision"` |
| + PublishedRevision int64 `json:"published_revision"` |
| + Limits *plans.Limits `json:"limits,omitempty"` |
| + LimitsError string `json:"limits_error,omitempty"` |
| +} |
| + |
| +type setAccountPlanRequest struct { |
| + Plan string `json:"plan"` |
| + Packs map[string]int `json:"packs"` |
| + Sponsored *plans.Limits `json:"sponsored"` |
| + Reason string `json:"reason"` |
| +} |
| + |
| +// accountPlanDoor is the shared gate: an operator, a known account, and a |
| +// catalog. It writes the refusal itself. |
| +func (s *Server) accountPlanDoor(w http.ResponseWriter, r *http.Request) (string, *Account, bool) { |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return "", nil, false |
| + } |
| + isOperator, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: api account plan: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return "", nil, false |
| + } |
| + if !isOperator { |
| + http.NotFound(w, r) |
| + return "", nil, false |
| + } |
| + acct, err := s.store.AccountBySqid(r.PathValue("account")) |
| + if err != nil { |
| + log.Printf("console: api account plan: account lookup: %v", err) |
| + http.NotFound(w, r) |
| + return "", nil, false |
| + } |
| + if acct == nil { |
| + http.NotFound(w, r) |
| + return "", nil, false |
| + } |
| + if s.cfg.PlanCatalog == nil { |
| + jsonError(w, http.StatusConflict, planCopyNoCatalog) |
| + return "", nil, false |
| + } |
| + return addr, acct, true |
| +} |
| + |
| +func (s *Server) handleAPIAccountPlanGet(w http.ResponseWriter, r *http.Request) { |
| + _, acct, ok := s.accountPlanDoor(w, r) |
| + if !ok { |
| + return |
| + } |
| + reply, err := s.accountPlanReplyFor(acct) |
| + if err != nil { |
| + log.Printf("console: api account plan %s: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, planCopySaveFailed) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, reply) |
| +} |
| + |
| +func (s *Server) handleAPIAccountPlanSet(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, ok := s.accountPlanDoor(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.allowWrite(Principal{Address: addr}, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req setAccountPlanRequest |
| + // Strict: a misspelt sponsored limit must be refused, not dropped. |
| + if !readJSONStrictLimit(w, r, &req, 8<<10) { |
| + return |
| + } |
| + reason := strings.TrimSpace(req.Reason) |
| + if reason == "" { |
| + jsonError(w, http.StatusBadRequest, planCopyReasonRequired) |
| + return |
| + } |
| + if msg := planRequestProblem(s.cfg.PlanCatalog, req); msg != "" { |
| + jsonError(w, http.StatusBadRequest, msg) |
| + return |
| + } |
| + if _, err := s.cfg.PlanCatalog.Effective(req.Plan, req.Packs, req.Sponsored); err != nil { |
| + jsonError(w, http.StatusBadRequest, err.Error()) |
| + return |
| + } |
| + source := PlanSourceOperator |
| + if req.Sponsored != nil { |
| + source = PlanSourceSponsored |
| + } |
| + now := time.Now() |
| + if _, err := s.store.SetAccountPlan(acct.ID, req.Plan, req.Packs, source, req.Sponsored, reason, addr, now); err != nil { |
| + log.Printf("console: api account plan %s: save: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, planCopySaveFailed) |
| + return |
| + } |
| + if row, err := s.store.AccountPlan(acct.ID); err != nil { |
| + log.Printf("console: api account plan %s: reread: %v", acct.Sqid, err) |
| + } else if row != nil && s.cfg.Releases != nil { |
| + // Best effort: the pass retries anything this misses. |
| + if err := s.publishAccountLimits(r.Context(), *row, now); err != nil { |
| + log.Printf("console: api account plan %s: publish: %v", acct.Sqid, err) |
| + } |
| + } |
| + reply, err := s.accountPlanReplyFor(acct) |
| + if err != nil { |
| + log.Printf("console: api account plan %s: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, planCopySaveFailed) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, reply) |
| +} |
| + |
| +// planRequestProblem names what is wrong with a requested plan and packs in |
| +// words an operator can act on, or "" when the catalog can price it. |
| +func planRequestProblem(cat *plans.Catalog, req setAccountPlanRequest) string { |
| + p, ok := cat.Plans[req.Plan] |
| + if !ok { |
| + return fmt.Sprintf(planCopyUnknownPlan, req.Plan) |
| + } |
| + names := make([]string, 0, len(req.Packs)) |
| + for name := range req.Packs { |
| + names = append(names, name) |
| + } |
| + slices.Sort(names) |
| + for _, name := range names { |
| + pk, ok := cat.Packs[name] |
| + if !ok { |
| + return fmt.Sprintf(planCopyUnknownPack, name) |
| + } |
| + if !slices.Contains(p.Packs, name) { |
| + return fmt.Sprintf(planCopyPackNotOffered, req.Plan, name) |
| + } |
| + if q := req.Packs[name]; q < 1 || q > pk.MaxQuantity { |
| + return fmt.Sprintf(planCopyPackQuantity, name, pk.MaxQuantity) |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +func (s *Server) accountPlanReplyFor(acct *Account) (accountPlanReply, error) { |
| + cat := s.cfg.PlanCatalog |
| + row, err := s.store.AccountPlan(acct.ID) |
| + if err != nil { |
| + return accountPlanReply{}, err |
| + } |
| + reply := accountPlanReply{Account: acct.Sqid, CatalogID: cat.ID(), Plan: cat.DefaultPlan, |
| + Packs: map[string]int{}, Source: PlanSourceDefault} |
| + if row != nil { |
| + reply.Plan, reply.Packs, reply.Source = row.Plan, row.Packs, row.Source |
| + reply.Sponsored, reply.Reason, reply.UpdatedBy = row.Sponsored, row.Reason, row.UpdatedBy |
| + reply.Revision, reply.PublishedRevision = row.Revision, row.PublishedRevision |
| + if row.PublishedCatalog != cat.ID() { |
| + reply.PublishedRevision = 0 |
| + } |
| + } |
| + if l, err := cat.Effective(reply.Plan, reply.Packs, reply.Sponsored); err != nil { |
| + reply.LimitsError = err.Error() |
| + } else { |
| + reply.Limits = &l |
| + } |
| + return reply, nil |
| +} |
diff --git a/internal/console/apiaccountplan_copy.go b/internal/console/apiaccountplan_copy.go| new file mode 100644 |
| index 000000000..e9e62051f |
| --- /dev/null |
| +++ b/internal/console/apiaccountplan_copy.go |
| @@ -0,0 +1,14 @@ |
| +// Code generated from the approved copy review of 2026-10-20 (plans 1). Apply |
| +// later edits through copy review, not by hand. |
| + |
| +package console |
| + |
| +const ( |
| + planCopyNoCatalog = "plans are off on this deployment. set CARLOS_PLAN_CATALOG on the console to turn them on" |
| + planCopyReasonRequired = "a reason is required to change an account's plan" |
| + planCopyUnknownPlan = "there is no %q plan in this deployment's catalog" |
| + planCopyUnknownPack = "there is no %q pack in this deployment's catalog" |
| + planCopyPackNotOffered = "the %s plan does not offer the %s pack" |
| + planCopyPackQuantity = "the %s pack can be bought 1 to %d times" |
| + planCopySaveFailed = "could not save the plan. try again" |
| +) |
diff --git a/internal/console/apiaccountplan_test.go b/internal/console/apiaccountplan_test.go| new file mode 100644 |
| index 000000000..cfa1382a4 |
| --- /dev/null |
| +++ b/internal/console/apiaccountplan_test.go |
| @@ -0,0 +1,202 @@ |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func accountPlanURL(fx *consoleFixture, sqid string) string { |
| + return fx.ts.URL + "/api/cli/accounts/" + sqid + "/plan" |
| +} |
| + |
| +func planOperatorToken(t *testing.T, fx *consoleFixture, address, code string) string { |
| + t.Helper() |
| + if err := fx.st.SetOperator(address, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + return cliToken(t, fx, address, code) |
| +} |
| + |
| +func planNum(t *testing.T, m map[string]any, key string) float64 { |
| + t.Helper() |
| + v, ok := m[key].(float64) |
| + if !ok { |
| + t.Fatalf("%s missing or not a number in %v", key, m) |
| + } |
| + return v |
| +} |
| + |
| +func TestAccountPlanDoorIsOperatorOnly(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + owner := "owner" + acct.Sqid + "@example.com" |
| + token := cliToken(t, fx, owner, "code-plan-owner") |
| + if status, body := getRaw(t, accountPlanURL(fx, acct.Sqid), token); status != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("GET by the account's owner = %d %q, want the stdlib 404", status, body) |
| + } |
| + body := map[string]any{"plan": "personal", "reason": "r"} |
| + if status, out := postRaw(t, accountPlanURL(fx, acct.Sqid), body, token); status != http.StatusNotFound || out != "404 page not found\n" { |
| + t.Fatalf("POST by the account's owner = %d %q, want the stdlib 404", status, out) |
| + } |
| + if row, _ := fx.st.AccountPlan(acct.ID); row != nil { |
| + t.Fatalf("refused POST wrote %+v", row) |
| + } |
| +} |
| + |
| +func TestAccountPlanDoorWithoutACatalog(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-nocat") |
| + status, out := getJSON(t, accountPlanURL(fx, acct.Sqid), token) |
| + if status != http.StatusConflict || out["error"] == nil { |
| + t.Fatalf("GET with no catalog = %d %v, want 409 with an error", status, out) |
| + } |
| + if status, _ := postJSON(t, accountPlanURL(fx, acct.Sqid), map[string]any{"plan": "free", "reason": "r"}, token); status != http.StatusConflict { |
| + t.Fatalf("POST with no catalog = %d, want 409", status) |
| + } |
| +} |
| + |
| +func TestAccountPlanDoorUnknownAccount(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-unknown") |
| + if status, _ := getRaw(t, accountPlanURL(fx, "zzzzzz"), token); status != http.StatusNotFound { |
| + t.Fatalf("unknown account = %d, want 404", status) |
| + } |
| +} |
| + |
| +func TestAccountPlanGetDefaults(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-get") |
| + status, out := getJSON(t, accountPlanURL(fx, acct.Sqid), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET = %d %v", status, out) |
| + } |
| + if out["plan"] != "free" || out["source"] != PlanSourceDefault || planNum(t, out, "revision") != 0 || |
| + out["catalog_id"] != fx.srv.cfg.PlanCatalog.ID() || out["account"] != acct.Sqid { |
| + t.Fatalf("reply %v", out) |
| + } |
| + if limits := block(t, out, "limits"); planNum(t, limits, "running_things") != 3 { |
| + t.Fatalf("limits %v", limits) |
| + } |
| +} |
| + |
| +func TestAccountPlanSetAndPublish(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-set") |
| + status, out := postJSON(t, accountPlanURL(fx, acct.Sqid), |
| + map[string]any{"plan": "personal", "packs": map[string]int{"apps": 1}, "reason": "upgraded"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("POST = %d %v", status, out) |
| + } |
| + if out["source"] != PlanSourceOperator || planNum(t, out, "revision") != 1 || planNum(t, out, "published_revision") != 1 || |
| + out["reason"] != "upgraded" { |
| + t.Fatalf("reply %v", out) |
| + } |
| + if limits := block(t, out, "limits"); planNum(t, limits, "running_things") != 30 { |
| + t.Fatalf("limits %v", limits) |
| + } |
| + r, _ := readLimits(t, fx, acct.Sqid) |
| + if r == nil || r.Revision != 1 || r.Plan != "personal" || r.Limits.RunningThings != 30 { |
| + t.Fatalf("bucket record %+v", r) |
| + } |
| + row, _ := fx.st.AccountPlan(acct.ID) |
| + if row.UpdatedBy != "ops@keymail.dev" { |
| + t.Fatalf("updated_by %q", row.UpdatedBy) |
| + } |
| +} |
| + |
| +func TestAccountPlanSetSponsored(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-sponsor") |
| + status, out := postJSON(t, accountPlanURL(fx, acct.Sqid), |
| + map[string]any{"plan": "free", "sponsored": map[string]any{"running_things": 12}, "reason": "existing account"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("POST = %d %v", status, out) |
| + } |
| + if out["source"] != PlanSourceSponsored { |
| + t.Fatalf("source %v", out["source"]) |
| + } |
| + if limits := block(t, out, "limits"); planNum(t, limits, "running_things") != 12 { |
| + t.Fatalf("limits %v", limits) |
| + } |
| + if sp := block(t, out, "sponsored"); planNum(t, sp, "running_things") != 12 { |
| + t.Fatalf("sponsored %v", sp) |
| + } |
| +} |
| + |
| +func TestAccountPlanSetRefuses(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-refuse") |
| + cases := map[string]map[string]any{ |
| + "unknown plan": {"plan": "gold", "reason": "r"}, |
| + "unknown pack": {"plan": "personal", "packs": map[string]int{"seats": 1}, "reason": "r"}, |
| + "pack not offered": {"plan": "free", "packs": map[string]int{"apps": 1}, "reason": "r"}, |
| + "zero quantity": {"plan": "personal", "packs": map[string]int{"apps": 0}, "reason": "r"}, |
| + "negative": {"plan": "personal", "packs": map[string]int{"apps": -1}, "reason": "r"}, |
| + "above max": {"plan": "personal", "packs": map[string]int{"apps": 21}, "reason": "r"}, |
| + "no reason": {"plan": "personal"}, |
| + "blank reason": {"plan": "personal", "reason": " "}, |
| + } |
| + for name, body := range cases { |
| + status, out := postJSON(t, accountPlanURL(fx, acct.Sqid), body, token) |
| + if status != http.StatusBadRequest || out["error"] == nil { |
| + t.Errorf("%s: %d %v, want 400 with an error", name, status, out) |
| + } |
| + } |
| + if row, _ := fx.st.AccountPlan(acct.ID); row != nil { |
| + t.Fatalf("a refused POST wrote %+v", row) |
| + } |
| +} |
| + |
| +func TestAccountPlanSetSurvivesABucketFailure(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-bucket") |
| + fp := &planFailingPuts{Store: fx.rel} |
| + fp.n.Store(1000) |
| + fx.srv.cfg.Releases = fp |
| + status, out := postJSON(t, accountPlanURL(fx, acct.Sqid), map[string]any{"plan": "personal", "reason": "r"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("POST = %d %v", status, out) |
| + } |
| + if planNum(t, out, "published_revision") >= planNum(t, out, "revision") { |
| + t.Fatalf("claimed published although the write failed: %v", out) |
| + } |
| + fp.n.Store(0) |
| + fx.srv.planLimitsPass(t.Context(), time.Now()) |
| + if r, _ := readLimits(t, fx, acct.Sqid); r == nil || r.Plan != "personal" { |
| + t.Fatalf("the next pass did not publish: %+v", r) |
| + } |
| +} |
| + |
| +// A misspelt sponsored limit must be refused, not silently dropped: an |
| +// override that quietly vanished would lower a sponsored account's limits. |
| +func TestAccountPlanSetRefusesUnknownFields(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-strict") |
| + for name, body := range map[string]map[string]any{ |
| + "misspelt sponsored limit": {"plan": "free", "sponsored": map[string]any{"running_thing": 12}, "reason": "r"}, |
| + "misspelt top-level field": {"plan": "free", "pack": map[string]int{"apps": 1}, "reason": "r"}, |
| + } { |
| + if status, out := postJSON(t, accountPlanURL(fx, acct.Sqid), body, token); status != http.StatusBadRequest { |
| + t.Errorf("%s: %d %v, want 400", name, status, out) |
| + } |
| + } |
| + if row, _ := fx.st.AccountPlan(acct.ID); row != nil { |
| + t.Fatalf("a refused POST wrote %+v", row) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 97fc1c8bb..b75f82a83 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -929,6 +929,8 @@ func (s *Server) registerCLIRoutes() { |
| // resolve their target without requiring operator membership in it. |
| s.cliRoute("POST /api/cli/accounts/{account}/store/apply", svcrole.Set{}, s.handleAPIStoreApply) |
| s.cliRoute("POST /api/cli/accounts/{account}/store/approve", svcrole.Set{}, s.handleAPIStoreApprove) |
| + s.cliRoute("GET /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanGet) |
| + s.cliRoute("POST /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanSet) |
| s.cliRoute("POST /api/cli/accounts/{account}/store/decline", svcrole.Set{}, s.handleAPIStoreDecline) |
| s.cliRoute("GET /api/cli/store/approvals", svcrole.Set{}, s.handleAPIStoreApprovals) |
| // The scan family (internal/console/apiscan.go). `scan` itself is the |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index cd0643edd..88b156d31 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -799,6 +799,7 @@ var claimFixtures = map[string]claimFixture{ |
| "route GET /api/cli/status/instances": claimRead("operator live tenancy view"), |
| "route GET /api/cli/status/layout": claimRead("operator layout view"), |
| "route GET /api/cli/store/approvals": claimRead("operator entitlement queue"), |
| + "route GET /api/cli/accounts/{account}/plan": claimRead("operator view of an account's plan and limits"), |
| "route GET /api/cli/system/antivirus": claimRead("reads the deployment scanning setting"), |
| "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"), |
| "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"), |
| @@ -1173,6 +1174,7 @@ var claimFixtures = map[string]claimFixture{ |
| "route POST /accounts/new": claimNonApp("creates an account and its owner membership"), |
| "route POST /api/cli/accounts/{account}/store/apply": claimNonApp("applies for the account's storage entitlement"), |
| "route POST /api/cli/accounts/{account}/store/approve": claimNonApp("operator approves an account's storage entitlement"), |
| + "route POST /api/cli/accounts/{account}/plan": claimNonApp("operator sets an account's plan, packs and sponsored limits; names no app"), |
| "route POST /api/cli/accounts/{account}/store/decline": claimNonApp("operator declines an account's storage entitlement"), |
| "route POST /api/cli/fleets/{account}/new": claimNonApp("creates a customer fleet in the registry"), |
| "route POST /api/cli/fleets/{account}/{fleet}/boxes": claimNonApp("enrolls a fleet box"), |
| @@ -1843,6 +1845,7 @@ var claimFixtures = map[string]claimFixture{ |
| "worker StartGeoipJobs": claimNonApp("GeoIP orphan sweep, D1 expiry and feed status under control/geoip/: the platform database, no app"), |
| "worker StartHostReports": claimNonApp("this console's census report (status/consoles) and roster activation release"), |
| "worker StartInfraPass": claimNonApp("the deployment's infrastructure inventory snapshot"), |
| + "worker StartPlanLimitsPass": claimNonApp("account plan rows and their limits records under accounts/<sqid>/limits.json; names no app"), |
| "worker StartClaimRecoveryPass": {door: &claimDoor{ |
| apps: claimRefs("bab/titogo"), |
| setup: func(t *testing.T, cx *claimCase) { cx.crashedRestore(t) }, |
diff --git a/internal/console/claim_invariant_probes_nonapp_test.go b/internal/console/claim_invariant_probes_nonapp_test.go| index d9aa1c8ae..9e7c1697e 100644 |
| --- a/internal/console/claim_invariant_probes_nonapp_test.go |
| +++ b/internal/console/claim_invariant_probes_nonapp_test.go |
| @@ -19,6 +19,7 @@ import ( |
| "github.com/carlosframework/platform/internal/economics" |
| "github.com/carlosframework/platform/internal/geoip" |
| "github.com/carlosframework/platform/internal/infraview" |
| + "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/seeds" |
| "github.com/carlosframework/platform/internal/telemetry" |
| @@ -600,6 +601,18 @@ var claimNonAppProbes = map[string]claimProbe{ |
| return "" |
| }, |
| }, |
| + "route POST /api/cli/accounts/{account}/plan": { |
| + setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) }, |
| + do: claimAPIReply(http.MethodPost, "/api/cli/accounts/bab/plan", `{"plan":"personal","reason":"census"}`), |
| + body: []string{`"plan":"personal"`}, |
| + effect: func(t *testing.T, cx *claimCase, r claimReply) string { |
| + var rec plans.Record |
| + if !cx.bucketJSON(plans.LimitsKey("bab"), &rec) || rec.Plan != "personal" { |
| + return "the personal plan was not published for bab" |
| + } |
| + return "" |
| + }, |
| + }, |
| "route POST /api/cli/accounts/{account}/store/approve": { |
| setup: func(t *testing.T, cx *claimCase) { |
| cx.mustAPI(t, http.MethodPost, "/api/cli/accounts/bab/store/apply", `{"note":"census"}`, http.StatusOK) |
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go| index b028dad14..307543163 100644 |
| --- a/internal/console/claim_invariant_probes_read_test.go |
| +++ b/internal/console/claim_invariant_probes_read_test.go |
| @@ -229,6 +229,8 @@ var claimReadProbes = map[string]claimProbe{ |
| "route GET /api/cli/status/instances": claimAPIRead(nil, "/api/cli/status/instances", `"instances"`), |
| "route GET /api/cli/status/layout": claimAPIRead(nil, "/api/cli/status/layout", `"boxes"`), |
| "route GET /api/cli/store/approvals": claimAPIRead(claimWorldApply, "/api/cli/store/approvals", `"applications"`, claimFxOwner), |
| + "route GET /api/cli/accounts/{account}/plan": claimAPIRead(func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) }, |
| + "/api/cli/accounts/bab/plan", `"plan":"free"`), |
| "route GET /api/cli/system/antivirus": claimAPIRead(nil, "/api/cli/system/antivirus", `"wired":true`), |
| "route GET /api/cli/system/geoip": claimAPIRead(claimWorldGeoip, "/api/cli/system/geoip", `"pointer"`, "GeoIP2-City"), |
| "route GET /api/cli/system/jobs/{id}": { |
diff --git a/internal/console/claim_invariant_probes_worker_test.go b/internal/console/claim_invariant_probes_worker_test.go| index 6e01a384a..c75eb79e2 100644 |
| --- a/internal/console/claim_invariant_probes_worker_test.go |
| +++ b/internal/console/claim_invariant_probes_worker_test.go |
| @@ -20,6 +20,7 @@ import ( |
| "github.com/carlosframework/platform/internal/infraview" |
| "github.com/carlosframework/platform/internal/metrics" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -189,4 +190,18 @@ var claimWorkerProbes = map[string]claimProbe{ |
| return "" |
| }, |
| }, |
| + "worker StartPlanLimitsPass": { |
| + setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) }, |
| + do: claimWorkerReply("StartPlanLimitsPass"), |
| + effect: func(t *testing.T, cx *claimCase, r claimReply) string { |
| + var rec plans.Record |
| + if !cx.bucketJSON(plans.LimitsKey("bab"), &rec) { |
| + return "no limits record was published for bab" |
| + } |
| + if rec.Plan != "free" || rec.Limits.RunningThings != 3 { |
| + return "the published limits record is not the default plan's" |
| + } |
| + return "" |
| + }, |
| + }, |
| } |
diff --git a/internal/console/claim_workers.go b/internal/console/claim_workers.go| index b7e8e527d..803e6bbc7 100644 |
| --- a/internal/console/claim_workers.go |
| +++ b/internal/console/claim_workers.go |
| @@ -14,6 +14,7 @@ func (s *Server) registerClaimWorkers() { |
| "StartScanPass": s.StartScanPass, |
| "StartScanDrain": s.StartScanDrain, |
| "StartExpiryPass": s.StartExpiryPass, |
| + "StartPlanLimitsPass": s.StartPlanLimitsPass, |
| "StartRunSweep": s.StartRunSweep, |
| "StartSeedsPass": s.StartSeedsPass, |
| "StartRelay": s.StartRelay, |
diff --git a/internal/console/planlimits.go b/internal/console/planlimits.go| new file mode 100644 |
| index 000000000..813f0020c |
| --- /dev/null |
| +++ b/internal/console/planlimits.go |
| @@ -0,0 +1,137 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const planLimitsPassInterval = time.Minute |
| + |
| +// planLimitsPassBatch is how many rows one query reads; the pass pages |
| +// through every due row regardless. A var so a test can shrink it. |
| +var planLimitsPassBatch = 200 |
| + |
| +// StartPlanLimitsPass keeps every account's limits record in the bucket in |
| +// step with its plan row and the configured catalog: now, then every |
| +// minute (StartExpiryPass's shape). With no catalog it does nothing at all. |
| +func (s *Server) StartPlanLimitsPass(ctx context.Context) { |
| + if s.cfg.Releases == nil || s.cfg.PlanCatalog == nil { |
| + return |
| + } |
| + t := time.NewTicker(planLimitsPassInterval) |
| + defer t.Stop() |
| + for { |
| + s.planLimitsPass(ctx, time.Now()) |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-t.C: |
| + } |
| + } |
| +} |
| + |
| +// planLimitsPass is one pass at the given clock: give unassigned accounts |
| +// the default plan, then publish every row the bucket is behind on. One |
| +// account's failure never stops the others. |
| +func (s *Server) planLimitsPass(ctx context.Context, now time.Time) { |
| + cat := s.cfg.PlanCatalog |
| + if s.cfg.Releases == nil || cat == nil { |
| + return |
| + } |
| + if _, err := s.store.EnsureDefaultPlans(cat.DefaultPlan, now); err != nil { |
| + log.Printf("console: plan limits: default plans: %v", err) |
| + return |
| + } |
| + // Page by account id: a row that cannot be published (an orphaned |
| + // assignment) stays due, but never holds a later account back. |
| + var after int64 |
| + for { |
| + rows, err := s.store.PlansToPublish(cat.ID(), after, planLimitsPassBatch) |
| + if err != nil { |
| + log.Printf("console: plan limits: list: %v", err) |
| + return |
| + } |
| + for _, row := range rows { |
| + if ctx.Err() != nil { |
| + return |
| + } |
| + if err := s.publishAccountLimits(ctx, row, now); err != nil { |
| + log.Printf("console: plan limits: %s: %v", row.Sqid, err) |
| + } |
| + after = row.AccountID |
| + } |
| + if len(rows) < planLimitsPassBatch { |
| + return |
| + } |
| + } |
| +} |
| + |
| +// publishAccountLimits writes row's effective limits to its account's |
| +// record. An assignment the catalog cannot price (a plan or pack it no |
| +// longer has) writes nothing, so boxes keep the last good record. A record |
| +// already at this revision or newer, under this catalog, is left alone. |
| +func (s *Server) publishAccountLimits(ctx context.Context, row AccountPlanRow, now time.Time) error { |
| + cat := s.cfg.PlanCatalog |
| + if cat == nil { |
| + return errors.New("no plan catalog") |
| + } |
| + limits, err := cat.Effective(row.Plan, row.Packs, row.Sponsored) |
| + if err != nil { |
| + return err |
| + } |
| + rec := &plans.Record{ |
| + Account: row.Sqid, |
| + CatalogID: cat.ID(), |
| + Plan: row.Plan, |
| + Source: row.Source, |
| + Revision: row.Revision, |
| + Limits: limits, |
| + IssuedAt: now.UTC().Truncate(time.Second), |
| + } |
| + for attempt := 0; attempt < 2; attempt++ { |
| + cur, etag, err := plans.GetLimits(ctx, s.cfg.Releases, row.Sqid) |
| + if err != nil { |
| + return err |
| + } |
| + if cur != nil && cur.CatalogID == rec.CatalogID && cur.Revision >= rec.Revision { |
| + return s.store.MarkPlanPublished(row.AccountID, row.Revision, cat.ID()) |
| + } |
| + _, err = plans.PutLimits(ctx, s.cfg.Releases, rec, etag) |
| + if errors.Is(err, release.ErrCASConflict) { |
| + continue |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + return s.store.MarkPlanPublished(row.AccountID, row.Revision, cat.ID()) |
| + } |
| + return errors.New("lost two write races; the next pass retries") |
| +} |
| + |
| +// EffectiveLimits is acct's limits under the configured catalog, or nil |
| +// when plans are off. An account with no row is on the default plan. |
| +func (s *Server) EffectiveLimits(acct *Account) (*plans.Limits, error) { |
| + cat := s.cfg.PlanCatalog |
| + if cat == nil { |
| + return nil, nil |
| + } |
| + row, err := s.store.AccountPlan(acct.ID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + plan, packs := cat.DefaultPlan, map[string]int(nil) |
| + var sponsored *plans.Limits |
| + if row != nil { |
| + plan, packs, sponsored = row.Plan, row.Packs, row.Sponsored |
| + } |
| + l, err := cat.Effective(plan, packs, sponsored) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &l, nil |
| +} |
diff --git a/internal/console/planlimits_test.go b/internal/console/planlimits_test.go| new file mode 100644 |
| index 000000000..fb81019c2 |
| --- /dev/null |
| +++ b/internal/console/planlimits_test.go |
| @@ -0,0 +1,240 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const exampleCatalogPath = "../plans/testdata/example-catalog.json" |
| + |
| +func examplePlanCatalog(t *testing.T) *plans.Catalog { |
| + t.Helper() |
| + c, err := plans.LoadCatalog(exampleCatalogPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return c |
| +} |
| + |
| +// bumpedCatalog is the example catalog with free's running_things set to n |
| +// under a new version. |
| +func bumpedCatalog(t *testing.T, version string, n string) *plans.Catalog { |
| + t.Helper() |
| + b, err := os.ReadFile(exampleCatalogPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s := strings.Replace(string(b), `"version": "2026-10-20-v1"`, `"version": "`+version+`"`, 1) |
| + s = strings.Replace(s, `"running_things": 3,`, `"running_things": `+n+`,`, 1) |
| + c, err := plans.ParseCatalog([]byte(s)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return c |
| +} |
| + |
| +// planAccount adds a console account with a valid sqid and returns it. |
| +func planAccount(t *testing.T, fx *consoleFixture, id int64) *Account { |
| + t.Helper() |
| + sqid := domains.Sqid(id) |
| + if err := fx.st.CreateAccount(id, sqid, "acct", "owner"+sqid+"@example.com", time.Unix(1_700_000_000, 0)); err != nil { |
| + t.Fatal(err) |
| + } |
| + acct, err := fx.st.AccountBySqid(sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("account %s: %v", sqid, err) |
| + } |
| + return acct |
| +} |
| + |
| +func readLimits(t *testing.T, fx *consoleFixture, sqid string) (*plans.Record, string) { |
| + t.Helper() |
| + r, etag, err := plans.GetLimits(context.Background(), fx.rel, sqid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return r, etag |
| +} |
| + |
| +func TestPlanLimitsPassOffWithoutACatalog(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + if r, _ := readLimits(t, fx, acct.Sqid); r != nil { |
| + t.Fatalf("wrote a record with no catalog: %+v", r) |
| + } |
| + if row, _ := fx.st.AccountPlan(acct.ID); row != nil { |
| + t.Fatalf("inserted a plan row with no catalog: %+v", row) |
| + } |
| +} |
| + |
| +func TestPlanLimitsPassPublishesDefaults(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + r, _ := readLimits(t, fx, acct.Sqid) |
| + if r == nil { |
| + t.Fatal("no record published") |
| + } |
| + if r.Plan != "free" || r.Source != PlanSourceDefault || r.Limits.RunningThings != 3 || r.CatalogID != fx.srv.cfg.PlanCatalog.ID() { |
| + t.Fatalf("record %+v", r) |
| + } |
| + row, _ := fx.st.AccountPlan(acct.ID) |
| + if row.PublishedRevision != row.Revision || row.PublishedCatalog != fx.srv.cfg.PlanCatalog.ID() { |
| + t.Fatalf("row not marked published: %+v", row) |
| + } |
| +} |
| + |
| +func TestPlanLimitsPassRepublishesOnCatalogChange(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + next := bumpedCatalog(t, "2026-10-21-v2", "4") |
| + fx.srv.cfg.PlanCatalog = next |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + r, etag := readLimits(t, fx, acct.Sqid) |
| + if r.CatalogID != next.ID() || r.Limits.RunningThings != 4 { |
| + t.Fatalf("not republished: %+v", r) |
| + } |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + if _, again := readLimits(t, fx, acct.Sqid); again != etag { |
| + t.Fatal("an up-to-date record was rewritten") |
| + } |
| +} |
| + |
| +func TestPlanLimitsPassSkipsAnOrphanedAssignment(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + orphan := planAccount(t, fx, 501) |
| + other := planAccount(t, fx, 502) |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + _, before := readLimits(t, fx, orphan.Sqid) |
| + if _, err := fx.st.SetAccountPlan(orphan.ID, "gold", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.SetAccountPlan(other.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + if _, after := readLimits(t, fx, orphan.Sqid); after != before { |
| + t.Fatal("the orphan's last good record was replaced") |
| + } |
| + if row, _ := fx.st.AccountPlan(orphan.ID); row.PublishedRevision == row.Revision { |
| + t.Fatal("the orphan was marked published") |
| + } |
| + if r, _ := readLimits(t, fx, other.Sqid); r.Plan != "personal" { |
| + t.Fatalf("the other account was not published: %+v", r) |
| + } |
| +} |
| + |
| +func TestPublishNeverOverwritesANewerRecord(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + cat := examplePlanCatalog(t) |
| + fx.srv.cfg.PlanCatalog = cat |
| + acct := planAccount(t, fx, 501) |
| + newer := &plans.Record{Account: acct.Sqid, CatalogID: cat.ID(), Plan: "personal", Source: PlanSourceOperator, Revision: 5} |
| + if _, err := plans.PutLimits(context.Background(), fx.rel, newer, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + for range 3 { |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + row, _ := fx.st.AccountPlan(acct.ID) |
| + if err := fx.srv.publishAccountLimits(context.Background(), *row, planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r, _ := readLimits(t, fx, acct.Sqid); r.Revision != 5 || r.Plan != "personal" { |
| + t.Fatalf("newer record overwritten: %+v", r) |
| + } |
| + if row, _ := fx.st.AccountPlan(acct.ID); row.PublishedRevision != 3 { |
| + t.Fatalf("published_revision %d, want 3", row.PublishedRevision) |
| + } |
| +} |
| + |
| +// planFailingPuts is a bucket whose next n Puts fail. |
| +type planFailingPuts struct { |
| + release.Store |
| + n atomic.Int32 |
| +} |
| + |
| +func (f *planFailingPuts) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if f.n.Add(-1) >= 0 { |
| + return "", errors.New("bucket unavailable") |
| + } |
| + return f.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func TestPublishRetriesAfterABucketFailure(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + fp := &planFailingPuts{Store: fx.rel} |
| + fx.srv.cfg.Releases = fp |
| + if _, err := fx.st.EnsureDefaultPlans("free", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + fp.n.Store(1000) // every Put fails during the first pass |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + if row, _ := fx.st.AccountPlan(acct.ID); row.PublishedRevision != 0 { |
| + t.Fatal("marked published although the write failed") |
| + } |
| + fp.n.Store(0) |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + if r, _ := readLimits(t, fx, acct.Sqid); r == nil { |
| + t.Fatal("not published on the next pass") |
| + } |
| +} |
| + |
| +func TestEffectiveLimits(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + if l, err := fx.srv.EffectiveLimits(acct); l != nil || err != nil { |
| + t.Fatalf("no catalog: %+v, %v", l, err) |
| + } |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + l, err := fx.srv.EffectiveLimits(acct) |
| + if err != nil || l == nil || l.RunningThings != 3 { |
| + t.Fatalf("default: %+v, %v", l, err) |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "personal", map[string]int{"apps": 1}, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + l, err = fx.srv.EffectiveLimits(acct) |
| + if err != nil || l.RunningThings != 30 { |
| + t.Fatalf("personal + apps: %+v, %v", l, err) |
| + } |
| +} |
| + |
| +// Orphaned assignments must not starve the rest: with a batch of two and |
| +// the two lowest accounts orphaned, one pass still reaches the third. |
| +func TestPlanLimitsPassOrphansDoNotStarveLaterAccounts(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + defer func(n int) { planLimitsPassBatch = n }(planLimitsPassBatch) |
| + planLimitsPassBatch = 2 |
| + a := planAccount(t, fx, 501) |
| + b := planAccount(t, fx, 502) |
| + c := planAccount(t, fx, 503) |
| + for _, acct := range []*Account{a, b} { |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "gold", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + fx.srv.planLimitsPass(context.Background(), planNow) |
| + if r, _ := readLimits(t, fx, c.Sqid); r == nil { |
| + t.Fatal("orphaned accounts starved a later valid account") |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 0904c6806..05b4e71a3 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -47,6 +47,7 @@ import ( |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/publicrelease" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -722,6 +723,10 @@ type Config struct { |
| // (handleAPIStoreCreate) — a ceiling only the automatic path respected |
| // would be a ceiling anyone could step over by hand. |
| StoreMaxPerApp int |
| + // PlanCatalog is the deployment's plan catalog (CARLOS_PLAN_CATALOG, |
| + // plans-caps-packs spec §4.1). nil means plans are off: no account has |
| + // limits, no limits record is published, and the plan door answers 409. |
| + PlanCatalog *plans.Catalog |
| // TemporaryApps turns on POST /api/cli/apps/{account}/new-temporary |
| // (temporary static sites spec §3). Off by default, and only switched on |
| // after every box runs a binary that carries the tmp- reservation and |
diff --git a/internal/console/store.go b/internal/console/store.go| index 7bfe00f00..21523c90e 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -381,6 +381,23 @@ func (s *Store) migrate() error { |
| warned INTEGER NOT NULL DEFAULT 0, |
| held INTEGER NOT NULL DEFAULT 0, |
| PRIMARY KEY (host, op, name) |
| +);`, |
| + // Plans (plans-caps-packs spec §4, §7): one row per account naming |
| + // its plan and packs. revision moves on every change; |
| + // published_revision/published_catalog say which revision, under |
| + // which catalog, the bucket's limits record already carries. |
| + `CREATE TABLE IF NOT EXISTS account_plans ( |
| + account_id INTEGER PRIMARY KEY, |
| + plan TEXT NOT NULL, |
| + packs TEXT NOT NULL DEFAULT '{}', |
| + source TEXT NOT NULL, |
| + sponsored TEXT NOT NULL DEFAULT '', |
| + reason TEXT NOT NULL DEFAULT '', |
| + revision INTEGER NOT NULL, |
| + published_revision INTEGER NOT NULL DEFAULT 0, |
| + published_catalog TEXT NOT NULL DEFAULT '', |
| + updated_by TEXT NOT NULL, |
| + updated_at INTEGER NOT NULL |
| );`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil { |
diff --git a/internal/console/store_gate.go b/internal/console/store_gate.go| index bda729a56..6fe9ae657 100644 |
| --- a/internal/console/store_gate.go |
| +++ b/internal/console/store_gate.go |
| @@ -208,6 +208,7 @@ var claimSQLTables = map[string]claimSQLTable{ |
| "accounts": {NonApp: "the tenancy primitive itself; names no app"}, |
| "memberships": {NonApp: "account membership, keyed by account and address; names no app"}, |
| "invites": {NonApp: "account invites, keyed by account and address; names no app"}, |
| + "account_plans": {NonApp: "an account's plan and packs, keyed by account; names no app"}, |
| "alert_seen": {NonApp: "operator alert memory, keyed by box, rule and address"}, |
| "claim_loss_pages": {NonApp: "claim recovery page attempts per loss record and condition, and scan progress; no app state or lifecycle authority"}, |
| "move_pages": {NonApp: "the move pass's last page attempt per finding (rule names the host and the move); no app column"}, |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 8a98f4ca0..804f1f078 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -190,6 +190,11 @@ var closedDoors = []string{ |
| "POST /api/cli/accounts/{account}/store/approve", |
| "POST /api/cli/accounts/{account}/store/decline", |
| "GET /api/cli/store/approvals", |
| + // An account's plan, packs and sponsored limits are a fleet operator's |
| + // decision (plans-caps-packs spec §4, §8.7); no machine credential reads |
| + // or sets them. |
| + "GET /api/cli/accounts/{account}/plan", |
| + "POST /api/cli/accounts/{account}/plan", |
| // Moving a route between channels decides what that route SERVES, which |
| // is release authority wearing a config-shaped hat: a credential the app |
| // itself holds must not be able to walk its own route onto a channel |
| @@ -998,8 +1003,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 61 { |
| - t.Errorf("walked %d closed doors, want all %d (and 61 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, three instance update settings doors, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, nine system doors, three deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, four store-entitlement doors, and the five run doors)", |
| + if seen != len(closedDoors) || seen != 63 { |
| + t.Errorf("walked %d closed doors, want all %d (and 63 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, three instance update settings doors, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, nine system doors, three deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, four store-entitlement doors, two account plan doors, and the five run doors)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index e154bb8c4..faf01f510 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -37,6 +37,7 @@ route GET /a/{sqid}/team |
| route GET /a/{sqid}/trash |
| route GET /a/{sqid}/{$} |
| route GET /accounts/new |
| +route GET /api/cli/accounts/{account}/plan |
| route GET /api/cli/apps |
| route GET /api/cli/apps/{account}/list |
| route GET /api/cli/apps/{account}/{app}/analytics |
| @@ -157,6 +158,7 @@ route POST /a/{sqid}/store/apply |
| route POST /a/{sqid}/trash/delete |
| route POST /a/{sqid}/trash/restore |
| route POST /accounts/new |
| +route POST /api/cli/accounts/{account}/plan |
| route POST /api/cli/accounts/{account}/store/apply |
| route POST /api/cli/accounts/{account}/store/approve |
| route POST /api/cli/accounts/{account}/store/decline |
| @@ -290,6 +292,7 @@ worker StartGeoipJobs |
| worker StartHostReports |
| worker StartInfraPass |
| worker StartMailEventPush |
| +worker StartPlanLimitsPass |
| worker StartRelay |
| worker StartRunSweep |
| worker StartScanDrain |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 7629174cf..4e72b2936 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -1434,3 +1434,11 @@ func TestReconcileLeavesAnAmbiguousCustomDomainUnstamped(t *testing.T) { |
| t.Errorf("account = %q, want unstamped — ambiguity is not ops's to win", rt.Account) |
| } |
| } |
| + |
| +// The plans limits record lives beside the manifest, at |
| +// accounts/<sqid>/limits.json. It must never be mistaken for one. |
| +func TestAccountManifestSqidIgnoresLimitsRecord(t *testing.T) { |
| + if _, ok := domains.AccountManifestSqid("accounts/abc/limits.json"); ok { |
| + t.Fatal("accounts/abc/limits.json parsed as an account manifest") |
| + } |
| +} |
diff --git a/internal/hostops/limits_classifier_test.go b/internal/hostops/limits_classifier_test.go| new file mode 100644 |
| index 000000000..2e714c110 |
| --- /dev/null |
| +++ b/internal/hostops/limits_classifier_test.go |
| @@ -0,0 +1,45 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +// TestClassifyAccountLimitsRecordIsExact: the console's per-account limits |
| +// record (plans-caps-packs spec §7) is a reviewed non-app family. It is only |
| +// ever put; nothing deletes it, and nothing else under accounts/<sqid>/ rides |
| +// on its classification. |
| +func TestClassifyAccountLimitsRecordIsExact(t *testing.T) { |
| + ctx := context.Background() |
| + key := "accounts/bab/limits.json" |
| + for _, c := range []struct { |
| + name string |
| + target MutationTarget |
| + }{ |
| + {"first put", MutationTarget{Surface: "bucket", Method: "Put", Key: key, After: []byte(`{}`)}}, |
| + {"replacing put", MutationTarget{Surface: "bucket", Method: "Put", Key: key, ETag: "x", Before: []byte(`{}`), After: []byte(`{}`)}}, |
| + } { |
| + got, err := ClassifyMutation(ctx, nil, nil, c.target) |
| + if err != nil { |
| + t.Errorf("%s: %v", c.name, err) |
| + continue |
| + } |
| + if got.Family != "account-limits" || got.NonApp == "" || len(got.Scopes) != 0 || got.MailGate { |
| + t.Errorf("%s: classified %+v, want non-app family account-limits with no scopes", c.name, got) |
| + } |
| + } |
| + for _, c := range []struct { |
| + name string |
| + target MutationTarget |
| + }{ |
| + {"a delete", MutationTarget{Surface: "bucket", Method: "Delete", Key: key}}, |
| + {"an invalid account", MutationTarget{Surface: "bucket", Method: "Put", Key: "accounts/BAB!/limits.json", After: []byte(`{}`)}}, |
| + {"a deeper key", MutationTarget{Surface: "bucket", Method: "Put", Key: "accounts/bab/x/limits.json", After: []byte(`{}`)}}, |
| + {"a lookalike name", MutationTarget{Surface: "bucket", Method: "Put", Key: "accounts/bab/limits.json.bak", After: []byte(`{}`)}}, |
| + } { |
| + if got, err := ClassifyMutation(ctx, nil, nil, c.target); !errors.Is(err, ErrUnclassified) { |
| + t.Errorf("%s: classified %+v, err %v; want ErrUnclassified", c.name, got, err) |
| + } |
| + } |
| +} |
diff --git a/internal/hostops/mutation_classifier.go b/internal/hostops/mutation_classifier.go| index 0a5c12d29..430496ec5 100644 |
| --- a/internal/hostops/mutation_classifier.go |
| +++ b/internal/hostops/mutation_classifier.go |
| @@ -141,6 +141,12 @@ func ClassifyMutation(ctx context.Context, raw release.Store, w *ControlWriter, |
| return c, err |
| case p[0] == "accounts" && len(p) == 3 && p[2] == "account.json" && controlname.ValidAccount(p[1]): |
| return Classification{Family: "account-manifest", NonApp: "account manifest; account deletion cascades are scoped by the SQL boundary"}, nil |
| + case p[0] == "accounts" && len(p) == 3 && p[2] == "limits.json" && controlname.ValidAccount(p[1]): |
| + // plans-caps-packs spec §7: the console publishes it; nothing deletes it. |
| + if t.Method != "Put" { |
| + return Classification{}, fmt.Errorf("%w: %s refuses deletes", ErrUnclassified, t.Key) |
| + } |
| + return Classification{Family: "account-limits", NonApp: "account limits record published from the plan catalog; no app state"}, nil |
| case t.Key == RelayAuthorityKey: |
| return classifyRelayAuthority(t) |
| case t.Key == RelayIssuerKey: |
diff --git a/internal/plans/catalog.go b/internal/plans/catalog.go| new file mode 100644 |
| index 000000000..cd9c2734b |
| --- /dev/null |
| +++ b/internal/plans/catalog.go |
| @@ -0,0 +1,263 @@ |
| +// Package plans is the per-account plan catalog and the limits it implies |
| +// (spec docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md §4). |
| +// |
| +// A deployment opts in by configuring a catalog. With none, there are no |
| +// caps and no billing: nothing in this package is consulted. The console is |
| +// the only writer of an account's effective limits; boxes read them from |
| +// the record in record.go. |
| +package plans |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "slices" |
| +) |
| + |
| +// Limits is one set of caps. In a plan it is the plan's caps; in a pack it |
| +// is what one unit of the pack adds; in a catalog's Ceilings it is the most |
| +// any packed plan may reach. Zero means "not set" for sponsored overrides |
| +// and ceilings. |
| +type Limits struct { |
| + RunningThings int `json:"running_things,omitempty"` |
| + SidecarsPerApp int `json:"sidecars_per_app,omitempty"` |
| + IdleSeconds int `json:"idle_seconds,omitempty"` |
| + MemoryTier string `json:"memory_tier,omitempty"` // MemorySmall | MemoryDefault |
| + AwakeHoursMonth int `json:"awake_hours_month,omitempty"` |
| + DiskBytes int64 `json:"disk_bytes,omitempty"` // per running thing |
| + StorageBytes int64 `json:"storage_bytes,omitempty"` // per account |
| + StorageSelfServe bool `json:"storage_self_serve,omitempty"` |
| + EmailPerDay int `json:"email_per_day,omitempty"` |
| + BandwidthBytesMonth int64 `json:"bandwidth_bytes_month,omitempty"` |
| + CustomDomains bool `json:"custom_domains,omitempty"` |
| + Fleets bool `json:"fleets,omitempty"` |
| + MinScheduleSeconds int `json:"min_schedule_seconds,omitempty"` |
| + HistoryDays int `json:"history_days,omitempty"` |
| +} |
| + |
| +// The memory tiers a plan may name. The numbers behind them belong to the |
| +// host (internal/cgroup), not to the catalog. |
| +const ( |
| + MemorySmall = "small" |
| + MemoryDefault = "default" |
| +) |
| + |
| +// Plan is one plan in a catalog. |
| +type Plan struct { |
| + Paid bool `json:"paid"` |
| + Limits Limits `json:"limits"` |
| + Packs []string `json:"packs,omitempty"` // pack names this plan may buy |
| +} |
| + |
| +// Pack is one pack type: what a unit adds, and how many units one account |
| +// may hold. |
| +type Pack struct { |
| + Adds Limits `json:"adds"` |
| + MaxQuantity int `json:"max_quantity"` |
| +} |
| + |
| +// Catalog is a deployment's plan catalog. |
| +type Catalog struct { |
| + Version string `json:"version"` |
| + DefaultPlan string `json:"default_plan"` |
| + FreeAccountsPerPerson int `json:"free_accounts_per_person"` |
| + Plans map[string]Plan `json:"plans"` |
| + Packs map[string]Pack `json:"packs"` |
| + Ceilings Limits `json:"ceilings"` |
| + |
| + id string |
| +} |
| + |
| +var ( |
| + ErrUnknownPlan = errors.New("no such plan") |
| + ErrUnknownPack = errors.New("no such pack") |
| + ErrPackNotOffered = errors.New("this plan does not offer that pack") |
| + ErrPackQuantity = errors.New("pack quantity out of range") |
| +) |
| + |
| +// LoadCatalog reads and parses the catalog file at path. |
| +func LoadCatalog(path string) (*Catalog, error) { |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + c, err := ParseCatalog(b) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", path, err) |
| + } |
| + return c, nil |
| +} |
| + |
| +// ParseCatalog parses and validates a catalog. Unknown fields are refused: |
| +// a misspelt limit would otherwise read as zero. |
| +func ParseCatalog(b []byte) (*Catalog, error) { |
| + dec := json.NewDecoder(bytes.NewReader(b)) |
| + dec.DisallowUnknownFields() |
| + var c Catalog |
| + if err := dec.Decode(&c); err != nil { |
| + return nil, fmt.Errorf("plan catalog: %w", err) |
| + } |
| + if err := c.validate(); err != nil { |
| + return nil, fmt.Errorf("plan catalog: %w", err) |
| + } |
| + canon, err := json.Marshal(c) // map keys marshal sorted: canonical |
| + if err != nil { |
| + return nil, err |
| + } |
| + sum := sha256.Sum256(canon) |
| + c.id = c.Version + "@" + hex.EncodeToString(sum[:])[:12] |
| + return &c, nil |
| +} |
| + |
| +// ID names this catalog's exact content: its version and a digest. A |
| +// catalog edited without a version bump gets a different ID, so a record |
| +// can never claim a version whose content it does not carry. |
| +func (c *Catalog) ID() string { return c.id } |
| + |
| +func (c *Catalog) validate() error { |
| + if c.Version == "" { |
| + return errors.New("version is required") |
| + } |
| + if len(c.Plans) == 0 { |
| + return errors.New("at least one plan is required") |
| + } |
| + if _, ok := c.Plans[c.DefaultPlan]; !ok { |
| + return fmt.Errorf("default_plan %q is not a plan", c.DefaultPlan) |
| + } |
| + if c.FreeAccountsPerPerson < 0 { |
| + return errors.New("free_accounts_per_person is negative") |
| + } |
| + if err := c.Ceilings.nonNegative(); err != nil { |
| + return fmt.Errorf("ceilings: %w", err) |
| + } |
| + for name, p := range c.Plans { |
| + if err := p.Limits.nonNegative(); err != nil { |
| + return fmt.Errorf("plan %q: %w", name, err) |
| + } |
| + switch p.Limits.MemoryTier { |
| + case "", MemorySmall, MemoryDefault: |
| + default: |
| + return fmt.Errorf("plan %q: memory_tier %q is not %q or %q", name, p.Limits.MemoryTier, MemorySmall, MemoryDefault) |
| + } |
| + for _, pk := range p.Packs { |
| + if _, ok := c.Packs[pk]; !ok { |
| + return fmt.Errorf("plan %q offers pack %q, which is not defined", name, pk) |
| + } |
| + } |
| + } |
| + for name, pk := range c.Packs { |
| + if pk.MaxQuantity < 1 { |
| + return fmt.Errorf("pack %q: max_quantity must be at least 1", name) |
| + } |
| + if err := pk.Adds.nonNegative(); err != nil { |
| + return fmt.Errorf("pack %q: %w", name, err) |
| + } |
| + if pk.Adds != pk.Adds.additive() { |
| + return fmt.Errorf("pack %q may only add running_things, disk_bytes, storage_bytes, email_per_day or bandwidth_bytes_month", name) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (l Limits) nonNegative() error { |
| + if l.RunningThings < 0 || l.SidecarsPerApp < 0 || l.IdleSeconds < 0 || l.AwakeHoursMonth < 0 || |
| + l.DiskBytes < 0 || l.StorageBytes < 0 || l.EmailPerDay < 0 || l.BandwidthBytesMonth < 0 || |
| + l.MinScheduleSeconds < 0 || l.HistoryDays < 0 { |
| + return errors.New("limits may not be negative") |
| + } |
| + return nil |
| +} |
| + |
| +// additive is l with only the fields a pack may raise, and a ceiling clamps. |
| +func (l Limits) additive() Limits { |
| + return Limits{ |
| + RunningThings: l.RunningThings, |
| + DiskBytes: l.DiskBytes, |
| + StorageBytes: l.StorageBytes, |
| + EmailPerDay: l.EmailPerDay, |
| + BandwidthBytesMonth: l.BandwidthBytesMonth, |
| + } |
| +} |
| + |
| +// Effective is an account's limits: the plan's, plus each pack's quantity |
| +// times its adds, clamped to the catalog's ceilings, then merged with a |
| +// sponsored override taking the more generous value per field. Sponsored |
| +// values are not clamped: a sponsored figure above a ceiling is an explicit |
| +// operator decision (spec §8.7). |
| +func (c *Catalog) Effective(plan string, packs map[string]int, sponsored *Limits) (Limits, error) { |
| + p, ok := c.Plans[plan] |
| + if !ok { |
| + return Limits{}, fmt.Errorf("%w: %q", ErrUnknownPlan, plan) |
| + } |
| + l := p.Limits |
| + names := make([]string, 0, len(packs)) |
| + for name := range packs { |
| + names = append(names, name) |
| + } |
| + slices.Sort(names) |
| + for _, name := range names { |
| + qty := packs[name] |
| + pk, ok := c.Packs[name] |
| + if !ok { |
| + return Limits{}, fmt.Errorf("%w: %q", ErrUnknownPack, name) |
| + } |
| + if !slices.Contains(p.Packs, name) { |
| + return Limits{}, fmt.Errorf("%w: %q on %q", ErrPackNotOffered, name, plan) |
| + } |
| + if qty < 1 || qty > pk.MaxQuantity { |
| + return Limits{}, fmt.Errorf("%w: %q has %d, allowed 1 to %d", ErrPackQuantity, name, qty, pk.MaxQuantity) |
| + } |
| + l.RunningThings += qty * pk.Adds.RunningThings |
| + l.DiskBytes += int64(qty) * pk.Adds.DiskBytes |
| + l.StorageBytes += int64(qty) * pk.Adds.StorageBytes |
| + l.EmailPerDay += qty * pk.Adds.EmailPerDay |
| + l.BandwidthBytesMonth += int64(qty) * pk.Adds.BandwidthBytesMonth |
| + } |
| + ce := c.Ceilings |
| + l.RunningThings = clamp(l.RunningThings, ce.RunningThings) |
| + l.DiskBytes = clamp(l.DiskBytes, ce.DiskBytes) |
| + l.StorageBytes = clamp(l.StorageBytes, ce.StorageBytes) |
| + l.EmailPerDay = clamp(l.EmailPerDay, ce.EmailPerDay) |
| + l.BandwidthBytesMonth = clamp(l.BandwidthBytesMonth, ce.BandwidthBytesMonth) |
| + l.IdleSeconds = clamp(l.IdleSeconds, ce.IdleSeconds) |
| + if sponsored != nil { |
| + l = l.generous(*sponsored) |
| + } |
| + return l, nil |
| +} |
| + |
| +func clamp[T int | int64](v, ceiling T) T { |
| + if ceiling > 0 && v > ceiling { |
| + return ceiling |
| + } |
| + return v |
| +} |
| + |
| +// generous merges o into l field by field, keeping the more generous value. |
| +// Zero in o means unset. |
| +func (l Limits) generous(o Limits) Limits { |
| + l.RunningThings = max(l.RunningThings, o.RunningThings) |
| + l.SidecarsPerApp = max(l.SidecarsPerApp, o.SidecarsPerApp) |
| + l.IdleSeconds = max(l.IdleSeconds, o.IdleSeconds) |
| + l.AwakeHoursMonth = max(l.AwakeHoursMonth, o.AwakeHoursMonth) |
| + l.DiskBytes = max(l.DiskBytes, o.DiskBytes) |
| + l.StorageBytes = max(l.StorageBytes, o.StorageBytes) |
| + l.EmailPerDay = max(l.EmailPerDay, o.EmailPerDay) |
| + l.BandwidthBytesMonth = max(l.BandwidthBytesMonth, o.BandwidthBytesMonth) |
| + l.HistoryDays = max(l.HistoryDays, o.HistoryDays) |
| + l.StorageSelfServe = l.StorageSelfServe || o.StorageSelfServe |
| + l.CustomDomains = l.CustomDomains || o.CustomDomains |
| + l.Fleets = l.Fleets || o.Fleets |
| + if o.MemoryTier == MemoryDefault { |
| + l.MemoryTier = MemoryDefault |
| + } |
| + // A shorter minimum interval is the more generous one; zero is unset. |
| + if o.MinScheduleSeconds > 0 && (l.MinScheduleSeconds == 0 || o.MinScheduleSeconds < l.MinScheduleSeconds) { |
| + l.MinScheduleSeconds = o.MinScheduleSeconds |
| + } |
| + return l |
| +} |
diff --git a/internal/plans/catalog_test.go b/internal/plans/catalog_test.go| new file mode 100644 |
| index 000000000..ce6c86c0f |
| --- /dev/null |
| +++ b/internal/plans/catalog_test.go |
| @@ -0,0 +1,204 @@ |
| +package plans |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +const examplePath = "testdata/example-catalog.json" |
| + |
| +func exampleCatalog(t *testing.T) *Catalog { |
| + t.Helper() |
| + c, err := LoadCatalog(examplePath) |
| + if err != nil { |
| + t.Fatalf("load example catalog: %v", err) |
| + } |
| + return c |
| +} |
| + |
| +func exampleBytes(t *testing.T) string { |
| + t.Helper() |
| + b, err := os.ReadFile(examplePath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +func TestParseExampleCatalog(t *testing.T) { |
| + c := exampleCatalog(t) |
| + if got := c.Plans["free"].Limits.RunningThings; got != 3 { |
| + t.Fatalf("free running_things = %d, want 3", got) |
| + } |
| + if c.DefaultPlan != "free" || c.FreeAccountsPerPerson != 1 { |
| + t.Fatalf("default_plan %q, free_accounts_per_person %d", c.DefaultPlan, c.FreeAccountsPerPerson) |
| + } |
| +} |
| + |
| +func TestParseCatalogRefuses(t *testing.T) { |
| + base := exampleBytes(t) |
| + cases := map[string]string{ |
| + "empty version": strings.Replace(base, `"version": "2026-10-20-v1"`, `"version": ""`, 1), |
| + "default plan missing": strings.Replace(base, `"default_plan": "free"`, `"default_plan": "gold"`, 1), |
| + "pack offered not defined": strings.Replace(base, `["apps", "storage", "email", "bandwidth"]`, `["apps", "seats"]`, 1), |
| + "pack adds idle": strings.Replace(base, `{"running_things": 5}`, `{"running_things": 5, "idle_seconds": 60}`, 1), |
| + "pack adds memory tier": strings.Replace(base, `{"running_things": 5}`, `{"memory_tier": "default"}`, 1), |
| + "pack adds a bool": strings.Replace(base, `{"running_things": 5}`, `{"custom_domains": true}`, 1), |
| + "max quantity zero": strings.Replace(base, `{"running_things": 5}, "max_quantity": 20`, `{"running_things": 5}, "max_quantity": 0`, 1), |
| + "unknown memory tier": strings.Replace(base, `"memory_tier": "small"`, `"memory_tier": "tiny"`, 1), |
| + "unknown field": strings.Replace(base, `"default_plan": "free",`, `"default_plan": "free", "surprise": 1,`, 1), |
| + "no plans": `{"version": "v", "default_plan": "free", "plans": {}}`, |
| + "negative running things": strings.Replace(base, `"running_things": 3,`, `"running_things": -3,`, 1), |
| + "pack adds negative amount": strings.Replace(base, `{"email_per_day": 1000}`, `{"email_per_day": -1000}`, 1), |
| + } |
| + for name, body := range cases { |
| + if body == base { |
| + t.Fatalf("%s: replacement did not apply", name) |
| + } |
| + if _, err := ParseCatalog([]byte(body)); err == nil { |
| + t.Errorf("%s: ParseCatalog accepted it", name) |
| + } |
| + } |
| +} |
| + |
| +func TestCatalogIDChangesWithContent(t *testing.T) { |
| + base := exampleBytes(t) |
| + a, err := ParseCatalog([]byte(base)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + again, err := ParseCatalog([]byte(base)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if a.ID() != again.ID() { |
| + t.Fatalf("same bytes, different IDs: %s vs %s", a.ID(), again.ID()) |
| + } |
| + if !strings.HasPrefix(a.ID(), "2026-10-20-v1@") || len(a.ID()) != len("2026-10-20-v1@")+12 { |
| + t.Fatalf("ID shape: %q", a.ID()) |
| + } |
| + changed, err := ParseCatalog([]byte(strings.Replace(base, `"running_things": 3,`, `"running_things": 4,`, 1))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if changed.ID() == a.ID() { |
| + t.Fatal("content changed without a version bump but the ID did not move") |
| + } |
| + reformatted := strings.Join(strings.Fields(base), " ") |
| + reformatted = strings.Replace(reformatted, `"free_accounts_per_person": 1, "plans"`, `"plans"`, 1) |
| + reformatted = strings.Replace(reformatted, `"version": "2026-10-20-v1",`, `"free_accounts_per_person": 1, "version": "2026-10-20-v1",`, 1) |
| + r, err := ParseCatalog([]byte(reformatted)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if r.ID() != a.ID() { |
| + t.Fatalf("whitespace and key order changed the ID: %s vs %s", r.ID(), a.ID()) |
| + } |
| +} |
| + |
| +func TestEffectivePlanOnly(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("free", nil, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != c.Plans["free"].Limits { |
| + t.Fatalf("got %+v, want %+v", got, c.Plans["free"].Limits) |
| + } |
| +} |
| + |
| +func TestEffectiveAddsPacks(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("personal", map[string]int{"apps": 2, "email": 1}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.RunningThings != 35 || got.EmailPerDay != 2000 { |
| + t.Fatalf("running_things %d (want 35), email_per_day %d (want 2000)", got.RunningThings, got.EmailPerDay) |
| + } |
| +} |
| + |
| +func TestEffectiveClampsToCeilings(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("personal", map[string]int{"apps": 20, "email": 20}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.RunningThings != 125 { |
| + t.Fatalf("running_things %d, want 125", got.RunningThings) |
| + } |
| + if got.EmailPerDay != 10000 { |
| + t.Fatalf("email_per_day %d, want the 10000 ceiling", got.EmailPerDay) |
| + } |
| + c.Ceilings.RunningThings = 100 |
| + got, err = c.Effective("personal", map[string]int{"apps": 20}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.RunningThings != 100 { |
| + t.Fatalf("running_things %d, want the 100 ceiling", got.RunningThings) |
| + } |
| +} |
| + |
| +func TestEffectiveRefuses(t *testing.T) { |
| + c := exampleCatalog(t) |
| + cases := []struct { |
| + name string |
| + plan string |
| + packs map[string]int |
| + want error |
| + }{ |
| + {"unknown plan", "gold", nil, ErrUnknownPlan}, |
| + {"unknown pack", "personal", map[string]int{"seats": 1}, ErrUnknownPack}, |
| + {"pack not offered", "free", map[string]int{"apps": 1}, ErrPackNotOffered}, |
| + {"above max", "personal", map[string]int{"apps": 21}, ErrPackQuantity}, |
| + {"negative", "personal", map[string]int{"apps": -1}, ErrPackQuantity}, |
| + {"zero", "personal", map[string]int{"apps": 0}, ErrPackQuantity}, |
| + } |
| + for _, tc := range cases { |
| + if _, err := c.Effective(tc.plan, tc.packs, nil); !errors.Is(err, tc.want) { |
| + t.Errorf("%s: err %v, want %v", tc.name, err, tc.want) |
| + } |
| + } |
| +} |
| + |
| +func TestEffectiveSponsoredTakesTheMoreGenerous(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("personal", nil, &Limits{RunningThings: 300, EmailPerDay: 20}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.RunningThings != 300 { |
| + t.Fatalf("running_things %d, want 300 (sponsored is not clamped)", got.RunningThings) |
| + } |
| + if got.EmailPerDay != 1000 { |
| + t.Fatalf("email_per_day %d, want the plan's 1000", got.EmailPerDay) |
| + } |
| + got, err = c.Effective("free", nil, &Limits{CustomDomains: true, MemoryTier: "default"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.CustomDomains || got.MemoryTier != "default" { |
| + t.Fatalf("custom_domains %v, memory_tier %q", got.CustomDomains, got.MemoryTier) |
| + } |
| +} |
| + |
| +func TestEffectiveSponsoredZeroIsUnset(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("free", nil, &Limits{RunningThings: 40}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.MinScheduleSeconds != 900 || got.RunningThings != 40 { |
| + t.Fatalf("min_schedule_seconds %d (want 900), running_things %d (want 40)", got.MinScheduleSeconds, got.RunningThings) |
| + } |
| + got, err = c.Effective("free", nil, &Limits{MinScheduleSeconds: 60}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.MinScheduleSeconds != 60 { |
| + t.Fatalf("min_schedule_seconds %d, want 60", got.MinScheduleSeconds) |
| + } |
| +} |
diff --git a/internal/plans/record.go b/internal/plans/record.go| new file mode 100644 |
| index 000000000..fd8314757 |
| --- /dev/null |
| +++ b/internal/plans/record.go |
| @@ -0,0 +1,86 @@ |
| +package plans |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// RecordSchema is the limits record's wire version. Fields are only ever |
| +// added (omitempty), and readers ignore fields they do not know, so an |
| +// older box reads a newer record. |
| +const RecordSchema = 1 |
| + |
| +// Record is one account's effective limits as published to the bucket by |
| +// the console, its only writer (spec §7). Boxes read it beside the |
| +// account's manifest, which the fleet read rule already exposes to the |
| +// account's own boxes (internal/edge/storedoor.go). |
| +type Record struct { |
| + Schema int `json:"schema"` |
| + Account string `json:"account"` // sqid; must equal the key's |
| + CatalogID string `json:"catalog_id"` |
| + Plan string `json:"plan"` |
| + Source string `json:"source"` |
| + Revision int64 `json:"revision"` |
| + Limits Limits `json:"limits"` |
| + IssuedAt time.Time `json:"issued_at"` |
| +} |
| + |
| +// LimitsKey is where an account's limits record lives. |
| +func LimitsKey(sqid string) string { return "accounts/" + sqid + "/limits.json" } |
| + |
| +// ParseLimits decodes a record read from sqid's key, refusing an unknown |
| +// schema or a body that names another account. |
| +func ParseLimits(b []byte, sqid string) (*Record, error) { |
| + var r Record |
| + if err := json.Unmarshal(b, &r); err != nil { |
| + return nil, fmt.Errorf("plans: %s: %w", LimitsKey(sqid), err) |
| + } |
| + if r.Schema != RecordSchema { |
| + return nil, fmt.Errorf("plans: %s: schema %d, want %d", LimitsKey(sqid), r.Schema, RecordSchema) |
| + } |
| + if r.Account != sqid { |
| + return nil, fmt.Errorf("plans: %s: names account %q", LimitsKey(sqid), r.Account) |
| + } |
| + return &r, nil |
| +} |
| + |
| +// GetLimits reads sqid's record and its etag. An absent record is nil, "", |
| +// nil (objstore.GetEntitlement's convention). |
| +func GetLimits(ctx context.Context, s release.Store, sqid string) (*Record, string, error) { |
| + if !release.ValidAccount(sqid) { |
| + return nil, "", fmt.Errorf("plans: invalid account %q", sqid) |
| + } |
| + raw, etag, err := s.Get(ctx, LimitsKey(sqid)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", nil |
| + } |
| + return nil, "", err |
| + } |
| + r, err := ParseLimits(raw, sqid) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + return r, etag, nil |
| +} |
| + |
| +// PutLimits CAS-writes r under its account's key: ifMatch "" is |
| +// create-only, otherwise replace-only. Schema is stamped here, not trusted |
| +// from the caller. |
| +func PutLimits(ctx context.Context, s release.Store, r *Record, ifMatch string) (string, error) { |
| + if !release.ValidAccount(r.Account) { |
| + return "", fmt.Errorf("plans: invalid account %q", r.Account) |
| + } |
| + r.Schema = RecordSchema |
| + body, err := json.MarshalIndent(r, "", " ") |
| + if err != nil { |
| + return "", err |
| + } |
| + return s.Put(ctx, LimitsKey(r.Account), append(body, '\n'), ifMatch) |
| +} |
diff --git a/internal/plans/record_test.go b/internal/plans/record_test.go| new file mode 100644 |
| index 000000000..669f03252 |
| --- /dev/null |
| +++ b/internal/plans/record_test.go |
| @@ -0,0 +1,106 @@ |
| +package plans |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestRecordKey(t *testing.T) { |
| + if got := LimitsKey("abc"); got != "accounts/abc/limits.json" { |
| + t.Fatalf("LimitsKey = %q", got) |
| + } |
| +} |
| + |
| +func sampleRecord(sqid string) *Record { |
| + return &Record{ |
| + Account: sqid, |
| + CatalogID: "v1@0123456789ab", |
| + Plan: "free", |
| + Source: "default", |
| + Revision: 1, |
| + Limits: Limits{RunningThings: 3, IdleSeconds: 90, MemoryTier: MemorySmall}, |
| + IssuedAt: time.Unix(1_800_000_000, 0).UTC(), |
| + } |
| +} |
| + |
| +func TestRecordRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + sqid := domains.Sqid(42) |
| + want := sampleRecord(sqid) |
| + etag, err := PutLimits(ctx, s, want, "") |
| + if err != nil || etag == "" { |
| + t.Fatalf("put: %q, %v", etag, err) |
| + } |
| + got, getEtag, err := GetLimits(ctx, s, sqid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if getEtag == "" || got.Schema != RecordSchema { |
| + t.Fatalf("etag %q, schema %d", getEtag, got.Schema) |
| + } |
| + want.Schema = RecordSchema |
| + if *got != *want { |
| + t.Fatalf("got %+v, want %+v", got, want) |
| + } |
| +} |
| + |
| +func TestGetRecordAbsent(t *testing.T) { |
| + got, etag, err := GetLimits(context.Background(), release.NewDirStore(t.TempDir()), domains.Sqid(42)) |
| + if got != nil || etag != "" || err != nil { |
| + t.Fatalf("got %+v, %q, %v; want nil, \"\", nil", got, etag, err) |
| + } |
| +} |
| + |
| +func TestParseRecordRefuses(t *testing.T) { |
| + cases := map[string]string{ |
| + "schema 2": `{"schema": 2, "account": "abc"}`, |
| + "wrong account": `{"schema": 1, "account": "other"}`, |
| + "malformed": `{"schema": 1,`, |
| + } |
| + for name, body := range cases { |
| + if _, err := ParseLimits([]byte(body), "abc"); err == nil { |
| + t.Errorf("%s: accepted", name) |
| + } |
| + } |
| +} |
| + |
| +func TestParseRecordIgnoresUnknownFields(t *testing.T) { |
| + r, err := ParseLimits([]byte(`{"schema": 1, "account": "abc", "plan": "free", "throttled": true}`), "abc") |
| + if err != nil || r.Plan != "free" { |
| + t.Fatalf("got %+v, %v", r, err) |
| + } |
| +} |
| + |
| +func TestPutRecordIsCAS(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + sqid := domains.Sqid(42) |
| + first, err := PutLimits(ctx, s, sampleRecord(sqid), "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := PutLimits(ctx, s, sampleRecord(sqid), ""); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("second create-only put: %v", err) |
| + } |
| + r := sampleRecord(sqid) |
| + r.Revision = 2 |
| + if _, err := PutLimits(ctx, s, r, first); err != nil { |
| + t.Fatalf("put with the current etag: %v", err) |
| + } |
| + if _, err := PutLimits(ctx, s, r, first); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("put with a stale etag: %v", err) |
| + } |
| +} |
| + |
| +func TestPutRecordRefusesAnInvalidAccount(t *testing.T) { |
| + r := sampleRecord("../escape") |
| + if _, err := PutLimits(context.Background(), release.NewDirStore(t.TempDir()), r, ""); err == nil { |
| + t.Fatal("accepted an invalid account") |
| + } |
| +} |
diff --git a/internal/plans/testdata/example-catalog.json b/internal/plans/testdata/example-catalog.json| new file mode 100644 |
| index 000000000..688b2c94d |
| --- /dev/null |
| +++ b/internal/plans/testdata/example-catalog.json |
| @@ -0,0 +1,53 @@ |
| +{ |
| + "version": "2026-10-20-v1", |
| + "default_plan": "free", |
| + "free_accounts_per_person": 1, |
| + "plans": { |
| + "free": { |
| + "paid": false, |
| + "limits": { |
| + "running_things": 3, |
| + "sidecars_per_app": 1, |
| + "idle_seconds": 90, |
| + "memory_tier": "small", |
| + "awake_hours_month": 100, |
| + "disk_bytes": 1073741824, |
| + "storage_bytes": 1073741824, |
| + "email_per_day": 50, |
| + "bandwidth_bytes_month": 10737418240, |
| + "min_schedule_seconds": 900 |
| + } |
| + }, |
| + "personal": { |
| + "paid": true, |
| + "limits": { |
| + "running_things": 25, |
| + "sidecars_per_app": 5, |
| + "idle_seconds": 900, |
| + "memory_tier": "default", |
| + "awake_hours_month": 1500, |
| + "disk_bytes": 10737418240, |
| + "storage_bytes": 26843545600, |
| + "storage_self_serve": true, |
| + "email_per_day": 1000, |
| + "bandwidth_bytes_month": 214748364800, |
| + "custom_domains": true, |
| + "fleets": true, |
| + "min_schedule_seconds": 60, |
| + "history_days": 7 |
| + }, |
| + "packs": ["apps", "storage", "email", "bandwidth"] |
| + } |
| + }, |
| + "packs": { |
| + "apps": {"adds": {"running_things": 5}, "max_quantity": 20}, |
| + "storage": {"adds": {"disk_bytes": 10737418240, "storage_bytes": 53687091200}, "max_quantity": 20}, |
| + "email": {"adds": {"email_per_day": 1000}, "max_quantity": 20}, |
| + "bandwidth": {"adds": {"bandwidth_bytes_month": 214748364800}, "max_quantity": 20} |
| + }, |
| + "ceilings": { |
| + "running_things": 200, |
| + "email_per_day": 10000, |
| + "idle_seconds": 900 |
| + } |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index fbe046f3d..2fff1e9c1 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -1048,6 +1048,20 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| t.Fatal(err) |
| } |
| seal = "cf0a526b5f7c913160b1e6a76fa34c34dacd60cbacfd8019e736c2ab852399e3" |
| + // Census overlay design-plans-and-caps (plans 1: plan catalog, account |
| + // plans, limits record publisher and operator door), loaded last. |
| + // PROPOSED-design-plans-and-caps, asked of the census owner on #280. |
| + // New console store methods, LoadCatalog and the CLI's --sponsored read |
| + // are accepted legacy at their siblings' disposition; serve and |
| + // Store.migrate are re-frozen. Frozen declarations are added, so the |
| + // seal moves; run-lifecycle-logs-325's is checked first. |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-design-plans-and-caps.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + seal = "4f5e0ed6adf7d6bcefdfc9ce50599a21adb06ae3e6d49cab71835e116968df17" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 4b065caba..49dfe13cd 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) { |
| if len(got) < 100 { |
| t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root) |
| } |
| - inventory := scanDemandWriteCensusInventory() |
| + inventory := plansCapsWriteCensusInventory() |
| problems := append(censusDiff(inventory, got), censusPolicy(inventory)...) |
| if len(problems) > 0 { |
| t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n ")) |
diff --git a/internal/provision/plans_caps_census_test.go b/internal/provision/plans_caps_census_test.go| new file mode 100644 |
| index 000000000..2efe5b4e8 |
| --- /dev/null |
| +++ b/internal/provision/plans_caps_census_test.go |
| @@ -0,0 +1,33 @@ |
| +package provision |
| + |
| +// Record-write census entries for design-plans-and-caps (plans 1), layered |
| +// on scanDemandWriteCensusInventory. Reviewed by the branch author; the |
| +// census owner rules on #280 (PROPOSED-design-plans-and-caps). |
| +// - Server.registerCLIRoutes gains the two account plan doors |
| +// (GET/POST /api/cli/accounts/{account}/plan); its instance and sidecar |
| +// literals are unchanged, so it keeps its key-site class. |
| +// - ClassifyMutation gains the put-only accounts/<sqid>/limits.json |
| +// family; it still only names Put as a method string. |
| +// - plans.PutLimits writes accounts/<sqid>/limits.json, an account limits |
| +// record, never an instance or sidecar record: nonrecord. |
| +func plansCapsWriteCensusInventory() []censusEntry { |
| + entries := append([]censusEntry(nil), scanDemandWriteCensusInventory()...) |
| + replacements := []censusEntry{ |
| + {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "baaf2626c2f32666f68109d38910b5e33349086de8b414bafe874a603d708490"}, |
| + {"internal/hostops/mutation_classifier.go", "ClassifyMutation", "reference", "str:Put", "b3e3b647a6786c67600ed808b45732e1b94a229c6706e58ee5547cba5f983633"}, |
| + {"internal/plans/record.go", "PutLimits", "nonrecord", "sel:Put", "4284a2f636e4d114538c16d7a4f6ec37bc2ecaf02bac034e8c7a4a0d43d96a9b"}, |
| + } |
| + for _, replacement := range replacements { |
| + found := false |
| + for i, entry := range entries { |
| + if entry.File == replacement.File && entry.Unit == replacement.Unit { |
| + entries[i], found = replacement, true |
| + break |
| + } |
| + } |
| + if !found { |
| + entries = append(entries, replacement) |
| + } |
| + } |
| + return entries |
| +} |
diff --git a/internal/provision/testdata/plan14-root-reads-design-plans-and-caps.tsv b/internal/provision/testdata/plan14-root-reads-design-plans-and-caps.tsv| new file mode 100644 |
| index 000000000..260fe0284 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-design-plans-and-caps.tsv |
| @@ -0,0 +1,25 @@ |
| +# Census overlay design-plans-and-caps (plans 1: the plan catalog, account_plans table, limits record publisher and operator door), loaded LAST, after run-lifecycle-logs-325. PROPOSED-design-plans-and-caps, asked of the census owner on #280. |
| +# Every unit is console or CLI code that makes no root decision. New units: the account_plans store methods (console SQL on its own table, J4 root-input like their store siblings), LoadCatalog (reads the operator's CARLOS_PLAN_CATALOG once at start) and the CLI's --sponsored file read (runs as the invoking user). Changed legacy units serve (starts the plan limits worker, loads the catalog) and Store.migrate (adds the account_plans table) are re-frozen at their main disposition. Frozen declarations are added, so the seal moves. |
| +@legacy 1b9b193a3f55138826e8a065961f0980f18388627ebe489becdbf5ffac494ac4 internal/console/accountplans.go Store.SetAccountPlan legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:QueryRow@442 7eb5c9bbffd4d730b95505d852a5a75fd7f07753ecf2d66d3b68407cbe76f69c |
| +@legacy 488e0480e559bc7f74cdc7eb6a093b306f5eafde135d5c7b9dc5ab0b9515daea internal/console/accountplans.go Store.EnsureDefaultPlans legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:Exec@98 c9e38a24f10b5506742cf8fb34f6fb10a0bd0f629f4803cbfdcc2d6694ab6a3d |
| +@legacy 514a165480c078a30e26cdbf8081220b81db5c0633316d1a46271f6bc072012c accounts_plan.go runAccountsPlanWithSession legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=CLI reads the operator's own --sponsored limits file and sends it to the console; runs as the invoking user, no root decision. file:ReadFile@1162 c8db16e139cc1a02d451335a28402c567c3908f384c14dea16e15e6026a44814 |
| +@legacy 5570650aa632c6a07aa544b20a6f4c01f31a5360444451d1916ef37d441ce7e5 internal/console/accountplans.go Store.MarkPlanPublished legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:Exec@98 43aa704ded511651c2948f44d6d0c804f3ff8e9d36499e9de657c99a283df927 |
| +@legacy 58d3755c518d7bf88610e288b493599964bb3afd703fa62bb7452b6332d13b8d internal/console/accountplans.go Store.AccountPlan legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:QueryRow@99 0c9fdbcd19e89a561c8dbc52e6a973eb1a35d8b146ce83687e85ff5184eea2af |
| +@legacy 61e20dfe844371a097829fb3f865859c6e71da4fdbef53f442c58e1e2e87786b internal/console/store.go Store.migrate legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@12017,file:Exec@17861 8007284feae307e4044f00985c762facbc04353c1c2ddc67875e4287f4837e45 |
| +@legacy 87a3e64380f0da91d8a7eb317008c513fbe31db96a227910603373c478e4b981 internal/console/accountplans.go Store.PlansToPublish legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:Query@124 51dd2e6ad481a44508e1263f1378b86f7967fa35f22b1fee81d8aeab99dd0134 |
| +@legacy 8b0591778ec6d0f70d57059e12892322e2f01e8d445494a3648e71653f3fbd11 cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=PROPOSED-scan-demand-revival;basis=Console service wires the optional callback signer and starts the claim-guarded queue worker. The same three data path literals move; it does not add a privileged reader. data-literal@4077,data-literal@5178,data-literal@5415 0ce0d9a987a1f90cebbee79dea2f0fc4d72a81bf12dc8f79a0db75125f8c1974 |
| +@legacy d8e10348b4d71b79ee5213dd91db825fd2b31b939ac344dd2a8c0e66aff139e4 internal/plans/catalog.go LoadCatalog legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=console reads the operator-configured CARLOS_PLAN_CATALOG file once at start; a bad file stops the console. file:ReadFile@61 d0b604d9cf163e4c63b96eb3f0460380840b37be30c6e208f5243152977a5361 |
| +accounts_plan.go runAccountsPlanWithSession legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=CLI reads the operator's own --sponsored limits file and sends it to the console; runs as the invoking user, no root decision. file:ReadFile@1162 c8db16e139cc1a02d451335a28402c567c3908f384c14dea16e15e6026a44814 |
| +cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=PROPOSED-scan-demand-revival;basis=Console service wires the optional callback signer and starts the claim-guarded queue worker. The same three data path literals move; it does not add a privileged reader. data-literal@4077,data-literal@5178,data-literal@5415 0ce0d9a987a1f90cebbee79dea2f0fc4d72a81bf12dc8f79a0db75125f8c1974 |
| +internal/console/accountplans.go Store.AccountPlan legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:QueryRow@99 0c9fdbcd19e89a561c8dbc52e6a973eb1a35d8b146ce83687e85ff5184eea2af |
| +internal/console/accountplans.go Store.EnsureDefaultPlans legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:Exec@98 c9e38a24f10b5506742cf8fb34f6fb10a0bd0f629f4803cbfdcc2d6694ab6a3d |
| +internal/console/accountplans.go Store.MarkPlanPublished legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:Exec@98 43aa704ded511651c2948f44d6d0c804f3ff8e9d36499e9de657c99a283df927 |
| +internal/console/accountplans.go Store.PlansToPublish legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:Query@124 51dd2e6ad481a44508e1263f1378b86f7967fa35f22b1fee81d8aeab99dd0134 |
| +internal/console/accountplans.go Store.SetAccountPlan legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:QueryRow@442 7eb5c9bbffd4d730b95505d852a5a75fd7f07753ecf2d66d3b68407cbe76f69c |
| +internal/console/store.go Store.migrate legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@12017,file:Exec@17861 8007284feae307e4044f00985c762facbc04353c1c2ddc67875e4287f4837e45 |
| +internal/plans/catalog.go LoadCatalog legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=console reads the operator-configured CARLOS_PLAN_CATALOG file once at start; a bad file stops the console. file:ReadFile@61 d0b604d9cf163e4c63b96eb3f0460380840b37be30c6e208f5243152977a5361 |
| +@coverage . 52c26e70c516dfc51c5eb22345764aac73a2648cb9401c1818a6291a76f04194 |
| +@coverage cmd/console 8e2b043dbb63154301a630b811eaf2c7fdf8ee43c4f2253f15a385ca5767c8ba |
| +@coverage internal/console e3d03d70aaab18ef02efc0eba542420c8052ad6dd99a7b1d5a1a053c5d7a69da |
| +@coverage internal/hostops 35d6280ea8252739aeb855cc107ad930af6513aaae73b24dbb3eacbbd52e9ec7 |
| +@coverage internal/plans b89e57501bba666ab00807986ccd921d419475db1cfe0b106241543496e7f6ee |