carlos / platform Public

plans 3a: fix Astra's whole-branch findings

- restore counts the app and its retained instance records
  (TestRestoreCountsTheAppsInstances)
- adopting an app counts its existing instance records
  (TestCreateCountsAnAdoptedAppsInstances)
- a person's account creates are serialised when plans are on
  (TestFreeAccountCapIsExactUnderConcurrentCreates)
- census overlay and record-write layer re-pinned

Findings committed unedited as CODEX-FINDINGS-PLANS-3A-CODE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2553baee5cb8752e5a4d404c23e3ffd563699719 parent 4681e4c
10 files changed, +149 −10
  • docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md +22 −0
  • internal/console/apicli.go +1 −1
  • internal/console/authcli.go +1 −0
  • internal/console/plancaps.go +48 −1
  • internal/console/plancaps_doors_test.go +31 −0
  • internal/console/plancaps_test.go +36 −0
  • internal/console/server.go +2 −1
  • internal/provision/plan14_root_reads_test.go +1 −1
  • internal/provision/plans3a_census_test.go +4 −3
  • internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv +3 −3

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (2 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 (23 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (176 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	4.867s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.988s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.750s
ok  	github.com/carlosframework/platform/internal/activator	60.467s
ok  	github.com/carlosframework/platform/internal/agentsock	0.036s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.031s
ok  	github.com/carlosframework/platform/internal/appconfig	3.464s
ok  	github.com/carlosframework/platform/internal/apperr	0.171s
ok  	github.com/carlosframework/platform/internal/applog	0.299s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.011s
ok  	github.com/carlosframework/platform/internal/boxid	0.919s
?   	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.016s
ok  	github.com/carlosframework/platform/internal/caps	0.019s
ok  	github.com/carlosframework/platform/internal/certwarm	0.008s
ok  	github.com/carlosframework/platform/internal/cgroup	0.800s
ok  	github.com/carlosframework/platform/internal/changelog	0.533s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.213s
ok  	github.com/carlosframework/platform/internal/clamd	0.072s
?   	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.337s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.012s
ok  	github.com/carlosframework/platform/internal/dbquery	0.006s
ok  	github.com/carlosframework/platform/internal/dbreads	2.216s
?   	github.com/carlosframework/platform/internal/dbreads/dbreadstest	[no test files]
ok  	github.com/carlosframework/platform/internal/dbverify	0.006s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.016s
ok  	github.com/carlosframework/platform/internal/domains	3.171s
ok  	github.com/carlosframework/platform/internal/doorkey	0.009s
ok  	github.com/carlosframework/platform/internal/economics	0.009s
ok  	github.com/carlosframework/platform/internal/edge	48.215s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.012s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.301s
ok  	github.com/carlosframework/platform/internal/gate	1.812s
ok  	github.com/carlosframework/platform/internal/genselect	0.031s
ok  	github.com/carlosframework/platform/internal/geo	0.025s
ok  	github.com/carlosframework/platform/internal/geoip	0.113s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.321s
ok  	github.com/carlosframework/platform/internal/host	141.421s
ok  	github.com/carlosframework/platform/internal/hostactors	6.223s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.018s
ok  	github.com/carlosframework/platform/internal/hostbox	2.924s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.781s
ok  	github.com/carlosframework/platform/internal/hostenv	0.016s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.018s
ok  	github.com/carlosframework/platform/internal/hostexec	5.459s
ok  	github.com/carlosframework/platform/internal/hostfence	0.042s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.463s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.029s
ok  	github.com/carlosframework/platform/internal/hostguard	1.062s
ok  	github.com/carlosframework/platform/internal/hostops	2.043s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.016s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.015s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.609s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.236s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.022s
ok  	github.com/carlosframework/platform/internal/infraview	0.018s
ok  	github.com/carlosframework/platform/internal/instancectl	5.246s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.795s
ok  	github.com/carlosframework/platform/internal/instancefs	0.020s
ok  	github.com/carlosframework/platform/internal/instancekey	0.018s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.015s
ok  	github.com/carlosframework/platform/internal/layout	1.746s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.015s
ok  	github.com/carlosframework/platform/internal/lease	10.329s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.576s
ok  	github.com/carlosframework/platform/internal/ledger	0.037s
ok  	github.com/carlosframework/platform/internal/mail	0.193s
ok  	github.com/carlosframework/platform/internal/maintenance	0.098s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.554s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.099s
ok  	github.com/carlosframework/platform/internal/metrics	0.009s
ok  	github.com/carlosframework/platform/internal/moves	24.547s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.033s
ok  	github.com/carlosframework/platform/internal/objstore	0.373s
ok  	github.com/carlosframework/platform/internal/ops	0.311s
ok  	github.com/carlosframework/platform/internal/park	0.339s
ok  	github.com/carlosframework/platform/internal/plans	0.005s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	26.091s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.710s
ok  	github.com/carlosframework/platform/internal/relay	7.230s
ok  	github.com/carlosframework/platform/internal/release	27.705s
ok  	github.com/carlosframework/platform/internal/rollout	3.807s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.010s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.064s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.601s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.005s
ok  	github.com/carlosframework/platform/internal/schedule	2.706s
ok  	github.com/carlosframework/platform/internal/seeds	0.015s
ok  	github.com/carlosframework/platform/internal/sim/engines	2.749s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	12.018s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.210s
ok  	github.com/carlosframework/platform/internal/status	0.073s
ok  	github.com/carlosframework/platform/internal/steering	0.009s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.239s
ok  	github.com/carlosframework/platform/internal/svcrole	0.005s
ok  	github.com/carlosframework/platform/internal/telemetry	0.005s
ok  	github.com/carlosframework/platform/internal/termfence	0.007s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.007s
ok  	github.com/carlosframework/platform/internal/usage	0.006s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.729s
=== internal_console@0 (79 s)
ok  	github.com/carlosframework/platform/internal/console	77.365s
=== internal_console@1 (39 s)
ok  	github.com/carlosframework/platform/internal/console	36.735s
=== internal_console@2 (38 s)
ok  	github.com/carlosframework/platform/internal/console	36.230s
=== internal_console@3 (47 s)
ok  	github.com/carlosframework/platform/internal/console	44.529s
=== internal_console@4 (40 s)
ok  	github.com/carlosframework/platform/internal/console	37.775s
=== internal_console@5 (44 s)
ok  	github.com/carlosframework/platform/internal/console	42.200s
=== internal_console@6 (45 s)
ok  	github.com/carlosframework/platform/internal/console	43.079s
=== internal_console@7 (42 s)
ok  	github.com/carlosframework/platform/internal/console	40.125s
=== root@0 (33 s)
ok  	github.com/carlosframework/platform	31.461s
=== root@1 (45 s)
ok  	github.com/carlosframework/platform	43.017s
=== root@2 (46 s)
ok  	github.com/carlosframework/platform	44.436s
=== 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: 50266274 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: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.024s
=== RUN   TestT4StorageProbeMinio
2026/10/22 21:37:22 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0F7BD086FF956, 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.021s
minio contract: PASS
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/internal/console/apicli.go b/internal/console/apicli.go
index 2fb2aa997..69e5f77f4 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -452,7 +452,7 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
// history is exactly the widening svcrole exists to prevent.
ownerClaim := p.Kind == PrincipalUser && acct.Role == RoleOwner
defer s.planLock(acct)()
- if err := s.checkRunningThings(r.Context(), acct, 1); err != nil {
+ 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
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 79e179505..dce2e358e 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -1775,6 +1775,7 @@ 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"})
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go
index 2b002e036..f775fcf54 100644
--- a/internal/console/plancaps.go
+++ b/internal/console/plancaps.go
@@ -97,7 +97,13 @@ func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding i
// checkRestore is checkRunningThings for restoring app, with the restore
// wording.
func (s *Server) checkRestore(ctx context.Context, acct *Account, app string) error {
- err := s.checkRunningThings(ctx, acct, 1)
+ // 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
}
@@ -241,3 +247,44 @@ func capOrError(ref *doorRefusal) map[string]any {
}
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_doors_test.go b/internal/console/plancaps_doors_test.go
index fd7f68bf4..78345a212 100644
--- a/internal/console/plancaps_doors_test.go
+++ b/internal/console/plancaps_doors_test.go
@@ -341,3 +341,34 @@ func TestPlatformHostnamesStayFreeOnFreePlans(t *testing.T) {
}
_ = 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
index ca7b920e5..57f123012 100644
--- a/internal/console/plancaps_test.go
+++ b/internal/console/plancaps_test.go
@@ -187,3 +187,39 @@ func TestFleetsAndCustomDomainsFollowThePlan(t *testing.T) {
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/server.go b/internal/console/server.go
index d6abba42b..342ed3492 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -2501,6 +2501,7 @@ func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) {
}
// 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) {
@@ -4392,7 +4393,7 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
// (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.checkRunningThings(r.Context(), acct, 1); err != nil {
+ if err := s.checkCreateApp(r.Context(), acct, name); err != nil {
var planCap *planCapError
if errors.As(err, &planCap) {
fail(planCap.Browser())
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 71a2cb365..21b896939 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -1354,7 +1354,7 @@ func TestPlan14RootReadCensus(t *testing.T) {
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 = "6cec096415c0a4af7b06977de61b71705ce122304a504af3929c0f5cd1667ab5"
+ seal = "ecb2e18d131446e09d5df36f53c1f6441ab0cf0dd8d4a8b0e51dcaff8a9bdfd4"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plans3a_census_test.go b/internal/provision/plans3a_census_test.go
index 5950f2ac0..aeada69ea 100644
--- a/internal/provision/plans3a_census_test.go
+++ b/internal/provision/plans3a_census_test.go
@@ -6,9 +6,9 @@ package provision
// - 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 and Server.checkSidecars only list and parse
-// record keys to count an account's running things and an app's
-// workers; they write nothing: key-site.
+// - 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), dbRegionForward1WriteCensusInventory()...)
replacements := []censusEntry{
@@ -18,6 +18,7 @@ func plans3aWriteCensusInventory() []censusEntry {
{"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
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
index 28cd74036..cade963e9 100644
--- a/internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv
+++ b/internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv
@@ -1,9 +1,9 @@
# Census overlay plans-3a-creation-caps (the console refuses creates past an account's plan limits), loaded LAST, after db-region-forward-1. 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 6ce6b99907df814b29ef0f1ee4393c690c6d3c7876f7bf3f8a94fbb16bfbab90 internal/console/apicli.go Server.handleAPICreateApp legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Account@3000,whole:Fleet@2988 0d4b969b1a27f8e2ea03933abe79f3aca440934c2f3bfb744c2f36e652ab72dc
@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 0d4b969b1a27f8e2ea03933abe79f3aca440934c2f3bfb744c2f36e652ab72dc
-@coverage internal/console 5914320fc7f1a87a1faf9623523e347d208fa62810eee9b480b0e43aad319d3d
+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 5e6b3f88108a401a17ce82da700e2284e20bfa2930670893c24e5af3041890b5