carlos / platform Public

plans-3a-creation-caps

Squashed from plans-3a-creation-caps into main.
claude agent, operated by paul@keymail.dev 6e8564ad8f45920588873e84ddc41b7b675cf009 parent 30c3327
25 files changed, +1277 −1
  • docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md +95 −0
  • docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md +22 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3A-CODE.md +33 −0
  • internal/console/accountplans.go +26 −0
  • internal/console/apicli.go +14 −0
  • internal/console/apidomains.go +6 −0
  • internal/console/apiinstances.go +12 −0
  • internal/console/apisidecars.go +11 −0
  • internal/console/apitemporary.go +11 −0
  • internal/console/authcli.go +11 −0
  • internal/console/claim_restore.go +7 −0
  • internal/console/domainattach.go +13 −0
  • internal/console/domaincatchall.go +4 −0
  • internal/console/fleets.go +10 −0
  • internal/console/ownership.go +4 −0
  • internal/console/plancaps.go +290 −0
  • internal/console/plancaps_copy.go +18 −0
  • internal/console/plancaps_doors_test.go +374 −0
  • internal/console/plancaps_test.go +225 −0
  • internal/console/restore.go +7 −0
  • internal/console/server.go +25 −0
  • internal/provision/plan14_root_reads_test.go +13 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/plans3a_census_test.go +36 −0
  • internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv +9 −0

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (19 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (172 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.589s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.836s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.775s
ok  	github.com/carlosframework/platform/internal/activator	60.315s
ok  	github.com/carlosframework/platform/internal/agentsock	0.093s
ok  	github.com/carlosframework/platform/internal/alerting	0.017s
ok  	github.com/carlosframework/platform/internal/analytics	0.041s
ok  	github.com/carlosframework/platform/internal/appconfig	2.906s
ok  	github.com/carlosframework/platform/internal/apperr	0.230s
ok  	github.com/carlosframework/platform/internal/applog	0.270s
ok  	github.com/carlosframework/platform/internal/awsinv	0.010s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.016s
ok  	github.com/carlosframework/platform/internal/boxid	0.652s
?   	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.019s
ok  	github.com/carlosframework/platform/internal/caps	0.022s
ok  	github.com/carlosframework/platform/internal/certwarm	0.010s
ok  	github.com/carlosframework/platform/internal/cgroup	0.750s
ok  	github.com/carlosframework/platform/internal/changelog	0.576s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.234s
ok  	github.com/carlosframework/platform/internal/clamd	0.069s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
?   	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	8.495s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.022s
ok  	github.com/carlosframework/platform/internal/dbquery	0.014s
ok  	github.com/carlosframework/platform/internal/dbreads	2.223s
?   	github.com/carlosframework/platform/internal/dbreads/dbreadstest	[no test files]
ok  	github.com/carlosframework/platform/internal/dbverify	0.016s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.014s
ok  	github.com/carlosframework/platform/internal/docsite	0.031s
ok  	github.com/carlosframework/platform/internal/domains	2.847s
ok  	github.com/carlosframework/platform/internal/doorkey	0.014s
ok  	github.com/carlosframework/platform/internal/economics	0.013s
ok  	github.com/carlosframework/platform/internal/edge	45.555s
?   	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.135s
ok  	github.com/carlosframework/platform/internal/gate	1.659s
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.033s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.158s
ok  	github.com/carlosframework/platform/internal/host	141.494s
ok  	github.com/carlosframework/platform/internal/hostactors	5.796s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.017s
ok  	github.com/carlosframework/platform/internal/hostbox	3.021s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.825s
ok  	github.com/carlosframework/platform/internal/hostenv	0.017s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.017s
ok  	github.com/carlosframework/platform/internal/hostexec	5.390s
ok  	github.com/carlosframework/platform/internal/hostfence	0.047s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.335s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.020s
ok  	github.com/carlosframework/platform/internal/hostguard	1.040s
ok  	github.com/carlosframework/platform/internal/hostops	1.963s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.022s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.015s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.726s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.198s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.015s
ok  	github.com/carlosframework/platform/internal/infraview	0.017s
ok  	github.com/carlosframework/platform/internal/instancectl	5.259s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.835s
ok  	github.com/carlosframework/platform/internal/instancefs	0.020s
ok  	github.com/carlosframework/platform/internal/instancekey	0.020s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.026s
ok  	github.com/carlosframework/platform/internal/layout	1.705s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.019s
ok  	github.com/carlosframework/platform/internal/lease	10.173s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.543s
ok  	github.com/carlosframework/platform/internal/ledger	0.047s
ok  	github.com/carlosframework/platform/internal/mail	0.204s
ok  	github.com/carlosframework/platform/internal/maintenance	0.106s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.560s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.114s
ok  	github.com/carlosframework/platform/internal/metrics	0.008s
ok  	github.com/carlosframework/platform/internal/moves	22.175s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.973s
ok  	github.com/carlosframework/platform/internal/objstore	0.303s
ok  	github.com/carlosframework/platform/internal/ops	0.232s
ok  	github.com/carlosframework/platform/internal/park	0.284s
ok  	github.com/carlosframework/platform/internal/plans	0.010s
ok  	github.com/carlosframework/platform/internal/privatefile	0.010s
ok  	github.com/carlosframework/platform/internal/provision	22.866s
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.546s
ok  	github.com/carlosframework/platform/internal/relay	4.237s
ok  	github.com/carlosframework/platform/internal/release	25.671s
ok  	github.com/carlosframework/platform/internal/rollout	3.846s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.009s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.058s
ok  	github.com/carlosframework/platform/internal/rootstate	0.027s
ok  	github.com/carlosframework/platform/internal/runs	0.556s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/safefile	0.007s
ok  	github.com/carlosframework/platform/internal/schedule	2.673s
ok  	github.com/carlosframework/platform/internal/seeds	0.023s
ok  	github.com/carlosframework/platform/internal/sim/engines	2.743s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.214s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.016s
ok  	github.com/carlosframework/platform/internal/status	0.073s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.324s
ok  	github.com/carlosframework/platform/internal/svcrole	0.006s
ok  	github.com/carlosframework/platform/internal/telemetry	0.005s
ok  	github.com/carlosframework/platform/internal/termfence	0.005s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
ok  	github.com/carlosframework/platform/internal/unixdial	0.005s
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.001s
ok  	github.com/carlosframework/platform/internal/vet	1.656s
=== internal_console@0 (81 s)
ok  	github.com/carlosframework/platform/internal/console	79.633s
=== internal_console@1 (41 s)
ok  	github.com/carlosframework/platform/internal/console	40.169s
=== internal_console@2 (44 s)
ok  	github.com/carlosframework/platform/internal/console	42.710s
=== internal_console@3 (42 s)
ok  	github.com/carlosframework/platform/internal/console	41.116s
=== internal_console@4 (46 s)
ok  	github.com/carlosframework/platform/internal/console	44.292s
=== internal_console@5 (42 s)
ok  	github.com/carlosframework/platform/internal/console	40.283s
=== internal_console@6 (40 s)
ok  	github.com/carlosframework/platform/internal/console	38.214s
=== internal_console@7 (36 s)
ok  	github.com/carlosframework/platform/internal/console	34.228s
=== root@0 (110 s)
ok  	github.com/carlosframework/platform	108.900s
=== root@1 (89 s)
ok  	github.com/carlosframework/platform	87.429s
=== root@2 (81 s)
ok  	github.com/carlosframework/platform	79.351s
=== 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: 50462882 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.023s
=== RUN   TestT4StorageProbeMinio
2026/10/22 23:14:49 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0FD0E61978632, 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.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.022s
=== RUN   TestS3StoreMinioContract
=== RUN   TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN   TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.02s)
    --- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
    --- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/release	0.029s
Bucket created successfully `local/dbf-deploy`.
Bucket created successfully `local/dbf-data-ie`.
Bucket created successfully `local/dbf-data-uk`.
local/dbf-deploy versioning is enabled
Created policy `dbf-console` successfully.
Created policy `dbf-ukbox` successfully.
Added user `dbfconsole` successfully.
Added user `dbfukbox` successfully.
Attached Policies: [dbf-console]
To User: dbfconsole
Attached Policies: [dbf-ukbox]
To User: dbfukbox
=== RUN   TestDBForwardMinio
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/22 23:14:52 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/22 23:14:52 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/22 23:14:52 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/22 23:14:52 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/22 23:14:52 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/22 23:14:52 console: could not notify member@example.com of a CLI token issue: keymail: lookup member@example.com: keymail: GET /api/federation/lookup?addr=member%40example.com&from=console%40127.0.0.1%3A43977: could not determine target mailbox
    agent.go:123: dbreads: dbr_ru7vyfkqhqydhjyusreexqvd7y: planned 3 files (1544 bytes) and restored to 3 in 9ms
    agent.go:123: dbreads: dbr_ru7vyfkqhqydhjyusreexqvd7y: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/22 23:14:53 console: db read dbr_ru7vyfkqhqydhjyusreexqvd7y: relayed 1 chunks (102 bytes) in 1.004s, ended done
    agent.go:123: dbreads: dbr_df3r2bsa4be454vrkxtrecioyu: planned 1 files (720 bytes) and restored to 1 in 8ms
    agent.go:123: dbreads: dbr_df3r2bsa4be454vrkxtrecioyu: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/22 23:14:54 console: db read dbr_df3r2bsa4be454vrkxtrecioyu: relayed 1 chunks (102 bytes) in 1.004s, ended done
=== RUN   TestDBForwardMinio/by_disconnect
=== NAME  TestDBForwardMinio
    agent.go:123: dbreads: dbr_r5zb77dvs3b6sxakrik5vwaaa4: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/22 23:14:54 console: db read dbr_r5zb77dvs3b6sxakrik5vwaaa4: relayed 0 chunks (0 bytes) in 18ms, ended client gone
    agent.go:123: dbreads: dbr_r5zb77dvs3b6sxakrik5vwaaa4: output 5 bytes in 1 confirmed chunks in 9ms
=== RUN   TestDBForwardMinio/by_timeout
=== NAME  TestDBForwardMinio
    agent.go:123: dbreads: dbr_h522yovgxhh44b4uqufirgapqq: planned 3 files (1544 bytes) and restored to 3 in 10ms
2026/10/22 23:15:14 console: db read dbr_h522yovgxhh44b4uqufirgapqq: relayed 0 chunks (0 bytes) in 19.628s, ended no-result
    agent.go:123: dbreads: dbr_h522yovgxhh44b4uqufirgapqq: output 5 bytes in 1 confirmed chunks in 19.616s
--- PASS: TestDBForwardMinio (21.95s)
    --- PASS: TestDBForwardMinio/by_disconnect (0.13s)
    --- PASS: TestDBForwardMinio/by_timeout (19.74s)
PASS
ok  	github.com/carlosframework/platform/internal/console	21.972s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md b/docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md
new file mode 100644
index 000000000..b10cae7d6
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md
@@ -0,0 +1,95 @@
+# Plans 3a: Creation Caps Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** When plans are on, the console refuses to create what an account's plan does not allow: more running things (apps and instances) than the cap, more workers per app than the cap, a second free account for the same person, fleets and custom domains on plans without them.
+
+**Architecture:** One file, `internal/console/plancaps.go`, holds the checks. Each takes the account's effective limits (Plan 1's `EffectiveLimits`; nil means plans are off and every check passes) and returns a `*planCapError` whose message is approved copy. Each capped door takes the account lock (`lockAccount`) around its count and its write so two concurrent creates cannot both pass. JSON doors answer 409 with `"code":"cap"`; browser doors flash the message, as they already do for their other refusals.
+
+**Tech Stack:** Go, `internal/console`, `internal/provision` (record listing).
+
+**Spec:** `docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md` §5.1 (running things, sidecars, creation has no grace), §8.1 (one free account per person, creation only), §8.4 (paid-only custom domains), table 4.2 (fleets). Builds on Plans 1 and 2 (merged f550afa, 0235046).
+
+## Global Constraints
+
+- **No catalog, no change.** Every check returns nil when `EffectiveLimits` is nil. Each task pins this with a test.
+- Creation refusals have no grace (spec §5.1): refuse at the cap, touch nothing that already runs.
+- **Running things** = live apps (including temporary apps and apps being restored: `deleted_at = 0 OR restoring = 1`) plus customer instance records of those live apps. Trashed apps and their instances do not count.
+- Lock order is account before app (`applocks.go:50`). A door takes `lockAccount` first, before any app lock.
+- Direct-mode creations never reach the console (`apps.go:546`) and are out of scope; adopting such an app through `ownerClaim` is capped like any create.
+- All refusal text is approved copy (copy review before the strings go into files). Browser copy is sentence case; CLI and JSON copy is lower case. No em dashes.
+- Tests: console tests run serially in practice; touched packages only, with `-run`; CI runs the full suite. New routes: none. Claim fixtures: none expected (no new doors or workers). The #280 root-read overlay is re-pinned in the last task.
+
+## Review Focus
+
+1. **Plans off.** With no catalog, every door behaves exactly as today, including under concurrency. Pinned per task (`...WithoutACatalog` tests).
+2. **Concurrent creates at the cap.** Two creates racing for the last slot: exactly one succeeds. Pinned in Task 2 (`TestRunningThingsCapIsExactUnderConcurrentCreates`).
+3. **Restore over the cap.** Restoring a trashed app when the account is at its cap is refused, with nothing changed. Pinned in Task 2 (`TestRestoreRefusedAtTheRunningThingsCap`).
+4. **A sponsored account above the catalog ceiling.** Its recorded limit, not the plan's, is the cap. Pinned in Task 1 (`TestRunningThingsCapUsesEffectiveLimits`).
+5. **Platform hostnames.** Attaching a platform hostname stays allowed on free plans; only custom domains are paid-only. Pinned in Task 5 (`TestPlatformHostnamesStayFreeOnFreePlans`).
+
+---
+
+### Task 1: The checks
+
+**Files:** Create `internal/console/plancaps.go`, `internal/console/plancaps_test.go`, `internal/console/plancaps_copy.go` (generated from approved copy); Modify `internal/console/accountplans.go` (one store query).
+
+**Interfaces — produces:**
+```go
+type planCapError struct{ msg string }
+func (e *planCapError) Error() string
+func (s *Store) LiveAppCount(accountID int64) (int, error) // deleted_at = 0 OR restoring = 1
+func (s *Server) runningThings(ctx context.Context, acct *Account) (int, error) // live apps + their instance records (key-only bucket list of apps/<sqid>/)
+func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding int) error
+func (s *Server) checkSidecars(ctx context.Context, acct *Account, app string) error
+func (s *Server) checkNewFreeAccount(addr string) error
+func (s *Server) checkFleets(acct *Account) error
+func (s *Server) checkCustomDomains(acct *Account) error
+```
+Each returns nil when plans are off, `*planCapError` at the cap, or an ordinary error when counting failed (doors answer that as they answer other internal errors).
+
+- [ ] **Step 1: Failing tests** in `plancaps_test.go`:
+ `TestPlanChecksPassWithoutACatalog` (every check nil with no catalog, even at absurd counts); `TestLiveAppCountCountsRestoringNotTrashed`; `TestRunningThingsCountsAppsAndInstances` (two apps, three instance records, one trashed app with an instance: 5); `TestRunningThingsCapAtAndBelow` (free cap 3: at 2 → nil, at 3 → planCapError); `TestRunningThingsCapUsesEffectiveLimits` (sponsored running_things 300 above the 200 ceiling: 250 → nil); `TestSidecarCap` (free: second sidecar refused; personal: sixth refused); `TestNewFreeAccountCap` (owner of one free account: refused; owner of one paid account: allowed; member, not owner, of a free account: allowed); `TestFleetsAndCustomDomainsFollowThePlan` (free refused, personal allowed).
+- [ ] **Step 2:** Run `go test ./internal/console/ -run 'PlanChecks|LiveAppCount|RunningThings|SidecarCap|NewFreeAccountCap|FleetsAndCustomDomains'` → FAIL.
+- [ ] **Step 3:** Implement. `runningThings` lists keys under `apps/<sqid>/` once and counts `provision.ParseRecordKey` hits whose app is in the live set from `AppsIn`. `checkNewFreeAccount` uses `AccountsFor(addr)`, owner role only, each account's plan from `AccountPlan` (nil → `DefaultPlan`), counting accounts whose plan is not `Paid`, refusing at `FreeAccountsPerPerson` (0 means no cap).
+- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: plan creation checks`.
+
+### Task 2: App creation and restore
+
+**Files:** Modify `server.go` (`handleNewApp`), `apicli.go` (`handleAPICreateApp`, `handleAPIRestoreApp`), `apitemporary.go`, `restore.go`, `claim_restore.go`, `ownership.go` (`handleRestoreApp`); tests beside each (`apicli_test.go`, `restore_test.go`, `apitemporary_test.go`).
+
+- [ ] **Step 1: Failing tests:** `TestCreateAppRefusedAtTheRunningThingsCap` (CLI: 409 `"code":"cap"`, no row; browser: flash with the approved message, no row); `TestTemporaryAppCountsTowardRunningThings`; `TestRestoreRefusedAtTheRunningThingsCap` (both restore doors, both claim modes; nothing changed); `TestRunningThingsCapIsExactUnderConcurrentCreates` (cap 3, two live, 8 concurrent CLI creates: exactly one 201); `TestCreateAppWithoutACatalogUnchanged`.
+- [ ] **Step 2:** Run → FAIL.
+- [ ] **Step 3:** In `handleNewApp` and `handleAPICreateApp`, take `defer s.lockAccount(acct.Sqid)()` before `claimApp` and call `checkRunningThings(ctx, acct, 1)`; refuse with the door's existing shape. In `/new-temporary`, call it after `temporaryCapCheck` (the lock is already held). In `restoreApp` and `restoreClaimApp`, call it for every row, beside the temporary check, returning the `*planCapError`; both restore doors map it with `errors.As` (CLI 409 `"code":"cap"`; browser `fail`).
+- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: the running-things cap on app creation and restore`.
+
+### Task 3: Instances, first-route attach and sidecars
+
+**Files:** Modify `apiinstances.go` (`handleAPIInstanceCreate`, before the seed branch at ~1580), `domainattach.go` (first-route record write ~237 and `attachClaimFirstRoute`), `apisidecars.go` (`handleAPISidecarCreate`); tests in `apiinstancescanary_test.go`, `apiinstanceseed_test.go`, `apidomains_firstroute_test.go`, `apisidecars_test.go`.
+
+- [ ] **Step 1: Failing tests:** `TestInstanceCreateRefusedAtTheRunningThingsCap` (plain, seeded and imported: 409 `"code":"cap"`, no record); `TestFirstRouteAttachRefusedAtTheRunningThingsCap`; `TestSidecarCreateRefusedAtTheSidecarCap`; `TestInstanceAndSidecarCreateWithoutACatalogUnchanged`.
+- [ ] **Step 2:** Run → FAIL.
+- [ ] **Step 3:** Each door takes `lockAccount` before its checks and calls the matching check; the instance door does so once, before the seed branch, so all three variants are covered.
+- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: caps on instances, first routes and sidecars`.
+
+### Task 4: One free account per person
+
+**Files:** Modify `server.go` (`handleNewAccount`), `authcli.go` (`handleCLINewAccount`); tests in `server_account_test.go`, `authcli_test.go`.
+
+- [ ] **Step 1: Failing tests:** `TestNewAccountRefusedForASecondFreeAccount` (browser flash; CLI 409 `"code":"cap"`; no account minted, no bucket manifest); `TestFirstAccountIsNeverRefused` (auto-mint on first sign-in with a catalog configured); `TestNewAccountAllowedWhenTheOtherIsPaid`; `TestNewAccountWithoutACatalogUnchanged`.
+- [ ] **Step 2:** Run → FAIL. **Step 3:** Call `checkNewFreeAccount(addr)` in both doors before `mintAccount`. `currentAccount`'s auto-mint is untouched (spec §1: landing never refuses). **Step 4:** Run → PASS. **Step 5:** Commit `console: one free account per person`.
+
+### Task 5: Fleets and custom domains
+
+**Files:** Modify `fleets.go` (`handleAPIFleetNew`), `domainattach.go` (`attachDomainAs`, after the platform-hostname check and above `heldElsewhere`, per the file's ordering rule), `apidomains.go` (alias branch above `heldElsewhere` ~525; map `*planCapError` to 409 `"code":"cap"`), `appdomains.go` (`attachInstanceCatchall` and the refusal render); tests in `fleets_test.go`, `apidomains_test.go`, `appdomains_test.go`.
+
+- [ ] **Step 1: Failing tests:** `TestFleetCreateRefusedOnAPlanWithoutFleets`; `TestCustomDomainRefusedOnAFreePlan` (CLI static, CLI alias, browser alias, browser catch-all: refused, nothing claimed); `TestPlatformHostnamesStayFreeOnFreePlans`; `TestFleetsAndDomainsWithoutACatalogUnchanged`.
+- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement the gates. **Step 4:** Run → PASS. **Step 5:** Commit `console: fleets and custom domains follow the plan`.
+
+### Task 6: Census and gates
+
+- [ ] Re-pin the #280 root-read overlay for the changed console units (generator recipe from Plans 1–2), loaded last, `rev3=PROPOSED-plans-3a-creation-caps`; check the record-write census; run the full claim invariant once; merge main; push; CI green; ask for the ruling on #280.
+
+## Done when
+
+Every task's tests pass, Review Focus 1–5 are pinned, CI is green after merging current main, and Astra's whole-branch review has no open Critical or Important finding.
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md
new file mode 100644
index 000000000..19590b5e4
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md
@@ -0,0 +1,22 @@
+1. **Important — Restore counts one slot but revives the app’s instances too.** [plancaps.go:100](internal/console/plancaps.go:100)
+ With two running things and a limit of three, restoring an app with two retained instance records passes, then raises the count to five. `runningThings` correctly excludes those records while the app is trashed, but `checkRestore` adds only one. Both restore modes use this check. **Fix:** count the full restored footprint before changing anything; test this scenario in both claim modes.
+
+2. **Important — Concurrent requests can mint multiple free accounts.** [authcli.go:1778](internal/console/authcli.go:1778)
+ Someone who owns only paid accounts can send two creation requests. Both can observe zero free accounts before either reaches `mintAccount`, so both succeed. The browser door has the same gap; the account allocator prevents duplicate IDs, not duplicate free allowances. **Fix:** serialize the check and mint by person across both doors. Coordinate auto-mint through the same mechanism while preserving its never-refuse behavior.
+
+3. **Important — Adopting an existing app undercounts its instances.** [apicli.go:455](internal/console/apicli.go:455)
+ An owner may adopt an app that already has bucket objects but no console row. Its instance records are excluded from `runningThings` because the app is outside `liveSet`. The new check reserves only one slot; inserting the app row makes all its instances count immediately. The browser door behaves identically. **Fix:** include existing instance records in the prospective count when adopting an app, under the account lock.
+
+4. **Important — Pending claim creates do not retain cap reservations.** [plancaps.go:73](internal/console/plancaps.go:73)
+ Counting includes only materialized instance keys. A claims-mode create can return `202` after an uncertain record write, releasing `planLock` while its claim remains pending. Another create can consume the same final slot before the original write becomes visible. `checkSidecars` has the equivalent omission. **Fix:** count durable pending reservations alongside records, deduplicating by identity, and release reservations only after definitive cancellation. Test a delayed record write followed by another create.
+
+5. **Minor — The sponsored-limit test does not exercise the catalog ceiling.** [plancaps_test.go:117](internal/console/plancaps_test.go:117)
+ The test sets a sponsored limit of 300 but creates only four apps. It would still pass if enforcement incorrectly clamped that limit to 200. **Fix:** use the planned count of 250 and assert both admission below 300 and refusal beyond it.
+
+**Declined to judge**
+
+- Refusal wording and settled policy decisions, as instructed.
+- Direct-to-bucket enforcement: the implementation plan excludes it, although spec §5.1 requires coverage. This review does not certify that path as capped.
+- Runtime test results and production-scale listing cost. Targeted tests could not start because the read-only sandbox prevented Go from creating its build directory. Census coverage and hashes matched on static inspection.
+
+**Verdict:** Request changes. The creation caps have Important bypasses. HEAD remains `4681e4c3660cccd6fc0c3958a9691bc45dc82754`; the working tree is unchanged.
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3A-CODE.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3A-CODE.md
new file mode 100644
index 000000000..a748e5f70
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3A-CODE.md
@@ -0,0 +1,33 @@
+# Whole-branch code review brief: Plans 3a (creation caps)
+
+You are a senior Go reviewer on the carlos platform. Review branch
+`plans-3a-creation-caps`, range `13e2f75f5c931b6f9ab2b8dad8192ec1553a9461..HEAD` (`git diff 13e2f75f5c931b6f9ab2b8dad8192ec1553a9461..HEAD`; docs
+under docs/superpowers/ are requirements). Read-only: do not modify the
+working tree, index, HEAD or branches. You may run `go test` with
+GOFLAGS=-buildvcs=false.
+
+Requirements, in order: the spec docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md
+(§5.1, §8.1, §8.4, table 4.2; section 3 decisions are not to be argued with);
+the plan docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md; the
+executor's `Ruling:` lines in .superpowers/sdd/2026-10-22-plans-3a-creation-caps/progress.md.
+
+Grade by effect on a reasonable operator, self-hoster or customer. Check
+deliberately (the plan's Review Focus): plans off changes nothing (including
+concurrency); concurrent creates at the cap (exactly one wins); restore over
+the cap; a sponsored account above the catalog ceiling; platform hostnames
+stay free on free plans.
+
+Also look hard at: every path that creates a running thing (apps, temporary
+apps, restore in both claim modes, plain/seeded/imported instances, first-route
+domain attach) and whether any bypasses the cap; lock order (account before
+app) and deadlock risk where planLock is taken, including long-running seed
+creates; how runningThings counts (trashed apps, restoring apps, claims-mode
+pending creates, bucket list cost); the custom-domain gate on every attach
+path (static, alias, catch-all, browser and CLI) and what counts as a custom
+domain; one free account per person (auto-mint never refused; paid accounts
+not counted); fleets; error handling when counting fails; census rows.
+
+Do not review the wording of the refusals (approved copy in plancaps_copy.go).
+
+Output: findings most severe first (Critical / Important / Minor, file:line,
+failure scenario, fix); then "Declined to judge"; then a verdict.
diff --git a/internal/console/accountplans.go b/internal/console/accountplans.go
index 2a2cac308..4917eb1b7 100644
--- a/internal/console/accountplans.go
+++ b/internal/console/accountplans.go
@@ -156,3 +156,29 @@ func (s *Store) MarkPlanPublished(accountID, revision int64, catalogID string) e
WHERE account_id = ? AND revision = ?`, revision, catalogID, accountID, revision)
return err
}
+
+// LiveAppCount counts the account's running apps for the creation caps:
+// live rows and rows being restored (temporary apps included).
+func (s *Store) LiveAppCount(accountID int64) (int, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND (deleted_at = 0 OR restoring = 1)`, accountID).Scan(&n)
+ return n, err
+}
+
+// RestoringAppsIn names the account's apps that are mid-restore.
+func (s *Store) RestoringAppsIn(accountID int64) ([]string, error) {
+ rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND deleted_at != 0 AND restoring = 1 ORDER BY app`, accountID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var a string
+ if err := rows.Scan(&a); err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index b5c75ea34..69e5f77f4 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -451,6 +451,16 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
// account role, and handing a CI credential the power to adopt release
// history is exactly the widening svcrole exists to prevent.
ownerClaim := p.Kind == PrincipalUser && acct.Role == RoleOwner
+ defer s.planLock(acct)()
+ if err := s.checkCreateApp(r.Context(), acct, name); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: api create %s/%s: plan cap: %v", acct.Sqid, name, err)
+ jsonError(w, http.StatusInternalServerError, "could not create the app — try again")
+ return
+ }
outcome, placed, held := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim, rq)
switch outcome {
case claimInvalid:
@@ -1584,6 +1594,7 @@ func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) {
// that comes back and then vanishes again on the next pass.
// restoreApp (restore.go) is the one restore every door runs: it locks,
// settles, checks the temporary cap and orders the row and marker writes.
+ var planCap *planCapError
var capErr *temporaryCapError
var rf *restoreFailure
switch err := s.restoreApp(ctx, acct, app, time.Now()); {
@@ -1591,6 +1602,9 @@ func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, errNotInTrash):
http.NotFound(w, r)
return
+ case errors.As(err, &planCap):
+ writeJSON(w, http.StatusConflict, map[string]any{"error": planCap.Error(), "code": "cap"})
+ return
case errors.As(err, &capErr):
writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"})
return
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index c97618b7d..a44711821 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -522,6 +522,12 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
// can refuse first, the two 409s are indistinguishable in status, body
// AND reachability. Anything added to this branch later belongs above
// this line, not below it.
+ // Plans 3a: custom domains need a plan that includes them (above
+ // heldElsewhere, per the rule).
+ if ref := s.customDomainRefusal(acct, host); ref != nil {
+ writeJSON(w, ref.Status, capOrError(ref))
+ return
+ }
if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil {
jsonError(w, ref.Status, ref.Msg)
return
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 1aed8a1d5..110867b64 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -1576,6 +1576,18 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
jsonError(w, ref.Status, ref.Msg)
return
}
+ // Plans 3a: an instance is a running thing. Checked once here, so plain,
+ // seeded and imported creates all meet the cap, under the account lock.
+ defer s.planLock(acct)()
+ if err := s.checkRunningThings(r.Context(), acct, 1); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: api instances create %s/%s: plan cap: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not create the instance — try again")
+ return
+ }
// Every ordinary check has passed: a seed door takes over from here, and
// writes its own record (apiinstanceseed.go).
if req.From != nil {
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go
index b16346bc7..698b7daa4 100644
--- a/internal/console/apisidecars.go
+++ b/internal/console/apisidecars.go
@@ -311,6 +311,17 @@ func (s *Server) handleAPISidecarCreate(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusBadRequest, err.Error())
return
}
+ // Plans 3a: the plan caps workers per app, under the account lock.
+ defer s.planLock(acct)()
+ if err := s.checkSidecars(r.Context(), acct, app); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: api sidecars create %s/%s: plan cap: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not create the sidecar — try again")
+ return
+ }
// The leftover-replica rail (#199, createleftovers.go): a sidecar's host
// is derived from its name, so a name reused after a delete lands on the
// deleted sidecar's replica. Refused, after every validation above.
diff --git a/internal/console/apitemporary.go b/internal/console/apitemporary.go
index dcfd00993..23166278d 100644
--- a/internal/console/apitemporary.go
+++ b/internal/console/apitemporary.go
@@ -171,6 +171,17 @@ func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Requ
jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again")
return
}
+ // A temporary app is a running thing too (plans 3a); the account lock is
+ // already held.
+ if err := s.checkRunningThings(r.Context(), acct, 1); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: temporary create %s: plan cap: %v", acct.Sqid, err)
+ jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again")
+ return
+ }
switch wired, err := s.accountAppHasRoutes(acct.Sqid, name); {
case err != nil:
log.Printf("console: temporary create %s/%s: routes: %v", acct.Sqid, name, err)
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index c55633abb..954e47d57 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -1777,6 +1777,17 @@ func (s *Server) handleCLINewAccount(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
+ // Plans 3a: one free account per person (spec §8.1).
+ defer s.planPersonLock(addr)()
+ if err := s.checkNewFreeAccount(addr); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: cli mint account for %s: plan cap: %v", addr, err)
+ jsonError(w, http.StatusBadGateway, "could not create the account — try again")
+ return
+ }
a, err := s.mintAccount(ctx, name, addr)
if err != nil {
// Logged, not returned: every other failure in this console answers a
diff --git a/internal/console/claim_restore.go b/internal/console/claim_restore.go
index 43a960fe5..cde0291bc 100644
--- a/internal/console/claim_restore.go
+++ b/internal/console/claim_restore.go
@@ -116,6 +116,13 @@ func (s *Server) restoreClaimApp(ctx context.Context, acct *Account, app string,
if life == nil || !life.Trashed() {
return errNotInTrash
}
+ // Plans 3a: restoring brings a running thing back, so it meets the cap.
+ if err := s.checkRestore(ctx, acct, app); err != nil {
+ if isPlanCap(err) {
+ return err
+ }
+ return &restoreFailure{Marker: true, Err: err}
+ }
if life.Temporary() {
if err := s.temporaryCapCheck(acct.ID); err != nil {
var capErr *temporaryCapError
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index 36769f333..34da425d3 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -137,6 +137,10 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k
return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
"that hostname is platform-managed"}
}
+ // Plans 3a: custom domains need a plan that includes them.
+ if ref := s.customDomainRefusal(acct, host); ref != nil {
+ return attachResult{}, ref
+ }
// The reserved sidecar suffix, refused at the same policy stage and with
// the same status as the two checks above: this door must never mint a
// claim (let alone a route record) under a name provision.SidecarSuffix
@@ -205,6 +209,15 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k
if ref != nil {
return attachResult{}, ref
}
+ // Plans 3a: a first route starts a running thing, so it meets the cap,
+ // above heldElsewhere per the ordering rule.
+ defer s.planLock(acct)()
+ if err := s.checkRunningThings(ctx, acct, 1); err != nil {
+ if isPlanCap(err) {
+ return attachResult{}, &doorRefusal{http.StatusConflict, err.Error()}
+ }
+ return attachResult{}, &doorRefusal{http.StatusBadGateway, "could not create the instance — try again"}
+ }
if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil {
return attachResult{}, ref
}
diff --git a/internal/console/domaincatchall.go b/internal/console/domaincatchall.go
index 578a96ced..e6a97a7c9 100644
--- a/internal/console/domaincatchall.go
+++ b/internal/console/domaincatchall.go
@@ -92,6 +92,10 @@ func (s *Server) attachInstanceCatchall(ctx context.Context, acct *Account, app,
if s.catchallParentRefused(parent, acct.Sqid) {
return false, &doorRefusal{http.StatusBadRequest, "that hostname is platform-managed"}
}
+ // Plans 3a: a catch-all is a custom domain too.
+ if ref := s.customDomainRefusal(acct, parent); ref != nil {
+ return false, ref
+ }
created, err := domains.AttachInstanceCatchall(ctx, s.cfg.Releases, parent, acct.Sqid, app,
time.Now().UTC().Format(time.RFC3339), principal)
switch {
diff --git a/internal/console/fleets.go b/internal/console/fleets.go
index dd445ddbc..39310ba62 100644
--- a/internal/console/fleets.go
+++ b/internal/console/fleets.go
@@ -172,6 +172,16 @@ func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) {
"fleet creation limit reached for today — try again tomorrow")
return
}
+ // Plans 3a: fleets need a plan that includes them.
+ if err := s.checkFleets(acct); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: fleet create %s: plan: %v", acct.Sqid, err)
+ jsonError(w, http.StatusBadGateway, "could not check this account's plan. Try again")
+ return
+ }
var req struct {
Name string `json:"name"`
Bucket string `json:"bucket"`
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index 72cc1eb9f..4868954cf 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -252,6 +252,7 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) {
}
// restoreApp (restore.go) is the one restore every door runs: it locks,
// settles, checks the temporary cap and orders the row and marker writes.
+ var planCap *planCapError
var capErr *temporaryCapError
var rf *restoreFailure
switch err := s.restoreApp(r.Context(), acct, app, time.Now()); {
@@ -259,6 +260,9 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, errNotInTrash):
fail("There is nothing called “" + app + "” in this account's trash.")
return
+ case errors.As(err, &planCap):
+ fail(planCap.Browser())
+ return
case errors.As(err, &capErr):
fail(capErr.Error())
return
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go
new file mode 100644
index 000000000..f775fcf54
--- /dev/null
+++ b/internal/console/plancaps.go
@@ -0,0 +1,290 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// Creation caps (plans-caps-packs spec §5.1, §8.1, §8.4; plans 3a). Every
+// check passes when plans are off (EffectiveLimits nil), so a deployment
+// without a catalog sees no change. A refusal is a *planCapError carrying
+// approved copy; JSON doors answer it 409 {"code":"cap"} and browser doors
+// flash it. Creation refusals have no grace: the cap refuses what is new and
+// never touches what already runs.
+
+// planCapError carries the CLI wording (Error) and, where a browser door can
+// meet it, the browser wording (Browser).
+type planCapError struct{ msg, browser string }
+
+func (e *planCapError) Error() string { return e.msg }
+
+// Browser is the sentence-case wording for a browser door.
+func (e *planCapError) Browser() string {
+ if e.browser != "" {
+ return e.browser
+ }
+ return e.msg
+}
+
+// capMessage formats approved copy by its review id (plancaps_copy.go).
+func capMessage(id string, args ...any) string {
+ format, ok := capCopy[id]
+ if !ok {
+ format = id
+ }
+ return fmt.Sprintf(format, args...)
+}
+
+// runningThings is the account's live apps (temporary and restoring
+// included) plus the instance records of those apps. Trashed apps and their
+// instances do not count. One key-only list of the account's prefix.
+func (s *Server) runningThings(ctx context.Context, acct *Account) (int, error) {
+ apps, err := s.store.LiveAppCount(acct.ID)
+ if err != nil {
+ return 0, err
+ }
+ live, err := s.store.AppsIn(acct.ID)
+ if err != nil {
+ return 0, err
+ }
+ restoring, err := s.store.RestoringAppsIn(acct.ID)
+ if err != nil {
+ return 0, err
+ }
+ liveSet := map[string]bool{}
+ for _, a := range append(live, restoring...) {
+ liveSet[a] = true
+ }
+ if s.cfg.Releases == nil {
+ return apps, nil
+ }
+ keys, err := s.cfg.Releases.List(ctx, "apps/"+acct.Sqid+"/")
+ if err != nil {
+ return 0, err
+ }
+ known := map[string]bool{acct.Sqid: true}
+ instances := 0
+ for _, k := range keys {
+ if _, app, _, ok := provision.ParseRecordKey(k, known); ok && liveSet[app] {
+ instances++
+ }
+ }
+ return apps + instances, nil
+}
+
+// checkRunningThings refuses when adding more running things would pass
+// the account's cap.
+func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding int) error {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil {
+ return err
+ }
+ n, err := s.runningThings(ctx, acct)
+ if err != nil {
+ return err
+ }
+ if n+adding > l.RunningThings {
+ return &planCapError{capMessage("cli.cap.running_things", l.RunningThings), capMessage("browser.cap.running_things", l.RunningThings)}
+ }
+ return nil
+}
+
+// checkRestore is checkRunningThings for restoring app, with the restore
+// wording.
+func (s *Server) checkRestore(ctx context.Context, acct *Account, app string) error {
+ // Restoring brings back the app and its retained instance records
+ // (Astra plans-3a finding 1).
+ n, err := s.appInstanceCount(ctx, acct, app)
+ if err != nil {
+ return err
+ }
+ err = s.checkRunningThings(ctx, acct, 1+n)
+ if !isPlanCap(err) {
+ return err
+ }
+ l, _ := s.EffectiveLimits(acct)
+ return &planCapError{capMessage("cli.cap.restore", app, l.RunningThings), capMessage("browser.cap.restore", app, l.RunningThings)}
+}
+
+// checkSidecars refuses a new worker when app already has as many as the
+// plan allows.
+func (s *Server) checkSidecars(ctx context.Context, acct *Account, app string) error {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil {
+ return err
+ }
+ if s.cfg.Releases == nil {
+ return nil
+ }
+ keys, err := s.cfg.Releases.List(ctx, provision.SidecarsPrefix(acct.Sqid, app))
+ if err != nil {
+ return err
+ }
+ n := 0
+ for _, k := range keys {
+ if strings.HasSuffix(k, ".json") {
+ n++
+ }
+ }
+ if n >= l.SidecarsPerApp {
+ return &planCapError{msg: capMessage("cli.cap.sidecars", app, l.SidecarsPerApp)}
+ }
+ return nil
+}
+
+// checkNewFreeAccount refuses a person a further account on a free plan once
+// they own the catalog's allowance of them (spec §8.1; creation only).
+func (s *Server) checkNewFreeAccount(addr string) error {
+ cat := s.cfg.PlanCatalog
+ if cat == nil || cat.FreeAccountsPerPerson <= 0 {
+ return nil
+ }
+ if p, ok := cat.Plans[cat.DefaultPlan]; ok && p.Paid {
+ return nil // new accounts start on a paid plan here: nothing to cap
+ }
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ return err
+ }
+ free := 0
+ for _, a := range accts {
+ if a.Role != RoleOwner {
+ continue
+ }
+ plan := cat.DefaultPlan
+ row, err := s.store.AccountPlan(a.ID)
+ if err != nil {
+ return err
+ }
+ if row != nil {
+ plan = row.Plan
+ }
+ if p, ok := cat.Plans[plan]; !ok || !p.Paid {
+ free++
+ }
+ }
+ if free >= cat.FreeAccountsPerPerson {
+ return &planCapError{capMessage("cli.cap.free_account"), capMessage("browser.cap.free_account")}
+ }
+ return nil
+}
+
+// checkFleets refuses a fleet on a plan without fleets.
+func (s *Server) checkFleets(acct *Account) error {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil || l.Fleets {
+ return err
+ }
+ return &planCapError{msg: capMessage("cli.cap.fleets")}
+}
+
+// checkCustomDomains refuses a custom domain on a plan without them.
+// Platform hostnames are not custom domains; callers check those first.
+func (s *Server) checkCustomDomains(acct *Account) error {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil || l.CustomDomains {
+ return err
+ }
+ return &planCapError{capMessage("cli.cap.custom_domain"), capMessage("browser.cap.custom_domain")}
+}
+
+func isPlanCap(err error) bool {
+ var c *planCapError
+ return errors.As(err, &c)
+}
+
+// planLock takes the account lock around a capped door's count and write,
+// so two concurrent creates cannot both pass. With plans off it takes
+// nothing, leaving those deployments exactly as they were. Never call it
+// from a door that already holds lockAccount (/new-temporary, restore).
+func (s *Server) planLock(acct *Account) func() {
+ if s.cfg.PlanCatalog == nil {
+ return func() {}
+ }
+ return s.lockAccount(acct.Sqid)
+}
+
+// customDomain reports whether host is a customer's own domain, the kind a
+// free plan may not attach: an open host outside the deployment's apps
+// domain. The account's own <sqid>.<apps-domain> addresses and the
+// operator's platform zones are not custom domains.
+func (s *Server) customDomain(host, account string) bool {
+ if s.domainHostPolicy(host, account) != hostOpen {
+ return false
+ }
+ if domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); domain != "" && strings.HasSuffix(host, "."+domain) {
+ return false
+ }
+ return true
+}
+
+// customDomainRefusal is checkCustomDomains as a door refusal, for the
+// shared attach paths; nil when the attach may go ahead.
+func (s *Server) customDomainRefusal(acct *Account, host string) *doorRefusal {
+ if !s.customDomain(host, acct.Sqid) {
+ return nil
+ }
+ if err := s.checkCustomDomains(acct); err != nil {
+ if isPlanCap(err) {
+ return &doorRefusal{http.StatusConflict, err.Error()}
+ }
+ return &doorRefusal{http.StatusBadGateway, "could not check this account's plan. Try again"}
+ }
+ return nil
+}
+
+// capOrError is a door refusal as a JSON body, with "code":"cap" when it is
+// a plan cap (the only 409 customDomainRefusal returns).
+func capOrError(ref *doorRefusal) map[string]any {
+ body := map[string]any{"error": ref.Msg}
+ if ref.Status == http.StatusConflict {
+ body["code"] = "cap"
+ }
+ return body
+}
+
+// checkCreateApp is the running-things check for creating (or adopting) app.
+func (s *Server) checkCreateApp(ctx context.Context, acct *Account, app string) error {
+ // Adopting an app whose objects already exist brings its instance records
+ // with it (Astra plans-3a finding 3); a brand-new name has none.
+ n, err := s.appInstanceCount(ctx, acct, app)
+ if err != nil {
+ return err
+ }
+ return s.checkRunningThings(ctx, acct, 1+n)
+}
+
+// appInstanceCount counts app's instance records in the bucket, whether or
+// not the app has a live console row.
+func (s *Server) appInstanceCount(ctx context.Context, acct *Account, app string) (int, error) {
+ if s.cfg.Releases == nil {
+ return 0, nil
+ }
+ keys, err := s.cfg.Releases.List(ctx, provision.RecordsPrefix(acct.Sqid, app))
+ if err != nil {
+ return 0, err
+ }
+ known := map[string]bool{acct.Sqid: true}
+ n := 0
+ for _, k := range keys {
+ if _, a, _, ok := provision.ParseRecordKey(k, known); ok && a == app {
+ n++
+ }
+ }
+ return n, nil
+}
+
+// planPersonLock serialises a person's account creates when plans are on,
+// so concurrent requests cannot each see room for one more free account
+// (Astra plans-3a finding 2). The key cannot collide with an account sqid.
+func (s *Server) planPersonLock(addr string) func() {
+ if s.cfg.PlanCatalog == nil {
+ return func() {}
+ }
+ return s.acctLocks.lock("person:" + addr)
+}
diff --git a/internal/console/plancaps_copy.go b/internal/console/plancaps_copy.go
new file mode 100644
index 000000000..6c43a14ef
--- /dev/null
+++ b/internal/console/plancaps_copy.go
@@ -0,0 +1,18 @@
+// Code generated from the approved copy review of 2026-10-22 (plans 3a).
+// Apply later edits through copy review, not by hand.
+
+package console
+
+// capCopy holds the creation-cap refusals by copy-review id.
+var capCopy = map[string]string{
+ "browser.cap.running_things": "This account is running %d apps and instances, the most its plan allows. Delete one to make room, or move to a plan that allows more.",
+ "browser.cap.restore": "Restoring %s would take this account past the %d apps and instances its plan allows. Delete one first, or move to a plan that allows more.",
+ "browser.cap.free_account": "You already own a free account, and each person can own one. Use that account, or ask an operator for another.",
+ "browser.cap.custom_domain": "Custom domains need a paid plan. On this plan the app keeps its platform address.",
+ "cli.cap.running_things": "this account is running %d apps and instances, the most its plan allows. delete one to make room, or move to a plan that allows more",
+ "cli.cap.restore": "restoring %s would take this account past the %d apps and instances its plan allows. delete one first, or move to a plan that allows more",
+ "cli.cap.sidecars": "%s already has %d workers, the most its plan allows. remove one first, or move to a plan that allows more",
+ "cli.cap.free_account": "you already own a free account, and each person can own one. use that account, or ask an operator for another",
+ "cli.cap.fleets": "this account's plan does not include its own boxes (fleets). move to a plan that does to add one",
+ "cli.cap.custom_domain": "custom domains need a paid plan. on this plan the app keeps its platform address",
+}
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go
new file mode 100644
index 000000000..78345a212
--- /dev/null
+++ b/internal/console/plancaps_doors_test.go
@@ -0,0 +1,374 @@
+package console
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// atRunningCap turns plans on and leaves the fixture's ops account with
+// exactly capN running things against a cap of capN (capN >= the free
+// plan's 3).
+func atRunningCap(t *testing.T, fx *consoleFixture, capN int) *Account {
+ t.Helper()
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("ops account: %v", err)
+ }
+ for i := 0; ; i++ {
+ n, err := fx.srv.runningThings(context.Background(), acct)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n > capN {
+ capN = n
+ }
+ if n == capN {
+ break
+ }
+ capsApp(t, fx, acct, fmt.Sprintf("filler%02d", i))
+ }
+ if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: capN}, "test", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ return acct
+}
+
+func raiseRunningCap(t *testing.T, fx *consoleFixture, acct *Account, by int) {
+ t.Helper()
+ n, err := fx.srv.runningThings(context.Background(), acct)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: n + by}, "test", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCreateAppRefusedAtTheRunningThingsCap(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct := atRunningCap(t, fx, 3)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-create")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "onemore"}, token)
+ if status != http.StatusConflict || out["code"] != "cap" || out["error"] == nil {
+ t.Fatalf("CLI create at the cap = %d %v, want 409 cap", status, out)
+ }
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-cap-create-web")
+ if st, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"webmore"}}); st != http.StatusSeeOther {
+ t.Fatalf("browser create at the cap = %d, want the flash redirect", st)
+ }
+ for _, app := range []string{"onemore", "webmore"} {
+ if has, _ := fx.st.HasAppIn(acct.ID, app); has {
+ t.Fatalf("%s was created past the cap", app)
+ }
+ }
+}
+
+func TestCreateAppBelowTheCapStillWorks(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct := atRunningCap(t, fx, 3)
+ raiseRunningCap(t, fx, acct, 1)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-below")
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "fits"}, token); status != http.StatusOK {
+ t.Fatalf("create below the cap = %d %v", status, out)
+ }
+}
+
+func TestTemporaryAppCountsTowardRunningThings(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.TemporaryApps = true
+ atRunningCap(t, fx, 3)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-temp")
+ status, out := createTemporary(t, fx, token, "tmp-capcheck", 3600)
+ if status != http.StatusConflict || out["code"] != "cap" {
+ t.Fatalf("temporary create at the running cap = %d %v, want 409 cap", status, out)
+ }
+}
+
+func TestRestoreRefusedAtTheRunningThingsCap(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-restore")
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token); status != http.StatusOK {
+ t.Fatalf("delete kass = %d %v", status, out)
+ }
+ atRunningCap(t, fx, 3)
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/restore", map[string]string{}, token)
+ if status != http.StatusConflict || out["code"] != "cap" {
+ t.Fatalf("CLI restore at the cap = %d %v, want 409 cap", status, out)
+ }
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-cap-restore-web")
+ if st, _, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}}); st != http.StatusSeeOther {
+ t.Fatalf("browser restore at the cap = %d, want the flash redirect", st)
+ }
+ if markerOf(t, fx, "kass") == nil {
+ t.Fatal("a refused restore removed the trash marker")
+ }
+}
+
+func TestRunningThingsCapIsExactUnderConcurrentCreates(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct := atRunningCap(t, fx, 3)
+ raiseRunningCap(t, fx, acct, 1)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-race")
+ var mu sync.Mutex
+ created := 0
+ var wg sync.WaitGroup
+ for i := range 8 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ status, _ := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": fmt.Sprintf("race%d", i)}, token)
+ if status == http.StatusOK {
+ mu.Lock()
+ created++
+ mu.Unlock()
+ }
+ }()
+ }
+ wg.Wait()
+ if created != 1 {
+ t.Fatalf("%d creates succeeded for one free slot, want exactly 1", created)
+ }
+}
+
+func TestCreateAppWithoutACatalogUnchanged(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct, _ := fx.st.AccountBySqid(fx.sqid)
+ for i := range 5 {
+ capsApp(t, fx, acct, fmt.Sprintf("many%d", i))
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-nocat")
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "stillfine"}, token); status != http.StatusOK {
+ t.Fatalf("create with no catalog = %d %v", status, out)
+ }
+}
+
+// sponsorCustomDomains keeps the cap as atRunningCap left it and adds custom
+// domains, so a first-route attach meets the running-things cap rather than
+// the custom-domain gate.
+func sponsorCustomDomains(t *testing.T, fx *consoleFixture, acct *Account) {
+ t.Helper()
+ row, err := fx.st.AccountPlan(acct.ID)
+ if err != nil || row == nil || row.Sponsored == nil {
+ t.Fatalf("plan row: %+v, %v", row, err)
+ }
+ sp := *row.Sponsored
+ sp.CustomDomains = true
+ if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &sp, "test", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestInstanceCreateRefusedAtTheRunningThingsCap(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-instance")
+ enableInstances(t, fx, "titogo", titoManifest())
+ atRunningCap(t, fx, 3)
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) {
+ t.Fatalf("instance create at the cap = %d %s, want 409 cap", status, out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io"); err == nil {
+ t.Fatal("an instance record was written past the cap")
+ }
+}
+
+func TestFirstRouteAttachRefusedAtTheRunningThingsCap(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-firstroute")
+ shipVersion(t, fx, "titogo", "v1")
+ seedChannel(t, fx, "titogo", "canary/live", "v1")
+ acct := atRunningCap(t, fx, 3)
+ sponsorCustomDomains(t, fx, acct)
+ status, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), token, `{"host":"shop.example.com","kind":"static"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("first-route attach at the cap = %d %s, want 409", status, out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "shop.example.com"); err == nil {
+ t.Fatal("a first-route record was written past the cap")
+ }
+}
+
+func TestSidecarCreateRefusedAtTheSidecarCap(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-sidecar")
+ enableSidecars(t, fx, "titogo", titoSidecarManifest())
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct, _ := fx.st.AccountBySqid("bab")
+ capsSidecar(t, fx, acct, "titogo", "first")
+ status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`)
+ if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) {
+ t.Fatalf("second sidecar on free = %d %s, want 409 cap", status, out)
+ }
+ if _, _, err := fx.rel.Get(context.Background(), provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil {
+ t.Fatal("a sidecar record was written past the cap")
+ }
+}
+
+func TestInstanceAndSidecarCreateWithoutACatalogUnchanged(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-inst-nocat")
+ enableInstances(t, fx, "titogo", titoManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("instance create with no catalog = %d %s", status, out)
+ }
+ enableSidecars(t, fx, "titogo", titoSidecarManifest())
+ acct, _ := fx.st.AccountBySqid("bab")
+ capsSidecar(t, fx, acct, "titogo", "first")
+ if status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("sidecar create with no catalog = %d %s", status, out)
+ }
+}
+
+func accountCount(t *testing.T, fx *consoleFixture) int {
+ t.Helper()
+ all, err := fx.st.Accounts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return len(all)
+}
+
+func TestNewAccountRefusedForASecondFreeAccount(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ before := accountCount(t, fx)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct")
+ status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "second"}, token)
+ if status != http.StatusConflict || out["code"] != "cap" {
+ t.Fatalf("CLI second free account = %d %v, want 409 cap", status, out)
+ }
+ c := signInPaul(t, fx)
+ st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {sessionToken(t, fx, c)}, "name": {"thirdtry"}})
+ if st != http.StatusSeeOther || loc != "/accounts/new" {
+ t.Fatalf("browser second free account = %d -> %q, want the flash redirect back to /accounts/new", st, loc)
+ }
+ if after := accountCount(t, fx); after != before {
+ t.Fatalf("accounts %d -> %d: one was minted past the cap", before, after)
+ }
+}
+
+func TestFirstAccountIsNeverRefused(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ signInAs(t, fx, "newcomer@keymail.dev", "code-cap-first")
+ accts, err := fx.st.AccountsFor("newcomer@keymail.dev")
+ if err != nil || len(accts) != 1 {
+ t.Fatalf("first sign-in with plans on: %d accounts, %v; want the auto-minted one", len(accts), err)
+ }
+}
+
+func TestNewAccountAllowedWhenTheOtherIsPaid(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ ops, _ := fx.st.AccountBySqid(fx.sqid)
+ if _, err := fx.st.SetAccountPlan(ops.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-paid")
+ if status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "second"}, token); status != http.StatusOK {
+ t.Fatalf("a first free account beside a paid one = %d %v", status, out)
+ }
+}
+
+func TestNewAccountWithoutACatalogUnchanged(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-nocat")
+ for _, name := range []string{"one", "two"} {
+ if status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": name}, token); status != http.StatusOK {
+ t.Fatalf("account %s with no catalog = %d %v", name, status, out)
+ }
+ }
+}
+
+func TestFleetCreateRefusedOnAPlanWithoutFleets(t *testing.T) {
+ fx, token := fleetFixture(t, "code-cap-fleet")
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ status, out := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token)
+ if status != http.StatusConflict || out["code"] != "cap" {
+ t.Fatalf("fleet create on free = %d %v, want 409 cap", status, out)
+ }
+}
+
+func TestCustomDomainRefusedOnAFreePlan(t *testing.T) {
+ fx, member := instanceFixture(t, "code-cap-domain")
+ shipVersion(t, fx, "titogo", "v1")
+ seedChannel(t, fx, "titogo", "canary/live", "v1")
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ for name, body := range map[string]string{
+ "alias": `{"host":"tickets.example.com","route":"go.tito.io"}`,
+ "first route": `{"host":"fresh.example.com","kind":"static"}`,
+ } {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
+ if code != http.StatusConflict {
+ t.Errorf("%s on free = %d %s, want 409", name, code, out)
+ }
+ }
+ claimAbsent(t, fx, "tickets.example.com")
+ claimAbsent(t, fx, "fresh.example.com")
+}
+
+func TestFleetsAndDomainsWithoutACatalogUnchanged(t *testing.T) {
+ fx, member := instanceFixture(t, "code-cap-domain-nocat")
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, `{"host":"tickets.example.com","route":"go.tito.io"}`); code != http.StatusOK {
+ t.Fatalf("alias attach with no catalog = %d %s", code, out)
+ }
+ fx2, token := fleetFixture(t, "code-cap-fleet-nocat")
+ if status, out := postJSON(t, fleetsURL(fx2, fx2.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token); status != http.StatusOK {
+ t.Fatalf("fleet create with no catalog = %d %v", status, out)
+ }
+}
+
+// Review Focus 5: an address under the deployment's apps domain is the
+// account's own platform address, not a custom domain, so free plans keep it.
+func TestPlatformHostnamesStayFreeOnFreePlans(t *testing.T) {
+ fx, member := instanceFixture(t, "code-cap-platform")
+ fx.srv.cfg.AppsDomain = "apps.example.com"
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct, _ := fx.st.AccountBySqid("bab")
+ if fx.srv.customDomain("shop.bab.apps.example.com", acct.Sqid) {
+ t.Fatal("an address under the apps domain was treated as a custom domain")
+ }
+ if !fx.srv.customDomain("shop.example.com", acct.Sqid) {
+ t.Fatal("a customer domain was not treated as a custom domain")
+ }
+ if ref := fx.srv.customDomainRefusal(acct, "shop.bab.apps.example.com"); ref != nil {
+ t.Fatalf("free plan refused its own platform address: %+v", ref)
+ }
+ _ = member
+}
+
+// Astra plans-3a finding 2: concurrent creates by one person who owns only
+// paid accounts must not mint more free accounts than the allowance.
+func TestFreeAccountCapIsExactUnderConcurrentCreates(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ ops, _ := fx.st.AccountBySqid(fx.sqid)
+ if _, err := fx.st.SetAccountPlan(ops.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-race")
+ var mu sync.Mutex
+ created := 0
+ var wg sync.WaitGroup
+ for i := range 6 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": fmt.Sprintf("race%d", i)}, token)
+ if status == http.StatusOK {
+ mu.Lock()
+ created++
+ mu.Unlock()
+ }
+ }()
+ }
+ wg.Wait()
+ if created != 1 {
+ t.Fatalf("%d free accounts minted concurrently, want exactly 1", created)
+ }
+}
diff --git a/internal/console/plancaps_test.go b/internal/console/plancaps_test.go
new file mode 100644
index 000000000..57f123012
--- /dev/null
+++ b/internal/console/plancaps_test.go
@@ -0,0 +1,225 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func capsApp(t *testing.T, fx *consoleFixture, acct *Account, app string) {
+ t.Helper()
+ if _, err := fx.st.CreateAppIn(acct.ID, app, "owner@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func capsInstance(t *testing.T, fx *consoleFixture, acct *Account, app, host string) {
+ t.Helper()
+ if _, err := fx.rel.Put(context.Background(), provision.RecordKey(acct.Sqid, app, host), []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func capsSidecar(t *testing.T, fx *consoleFixture, acct *Account, app, name string) {
+ t.Helper()
+ if _, err := fx.rel.Put(context.Background(), provision.SidecarRecordKey(acct.Sqid, app, name), []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func isCap(err error) bool {
+ var c *planCapError
+ return errors.As(err, &c)
+}
+
+func TestPlanChecksPassWithoutACatalog(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct := planAccount(t, fx, 501)
+ for i := range 5 {
+ capsApp(t, fx, acct, "app"+string(rune('a'+i)))
+ }
+ capsSidecar(t, fx, acct, "appa", "w1")
+ capsSidecar(t, fx, acct, "appa", "w2")
+ ctx := context.Background()
+ for name, err := range map[string]error{
+ "running things": fx.srv.checkRunningThings(ctx, acct, 1),
+ "sidecars": fx.srv.checkSidecars(ctx, acct, "appa"),
+ "free account": fx.srv.checkNewFreeAccount("owner" + acct.Sqid + "@example.com"),
+ "fleets": fx.srv.checkFleets(acct),
+ "custom domains": fx.srv.checkCustomDomains(acct),
+ } {
+ if err != nil {
+ t.Errorf("%s with no catalog: %v", name, err)
+ }
+ }
+}
+
+func TestLiveAppCountCountsRestoringNotTrashed(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct := planAccount(t, fx, 501)
+ for _, app := range []string{"live", "gone", "coming-back"} {
+ capsApp(t, fx, acct, app)
+ }
+ if _, err := fx.st.db.Exec(`UPDATE apps SET deleted_at = ? WHERE account_id = ? AND app IN ('gone', 'coming-back')`, planNow.Unix(), acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.db.Exec(`UPDATE apps SET restoring = 1 WHERE account_id = ? AND app = 'coming-back'`, acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := fx.st.LiveAppCount(acct.ID); err != nil || n != 2 {
+ t.Fatalf("LiveAppCount = %d, %v; want 2 (live + restoring)", n, err)
+ }
+}
+
+func TestRunningThingsCountsAppsAndInstances(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct := planAccount(t, fx, 501)
+ capsApp(t, fx, acct, "shop")
+ capsApp(t, fx, acct, "blog")
+ capsApp(t, fx, acct, "old")
+ capsInstance(t, fx, acct, "shop", "a.shop.example.com")
+ capsInstance(t, fx, acct, "shop", "b.shop.example.com")
+ capsInstance(t, fx, acct, "blog", "c.blog.example.com")
+ capsInstance(t, fx, acct, "old", "d.old.example.com")
+ if err := fx.st.TrashAppRow(acct.ID, "old", planNow); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := fx.srv.runningThings(context.Background(), acct); err != nil || n != 5 {
+ t.Fatalf("runningThings = %d, %v; want 5 (2 apps + 3 instances; the trashed app and its instance do not count)", n, err)
+ }
+}
+
+func TestRunningThingsCapAtAndBelow(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ capsApp(t, fx, acct, "one")
+ capsApp(t, fx, acct, "two")
+ if err := fx.srv.checkRunningThings(context.Background(), acct, 1); err != nil {
+ t.Fatalf("at 2 of 3: %v", err)
+ }
+ capsApp(t, fx, acct, "three")
+ if err := fx.srv.checkRunningThings(context.Background(), acct, 1); !isCap(err) {
+ t.Fatalf("at 3 of 3: %v, want a plan cap", err)
+ }
+}
+
+func TestRunningThingsCapUsesEffectiveLimits(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: 300}, "existing account", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ for i := range 4 {
+ capsApp(t, fx, acct, "app"+string(rune('a'+i)))
+ }
+ if err := fx.srv.checkRunningThings(context.Background(), acct, 1); err != nil {
+ t.Fatalf("sponsored 300, running 4: %v", err)
+ }
+}
+
+func TestSidecarCap(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ capsApp(t, fx, acct, "shop")
+ if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); err != nil {
+ t.Fatalf("free, no sidecars: %v", err)
+ }
+ capsSidecar(t, fx, acct, "shop", "w1")
+ if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); !isCap(err) {
+ t.Fatalf("free, one sidecar: %v, want a plan cap", err)
+ }
+ if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ for _, n := range []string{"w2", "w3", "w4"} {
+ capsSidecar(t, fx, acct, "shop", n)
+ }
+ if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); err != nil {
+ t.Fatalf("personal, four sidecars: %v", err)
+ }
+ capsSidecar(t, fx, acct, "shop", "w5")
+ if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); !isCap(err) {
+ t.Fatalf("personal, five sidecars: %v, want a plan cap", err)
+ }
+}
+
+func TestNewFreeAccountCap(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ owner := "owner" + acct.Sqid + "@example.com"
+ if err := fx.srv.checkNewFreeAccount(owner); !isCap(err) {
+ t.Fatalf("owner of one free account: %v, want a plan cap", err)
+ }
+ if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.srv.checkNewFreeAccount(owner); err != nil {
+ t.Fatalf("owner of one paid account: %v", err)
+ }
+ member := "member@example.com"
+ other := planAccount(t, fx, 502)
+ if err := fx.st.AddMember(other.ID, member, RoleMember, planNow); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.srv.checkNewFreeAccount(member); err != nil {
+ t.Fatalf("member, not owner, of a free account: %v", err)
+ }
+}
+
+func TestFleetsAndCustomDomainsFollowThePlan(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ if !isCap(fx.srv.checkFleets(acct)) || !isCap(fx.srv.checkCustomDomains(acct)) {
+ t.Fatal("free plan allowed fleets or custom domains")
+ }
+ if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ if fx.srv.checkFleets(acct) != nil || fx.srv.checkCustomDomains(acct) != nil {
+ t.Fatal("personal plan refused fleets or custom domains")
+ }
+}
+
+// Astra plans-3a finding 1: restoring an app brings back its retained
+// instance records too, so the check must count the whole footprint.
+func TestRestoreCountsTheAppsInstances(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ capsApp(t, fx, acct, "live")
+ capsApp(t, fx, acct, "old")
+ capsInstance(t, fx, acct, "old", "a.old.example.com")
+ capsInstance(t, fx, acct, "old", "b.old.example.com")
+ if err := fx.st.TrashAppRow(acct.ID, "old", planNow); err != nil {
+ t.Fatal(err)
+ }
+ // One running thing of three: restoring "old" would make four.
+ if err := fx.srv.checkRestore(context.Background(), acct, "old"); !isCap(err) {
+ t.Fatalf("restore bringing back an app and two instances at 1 of 3: %v, want a plan cap", err)
+ }
+}
+
+// Astra plans-3a finding 3: adopting an app whose objects already exist in
+// the bucket brings its instance records with it.
+func TestCreateCountsAnAdoptedAppsInstances(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ capsApp(t, fx, acct, "live")
+ capsInstance(t, fx, acct, "legacy", "a.legacy.example.com")
+ capsInstance(t, fx, acct, "legacy", "b.legacy.example.com")
+ if err := fx.srv.checkCreateApp(context.Background(), acct, "legacy"); !isCap(err) {
+ t.Fatalf("adopting an app with two instances at 1 of 3: %v, want a plan cap", err)
+ }
+ if err := fx.srv.checkCreateApp(context.Background(), acct, "fresh"); err != nil {
+ t.Fatalf("a new app at 1 of 3: %v", err)
+ }
+}
diff --git a/internal/console/restore.go b/internal/console/restore.go
index 5887d32d5..325e225ed 100644
--- a/internal/console/restore.go
+++ b/internal/console/restore.go
@@ -83,6 +83,13 @@ func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now
if life == nil || !life.Trashed() {
return errNotInTrash
}
+ // Plans 3a: restoring brings a running thing back, so it meets the cap.
+ if err := s.checkRestore(ctx, acct, app); err != nil {
+ if isPlanCap(err) {
+ return err
+ }
+ return &restoreFailure{Marker: true, Err: err}
+ }
if life.Temporary() {
if err := s.temporaryCapCheck(acct.ID); err != nil {
var capErr *temporaryCapError
diff --git a/internal/console/server.go b/internal/console/server.go
index 49f65a74b..e7c940087 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -2523,6 +2523,20 @@ func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/accounts/new", http.StatusSeeOther)
return
}
+ // Plans 3a: one free account per person, checked at creation only (spec
+ // §8.1). A person's first account, minted on landing, is never refused.
+ defer s.planPersonLock(addr)()
+ if err := s.checkNewFreeAccount(addr); err != nil {
+ var planCap *planCapError
+ if errors.As(err, &planCap) {
+ s.setFlash(w, "err", planCap.Browser())
+ http.Redirect(w, r, "/accounts/new", http.StatusSeeOther)
+ return
+ }
+ log.Printf("console: create account for %s: plan cap: %v", addr, err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create the account. Please try again.")
+ return
+ }
a, err := s.mintAccount(r.Context(), name, addr)
if err != nil {
log.Printf("console: create account for %s: %v", addr, err)
@@ -4402,6 +4416,17 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
// The browser door never places at create time, so the second return
// (what actually stuck) is always "" here and there is nothing to echo
// it to — this form has no placement concept at all.
+ defer s.planLock(acct)()
+ if err := s.checkCreateApp(r.Context(), acct, name); err != nil {
+ var planCap *planCapError
+ if errors.As(err, &planCap) {
+ fail(planCap.Browser())
+ return
+ }
+ log.Printf("console: create %s/%s: plan cap: %v", acct.Sqid, name, err)
+ fail("Could not create the app. Please try again.")
+ return
+ }
outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq)
switch outcome {
case claimInvalid:
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index c826ea1eb..ba87f3e80 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -1382,6 +1382,19 @@ func TestPlan14RootReadCensus(t *testing.T) {
t.Fatal(err)
}
seal = "bd4cc1909225cd574c2c609f0cd02177076ddb5c1476d9cf5e178be05acccae1"
+ // Census overlay plans-3a-creation-caps (the console refuses creates past
+ // an account's plan limits), loaded last, after db-region-forward-2.
+ // PROPOSED-plans-3a-creation-caps, asked of the census owner on #280. Two
+ // new console store counts, handleAPICreateApp re-frozen, the console
+ // envelope re-pinned. Frozen declarations are added, so the seal moves;
+ // db-region-forward-2'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-plans-3a-creation-caps.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ seal = "dc7dbb4f562fe31556e7ed1e09fcfb380f551b45a444e94e72662f0b202c605b"
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 b714ac32f..389ad2e5c 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 := dbRegionForward2WriteCensusInventory()
+ inventory := plans3aWriteCensusInventory()
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/plans3a_census_test.go b/internal/provision/plans3a_census_test.go
new file mode 100644
index 000000000..ba8736a84
--- /dev/null
+++ b/internal/provision/plans3a_census_test.go
@@ -0,0 +1,36 @@
+package provision
+
+// Record-write census entries for plans-3a-creation-caps, layered on
+// dbRegionForward2WriteCensusInventory. Reviewed by the branch author; the census
+// owner rules on #280 (PROPOSED-plans-3a-creation-caps).
+// - The instance, sidecar and both domain-attach doors gain a plan-cap
+// check under the account lock before their existing writes; their
+// write sites and classes are unchanged.
+// - Server.runningThings, Server.checkSidecars and Server.appInstanceCount
+// only list and parse record keys to count an account's running things
+// and an app's workers; they write nothing: key-site.
+func plans3aWriteCensusInventory() []censusEntry {
+ entries := append([]censusEntry(nil), dbRegionForward2WriteCensusInventory()...)
+ replacements := []censusEntry{
+ {"internal/console/apidomains.go", "Server.handleAPIDomainAttach", "key-site", "lit:instances", "27aa0452556355854cf2a211bd21c33092f241f450d8263d3058cd6c424bcfd9"},
+ {"internal/console/apiinstances.go", "Server.handleAPIInstanceCreate", "record-writer", "key:CreateRecord", "e3ecf19f43f8ebf7cd117c6f78393e35bf8fe35571df50e3df8b7d120d393928"},
+ {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "bbcb85365658ed1ba2bb090a1a3d4f02123ecd8d7d6d04a6bbbf433e94f965ed"},
+ {"internal/console/domainattach.go", "Server.attachDomainAs", "record-writer", "key:CreateRecord", "dcb7c4fc09a181d27f6a549965273b27b24ac8180aa38abc1b250923fc3ccb3b"},
+ {"internal/console/plancaps.go", "Server.checkSidecars", "key-site", "key:SidecarsPrefix", "01c9ce701fd8da1d2d41a42eb048c0f2dcfe6654ee245a0a43d8944294d46a62"},
+ {"internal/console/plancaps.go", "Server.runningThings", "key-site", "key:ParseRecordKey", "6668bb47e5364f4064abd0c4e92f06901fb5803e13e5b3ccb71e169d4afde1f6"},
+ {"internal/console/plancaps.go", "Server.appInstanceCount", "key-site", "key:ParseRecordKey,key:RecordsPrefix", "e49e0b3ca9ee49240fd88840997fb93ea39aaa7c8e51d6ae634099f5e47595e7"},
+ }
+ 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-plans-3a-creation-caps.tsv b/internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv
new file mode 100644
index 000000000..1f7114528
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv
@@ -0,0 +1,9 @@
+# Census overlay plans-3a-creation-caps (the console refuses creates past an account's plan limits), loaded LAST, after db-region-forward-2. PROPOSED-plans-3a-creation-caps, asked of the census owner on #280.
+# Every unit runs in the console. New: Store.LiveAppCount and Store.RestoringAppsIn count the account's own apps rows (accepted legacy at their store siblings' J4 root-input disposition). Changed: Server.handleAPICreateApp gains the plan-cap check under the account lock and is re-frozen at its main disposition. The internal/console envelope is re-pinned. Frozen declarations are added, so the seal moves.
+@legacy 287d2aad49adf3fe22a00bb5f7ed077ee36c027a9fabe5f3dfdfb4130b56aa53 internal/console/apicli.go Server.handleAPICreateApp legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Account@3000,whole:Fleet@2988 7f12ab30b790991160ba2127958e4a45eac4fd14f93830680002291f47976c95
+@legacy 2cfd2d66db5f0e7b8f96b45de4d8645016c84d1c93c98e825ae6363a450937dc internal/console/accountplans.go Store.LiveAppCount legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-3a-creation-caps;basis=root-input; console SQL counting the account's own apps rows for the creation caps, as its store siblings (J4). file:QueryRow@80 4358defda4e98b29886cb8218d135abd30c4dbeb15b0b33ee183afeb7c08af05
+@legacy 933adb95b942d26c8b17fd2c218fa307674e4ba0c84bdbc7e281ae8c48c37638 internal/console/accountplans.go Store.RestoringAppsIn legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-3a-creation-caps;basis=root-input; console SQL counting the account's own apps rows for the creation caps, as its store siblings (J4). file:Query@83 ed8bb63453ac4e36d685659f77c87c6fa8c880ae113f37f1378b84acddec5af0
+internal/console/accountplans.go Store.LiveAppCount legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-3a-creation-caps;basis=root-input; console SQL counting the account's own apps rows for the creation caps, as its store siblings (J4). file:QueryRow@80 4358defda4e98b29886cb8218d135abd30c4dbeb15b0b33ee183afeb7c08af05
+internal/console/accountplans.go Store.RestoringAppsIn legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-3a-creation-caps;basis=root-input; console SQL counting the account's own apps rows for the creation caps, as its store siblings (J4). file:Query@83 ed8bb63453ac4e36d685659f77c87c6fa8c880ae113f37f1378b84acddec5af0
+internal/console/apicli.go Server.handleAPICreateApp legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Account@3000,whole:Fleet@2988 7f12ab30b790991160ba2127958e4a45eac4fd14f93830680002291f47976c95
+@coverage internal/console 393989f1998a6d9ed7699ad1fb04280c22016c8a02a802d9582e04960806fa24