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>
10 files changed,
+149
−10
docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md+22 −0internal/console/apicli.go+1 −1internal/console/authcli.go+1 −0internal/console/plancaps.go+48 −1internal/console/plancaps_doors_test.go+31 −0internal/console/plancaps_test.go+36 −0internal/console/server.go+2 −1internal/provision/plan14_root_reads_test.go+1 −1internal/provision/plans3a_census_test.go+4 −3internal/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 |