Plans 3b: never-sleep and schedule refusals on shared boxes (daemon releases at ship and every promote/rollback door, hibernate:false sidecars, schedules under the plan minimum); always-on allowed on the account's own fleet; dormant without CARLOS_PLAN_CATALOG
Squashed from plans-3b-never-sleep into main.
30 files changed,
+1525
−9
docs/superpowers/notes/census-proposal-design-plans-and-caps.md+10 −0docs/superpowers/notes/census-proposal-plans-2-box-limits.md+10 −0docs/superpowers/notes/census-proposal-plans-3a-creation-caps.md+10 −0docs/superpowers/notes/census-proposal-plans-3b-never-sleep.md+27 −0docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md+103 −0docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R2.md+41 −0docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R3.md+29 −0docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R4.md+16 −0docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE.md+25 −0docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R2.md+31 −0docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R3.md+29 −0docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R4.md+22 −0docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE.md+33 −0internal/console/apicli.go+50 −2internal/console/apischedule.go+10 −0internal/console/apisidecars.go+25 −0internal/console/fleets.go+1 −1internal/console/pendinghandlers.go+15 −0internal/console/plancaps.go+229 −1internal/console/plancaps_copy.go+9 −1internal/console/plancaps_doors_test.go+534 −0internal/console/server.go+52 −3internal/provision/plan9_b8_census_test.go+1 −1internal/provision/plans3b_census_test.go+31 −0internal/provision/sidecar.go+34 −0internal/provision/testdata/plan14-root-reads-chain.tsv+2 −0internal/provision/testdata/plan14-root-reads-plans-3b-fleet-owner.tsv+3 −0internal/provision/testdata/plan14-root-reads-plans-3b-never-sleep.tsv+7 −0internal/schedule/mingap.go+93 −0internal/schedule/mingap_test.go+43 −0
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok amadan.net/carlos/platform.git/launchctx 0.012s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (20 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (74 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.942s
ok github.com/carlosframework/platform/cmd/moveprobe 11.895s
ok github.com/carlosframework/platform/cmd/zddprobe 0.750s
ok github.com/carlosframework/platform/internal/activator 58.701s
ok github.com/carlosframework/platform/internal/agentsock 0.040s
ok github.com/carlosframework/platform/internal/alerting 0.010s
ok github.com/carlosframework/platform/internal/analytics 0.042s
ok github.com/carlosframework/platform/internal/appconfig 3.036s
ok github.com/carlosframework/platform/internal/apperr 0.191s
ok github.com/carlosframework/platform/internal/applog 0.347s
ok github.com/carlosframework/platform/internal/assetcache 0.256s
ok github.com/carlosframework/platform/internal/awsinv 0.006s
ok github.com/carlosframework/platform/internal/boxchoice 0.017s
ok github.com/carlosframework/platform/internal/boxid 0.719s
? 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.009s
ok github.com/carlosframework/platform/internal/caps 0.017s
ok github.com/carlosframework/platform/internal/certwarm 0.009s
ok github.com/carlosframework/platform/internal/cgroup 0.769s
ok github.com/carlosframework/platform/internal/changelog 0.614s
ok github.com/carlosframework/platform/internal/claimactivation 1.237s
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 7.653s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.021s
ok github.com/carlosframework/platform/internal/dbquery 0.014s
ok github.com/carlosframework/platform/internal/dbreads 2.165s
? github.com/carlosframework/platform/internal/dbreads/dbreadstest [no test files]
ok github.com/carlosframework/platform/internal/dbverify 0.008s
ok github.com/carlosframework/platform/internal/dnsrec 0.010s
ok github.com/carlosframework/platform/internal/docsite 0.009s
ok github.com/carlosframework/platform/internal/domains 2.964s
ok github.com/carlosframework/platform/internal/doorkey 0.015s
ok github.com/carlosframework/platform/internal/economics 0.010s
ok github.com/carlosframework/platform/internal/edge 45.517s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.016s
ok github.com/carlosframework/platform/internal/fleetapi 0.265s
ok github.com/carlosframework/platform/internal/gate 1.821s
ok github.com/carlosframework/platform/internal/genselect 0.052s
ok github.com/carlosframework/platform/internal/geo 0.013s
ok github.com/carlosframework/platform/internal/geoip 0.085s
ok github.com/carlosframework/platform/internal/historyreadback 0.351s
ok github.com/carlosframework/platform/internal/hostactors 6.476s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.014s
ok github.com/carlosframework/platform/internal/hostbox 2.919s
ok github.com/carlosframework/platform/internal/hostclaim 0.824s
ok github.com/carlosframework/platform/internal/hostenv 0.010s
ok github.com/carlosframework/platform/internal/hostevidence 0.013s
ok github.com/carlosframework/platform/internal/hostexec 5.274s
ok github.com/carlosframework/platform/internal/hostfence 0.111s
ok github.com/carlosframework/platform/internal/hostfiles 10.338s
ok github.com/carlosframework/platform/internal/hostfloor 0.037s
ok github.com/carlosframework/platform/internal/hostguard 1.070s
ok github.com/carlosframework/platform/internal/hostops 1.925s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.006s
ok github.com/carlosframework/platform/internal/hostquarantine 0.008s
ok github.com/carlosframework/platform/internal/hostrelocate 0.686s
ok github.com/carlosframework/platform/internal/hostresolve 0.209s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.008s
ok github.com/carlosframework/platform/internal/instancectl 5.240s
ok github.com/carlosframework/platform/internal/instanceexec 2.848s
ok github.com/carlosframework/platform/internal/instancefs 0.008s
ok github.com/carlosframework/platform/internal/instancekey 0.002s
ok github.com/carlosframework/platform/internal/instanceupdates 0.019s
ok github.com/carlosframework/platform/internal/layout 1.772s
ok github.com/carlosframework/platform/internal/lbhealth 0.003s
ok github.com/carlosframework/platform/internal/lease 10.199s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.644s
ok github.com/carlosframework/platform/internal/ledger 0.038s
ok github.com/carlosframework/platform/internal/mail 0.243s
ok github.com/carlosframework/platform/internal/maintenance 0.104s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.647s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.121s
ok github.com/carlosframework/platform/internal/metrics 0.011s
ok github.com/carlosframework/platform/internal/moves 23.111s
ok github.com/carlosframework/platform/internal/moves/archive 1.058s
ok github.com/carlosframework/platform/internal/objstore 0.333s
ok github.com/carlosframework/platform/internal/ops 0.233s
ok github.com/carlosframework/platform/internal/park 0.326s
ok github.com/carlosframework/platform/internal/plans 0.006s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 25.485s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.606s
ok github.com/carlosframework/platform/internal/relay 7.242s
ok github.com/carlosframework/platform/internal/release 26.624s
ok github.com/carlosframework/platform/internal/rollout 3.727s
ok github.com/carlosframework/platform/internal/rolloutwords 0.007s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.061s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.583s
ok github.com/carlosframework/platform/internal/s3compat 0.009s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.649s
ok github.com/carlosframework/platform/internal/seeds 0.012s
ok github.com/carlosframework/platform/internal/sim/engines 2.256s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.874s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.585s
ok github.com/carlosframework/platform/internal/status 0.061s
ok github.com/carlosframework/platform/internal/steering 0.011s
ok github.com/carlosframework/platform/internal/stopevidence 0.133s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.005s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.591s
=== internal_console@0 (75 s)
ok github.com/carlosframework/platform/internal/console 73.351s
=== internal_console@1 (47 s)
ok github.com/carlosframework/platform/internal/console 45.270s
=== internal_console@2 (41 s)
ok github.com/carlosframework/platform/internal/console 39.716s
=== internal_console@3 (47 s)
ok github.com/carlosframework/platform/internal/console 45.713s
=== internal_console@4 (43 s)
ok github.com/carlosframework/platform/internal/console 41.459s
=== internal_console@5 (46 s)
ok github.com/carlosframework/platform/internal/console 44.837s
=== internal_console@6 (46 s)
ok github.com/carlosframework/platform/internal/console 44.750s
=== internal_console@7 (50 s)
ok github.com/carlosframework/platform/internal/console 48.614s
=== internal_host@0 (65 s)
ok github.com/carlosframework/platform/internal/host 62.867s
=== internal_host@1 (31 s)
ok github.com/carlosframework/platform/internal/host 29.150s
=== internal_host@2 (31 s)
ok github.com/carlosframework/platform/internal/host 29.072s
=== internal_host@3 (32 s)
ok github.com/carlosframework/platform/internal/host 29.999s
=== root@0 (70 s)
ok github.com/carlosframework/platform 68.745s
=== root@1 (49 s)
ok github.com/carlosframework/platform 48.035s
=== root@2 (61 s)
ok github.com/carlosframework/platform 59.883s
=== root@3 (53 s)
ok github.com/carlosframework/platform 51.575s
=== root@4 (59 s)
ok github.com/carlosframework/platform 57.650s
=== 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: 50659490 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.024s
=== RUN TestT4StorageProbeMinio
2026/10/25 08:29:46 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E1B8801266D9D2, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
=== RUN TestS3StoreMinioContract
=== RUN TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.03s)
--- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
--- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.02s)
PASS
ok github.com/carlosframework/platform/internal/release 0.032s
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/25 08:29:49 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/25 08:29:49 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/25 08:29:49 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/25 08:29:49 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/25 08:29:49 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/25 08:29:49 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/25 08:29:49 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/25 08:29:49 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/25 08:29:49 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/25 08:29:49 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/25 08:29:49 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/25 08:29:49 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/25 08:29:49 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/25 08:29:49 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%3A44325: could not determine target mailbox
agent.go:135: dbreads: dbr_5tigzvwn5p7frstovi7kdppotu: planned 3 files (1544 bytes) and restored to 3 in 9ms
agent.go:135: dbreads: dbr_5tigzvwn5p7frstovi7kdppotu: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/25 08:29:50 console: db read dbr_5tigzvwn5p7frstovi7kdppotu: relayed 1 chunks (102 bytes) in 1.004s, ended done
agent.go:135: dbreads: dbr_jk5kqpykvnvcl5yo7xouroetam: planned 1 files (720 bytes) and restored to 1 in 9ms
agent.go:135: dbreads: dbr_jk5kqpykvnvcl5yo7xouroetam: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/25 08:29:51 console: db read dbr_jk5kqpykvnvcl5yo7xouroetam: relayed 1 chunks (102 bytes) in 1.004s, ended done
agent.go:135: dbreads: dbr_oend5vzdz4qcmt76r5poicn4da: planned 3 files (1544 bytes) and held through 3 in 9ms
agent.go:135: dbreads: dbr_oend5vzdz4qcmt76r5poicn4da: output 1544 bytes in 1 confirmed chunks in 1ms
2026/10/25 08:29:52 console: db read dbr_oend5vzdz4qcmt76r5poicn4da: relayed 1 chunks (1641 bytes) in 1.004s, ended done
=== RUN TestDBForwardMinio/by_disconnect
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_en5fbtdatoogdmvemdn3wenapq: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/25 08:29:52 console: db read dbr_en5fbtdatoogdmvemdn3wenapq: relayed 0 chunks (0 bytes) in 11ms, ended client gone
agent.go:135: dbreads: dbr_en5fbtdatoogdmvemdn3wenapq: output 5 bytes in 1 confirmed chunks in 103ms
=== RUN TestDBForwardMinio/by_timeout
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_h5r6nfrawuccgiarqvz6cit7pa: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/25 08:30:12 console: db read dbr_h5r6nfrawuccgiarqvz6cit7pa: relayed 0 chunks (0 bytes) in 19.397s, ended no-result
agent.go:135: dbreads: dbr_h5r6nfrawuccgiarqvz6cit7pa: output 5 bytes in 1 confirmed chunks in 19.374s
--- PASS: TestDBForwardMinio (22.83s)
--- PASS: TestDBForwardMinio/by_disconnect (0.22s)
--- PASS: TestDBForwardMinio/by_timeout (19.51s)
PASS
ok github.com/carlosframework/platform/internal/console 22.850s
minio contract: PASS
diff --git a/docs/superpowers/notes/census-proposal-design-plans-and-caps.md b/docs/superpowers/notes/census-proposal-design-plans-and-caps.md| new file mode 100644 |
| index 000000000..380072def |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-design-plans-and-caps.md |
| @@ -0,0 +1,10 @@ |
| +# Census: Plans 1, plan catalog and account limits |
| + |
| +Status: APPROVED by Paul (census owner), 2026-10-25, recorded on #280. The |
| +rows keep `rev3=PROPOSED-design-plans-and-caps`: under the #369 process nothing is flipped, |
| +and later overlays re-froze units at these dispositions, so a flip would |
| +move their fingerprints. |
| + |
| +Overlay: `internal/provision/testdata/plan14-root-reads-design-plans-and-caps.tsv` (merged as f550afa). |
| + |
| +New units: the account_plans store methods (console SQL on its own table, J4 root-input like their store siblings), plans.LoadCatalog (the console reads the operator's CARLOS_PLAN_CATALOG once at start) and the CLI's --sponsored file read (the invoking user). Re-frozen: cmd/console serve and Store.migrate. |
diff --git a/docs/superpowers/notes/census-proposal-plans-2-box-limits.md b/docs/superpowers/notes/census-proposal-plans-2-box-limits.md| new file mode 100644 |
| index 000000000..cb85dae9c |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-plans-2-box-limits.md |
| @@ -0,0 +1,10 @@ |
| +# Census: Plans 2, boxes read account limits |
| + |
| +Status: APPROVED by Paul (census owner), 2026-10-25, recorded on #280. The |
| +rows keep `rev3=PROPOSED-plans-2-box-limits`: under the #369 process nothing is flipped, |
| +and later overlays re-froze units at these dispositions, so a flip would |
| +move their fingerprints. |
| + |
| +Overlay: `internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv` (merged as 0235046). |
| + |
| +New units: host plansPass (serialised; reads its routes' accounts, control/plans.json and each served account's limits record into a cache) and planIdleFor (answers the activator's idle hook), accepted legacy at telemetrySwitches' J1 root-route disposition. Re-frozen: activator Service.sweepOnce (narrows an idle window only), cmd/console serve, Store.SetAccountPlan. Re-pinned at their authority class: runAgent, fastConvergeLoop, activator type Service. |
diff --git a/docs/superpowers/notes/census-proposal-plans-3a-creation-caps.md b/docs/superpowers/notes/census-proposal-plans-3a-creation-caps.md| new file mode 100644 |
| index 000000000..f01c3ad7d |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-plans-3a-creation-caps.md |
| @@ -0,0 +1,10 @@ |
| +# Census: Plans 3a, creation caps |
| + |
| +Status: APPROVED by Paul (census owner), 2026-10-25, recorded on #280. The |
| +rows keep `rev3=PROPOSED-plans-3a-creation-caps`: under the #369 process nothing is flipped, |
| +and later overlays re-froze units at these dispositions, so a flip would |
| +move their fingerprints. |
| + |
| +Overlay: `internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv` (merged as 6e8564a). |
| + |
| +New units: Store.LiveAppCount and Store.RestoringAppsIn (count the account's own apps rows, J4 root-input). Re-frozen: Server.handleAPICreateApp (plan-cap check under the account lock). Console envelope re-pinned. |
diff --git a/docs/superpowers/notes/census-proposal-plans-3b-never-sleep.md b/docs/superpowers/notes/census-proposal-plans-3b-never-sleep.md| new file mode 100644 |
| index 000000000..3b04fec5d |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-plans-3b-never-sleep.md |
| @@ -0,0 +1,27 @@ |
| +# Census approval: plans-3b-never-sleep |
| + |
| +Status: APPROVED by Paul (census owner), 2026-10-25, in session, at tip 6b7a8fa3 (CI passed). Covers the approval below and the hand-written decision (census-ruling-plans-3b-fleet-owner). It re-approves, after Astra rounds 2-4, the approval first given at 7d572577. Recorded on #280. |
| + |
| +Ruling `census-ruling-plans-3b-never-sleep`. Every change below keeps the disposition it already has, so it needs an approval, not a decision. |
| + |
| +| unit | change | read sites | what changed | |
| +|---|---|---|---| |
| +| `internal/console/apicli.go` `Server.handleAPIShip` | body | unchanged (1) | after the capability block is validated, a release declaring daemon is refused (409 cap) for an app on shared boxes under a plan; an app on a fleet the account owns, or any app with plans off, ships as before. No new direct read at this site; the plan check it calls reads the account plan, the app placement and (through ownsFleet) the owner of the fleet named. | |
| + |
| +Envelopes re-pinned: `internal/console`, `internal/provision`, `internal/schedule`. |
| + |
| +Seal: `74ff021155e1f12ea81cac07f183b528bf5748d6fc06ae7903ed0f91842458f2` → `d62eabd30c03e73ec1348d8e64912678ff8523ac07252c9283ef9f445ca4e944`. |
| + |
| +Overlay: `internal/provision/testdata/plan14-root-reads-plans-3b-never-sleep.tsv`, appended to `internal/provision/testdata/plan14-root-reads-chain.tsv`. |
| +## Decision (hand-written, census-ruling-plans-3b-fleet-owner) |
| + |
| +One new read, so it needs a decision rather than an approval. It has its own overlay, `internal/provision/testdata/plan14-root-reads-plans-3b-fleet-owner.tsv`, chained just before this one (seal `86570d13…` → `74ff0211…`). |
| + |
| +| unit | change | reads | what it does | |
| +|---|---|---|---| |
| +| `internal/console/plancaps.go` `ownsFleet` | new | `registry:Account`, `whole:Fleet` (one `GetFleet`) | reads who owns the fleet an app, or one of its sidecar records, is placed on. The plan's daemon, always-on and schedule refusals are lifted only when that owner is this account. An unknown or foreign fleet keeps the refusal, and a registry that can't be read is an error, not a pass. Nothing is granted on any box. Classed `legacy`/`accepted-legacy`, like every other console registry read (Astra plans-3b finding 5). | |
| + |
| +Also reachable now, with no new census site: CLI, browser, canary and pending promote read the target release's manifest (each already did) and, when it declares daemon, the account plan and the app's placement (Astra plans-3b finding 2). The record-write census gains `plans3bWriteCensusInventory`: `handleAPISidecarCreate` has a new refusal but writes nothing new (finding 3). |
| + |
| +Round 2 (Astra) adds no census decision. `internal/provision` is re-pinned for `ListSidecarRecordsStrict`, a new key site in the record-write census (it lists sidecar records under `SidecarsPrefix`, uncapped, and fails on any it can't read). CLI and browser rollback now read the target release's manifest before writing, and again inside the rollback's authorize hook. Neither is a census unit. The earlier approval at 7d572577 covered the card before these changes. It needs approving again at the new tip. |
| +Round 3 adds no census change beyond the console envelope: with plans off, rollback and pending execution read no manifest, and a guard read failure is answered as a failed plan check. |
diff --git a/docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md b/docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md| new file mode 100644 |
| index 000000000..247d5d0c1 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md |
| @@ -0,0 +1,103 @@ |
| +# Plans 3b: Never-Sleep and Schedule Refusals 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:** Under a plan, the console refuses the three things that would keep an app awake beyond its plan: shipping a release that declares `daemon`, enabling sidecars with `hibernate: false`, and declaring a schedule more frequent than the plan's minimum. |
| + |
| +**Architecture:** Three checks join `internal/console/plancaps.go`, each nil when plans are off, each a `*planCapError` with approved copy, answered 409 `"code":"cap"` by its JSON door. Cron schedules need a minimum-gap helper in `internal/schedule`. |
| + |
| +**Tech Stack:** Go; `internal/console`, `internal/schedule`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md` §5.2 (never sleeping is refused for plan-governed accounts; schedules: free minimum 15 minutes, paid 1 minute, validated where `ValidEvery` runs). Paul (2026-10-20): all apps hibernate; no plan offers always-on. Builds on Plans 1–3a. |
| + |
| +## Own boxes (Paul, 2026-10-24) |
| + |
| +Asked "Should we not just have plans that include daemons and sidecars? Or do we limit those to folks bringing their own boxes?", the answer taken (author's recommendation, matching the rootfs-apps ruling "own boxes only, always-on by default"): always-on is allowed when the app is placed on the account's own fleet (paid plans include fleets), and refused only on shared boxes. All three checks therefore pass when the app's placement is one of the account's fleets (`registry.FleetPlacement` of `appPlacement`); sidecars of a placed app inherit its fleet region. An always-on pack for shared boxes is a later pack (Plan 6), when someone asks. |
| + |
| +## Global Constraints |
| + |
| +- No catalog, no change: every check returns nil when `EffectiveLimits` is nil. |
| +- Only new writes are refused. A sidecar manifest or schedule written before plans were switched on keeps working; lapses and over-limit leftovers are Plan 4's. |
| +- Address-proxy routes and instance hibernation are already operator-only (`refuseMemberUpstreams`, `apiinstances.go:1874`; no member field sets `Record.Hibernate`); nothing to do. |
| +- Operators' direct-bucket `carlos ship` bypasses the console and is out of scope (operator-only). |
| +- Copy goes through copy review before it lands; strings join `capCopy` in `plancaps_copy.go`. |
| +- Touched packages only, with `-run`; CI runs the full suite; census entries in the last task. |
| + |
| +## Review Focus |
| + |
| +1. **Plans off.** Daemon ships, always-on sidecars and one-minute schedules all work exactly as today. Pinned per task. |
| +2. **A re-ship of an already-shipped daemon release** is refused under a plan too (the identical-reship short-circuit must not skip the check). Pinned in Task 1 (`TestShipRefusesDaemonEvenOnReship`). |
| +3. **Cron that fires faster than the minimum** (`*/1 * * * *`, `0,5 * * * *`) is refused, and a cron whose shortest gap meets the minimum (`*/15 * * * *` on free) is allowed. Pinned in Task 3 (`TestCronMinGap`, `TestScheduleRefusedBelowThePlanMinimum`). |
| +4. **Enabling sidecars without a `hibernate` field** on an existing always-on manifest is not a new always-on write and is allowed. Pinned in Task 2 (`TestSidecarEnableKeepsAnExistingSettingWhenOmitted`). |
| + |
| +--- |
| + |
| +### Task 1: Daemon releases |
| + |
| +**Files:** Modify `internal/console/plancaps.go` (`checkDaemon(acct *Account) error`), `internal/console/apicli.go` (`handleAPIShip`, right after the capability block is validated at ~`:2010`, before the identical-reship short-circuit at ~`:2203`); tests in `internal/console/plancaps_doors_test.go`. |
| + |
| +- [ ] **Step 1: Failing tests:** `TestShipRefusesDaemonUnderAPlan` (ship with `{"daemon": true}` capabilities: 409 `"code":"cap"`, no release written); `TestShipRefusesDaemonEvenOnReship`; `TestShipDaemonWithoutACatalogUnchanged`; `TestShipWithoutDaemonUnderAPlanUnchanged`. |
| +- [ ] **Step 2:** Run `go test ./internal/console/ -run 'ShipRefusesDaemon|ShipDaemonWithout|ShipWithoutDaemon'` → FAIL. |
| +- [ ] **Step 3:** Implement `checkDaemon` (nil when plans off; otherwise `&planCapError{msg: capMessage("cli.cap.daemon")}`) and call it when `declared != nil && declared.Daemon`. |
| +- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: no daemon releases under a plan`. |
| + |
| +### Task 2: Always-on sidecars |
| + |
| +**Files:** Modify `plancaps.go` (`checkAlwaysOn(acct *Account) error`), `internal/console/apisidecars.go` (`handleAPISidecarEnable`, after body validation, before the manifest read); tests in `plancaps_doors_test.go`. |
| + |
| +- [ ] **Step 1: Failing tests:** `TestSidecarEnableRefusesHibernateFalseUnderAPlan` (409 cap, manifest unchanged); `TestSidecarEnableKeepsAnExistingSettingWhenOmitted` (an existing `hibernate: false` manifest, enable without the field: allowed, unchanged); `TestSidecarEnableHibernateFalseWithoutACatalogUnchanged`. |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Refuse only when `req.Hibernate != nil && !*req.Hibernate`. **Step 4:** Run → PASS. **Step 5:** Commit `console: no always-on sidecars under a plan`. |
| + |
| +### Task 3: Schedule minimum |
| + |
| +**Files:** Create `internal/schedule/mingap.go`, `internal/schedule/mingap_test.go`; modify `plancaps.go` (`checkSchedule(acct *Account, sc schedule.Schedule) error`), `internal/console/apischedule.go` (`handleAPIScheduleSet`, right after `sc.Validate()`); tests in `plancaps_doors_test.go`. |
| + |
| +**Interfaces:** `func (s Schedule) ShortestInterval() (time.Duration, error)` in `internal/schedule`: `Every`'s duration, or for cron the smallest gap between consecutive `Next` firings over a 7-day window from a fixed start (covers hourly, daily and day-of-week patterns). |
| + |
| +- [ ] **Step 1: Failing tests:** `TestCronMinGap` (`*/1 * * * *` → 1m; `*/15 * * * *` → 15m; `0,5 * * * *` → 5m; `0 3 * * *` → 24h; `every: 10m` → 10m); `TestScheduleRefusedBelowThePlanMinimum` (free: `every: 5m` and `*/1 * * * *` → 409 cap, nothing stored; `every: 15m` and `*/15 * * * *` → stored); `TestScheduleMinimumOnAPaidPlan` (personal: `every: 1m` stored); `TestScheduleWithoutACatalogUnchanged`. |
| +- [ ] **Step 2:** Run `go test ./internal/schedule/ -run CronMinGap` and the console tests → FAIL. |
| +- [ ] **Step 3:** Implement `ShortestInterval` and `checkSchedule` (refuse when `ShortestInterval < MinScheduleSeconds`, message `cli.cap.schedule` with the minimum in minutes). |
| +- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: schedules meet the plan's minimum interval`. |
| + |
| +### Task 4: Census and gates |
| + |
| +- [ ] Re-pin the #280 root-read overlay (`~/.cache/tmp/claude-1001/census-gen/gen.sh PROPOSED-plans-3b-never-sleep <basis>`), 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–4 are pinned, CI is green after merging current main, and Astra's whole-branch review has no open Critical or Important finding. |
| + |
| +## Astra review (2026-10-25) |
| + |
| +Findings in `docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE.md`. All six were fixed: |
| + |
| +1. Cron gap: `ShortestInterval` is now a conservative bound over the minute and hour fields. It ignores the day fields, so seasonal and leap-day crons can't slip through. |
| +2. Promotion: CLI promote, browser promote, new canaries and pending promote all refuse a daemon release on shared boxes under a plan (`checkDaemonRelease`). The pending check runs at execution, against the plan as it stands then. |
| +3. Sidecar create: refused on shared boxes when the app's manifest says `hibernate: false` (`checkAlwaysOnCreate`). |
| +4. Always-on enable on a fleet: every existing sidecar record must already be on a fleet the account owns. |
| +5. Fleet ownership: `ownsFleet` checks that the fleet exists and that `Account == acct.Sqid`. Lookup failures are errors. |
| +6. Census card: names the new indirect reads, and records `ownsFleet` as a hand-written decision. |
| + |
| +Rulings taken in the fix pass: |
| + |
| +- ~~**Rollback and fast track are not checked.**~~ Rollback reversed in round 2 (below). Fast track and retry still stand. They only re-serve a version that is already on one of this app's channels. Every way onto a channel is now checked: ship, promote, canary and pending promote. So a daemon version can only be there if it got there before plans were on, or while the app was on its fleet. Those leftovers belong to Plan 4, the same as an always-on app that moves from its fleet back to shared boxes. Pending rollbacks and retries are exempt for the same reason. The box's converge step still refuses a daemon whose hibernation is on. |
| +- **No new copy.** The promote doors reuse the approved `cli.cap.daemon` string ("…or ship it without daemon" still applies). The browser doors show it through `Browser()`, which falls back to the CLI text the same way `cli.cap.sidecars` does. A failed plan check shows `planCheckFailedCopy`. |
| + |
| +## Astra review, round 2 (2026-10-25) |
| + |
| +Findings in `docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R2.md`. Round 1's findings 1, 3, 5 and 6 are confirmed fixed. Three new Important findings, all fixed: |
| + |
| +1. **Sidecar listing.** `checkAlwaysOn` now uses `provision.ListSidecarRecordsStrict`. It has no cap, and it fails on any record it can't read, so a skipped worker can no longer earn the own-fleet exemption. A record that disappears between the list and the read is skipped. Covered by `TestAlwaysOnRefusedPastAnUnreadableSidecarRecord`. |
| +2. **Rollback is checked.** A rollback target need not be on any channel now, so CLI rollback, browser rollback and pending rollback all apply the daemon rule to it. The direct doors check the target they read. They also pass `rollbackDaemonGuard` as Rollback's authorize hook. That hook re-reads the pointer after Rollback's own read and before its conditional write, so the version actually written is the one checked. Fast track and retry stay unchecked: they keep the version the channel already serves. Covered by `TestRollbackToADaemonReleaseRefusedUnderAPlan`, `TestRollbackDaemonGuardReadsThePointerAtWriteTime` and `TestPendingRollbackToADaemonReleaseRefusedUnderAPlan`. |
| +3. **Exact cron gap.** `ShortestInterval` now walks a whole 400-year Gregorian cycle, which is a whole number of weeks, to find the closest pair of matching days. Weekly and monthly lines get their real gaps (`0,55 0,23 * * 1` is 55 minutes), and seasonal and leap-day bursts are still caught. Cron runs in UTC, so there is no daylight-saving shift. |
| + |
| +## Astra review, round 3 (2026-10-25) |
| + |
| +Findings in `docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R3.md`. All three round-2 findings are confirmed fixed. Two new findings, both fixed: |
| + |
| +1. **Important: with plans off, rollback read a manifest.** `checkDaemonVersion` now checks the plan and the placement before it reads anything. Pending execution goes through it too. So with plans off, neither rollback nor a pending change reads a manifest it didn't read before. Covered by `TestRollbackWithoutACatalogNeedsNoManifest` (mutation-checked). |
| +2. **Minor: guard read failures looked like deploy refusals.** A guard that can't read now returns `*planGuardError`. Both rollback doors answer it as a failed plan check: a 502 with `planCheckFailedCopy` on the API and the same text as a browser flash. Covered by `TestRollbackGuardReadFailureIsAPlanCheckFailure`. |
| + |
| +## Astra review, round 4 (2026-10-25) |
| + |
| +Verdict: accept (`docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R4.md`). Both round-3 findings are confirmed fixed, there are no new findings, and with plans off nothing differs from main. |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R2.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R2.md| new file mode 100644 |
| index 000000000..2ab705096 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R2.md |
| @@ -0,0 +1,41 @@ |
| +| Round 1 finding | Disposition | Evidence at reviewed HEAD | |
| +|---|---|---| |
| +| 1. Seasonal crons bypass the minimum | **Fixed** | [mingap.go:34](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/schedule/mingap.go:34) examines every permitted hour/minute combination. `TestCronMinGap` includes seasonal, leap-day and combined day-field cases. A new false-refusal issue remains below. | |
| +| 2. Promotion bypasses daemon refusal | **Partly fixed** | [apicli.go:851](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/apicli.go:851) and the browser, canary and pending execution paths now check. Covered by `TestPromoteRefusesADaemonReleaseUnderAPlan`, `TestBrowserPromoteAndCanaryRefuseADaemonReleaseUnderAPlan` and `TestPendingPromoteOfADaemonReleaseRefusedUnderAPlan`. Rollback remains unchecked. | |
| +| 3. Sidecar creation permits new always-on workers | **Fixed** | [apisidecars.go:332](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/apisidecars.go:332) checks an existing `hibernate:false` manifest before either write path. `TestSidecarCreateRefusedUnderAnAlwaysOnManifest` covers refusal. | |
| +| 4. App placement does not prove sidecars moved | **Partly fixed** | [plancaps.go:350](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/plancaps.go:350) checks individual records. `TestAlwaysOnRefusedWhileSidecarsRemainOnSharedBoxes` covers an ordinary shared worker, but enumeration can silently omit workers. | |
| +| 5. Fleet syntax substitutes for ownership | **Fixed** | [plancaps.go:426](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/plancaps.go:426) requires an existing fleet with matching account and propagates lookup errors. `TestOwnBoxesNeedsAFleetTheAccountOwns` covers owned, foreign, missing and unreadable cases. | |
| +| 6. Census card omits indirect reads | **Fixed** | [approval card:9](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/docs/superpowers/notes/census-proposal-plans-3b-never-sleep.md:9) names indirect reads; its decision section identifies `ownsFleet`. Relevant gates are `TestPlan14RootReadCensus` and `TestPlan9B8RecordWriteCensus`. | |
| + |
| +Reviewed `f808aa12b92d522a82ae3deabb4944103886bbed`. Test names above identify coverage inspected; Go tests could not start in this sandbox. |
| + |
| +1. **Important — Incomplete sidecar enumeration grants the always-on exception.** |
| + [plancaps.go:350](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/plancaps.go:350) discards `truncated`, and [`ListSidecarRecords`:389](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/provision/sidecar.go:389) silently skips individual read failures. |
| + |
| + **Failure scenario:** An app is placed on its owned fleet while a hibernating sidecar remains on shared boxes. Listing succeeds, but reading that sidecar temporarily fails. The helper returns no records and no error, so enabling `hibernate:false` succeeds. When the record becomes readable again, shared-box reconciliation applies the always-on manifest. Likewise, a shared worker sorted beyond the first 500 records is never checked. |
| + |
| + **Fix:** Require complete enumeration and successful reads before granting the exception. Refuse on truncation or unresolved record-read failures. Add tests for both conditions. |
| + |
| +2. **Important — The rollback exemption can restore a daemon that no channel currently serves.** |
| + [pendinghandlers.go:549](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/pendinghandlers.go:549) explicitly bypasses the check for rollback. Direct API and browser rollback also omit it. |
| + |
| + **Failure scenario:** Before plans are enabled, promote daemon release D, then ordinary release N onto the same channel. Enable plans on shared boxes. A member can now roll back N to D, directly or through an approved proposal. D need exist only in history; it need not remain on any current channel. |
| + |
| + This changes a compliant channel to a prohibited release. It therefore exceeds preserving existing running leftovers. Convergence refusing D does not satisfy §5.2’s required refusal before changing the channel. |
| + |
| + **Fix:** Check the actual rollback target against current limits and placement at execution, including approved rollbacks. Fast-track and retry preserve the current version; that rationale does not cover rollback. |
| + |
| +3. **Important — The cron fix rejects schedules that meet the minimum.** |
| + [mingap.go:49](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/schedule/mingap.go:49) always includes a next-day wraparound gap, even when consecutive matching days are impossible. |
| + |
| + **Failure scenario:** `0,55 0,23 * * 1` fires Mondays at 00:00, 00:55, 23:00 and 23:55. Its actual shortest interval is **55 minutes**, but the implementation returns **5 minutes**, imagining a Tuesday 00:00 firing. Free accounts therefore receive a cap refusal for a compliant schedule. |
| + |
| + The bound is conservative and closes the seasonal bypass, but violates the requirement to allow schedules meeting the minimum. |
| + |
| + **Fix:** Account for calendar restrictions when evaluating cross-day gaps, while retaining complete seasonal and leap-day coverage. Add this weekly case as an acceptance test. |
| + |
| +I independently recomputed both new census seals, frozen fingerprints, touched function digests and package envelope hashes; they match. The record-write inventory correctly retains the existing writer classification. |
| + |
| +**Declined to judge:** approved wording, excluded operator paths and external approval provenance. Targeted Go tests failed before execution because temporary build-directory creation was denied. HEAD and the clean working tree remained unchanged. |
| + |
| +**Verdict: Changes required — three Important findings.** |
| \ No newline at end of file |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R3.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R3.md| new file mode 100644 |
| index 000000000..a66f8ac29 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R3.md |
| @@ -0,0 +1,29 @@ |
| +| Round 2 finding | Disposition | Evidence at reviewed HEAD | |
| +|---|---|---| |
| +| Incomplete sidecar enumeration | **Fixed** | [sidecar.go:404](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/provision/sidecar.go:404) reads the complete listing and propagates record errors, skipping only deleted records. `TestAlwaysOnRefusedPastAnUnreadableSidecarRecord` covers unreadable data. | |
| +| Rollback restores prohibited daemon releases | **Fixed** | [plancaps.go:338](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/plancaps.go:338) adds the execution-time guard. Coverage: `TestRollbackToADaemonReleaseRefusedUnderAPlan`, `TestRollbackDaemonGuardReadsThePointerAtWriteTime`, and `TestPendingRollbackToADaemonReleaseRefusedUnderAPlan`. | |
| +| Compliant weekly crons refused | **Fixed** | [mingap.go:67](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/schedule/mingap.go:67) includes calendar restrictions and cycle wraparound. `TestCronMinGap` covers weekly, seasonal, leap-day and combined day-field cases. | |
| + |
| +Reviewed **`eb0c40ceb92f23bc5fc4a65d02e1688e68f00d45`**. Test names above identify inspected coverage, not successful executions. |
| + |
| +1. **Important — Rollback gains a manifest dependency even with plans off.** |
| + [plancaps.go:323](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/plancaps.go:323) |
| + |
| + `checkDaemonVersion` reads and validates the manifest before checking whether plans apply. Both direct rollback doors call it unconditionally. |
| + |
| + **Failure scenario:** With `PlanCatalog == nil`, the channel and history remain readable, but fetching the historical manifest fails. Previously, rollback could swap the signed pointer using its history; now the API returns 502 and the browser refuses. Returning a nil `rollbackDaemonGuard` does not help because this earlier check already failed. This violates “plans off changes nothing.” |
| + |
| + **Fix:** Short-circuit the plan check before fetching the manifest when no effective limits apply. Add plans-off rollback tests that fail manifest reads while allowing pointer operations. |
| + |
| +2. **Minor — Guard read failures are handled as deploy-authorization failures.** |
| + [apicli.go:1258](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/apicli.go:1258), [server.go:4884](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/server.go:4884) |
| + |
| + **Failure scenario:** The initial checks succeed, but the guard’s pointer or manifest reread fails. `RollbackAuthorized` wraps that failure in `AuthorizationError`. The API forwards it to `writeDeployRefusal`, which returns an unlogged generic 500 because it is not a deploy refusal. The browser instead exposes the underlying error through its generic rollback flash. Neither uses the required plan-check failure handling. |
| + |
| + **Fix:** Distinguish guard failures from deploy-scope refusals. Preserve cap responses and genuine deploy 403s; log other guard failures and use `planCheckFailedCopy` with API status 502. |
| + |
| +No additional finding emerged from listing pagination/deletion semantics, deploy-scope composition, cron month/OR rules, or bounded calendar cost. Strict listing costs one complete listing plus one read per record. An independent calendar calculation matched all 17 cron expectations. Census digests, envelope hashes and seals match; the card correctly states that renewed approval is needed. |
| + |
| +**Declined to judge:** approved wording and external approval provenance. Targeted Go tests could not start because the sandbox denied temporary build-directory creation. HEAD and the clean working tree remained unchanged. |
| + |
| +**Verdict: Changes required — one Important and one Minor finding.** |
| \ No newline at end of file |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R4.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R4.md| new file mode 100644 |
| index 000000000..6a119949a |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R4.md |
| @@ -0,0 +1,16 @@ |
| +Reviewed **`8caf8a3ee1dd82571993f5092226827a38444481`**. |
| + |
| +| Round 3 finding | Disposition | Evidence at reviewed HEAD | |
| +|---|---|---| |
| +| Rollback gains a manifest dependency with plans off | **Fixed** | [plancaps.go:325](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/plancaps.go:325) returns before placement or manifest reads when limits are nil. The rollback guard likewise returns nil. [Pending execution:550](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/pendinghandlers.go:550) uses the same helper. | |
| +| Guard read failures handled as deploy-authorization failures | **Fixed** | Pointer and manifest failures become `planGuardError`. The [API:1258](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/apicli.go:1258) logs them and returns 502 with `planCheckFailedCopy`; the [browser:4890](/home/paulca/.cache/tmp/claude-1001/plans-3b-never-sleep/internal/console/server.go:4890) logs them and flashes that copy. Cap responses and deploy-scope handling remain intact. | |
| + |
| +**New findings:** None in `6ff96b02` or the merge. I found no remaining Plans 3b difference in reads, refusals, or status codes with plans off compared with merged main (`236753a7`). |
| + |
| +The census merge preserves both overlay sequences. Independent read-only checks confirmed every manifest-chain seal, frozen-entry fingerprint, the affected declaration digests, and all five affected package fingerprints. |
| + |
| +**Validation limitation:** Targeted Go tests could not start: the sandbox denied temporary build-directory creation. The new guard-error test checks the guard directly, but its HTTP request fails at the earlier precheck; it does not exercise the handler’s guard-error branch. Handler correctness here is established by inspection, not successful test execution. |
| + |
| +**Declined to judge:** Approved wording and external census-approval provenance. HEAD, index, branches, and the clean working tree remain unchanged. |
| + |
| +**Verdict: Accept.** |
| \ No newline at end of file |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE.md| new file mode 100644 |
| index 000000000..bafe3c464 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE.md |
| @@ -0,0 +1,25 @@ |
| +1. **Important — Seasonal crons bypass the minimum interval.** [internal/schedule/mingap.go:30](internal/schedule/mingap.go:30) |
| + `*/1 * * 4 *` returns 62 days because its first firing falls outside the fixed January–March window. The console therefore accepts it on free, although it fires every minute throughout April. Leap-day bursts such as `0,5 0 29 2 *` escape too. **Fix:** establish the shortest gap across the expression’s permitted calendar patterns; absence from this window cannot prove compliance. Add seasonal, leap-day, and combined day-of-month/day-of-week cases. |
| + |
| +2. **Important — Promotion bypasses the daemon refusal.** [internal/console/apicli.go:846](internal/console/apicli.go:846) |
| + Promotion reads an existing manifest but discards its capabilities. A daemon release shipped before plans were enabled can still be promoted on shared boxes. Browser promotion and approved-proposal execution also lack the check. Even where convergence subsequently refuses the daemon, the channel has already changed, violating §5.2’s explicit promotion-time refusal. **Fix:** check the target manifest’s daemon capability against current limits and placement before every member-facing promotion executes. |
| + |
| +3. **Important — Creating a sidecar can introduce a new always-on worker.** [internal/console/apisidecars.go:323](internal/console/apisidecars.go:323) |
| + An older `hibernate:false` manifest survives enabling plans, as intended. But a member below the worker-count cap can then create a new sidecar on shared boxes: creation checks only the count, and reconciliation applies the manifest’s `false` setting to the new worker. Preserving existing workers does not justify creating additional always-on workers. **Fix:** check the effective manifest hibernation setting during creation, before either record-write path. Preserve the required omitted-field behavior for enable. |
| + |
| +4. **Important — App placement does not prove existing sidecars moved to own boxes.** [internal/console/plancaps.go:315](internal/console/plancaps.go:315) |
| + With `HostClaim` off, place an app containing hibernating sidecars onto an owned fleet, then enable `hibernate:false`. The new check permits this from stored app placement. However, `placeAppTarget` rewrites instance records, not sidecar records; shared-box reconciliation still selects those sidecars by their original record region and applies the new hibernation setting. This creates new always-on behavior on shared boxes, rather than preserving the explicitly deferred fleet-to-shared leftovers. **Fix:** verify affected sidecar records actually target owned fleets before granting the exception, or complete their relocation first. |
| + |
| +5. **Minor — Fleet syntax is treated as ownership evidence.** [internal/console/plancaps.go:360](internal/console/plancaps.go:360) |
| + `FleetPlacement` only parses the string. A stored or fallback placement naming a nonexistent fleet or another account’s fleet receives all three exemptions. The new test helper even creates no fleet record. Normal placement APIs validate ownership, which limits ordinary reachability, but this helper does not establish what it claims. **Fix:** resolve the fleet, require `fleet.Account == acct.Sqid`, and propagate lookup failures. Test owned, foreign, missing, and unreadable fleets. |
| + |
| +6. **Minor — The census card omits new indirect reads.** [docs/superpowers/notes/census-proposal-plans-3b-never-sleep.md:7](docs/superpowers/notes/census-proposal-plans-3b-never-sleep.md:7), at the reviewed commit. |
| + The unchanged direct read-site count is consistent with the scanner, but “No new read” does not describe the added execution path: shipping now reads account-plan and app-placement state and can open/list the registry. **Fix:** distinguish unchanged direct census sites from these newly reachable reads so the approval describes the actual change. |
| + |
| +**Declined to judge** |
| + |
| +- Approved copy, section 3 decisions, operator-only paths, and the explicitly deferred fleet-to-shared leftovers. |
| +- Test success and census approval provenance. Targeted Go tests could not start because the sandbox refused the temporary build directory. Static inspection supports plans-off behavior, identical daemon re-ship refusal, and preservation of omitted hibernation settings; `git diff --check` passed. |
| +- The checkout advanced externally to `9f19e13` during review, adding only census approval notes. Findings remain scoped to `4d2b80284b80165b8dc7a253495303cd7c5ad12f`. |
| + |
| +**Verdict:** Changes required: four Important findings remain. I made no repository changes. |
| \ No newline at end of file |
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R2.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R2.md| new file mode 100644 |
| index 000000000..15f301813 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R2.md |
| @@ -0,0 +1,31 @@ |
| +# Code review brief, round 2: Plans 3b (never-sleep and schedule refusals) |
| + |
| +You are a senior Go reviewer on the carlos platform. Round 1 reviewed |
| +`4d2b80284b80165b8dc7a253495303cd7c5ad12f`; its findings are |
| +docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE.md. The fix pass is |
| +commit 8ecf33fa (`git show 8ecf33fa`), plus a main merge (7d572577, CI |
| +files only) and an approval note (1f329af3). The whole branch is |
| +`bcae2a72b005abe86b99edc1a51d49b6d4f6bbe1..HEAD`. Read-only: do not modify |
| +the working tree, index, HEAD or branches. You may run `go test` with |
| +GOFLAGS=-buildvcs=false, targeted with -run. |
| + |
| +Requirements are as in round 1 (CODEX-REVIEW-BRIEF-PLANS-3B-CODE.md), plus |
| +the "Astra review (2026-10-25)" section the fix pass added to |
| +docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md. Its rulings |
| +(rollback, fast track, pending rollback/retry not checked; no new copy) |
| +are the author's; judge them, but grade by effect. |
| + |
| +1. First, a disposition table for round 1's six findings: fixed, partly |
| + fixed or not fixed, each with the evidence (file:line, test name). |
| +2. Then only NEW findings in the fix pass or that it exposed, graded |
| + Critical / Important / Minor, each with a concrete failure scenario. |
| + Look at: the promote doors (CLI, browser, canary, pending execution) |
| + and any member promote path still missing the check; ownsFleet and its |
| + callers (registry unset, remote registry, error propagation, the |
| + sidecar loop); ShortestInterval's bound (is it ever too permissive?); |
| + the census overlays (hand-written plans-3b-fleet-owner row and seal, |
| + re-proposed plans-3b overlay, plans3bWriteCensusInventory) and whether |
| + the card describes them honestly. |
| +3. A verdict: accept, accept with fixes, or changes required. |
| + |
| +Do not review the wording (approved copy in plancaps_copy.go). |
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R3.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R3.md| new file mode 100644 |
| index 000000000..ddaf527fe |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R3.md |
| @@ -0,0 +1,29 @@ |
| +# Code review brief, round 3: Plans 3b (never-sleep and schedule refusals) |
| + |
| +You are a senior Go reviewer on the carlos platform. Round 2 reviewed |
| +f808aa12; its findings are docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R2.md |
| +(round 1's: CODEX-FINDINGS-PLANS-3B-CODE.md). The round 2 fix pass is |
| +commit 67f1d32d (`git show 67f1d32d`). The whole branch is |
| +`bcae2a72b005abe86b99edc1a51d49b6d4f6bbe1..HEAD`. Read-only: do not modify |
| +the working tree, index, HEAD or branches. You may run `go test` with |
| +GOFLAGS=-buildvcs=false, targeted with -run. |
| + |
| +Requirements are as in round 1 (CODEX-REVIEW-BRIEF-PLANS-3B-CODE.md), plus |
| +the two "Astra review" sections of |
| +docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md, whose rulings |
| +(fast track and retry not checked; no new copy) are the author's: judge |
| +them, but grade by effect. |
| + |
| +1. First, a disposition table for round 2's three findings: fixed, partly |
| + fixed or not fixed, with evidence (file:line, test name). |
| +2. Then only NEW findings in 67f1d32d or that it exposed, graded |
| + Critical / Important / Minor, each with a concrete failure scenario. |
| + Look at: ListSidecarRecordsStrict (list semantics, deleted-between |
| + records, cost); rollbackDaemonGuard and bothAuthorize (does the |
| + CAS argument hold; error mapping through AuthorizationError on the CLI |
| + and browser doors; deploy-credential rollbacks); the exact cron gap |
| + (correctness of the 400-year walk, month and Vixie OR rules, cost per |
| + call at a console door); census and card honesty. |
| +3. A verdict: accept, accept with fixes, or changes required. |
| + |
| +Do not review the wording (approved copy in plancaps_copy.go). |
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R4.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R4.md| new file mode 100644 |
| index 000000000..594ada059 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE-R4.md |
| @@ -0,0 +1,22 @@ |
| +# Code review brief, round 4: Plans 3b (never-sleep and schedule refusals) |
| + |
| +You are a senior Go reviewer on the carlos platform. Round 3 reviewed |
| +eb0c40ce; its findings are docs/superpowers/specs/CODEX-FINDINGS-PLANS-3B-CODE-R3.md. |
| +The round 3 fix pass is commit 6ff96b02 (`git show 6ff96b02`), followed by |
| +a main merge (fd9aa04e: census chain re-layered; check it). The whole |
| +branch is `bcae2a72b005abe86b99edc1a51d49b6d4f6bbe1..HEAD`. Read-only: do |
| +not modify the working tree, index, HEAD or branches. You may run |
| +`go test` with GOFLAGS=-buildvcs=false, targeted with -run. |
| + |
| +Requirements are as in round 1 (CODEX-REVIEW-BRIEF-PLANS-3B-CODE.md) plus |
| +the "Astra review" sections of |
| +docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md. |
| + |
| +1. A disposition table for round 3's two findings, with evidence. |
| +2. Only NEW findings in 6ff96b02 or the merge, graded Critical / |
| + Important / Minor, each with a concrete failure scenario. In |
| + particular: is anything left that a plans-off deployment does |
| + differently from main (reads, refusals, status codes)? |
| +3. A verdict: accept, accept with fixes, or changes required. |
| + |
| +Do not review the wording (approved copy in plancaps_copy.go). |
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE.md| new file mode 100644 |
| index 000000000..961cec610 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3B-CODE.md |
| @@ -0,0 +1,33 @@ |
| +# Whole-branch code review brief: Plans 3b (never-sleep and schedule refusals) |
| + |
| +You are a senior Go reviewer on the carlos platform. Review branch |
| +`plans-3b-never-sleep`, range `bcae2a72b005abe86b99edc1a51d49b6d4f6bbe1..HEAD` (`git diff bcae2a72b005abe86b99edc1a51d49b6d4f6bbe1..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: the spec docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md |
| +§5.2 (section 3 decisions are not to be argued with); the plan |
| +docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md, including its |
| +"Own boxes" section (always-on allowed when the app is placed on the |
| +account's own fleet); the executor's `Ruling:` lines in |
| +.superpowers/sdd/2026-10-23-plans-3b-never-sleep/progress.md. |
| + |
| +Grade by effect. Check deliberately (Review Focus): plans off changes |
| +nothing; a re-ship of an already-shipped daemon release is refused under a |
| +plan on shared boxes; cron lines that fire faster than the minimum are |
| +refused and ones that meet it are allowed (schedule.ShortestInterval, its |
| +62-day window, irregular crons, day-of-month/day-of-week combinations); |
| +enabling sidecars without a hibernate field leaves an existing setting. |
| + |
| +Also look hard at: every member path that can make an app never sleep (ship, |
| +deploy, promote of an already-shipped daemon release, sidecar create vs |
| +enable, operator-only paths excluded) and whether any bypasses the check; |
| +how onOwnBoxes decides (stored placement, route fallback, registry open |
| +failures, a placement naming a fleet the account does not own); schedule |
| +paths other than the console door; error handling; the census card's |
| +honesty. |
| + |
| +Do not review the wording (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/apicli.go b/internal/console/apicli.go| index 69e5f77f4..77c6e47dc 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -843,10 +843,20 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| - if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil { |
| + man, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version) |
| + if err != nil { |
| jsonError(w, http.StatusNotFound, "Version "+version+" is not a shipped release of "+app+".") |
| return |
| } |
| + if err := s.checkDaemonRelease(acct, app, man); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api promote %s/%s: plan: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + return |
| + } |
| // The pipeline is read ONCE, for the gate below and for the held answer |
| // further down. An unreadable one refuses outright: GetPipeline's error |
| // means "not safely nil", and treating it as "no pipeline" would drop |
| @@ -1174,6 +1184,15 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| target := cur.History[0] |
| + if err := s.checkDaemonVersion(ctx, acct, app, target); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api rollback %s/%s: plan: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + return |
| + } |
| // Rollback moves the same pointer promote moves, so it wears the same |
| // service refusal — a credential barred from promoting the control plane |
| // must not reach the identical effect through the other verb. |
| @@ -1225,7 +1244,23 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| signer = sg |
| authorize = deployScopeAuthorize(p, channel) |
| } |
| - if err := signer.RollbackAuthorized(ctx, s.cfg.Releases, acct.Sqid, app, channel, authorize); err != nil { |
| + guard, gerr := s.rollbackDaemonGuard(ctx, acct, app, channel) |
| + if gerr != nil { |
| + log.Printf("console: api rollback %s/%s: plan: %v", acct.Sqid, app, gerr) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + return |
| + } |
| + if err := signer.RollbackAuthorized(ctx, s.cfg.Releases, acct.Sqid, app, channel, bothAuthorize(authorize, guard)); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + var ge *planGuardError |
| + if errors.As(err, &ge) { |
| + log.Printf("console: api rollback %s/%s: plan: %v", acct.Sqid, app, ge.err) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + return |
| + } |
| var ae *release.AuthorizationError |
| if errors.As(err, &ae) { |
| s.writeDeployRefusal(w, ae.Err) |
| @@ -2008,6 +2043,19 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, capsRefusal(err)) |
| return |
| } |
| + // Plans 3b: a daemon never sleeps, and every plan's apps do. Before |
| + // the identical-reship short-circuit, so a re-ship cannot slip past. |
| + if declared.Daemon { |
| + if err := s.checkDaemon(acct, app); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api ship %s/%s: plan: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + return |
| + } |
| + } |
| } |
| if refuseBadSwap(w, meta.Swap) { |
| return |
diff --git a/internal/console/apischedule.go b/internal/console/apischedule.go| index ae63a84f5..ff9dec4c8 100644 |
| --- a/internal/console/apischedule.go |
| +++ b/internal/console/apischedule.go |
| @@ -219,6 +219,16 @@ func (s *Server) handleAPIScheduleSet(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, err.Error()) |
| return |
| } |
| + // Plans 3b: a schedule may not run more often than the plan allows. |
| + if err := s.checkSchedule(acct, app, sc); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api schedule set %s/%s: plan: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| // ReadDeclaration already folds a genuine not-found into an empty, |
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go| index 17c8aa12d..b56d45c93 100644 |
| --- a/internal/console/apisidecars.go |
| +++ b/internal/console/apisidecars.go |
| @@ -329,6 +329,17 @@ func (s *Server) handleAPISidecarCreate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadGateway, "could not create the sidecar — try again") |
| return |
| } |
| + if m.Hibernate != nil && !*m.Hibernate { |
| + if err := s.checkAlwaysOnCreate(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: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + 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. |
| @@ -605,6 +616,20 @@ func (s *Server) handleAPISidecarEnable(w http.ResponseWriter, r *http.Request) |
| // swallow an operator's store-mode write landing in the same window, |
| // which is exactly the pool/regions policy this door promises not to |
| // touch. |
| + // Plans 3b: no plan offers workers that never sleep. Only a request that |
| + // asks for hibernate:false is refused; one that omits the field leaves an |
| + // existing manifest as it was. |
| + if req.Hibernate != nil && !*req.Hibernate { |
| + if err := s.checkAlwaysOn(ctx, 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 enable %s/%s: plan: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + return |
| + } |
| + } |
| m, etag, err := provision.GetSidecarManifestETag(ctx, s.cfg.Releases, acct.Sqid, app) |
| switch { |
| case errors.Is(err, os.ErrNotExist): |
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index 39310ba62..0b5ad3acb 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -179,7 +179,7 @@ func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) { |
| 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") |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| return |
| } |
| var req struct { |
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go| index a28a1247a..c28257532 100644 |
| --- a/internal/console/pendinghandlers.go |
| +++ b/internal/console/pendinghandlers.go |
| @@ -542,6 +542,21 @@ func (s *Server) executePendingPromote(ctx context.Context, acct *Account, app, |
| return "", staleProposal(aerr.Error()) |
| } |
| } |
| + // A daemon release waiting in a proposal meets the plan as it stands at |
| + // execution, not at propose time (Astra plans-3b finding 2). Rollbacks |
| + // too: the target need be on no channel now (round 2 finding 2). A retry |
| + // only renews the version the channel already serves. |
| + // With plans off this reads nothing (checkDaemonVersion). |
| + if !p.Retry { |
| + if cerr := s.checkDaemonVersion(ctx, acct, app, p.Version); cerr != nil { |
| + var planCap *planCapError |
| + if errors.As(cerr, &planCap) { |
| + return "", errors.New(planCap.Browser()) |
| + } |
| + log.Printf("console: execute promote %s/%s: plan: %v", acct.Sqid, app, cerr) |
| + return "", errors.New(planCheckFailedCopy) |
| + } |
| + } |
| res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channel, p.Version, |
| release.PromoteOptions{Actor: actor, Hotfix: p.Hotfix, Retry: p.Retry}) |
| if err != nil { |
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go| index f775fcf54..996e381e7 100644 |
| --- a/internal/console/plancaps.go |
| +++ b/internal/console/plancaps.go |
| @@ -6,8 +6,12 @@ import ( |
| "fmt" |
| "net/http" |
| "strings" |
| + "time" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/schedule" |
| ) |
| // Creation caps (plans-caps-packs spec §5.1, §8.1, §8.4; plans 3a). Every |
| @@ -233,7 +237,7 @@ func (s *Server) customDomainRefusal(acct *Account, host string) *doorRefusal { |
| if isPlanCap(err) { |
| return &doorRefusal{http.StatusConflict, err.Error()} |
| } |
| - return &doorRefusal{http.StatusBadGateway, "could not check this account's plan. Try again"} |
| + return &doorRefusal{http.StatusBadGateway, planCheckFailedCopy} |
| } |
| return nil |
| } |
| @@ -288,3 +292,227 @@ func (s *Server) planPersonLock(addr string) func() { |
| } |
| return s.acctLocks.lock("person:" + addr) |
| } |
| + |
| +// checkDaemon refuses a release that declares the daemon capability, which |
| +// keeps an app running all the time (plans 3b; spec §5.2). |
| +func (s *Server) checkDaemon(acct *Account, app string) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil { |
| + return err |
| + } |
| + if own, err := s.onOwnBoxes(acct, app); err != nil || own { |
| + return err |
| + } |
| + return &planCapError{msg: capMessage("cli.cap.daemon")} |
| +} |
| + |
| +// checkDaemonRelease is checkDaemon for a release already shipped: a |
| +// daemon release shipped before plans were on must not be promoted onto |
| +// shared boxes under one (Astra plans-3b finding 2). A release without the |
| +// capability passes. |
| +func (s *Server) checkDaemonRelease(acct *Account, app string, m *release.Manifest) error { |
| + if m == nil || m.Capabilities == nil || !m.Capabilities.Daemon { |
| + return nil |
| + } |
| + return s.checkDaemon(acct, app) |
| +} |
| + |
| +// checkDaemonVersion is checkDaemonRelease for a version named by its |
| +// channel history, as a rollback target is (Astra plans-3b round 2 |
| +// finding 2). It reads the manifest only when a plan applies to an app on |
| +// shared boxes, so with plans off a rollback depends on nothing it did not |
| +// before (round 3 finding 1). |
| +func (s *Server) checkDaemonVersion(ctx context.Context, acct *Account, app, version string) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil { |
| + return err |
| + } |
| + if own, err := s.onOwnBoxes(acct, app); err != nil || own { |
| + return err |
| + } |
| + m, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version) |
| + if err != nil { |
| + return err |
| + } |
| + if m.Capabilities == nil || !m.Capabilities.Daemon { |
| + return nil |
| + } |
| + return &planCapError{msg: capMessage("cli.cap.daemon")} |
| +} |
| + |
| +// planGuardError is a rollback guard that could not read what it checks. |
| +// Rollback wraps every hook failure in an AuthorizationError, so the doors |
| +// tell this apart from a deploy-scope refusal and answer it as a failed |
| +// plan check (round 3 finding 2). |
| +type planGuardError struct{ err error } |
| + |
| +func (e *planGuardError) Error() string { return e.err.Error() } |
| +func (e *planGuardError) Unwrap() error { return e.err } |
| + |
| +// rollbackDaemonGuard is the authorize hook a rollback door passes so the |
| +// daemon rule holds for the version the rollback actually writes. The door |
| +// checked the target it read, but the pointer can move before the write. |
| +// The hook runs after Rollback's own pointer read and before its |
| +// conditional write, so a pointer that moved in between fails that write, |
| +// and one that did not has the History[0] this re-read sees. nil when plans |
| +// are off or the app is on its own fleet. |
| +func (s *Server) rollbackDaemonGuard(ctx context.Context, acct *Account, app, channel string) (func(*release.Pipeline) error, error) { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil { |
| + return nil, err |
| + } |
| + if own, err := s.onOwnBoxes(acct, app); err != nil || own { |
| + return nil, err |
| + } |
| + return func(*release.Pipeline) error { |
| + cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) |
| + if err != nil { |
| + return &planGuardError{err} |
| + } |
| + if len(cur.History) == 0 { |
| + return nil // Rollback refuses this itself |
| + } |
| + m, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, cur.History[0]) |
| + if err != nil { |
| + return &planGuardError{err} |
| + } |
| + if m.Capabilities == nil || !m.Capabilities.Daemon { |
| + return nil |
| + } |
| + return &planCapError{msg: capMessage("cli.cap.daemon")} |
| + }, nil |
| +} |
| + |
| +// bothAuthorize runs a then b, either of which may be nil. |
| +func bothAuthorize(a, b func(*release.Pipeline) error) func(*release.Pipeline) error { |
| + switch { |
| + case a == nil: |
| + return b |
| + case b == nil: |
| + return a |
| + } |
| + return func(p *release.Pipeline) error { |
| + if err := a(p); err != nil { |
| + return err |
| + } |
| + return b(p) |
| + } |
| +} |
| + |
| +// checkAlwaysOnCreate refuses a new sidecar that would never sleep (its |
| +// app's manifest says hibernate:false) on shared boxes (plans 3b; Astra |
| +// plans-3b finding 3). A new sidecar of an app on its own fleet lands there. |
| +func (s *Server) checkAlwaysOnCreate(acct *Account, app string) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil { |
| + return err |
| + } |
| + if own, err := s.onOwnBoxes(acct, app); err != nil || own { |
| + return err |
| + } |
| + return &planCapError{msg: capMessage("cli.cap.always_on")} |
| +} |
| + |
| +// checkAlwaysOn refuses settings that would keep an app's sidecars awake |
| +// for good (plans 3b; spec §5.2). On its own fleet an app may, but only once |
| +// every existing sidecar runs there too: placing an app does not move its |
| +// sidecar records (Astra plans-3b finding 4). |
| +func (s *Server) checkAlwaysOn(ctx context.Context, acct *Account, app string) error { |
| + if err := s.checkAlwaysOnCreate(acct, app); !isPlanCap(err) { |
| + if err != nil { |
| + return err |
| + } |
| + if s.cfg.Releases == nil { |
| + return nil |
| + } |
| + l, _ := s.EffectiveLimits(acct) |
| + if l == nil { |
| + return nil |
| + } |
| + recs, err := provision.ListSidecarRecordsStrict(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + return err |
| + } |
| + if len(recs) == 0 { |
| + return nil |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return err |
| + } |
| + for _, rec := range recs { |
| + own, err := ownsFleet(reg, acct, rec.Region) |
| + if err != nil { |
| + return err |
| + } |
| + if !own { |
| + return &planCapError{msg: capMessage("cli.cap.always_on")} |
| + } |
| + } |
| + return nil |
| + } |
| + return &planCapError{msg: capMessage("cli.cap.always_on")} |
| +} |
| + |
| +// checkSchedule refuses a schedule that would run more often than the |
| +// plan's minimum interval (plans 3b; spec §5.2). Cron lines are held to |
| +// their shortest gap between firings. |
| +func (s *Server) checkSchedule(acct *Account, app string, sc schedule.Schedule) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil || l.MinScheduleSeconds <= 0 { |
| + return err |
| + } |
| + if own, err := s.onOwnBoxes(acct, app); err != nil || own { |
| + return err |
| + } |
| + shortest, err := sc.ShortestInterval() |
| + if err != nil { |
| + return err |
| + } |
| + if shortest < time.Duration(l.MinScheduleSeconds)*time.Second { |
| + return &planCapError{msg: capMessage("cli.cap.schedule", l.MinScheduleSeconds/60)} |
| + } |
| + return nil |
| +} |
| + |
| +// onOwnBoxes reports whether app is placed on one of the account's own |
| +// fleets. Always-on work there costs the deployment nothing, so the |
| +// never-sleep and schedule checks pass for it (Paul, 2026-10-24: always-on |
| +// on your own boxes). New sidecars of a placed app inherit its fleet region; |
| +// existing ones keep theirs, which checkAlwaysOn reads. A console with no |
| +// registry has no fleets, so nothing it serves is on own boxes. |
| +func (s *Server) onOwnBoxes(acct *Account, app string) (bool, error) { |
| + placement, err := s.store.AppPlacement(acct.ID, app) |
| + if err != nil { |
| + return false, err |
| + } |
| + if s.cfg.Registry == "" { |
| + return false, nil |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return false, err |
| + } |
| + if placement == "" { |
| + if placement, err = s.appPlacement(reg, acct, app); err != nil { |
| + return false, err |
| + } |
| + } |
| + return ownsFleet(reg, acct, placement) |
| +} |
| + |
| +// ownsFleet reports whether placement names a fleet acct itself owns. The |
| +// "fleet/<name>" shape alone proves nothing: fleet names are one namespace |
| +// across accounts, so an unknown or foreign fleet is shared boxes as far as |
| +// a plan is concerned (Astra plans-3b finding 5). |
| +func ownsFleet(reg consoleRegistry, acct *Account, placement string) (bool, error) { |
| + name, ok := registry.FleetPlacement(placement) |
| + if !ok { |
| + return false, nil |
| + } |
| + f, err := reg.GetFleet(name) |
| + if err != nil { |
| + return false, err |
| + } |
| + return f != nil && f.Account == acct.Sqid, nil |
| +} |
diff --git a/internal/console/plancaps_copy.go b/internal/console/plancaps_copy.go| index 6c43a14ef..81475dd29 100644 |
| --- a/internal/console/plancaps_copy.go |
| +++ b/internal/console/plancaps_copy.go |
| @@ -1,4 +1,5 @@ |
| -// Code generated from the approved copy review of 2026-10-22 (plans 3a). |
| +// Code generated from the approved copy reviews of 2026-10-22 (plans 3a) |
| +// and 2026-10-25 (plans 3b). |
| // Apply later edits through copy review, not by hand. |
| package console |
| @@ -15,4 +16,11 @@ var capCopy = map[string]string{ |
| "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", |
| + "cli.cap.daemon": "this release declares the daemon capability, which keeps an app running all the time. on shared boxes apps sleep when idle, so place the app on your own boxes (a fleet) to run it, or ship it without daemon", |
| + "cli.cap.always_on": "on shared boxes apps sleep when idle, so sidecars cannot be set to stay awake. place the app on your own boxes (a fleet) to keep them running, or remove hibernate: false", |
| + "cli.cap.schedule": "on shared boxes this plan runs schedules at most every %d minutes. choose a longer interval, place the app on your own boxes (a fleet), or move to a plan that allows more", |
| } |
| + |
| +// planCheckFailedCopy is the approved answer when an account's plan could |
| +// not be read (copy review 2026-10-22, cli.err.plan_check_failed). |
| +const planCheckFailedCopy = "could not check this account's plan. Try again" |
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go| index 78345a212..b8238cd82 100644 |
| --- a/internal/console/plancaps_doors_test.go |
| +++ b/internal/console/plancaps_doors_test.go |
| @@ -2,15 +2,21 @@ package console |
| import ( |
| "context" |
| + "errors" |
| "fmt" |
| "net/http" |
| "net/url" |
| + "path/filepath" |
| "strings" |
| "sync" |
| "testing" |
| + "time" |
| + "github.com/carlosframework/platform/internal/caps" |
| "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // atRunningCap turns plans on and leaves the fixture's ops account with |
| @@ -372,3 +378,531 @@ func TestFreeAccountCapIsExactUnderConcurrentCreates(t *testing.T) { |
| t.Fatalf("%d free accounts minted concurrently, want exactly 1", created) |
| } |
| } |
| + |
| +// ---- Plans 3b: things that never sleep ---- |
| + |
| +const daemonBlock = `{"v":1,"command":"relay","daemon":true}` |
| + |
| +func shipDaemon(t *testing.T, fx *consoleFixture, token, version string) (int, map[string]any) { |
| + t.Helper() |
| + return postShip(t, fx.ts.URL+"/api/cli/apps/bab/relayapp/releases", token, |
| + []shipPart{metaPartCaps(t, version, "binary", daemonBlock), {name: "linux-arm64", body: []byte("daemon-binary-bytes")}}) |
| +} |
| + |
| +func daemonFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "relayapp", "paul@keymail.dev", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "paul@keymail.dev", code) |
| +} |
| + |
| +func TestShipRefusesDaemonUnderAPlan(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-daemon") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + status, out := shipDaemon(t, fx, token, "dmn00001") |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("daemon ship under a plan = %d %v, want 409 cap", status, out) |
| + } |
| + if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "relayapp", "dmn00001"); err == nil { |
| + t.Fatal("a daemon release was written under a plan") |
| + } |
| +} |
| + |
| +func TestShipRefusesDaemonEvenOnReship(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-daemon-reship") |
| + if status, out := shipDaemon(t, fx, token, "dmn00002"); status != http.StatusCreated { |
| + t.Fatalf("setup ship with no catalog = %d %v", status, out) |
| + } |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + if status, out := shipDaemon(t, fx, token, "dmn00002"); status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("identical daemon re-ship under a plan = %d %v, want 409 cap", status, out) |
| + } |
| +} |
| + |
| +func TestShipDaemonWithoutACatalogUnchanged(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-daemon-nocat") |
| + if status, out := shipDaemon(t, fx, token, "dmn00003"); status != http.StatusCreated { |
| + t.Fatalf("daemon ship with no catalog = %d %v", status, out) |
| + } |
| +} |
| + |
| +func TestShipWithoutDaemonUnderAPlanUnchanged(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-nodaemon") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/relayapp/releases", token, |
| + []shipPart{metaPartCaps(t, "plain001", "binary", `{"v":1,"command":"relay"}`), {name: "linux-arm64", body: []byte("plain-binary-bytes")}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship without daemon under a plan = %d %v", status, out) |
| + } |
| +} |
| + |
| +func sidecarHibernate(t *testing.T, fx *consoleFixture) *bool { |
| + t.Helper() |
| + m, err := provision.GetSidecarManifest(context.Background(), fx.rel, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return m.Hibernate |
| +} |
| + |
| +func TestSidecarEnableRefusesHibernateFalseUnderAPlan(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-alwayson") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"hibernate":false}`) |
| + if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) { |
| + t.Fatalf("hibernate:false under a plan = %d %s, want 409 cap", status, out) |
| + } |
| + if h := sidecarHibernate(t, fx); h != nil && !*h { |
| + t.Fatal("the manifest was set to stay awake under a plan") |
| + } |
| +} |
| + |
| +func TestSidecarEnableKeepsAnExistingSettingWhenOmitted(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-alwayson-keep") |
| + m := titoSidecarManifest() |
| + off := false |
| + m.Hibernate = &off |
| + enableSidecars(t, fx, "titogo", m) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"idle_seconds":120}`); status != http.StatusOK { |
| + t.Fatalf("enable without a hibernate field = %d %s, want 200", status, out) |
| + } |
| + if h := sidecarHibernate(t, fx); h == nil || *h { |
| + t.Fatal("an existing setting was changed by an enable that did not name it") |
| + } |
| +} |
| + |
| +func TestSidecarEnableHibernateFalseWithoutACatalogUnchanged(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-alwayson-nocat") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"hibernate":false}`); status != http.StatusOK { |
| + t.Fatalf("hibernate:false with no catalog = %d %s, want 200", status, out) |
| + } |
| +} |
| + |
| +func setSchedule(t *testing.T, fx *consoleFixture, token, body string) int { |
| + t.Helper() |
| + status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token, body) |
| + return status |
| +} |
| + |
| +func TestScheduleRefusedBelowThePlanMinimum(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-cap-schedule") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + for _, body := range []string{ |
| + `{"name":"often","every":"5m","path":"/jobs/x"}`, |
| + `{"name":"often","cron":"*/1 * * * *","path":"/jobs/x"}`, |
| + } { |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token, body) |
| + if status != http.StatusConflict || !strings.Contains(string(out), `"code":"cap"`) { |
| + t.Errorf("%s on free = %d %s, want 409 cap", body, status, out) |
| + } |
| + } |
| + if _, etag := declaredSchedules(t, fx, "titogo"); etag != "" { |
| + t.Fatal("a refused schedule was written") |
| + } |
| + for _, body := range []string{ |
| + `{"name":"quarter","every":"15m","path":"/jobs/x"}`, |
| + `{"name":"quartercron","cron":"*/15 * * * *","path":"/jobs/y"}`, |
| + } { |
| + if status := setSchedule(t, fx, token, body); status != http.StatusOK { |
| + t.Errorf("%s on free = %d, want 200", body, status) |
| + } |
| + } |
| +} |
| + |
| +func TestScheduleMinimumOnAPaidPlan(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-cap-schedule-paid") |
| + 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) |
| + } |
| + if status := setSchedule(t, fx, token, `{"name":"minutely","every":"1m","path":"/jobs/x"}`); status != http.StatusOK { |
| + t.Fatalf("every 1m on personal = %d, want 200", status) |
| + } |
| +} |
| + |
| +func TestScheduleWithoutACatalogUnchanged(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-cap-schedule-nocat") |
| + if status := setSchedule(t, fx, token, `{"name":"minutely","every":"1m","path":"/jobs/x"}`); status != http.StatusOK { |
| + t.Fatalf("every 1m with no catalog = %d, want 200", status) |
| + } |
| +} |
| + |
| +// ---- Plans 3b: own boxes (Paul, 2026-10-24) ---- |
| + |
| +func placeOnFleet(t *testing.T, fx *consoleFixture, app string) { |
| + t.Helper() |
| + addFleet(t, fx, "ownbox", fx.sqid) |
| + placeOnNamedFleet(t, fx, app, "ownbox") |
| +} |
| + |
| +// addFleet registers a fleet owned by account in the fixture's registry. |
| +func addFleet(t *testing.T, fx *consoleFixture, name, account string) { |
| + t.Helper() |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + if err := reg.UpsertFleet(registry.Fleet{Name: name, Account: account, Bucket: name + "-bucket"}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// placeOnNamedFleet stores app's placement as fleet/<name>, whether or not |
| +// that fleet exists or is the account's own. |
| +func placeOnNamedFleet(t *testing.T, fx *consoleFixture, app, name string) { |
| + t.Helper() |
| + ops, _ := fx.st.AccountBySqid(fx.sqid) |
| + if err := fx.st.SetAppPlacement(ops.ID, app, registry.PlacementTarget(name)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Astra plans-3b finding 5: the fleet/<name> shape is not ownership. Only a |
| +// fleet the account owns exempts an app; a missing or foreign one does not, |
| +// and a registry that cannot be read is an error, not a pass. |
| +func TestOwnBoxesNeedsAFleetTheAccountOwns(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct, _ := fx.st.AccountBySqid(fx.sqid) |
| + addFleet(t, fx, "mine", fx.sqid) |
| + addFleet(t, fx, "theirs", "zzz") |
| + for _, tc := range []struct { |
| + fleet string |
| + own bool |
| + }{{"mine", true}, {"theirs", false}, {"nowhere", false}} { |
| + placeOnNamedFleet(t, fx, "kass", tc.fleet) |
| + got, err := fx.srv.onOwnBoxes(acct, "kass") |
| + if err != nil || got != tc.own { |
| + t.Errorf("onOwnBoxes on fleet/%s = %v, %v, want %v", tc.fleet, got, err, tc.own) |
| + } |
| + if err := fx.srv.checkDaemon(acct, "kass"); (err == nil) != tc.own { |
| + t.Errorf("checkDaemon on fleet/%s = %v, want refused=%v", tc.fleet, err, !tc.own) |
| + } |
| + } |
| + |
| + placeOnNamedFleet(t, fx, "kass", "mine") |
| + fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "missing", "registry.db") |
| + if _, err := fx.srv.onOwnBoxes(acct, "kass"); err == nil { |
| + t.Error("onOwnBoxes with an unreadable registry = nil error, want the failure") |
| + } |
| + if err := fx.srv.checkDaemon(acct, "kass"); err == nil || isPlanCap(err) { |
| + t.Errorf("checkDaemon with an unreadable registry = %v, want a non-cap error", err) |
| + } |
| +} |
| + |
| +func TestDaemonAllowedOnOwnBoxes(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-daemon-fleet") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + placeOnFleet(t, fx, "relayapp") |
| + if status, out := shipDaemon(t, fx, token, "dmn00010"); status != http.StatusCreated { |
| + t.Fatalf("daemon ship for an app on its own fleet = %d %v, want 201", status, out) |
| + } |
| +} |
| + |
| +func TestAlwaysOnSidecarsAllowedOnOwnBoxes(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-alwayson-fleet") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + placeOnFleet(t, fx, "titogo") |
| + if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"hibernate":false}`); status != http.StatusOK { |
| + t.Fatalf("hibernate:false for an app on its own fleet = %d %s, want 200", status, out) |
| + } |
| +} |
| + |
| +func TestFrequentSchedulesAllowedOnOwnBoxes(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-cap-schedule-fleet") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + placeOnFleet(t, fx, "titogo") |
| + if status := setSchedule(t, fx, token, `{"name":"minutely","every":"1m","path":"/jobs/x"}`); status != http.StatusOK { |
| + t.Fatalf("every 1m for an app on its own fleet = %d, want 200", status) |
| + } |
| +} |
| + |
| +// Astra plans-3b finding 3: a manifest set to stay awake before plans were |
| +// on must not let a new always-on worker be created on shared boxes. |
| +func TestSidecarCreateRefusedUnderAnAlwaysOnManifest(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-create-alwayson") |
| + m := titoSidecarManifest() |
| + off := false |
| + m.Hibernate = &off |
| + enableSidecars(t, fx, "titogo", m) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + 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("new worker under an always-on manifest on shared boxes = %d %s, want 409 cap", status, out) |
| + } |
| +} |
| + |
| +// Astra plans-3b finding 4: an app placed on its fleet whose existing |
| +// sidecars still run on shared boxes may not be switched to stay awake. |
| +func TestAlwaysOnRefusedWhileSidecarsRemainOnSharedBoxes(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-alwayson-moved") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + if status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("setup sidecar on shared boxes = %d %s", status, out) |
| + } |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + placeOnFleet(t, fx, "titogo") |
| + status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"hibernate":false}`) |
| + if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) { |
| + t.Fatalf("hibernate:false while a sidecar runs on shared boxes = %d %s, want 409 cap", status, out) |
| + } |
| +} |
| + |
| +// Astra plans-3b finding 2: a daemon release shipped before plans were on |
| +// must not be promoted on shared boxes under a plan. |
| +func TestPromoteRefusesADaemonReleaseUnderAPlan(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-daemon-promote") |
| + if status, out := shipDaemon(t, fx, token, "dmn00020"); status != http.StatusCreated { |
| + t.Fatalf("setup daemon ship with no catalog = %d %v", status, out) |
| + } |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/relayapp/promote", |
| + map[string]string{"version": "dmn00020", "channel": "canary/x"}, token) |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("promote a daemon release under a plan = %d %v, want 409 cap", status, out) |
| + } |
| + placeOnFleet(t, fx, "relayapp") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/relayapp/promote", |
| + map[string]string{"version": "dmn00020", "channel": "canary/x"}, token); status != http.StatusOK { |
| + t.Fatalf("promote a daemon release on its own fleet = %d %v, want 200", status, out) |
| + } |
| +} |
| + |
| +// shipDaemonVersion writes a release declaring the daemon capability |
| +// straight to the bucket, the state a ship from before plans were on leaves. |
| +func shipDaemonVersion(t *testing.T, fx *consoleFixture, app, version string) { |
| + t.Helper() |
| + m := release.Manifest{ |
| + Schema: 1, App: app, Version: version, Kind: "binary", |
| + BuiltAt: "2026-07-03T10:00:00Z", |
| + Artifacts: map[string]release.Artifact{"linux-amd64": {File: app}}, |
| + Capabilities: &caps.Set{V: caps.Version, Daemon: true}, |
| + } |
| + if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, m, |
| + map[string][]byte{"linux-amd64": []byte("binary " + version)}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestBrowserPromoteAndCanaryRefuseADaemonReleaseUnderAPlan(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipDaemonVersion(t, fx, "kass", "dmn1") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + postForm(t, c, fx.u("/apps/kass/promote"), |
| + url.Values{"csrf": {csrf}, "channel": {"canary/x"}, "version": {"dmn1"}, "confirmed": {"1"}}) |
| + if p := fx.channelVersion(t, "kass", "canary/x"); p.Version == "dmn1" { |
| + t.Fatal("the browser promoted a daemon release onto shared boxes under a plan") |
| + } |
| + postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"dmn1"}}) |
| + if _, _, err := fx.rel.Get(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "canary/rc")); err == nil { |
| + t.Fatal("a canary of a daemon release was created on shared boxes under a plan") |
| + } |
| + |
| + placeOnFleet(t, fx, "kass") |
| + postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"dmn1"}}) |
| + if p := fx.channelVersion(t, "kass", "canary/rc"); p.Version != "dmn1" { |
| + t.Fatalf("canary/rc = %+v on its own fleet, want dmn1", p) |
| + } |
| +} |
| + |
| +// A daemon proposal made before plans were on meets the plan at execution. |
| +func TestPendingPromoteOfADaemonReleaseRefusedUnderAPlan(t *testing.T) { |
| + fx, c, csrf := memberFixture(t) |
| + pipelinedApp(t, fx, "widget", "v2", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) |
| + shipDaemonVersion(t, fx, "widget", "dmn2") |
| + seedChannel(t, fx, "widget", "edge", "dmn2") |
| + seedChannel(t, fx, "widget", "production", "v2") |
| + |
| + if status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), |
| + url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"dmn2"}, "confirmed": {"1"}}); status != http.StatusSeeOther { |
| + t.Fatalf("proposal = %d -> %q:\n%s", status, loc, body) |
| + } |
| + pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) |
| + if err != nil || len(pending) != 1 { |
| + t.Fatalf("pending = %v, %v, want exactly one", pending, err) |
| + } |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + |
| + kim := newBrowser(t) |
| + fx.setUser("kim@keymail.dev") |
| + signUp(t, fx, kim, "code-kim-daemon-pending") |
| + postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), |
| + url.Values{"csrf": {sessionToken(t, fx, kim)}}) |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" { |
| + t.Fatalf("production serves %q after confirming a daemon proposal under a plan, want v2", got.Version) |
| + } |
| +} |
| + |
| +// Astra plans-3b round 2 finding 1: a sidecar record that cannot be read is |
| +// a worker nobody checked, so always-on is not granted past it. |
| +func TestAlwaysOnRefusedPastAnUnreadableSidecarRecord(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-alwayson-unreadable") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + if _, err := fx.rel.Put(context.Background(), provision.SidecarRecordKey(release.OpsAccount, "titogo", "bot"), []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + placeOnFleet(t, fx, "titogo") |
| + status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"hibernate":false}`) |
| + if status == http.StatusOK || status == http.StatusNoContent { |
| + t.Fatalf("hibernate:false past an unreadable sidecar record = %d %s, want a refusal", status, out) |
| + } |
| + m, err := provision.GetSidecarManifest(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| + if err != nil || m == nil || !m.HibernateOrDefault() { |
| + t.Fatalf("manifest after the refusal = %+v, %v, want hibernation still on", m, err) |
| + } |
| +} |
| + |
| +// daemonInHistory leaves canary/x serving an ordinary release with a daemon |
| +// release one rollback back, the state promotions made before plans were on |
| +// leave. |
| +func daemonInHistory(t *testing.T, fx *consoleFixture, app string) { |
| + t.Helper() |
| + shipDaemonVersion(t, fx, app, "dmn3") |
| + shipVersion(t, fx, app, "plain4") |
| + seedChannel(t, fx, app, "canary/x", "dmn3") |
| + seedChannel(t, fx, app, "canary/x", "plain4") |
| +} |
| + |
| +// Astra plans-3b round 2 finding 2: a rollback can put back a daemon |
| +// release that no channel serves any more, so it meets the rule too. |
| +func TestRollbackToADaemonReleaseRefusedUnderAPlan(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + daemonInHistory(t, fx, "kass") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-daemon-rollback") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/kass/rollback", map[string]string{"channel": "canary/x"}, token) |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("CLI rollback onto a daemon release under a plan = %d %v, want 409 cap", status, out) |
| + } |
| + c := signInPaul(t, fx) |
| + postForm(t, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {sessionToken(t, fx, c)}, "channel": {"canary/x"}, "confirmed": {"1"}}) |
| + if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "plain4" { |
| + t.Fatalf("canary/x = %s after rollbacks refused under a plan, want plain4", p.Version) |
| + } |
| + |
| + placeOnFleet(t, fx, "kass") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/kass/rollback", map[string]string{"channel": "canary/x"}, token); status != http.StatusOK { |
| + t.Fatalf("CLI rollback onto a daemon release on its own fleet = %d %v, want 200", status, out) |
| + } |
| +} |
| + |
| +// The guard re-reads the pointer at write time, so a target that changed |
| +// after the door's check is still held to the rule. |
| +func TestRollbackDaemonGuardReadsThePointerAtWriteTime(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "plain2") |
| + seedChannel(t, fx, "kass", "canary/x", "plain2") |
| + acct, _ := fx.st.AccountBySqid(fx.sqid) |
| + ctx := context.Background() |
| + |
| + if guard, err := fx.srv.rollbackDaemonGuard(ctx, acct, "kass", "canary/x"); err != nil || guard != nil { |
| + t.Fatalf("guard with plans off = %v, %v, want none", guard != nil, err) |
| + } |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + guard, err := fx.srv.rollbackDaemonGuard(ctx, acct, "kass", "canary/x") |
| + if err != nil || guard == nil { |
| + t.Fatalf("guard under a plan = %v, %v, want one", guard != nil, err) |
| + } |
| + daemonInHistory(t, fx, "kass") // the pointer moves after the door's check |
| + if err := guard(nil); !isPlanCap(err) { |
| + t.Fatalf("guard after the pointer moved onto a daemon target = %v, want a cap", err) |
| + } |
| + placeOnFleet(t, fx, "kass") |
| + if guard, err := fx.srv.rollbackDaemonGuard(ctx, acct, "kass", "canary/x"); err != nil || guard != nil { |
| + t.Fatalf("guard on its own fleet = %v, %v, want none", guard != nil, err) |
| + } |
| +} |
| + |
| +// A pending rollback onto a daemon release is checked at execution too. |
| +func TestPendingRollbackToADaemonReleaseRefusedUnderAPlan(t *testing.T) { |
| + fx, c, csrf := memberFixture(t) |
| + pipelinedApp(t, fx, "widget", "v2", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) |
| + shipDaemonVersion(t, fx, "widget", "dmn5") |
| + seedChannel(t, fx, "widget", "production", "dmn5") |
| + seedChannel(t, fx, "widget", "production", "v2") |
| + |
| + if status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/rollback"), |
| + url.Values{"csrf": {csrf}, "channel": {"production"}, "confirmed": {"1"}}); status != http.StatusSeeOther { |
| + t.Fatalf("rollback proposal = %d -> %q:\n%s", status, loc, body) |
| + } |
| + pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) |
| + if err != nil || len(pending) != 1 { |
| + t.Fatalf("pending = %v, %v, want exactly one", pending, err) |
| + } |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + kim := newBrowser(t) |
| + fx.setUser("kim@keymail.dev") |
| + signUp(t, fx, kim, "code-kim-daemon-rollback") |
| + postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), |
| + url.Values{"csrf": {sessionToken(t, fx, kim)}}) |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" { |
| + t.Fatalf("production serves %q after confirming a daemon rollback under a plan, want v2", got.Version) |
| + } |
| +} |
| + |
| +// Astra plans-3b round 3 finding 1: with plans off a rollback reads no |
| +// manifest, so one that cannot be read does not stop it. |
| +func TestRollbackWithoutACatalogNeedsNoManifest(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "old1") |
| + shipVersion(t, fx, "kass", "new2") |
| + seedChannel(t, fx, "kass", "canary/x", "old1") |
| + seedChannel(t, fx, "kass", "canary/x", "new2") |
| + if err := fx.rel.Delete(context.Background(), release.ManifestKey(release.OpsAccount, "kass", "old1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-nomanifest") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/kass/rollback", map[string]string{"channel": "canary/x"}, token); status != http.StatusOK { |
| + t.Fatalf("CLI rollback with plans off and the target's manifest unreadable = %d %v, want 200", status, out) |
| + } |
| + c := signInPaul(t, fx) |
| + postForm(t, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {sessionToken(t, fx, c)}, "channel": {"canary/x"}, "confirmed": {"1"}}) |
| + if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "new2" { |
| + t.Fatalf("canary/x = %s after a second rollback with plans off, want new2", p.Version) |
| + } |
| +} |
| + |
| +// Round 3 finding 2: a guard that cannot read is a failed plan check (502), |
| +// not a deploy refusal. |
| +func TestRollbackGuardReadFailureIsAPlanCheckFailure(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "old1") |
| + shipVersion(t, fx, "kass", "new2") |
| + seedChannel(t, fx, "kass", "canary/x", "old1") |
| + seedChannel(t, fx, "kass", "canary/x", "new2") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + if err := fx.rel.Delete(context.Background(), release.ManifestKey(release.OpsAccount, "kass", "old1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-guardfail") |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/kass/rollback", map[string]string{"channel": "canary/x"}, token) |
| + if status != http.StatusBadGateway || out["error"] != planCheckFailedCopy { |
| + t.Fatalf("CLI rollback with an unreadable target manifest under a plan = %d %v, want 502 %q", status, out, planCheckFailedCopy) |
| + } |
| + acct, _ := fx.st.AccountBySqid(fx.sqid) |
| + guard, err := fx.srv.rollbackDaemonGuard(context.Background(), acct, "kass", "canary/x") |
| + if err != nil || guard == nil { |
| + t.Fatalf("guard = %v, %v", guard != nil, err) |
| + } |
| + var ge *planGuardError |
| + if gerr := guard(nil); !errors.As(gerr, &ge) { |
| + t.Fatalf("guard read failure = %v, want a *planGuardError", gerr) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 433d08e15..49a1022e3 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -4532,10 +4532,21 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| back("err", "Invalid version.") |
| return |
| } |
| - if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil { |
| + man, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version) |
| + if err != nil { |
| back("err", "Version "+version+" is not a shipped release of "+app+".") |
| return |
| } |
| + if err := s.checkDaemonRelease(acct, app, man); err != nil { |
| + var planCap *planCapError |
| + if errors.As(err, &planCap) { |
| + back("err", planCap.Browser()) |
| + return |
| + } |
| + log.Printf("console: promote %s/%s: plan: %v", acct.Sqid, app, err) |
| + back("err", planCheckFailedCopy) |
| + return |
| + } |
| // Which gate this promotion meets is the pipeline's answer (release |
| // pipelines v2 §5), so the pipeline is read before any of them. An |
| // unreadable one refuses: "not safely nil" must never fall back to the |
| @@ -4798,6 +4809,16 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| target := cur.History[0] |
| + if err := s.checkDaemonVersion(ctx, acct, app, target); err != nil { |
| + var planCap *planCapError |
| + if errors.As(err, &planCap) { |
| + back("err", planCap.Browser()) |
| + return |
| + } |
| + log.Printf("console: rollback %s/%s: plan: %v", acct.Sqid, app, err) |
| + back("err", planCheckFailedCopy) |
| + return |
| + } |
| if !confirmed(r) { |
| s.confirmRollback(w, r, addr, raw, acct, role, app, channel, cur.Version, target) |
| return |
| @@ -4854,7 +4875,24 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| } |
| - if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil { |
| + guard, gerr := s.rollbackDaemonGuard(ctx, acct, app, channel) |
| + if gerr != nil { |
| + log.Printf("console: rollback %s/%s: plan: %v", acct.Sqid, app, gerr) |
| + back("err", planCheckFailedCopy) |
| + return |
| + } |
| + if err := release.RootSigner(s.cfg.Key).RollbackAuthorized(ctx, s.cfg.Releases, acct.Sqid, app, channel, guard); err != nil { |
| + var planCap *planCapError |
| + if errors.As(err, &planCap) { |
| + back("err", planCap.Browser()) |
| + return |
| + } |
| + var ge *planGuardError |
| + if errors.As(err, &ge) { |
| + log.Printf("console: rollback %s/%s: plan: %v", acct.Sqid, app, ge.err) |
| + back("err", planCheckFailedCopy) |
| + return |
| + } |
| log.Printf("console: rollback %s %s: %v", app, channel, err) |
| back("err", "Rollback failed: "+err.Error()) |
| return |
| @@ -5122,10 +5160,21 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| - if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil { |
| + man, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version) |
| + if err != nil { |
| back("err", "Version "+version+" is not a shipped release of "+app+".") |
| return |
| } |
| + if err := s.checkDaemonRelease(acct, app, man); err != nil { |
| + var planCap *planCapError |
| + if errors.As(err, &planCap) { |
| + back("err", planCap.Browser()) |
| + return |
| + } |
| + log.Printf("console: canary %s/%s: plan: %v", acct.Sqid, app, err) |
| + back("err", planCheckFailedCopy) |
| + return |
| + } |
| // New means new: an existing canary is promoted to (releases table) or |
| // retired, never quietly re-created. |
| if _, _, err := s.cfg.Releases.Get(ctx, release.ChannelKey(acct.Sqid, app, channel)); err == nil { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 7a4016832..678cca769 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 := wakeLatency3WriteCensusInventory() |
| + inventory := plans3bWriteCensusInventory() |
| 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/plans3b_census_test.go b/internal/provision/plans3b_census_test.go| new file mode 100644 |
| index 000000000..6f37bcc4e |
| --- /dev/null |
| +++ b/internal/provision/plans3b_census_test.go |
| @@ -0,0 +1,31 @@ |
| +package provision |
| + |
| +// Record-write census entries for plans 3b (plans-3b-never-sleep), layered |
| +// on wakeLatency3WriteCensusInventory (main's last at the branch's merge). |
| +// handleAPISidecarCreate gains one early return after the sidecar-count cap: |
| +// under a plan, an app whose sidecar manifest says hibernate:false is refused |
| +// a new sidecar on shared boxes (Astra plans-3b finding 3). Its write is |
| +// unchanged: the same CreateSidecarRecord create-only write, or the |
| +// host-claim create. No write site is added; the refusal writes nothing. |
| +// ListSidecarRecordsStrict is a new key site, not a writer: it lists the |
| +// app's sidecar records under SidecarsPrefix, as ListSidecarRecords does, |
| +// with no cap and failing on any unreadable record (round 2 finding 1). |
| +func plans3bWriteCensusInventory() []censusEntry { |
| + entries := append([]censusEntry(nil), wakeLatency3WriteCensusInventory()...) |
| + replacements := []censusEntry{ |
| + {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "77b9512efbfee3839265e83e671c00dbd97921460b115ef936eed810fcc409a7"}, |
| + {"internal/provision/sidecar.go", "ListSidecarRecordsStrict", "key-site", "key:SidecarsPrefix", "6f4990138a11b6c45ffce4cb44aa944403b609c672bc74b28a2188ecffabbe46"}, |
| + } |
| + for _, r := range replacements { |
| + found := false |
| + for i, e := range entries { |
| + if e.File == r.File && e.Unit == r.Unit { |
| + entries[i], found = r, true |
| + } |
| + } |
| + if !found { |
| + entries = append(entries, r) |
| + } |
| + } |
| + return entries |
| +} |
diff --git a/internal/provision/sidecar.go b/internal/provision/sidecar.go| index aa77c8a2c..6bdbcf8c2 100644 |
| --- a/internal/provision/sidecar.go |
| +++ b/internal/provision/sidecar.go |
| @@ -395,6 +395,40 @@ func ListSidecarRecords(ctx context.Context, s release.Store, acct, app string, |
| return recs, truncated, nil |
| } |
| +// ListSidecarRecordsStrict lists every one of an app's sidecar records, |
| +// with no cap, and fails on any record it cannot read or parse. A record |
| +// deleted between the list and its read is skipped. It is for decisions |
| +// that must see every worker: plans 3b grants always-on on an app's own |
| +// fleet only once each sidecar is there, and a skipped record would be a |
| +// worker nobody checked (Astra plans-3b round 2 finding 1). |
| +func ListSidecarRecordsStrict(ctx context.Context, s release.Store, acct, app string) ([]SidecarRecord, error) { |
| + prefix := SidecarsPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var recs []SidecarRecord |
| + for _, key := range keys { |
| + rest, ok := strings.CutPrefix(key, prefix) |
| + if !ok || rest == "" || strings.Contains(rest, "/") { |
| + continue |
| + } |
| + name, ok := strings.CutSuffix(rest, ".json") |
| + if !ok || name == "" { |
| + continue |
| + } |
| + rec, err := GetSidecarRecord(ctx, s, acct, app, name) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + recs = append(recs, *rec) |
| + } |
| + return recs, nil |
| +} |
| + |
| // DeleteSidecarRecord retires a sidecar's provisioning authorization. Object |
| // deletion only; Store's Delete is idempotent, so retiring an already-gone |
| // record is not an error. |
diff --git a/internal/provision/testdata/plan14-root-reads-chain.tsv b/internal/provision/testdata/plan14-root-reads-chain.tsv| index 9c3817b47..5c5adaa0d 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-chain.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-chain.tsv |
| @@ -12,3 +12,5 @@ plan14-root-reads-pro-6214-service-runs.tsv 86570d1326baad6d866b4066b6bf65218434 |
| plan14-root-reads-wake-latency-3.tsv 86570d1326baad6d866b4066b6bf65218434b201fb1f2c05ebc6e3c79f9c5a98 |
| plan14-root-reads-wake-latency-4-new.tsv 86570d1326baad6d866b4066b6bf65218434b201fb1f2c05ebc6e3c79f9c5a98 |
| plan14-root-reads-wake-latency-4.tsv 86570d1326baad6d866b4066b6bf65218434b201fb1f2c05ebc6e3c79f9c5a98 |
| +plan14-root-reads-plans-3b-fleet-owner.tsv 74ff021155e1f12ea81cac07f183b528bf5748d6fc06ae7903ed0f91842458f2 |
| +plan14-root-reads-plans-3b-never-sleep.tsv d62eabd30c03e73ec1348d8e64912678ff8523ac07252c9283ef9f445ca4e944 |
diff --git a/internal/provision/testdata/plan14-root-reads-plans-3b-fleet-owner.tsv b/internal/provision/testdata/plan14-root-reads-plans-3b-fleet-owner.tsv| new file mode 100644 |
| index 000000000..c2bcaaada |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-plans-3b-fleet-owner.tsv |
| @@ -0,0 +1,3 @@ |
| +# Census overlay plans-3b-fleet-owner, written by hand: a new read (the owner of a fleet), a decision for the census owner. census-ruling-plans-3b-fleet-owner; the card is docs/superpowers/notes/census-proposal-plans-3b-never-sleep.md. |
| +@legacy c041bcf8d643e5a7178342ee7834942f7aae28ff583c4e40094269b9e0fd5a28 internal/console/plancaps.go ownsFleet legacy accepted-legacy accepted-legacy;rev3=census-ruling-plans-3b-fleet-owner;basis=the console (cmd/console serve under console.service, user carlos-console): reads the owning account of the fleet an app or sidecar record is placed on, to decide whether a plan's never-sleep and schedule refusals apply; an owned fleet only lifts a console-side refusal, an unknown or foreign fleet keeps it, and nothing is granted on any box (Astra plans-3b finding 5) registry:Account@256,whole:Fleet@244 1ce3c80b1ed62a205c1ca8ca0d9482421165a1b621d1a5e573d155d235bd6ff5 |
| +internal/console/plancaps.go ownsFleet legacy accepted-legacy accepted-legacy;rev3=census-ruling-plans-3b-fleet-owner;basis=the console (cmd/console serve under console.service, user carlos-console): reads the owning account of the fleet an app or sidecar record is placed on, to decide whether a plan's never-sleep and schedule refusals apply; an owned fleet only lifts a console-side refusal, an unknown or foreign fleet keeps it, and nothing is granted on any box (Astra plans-3b finding 5) registry:Account@256,whole:Fleet@244 1ce3c80b1ed62a205c1ca8ca0d9482421165a1b621d1a5e573d155d235bd6ff5 |
diff --git a/internal/provision/testdata/plan14-root-reads-plans-3b-never-sleep.tsv b/internal/provision/testdata/plan14-root-reads-plans-3b-never-sleep.tsv| new file mode 100644 |
| index 000000000..35b5934e9 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-plans-3b-never-sleep.tsv |
| @@ -0,0 +1,7 @@ |
| +# Census overlay plans-3b-never-sleep, written by census-propose (#369). census-ruling-plans-3b-never-sleep, approved by the census owner before the merge; the approval card is docs/superpowers/notes/census-proposal-plans-3b-never-sleep.md. |
| +# 1 body, 0 sites, 3 envelope: each changed unit re-frozen at its existing disposition with its claim appended. |
| +@legacy 8d3bb7b6f3218fb051765cc3d958f58c32f1b2f43735b891f723d05d2f7e26ad internal/console/apicli.go Server.handleAPIShip legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;rev3=census-ruling-plans-3b-never-sleep;basis=after the capability block is validated, a release declaring daemon is refused (409 cap) for an app on shared boxes under a plan; an app on a fleet the account owns, or any app with plans off, ships as before. No new direct read at this site; the plan check it calls reads the account plan, the app placement and (through ownsFleet) the owner of the fleet named. file:ReadAll@11378 c431049c96beaa5858c1aaf58e23fa2f32c40c2319297cac2b42527a3b879359 |
| +internal/console/apicli.go Server.handleAPIShip legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;rev3=census-ruling-plans-3b-never-sleep;basis=after the capability block is validated, a release declaring daemon is refused (409 cap) for an app on shared boxes under a plan; an app on a fleet the account owns, or any app with plans off, ships as before. No new direct read at this site; the plan check it calls reads the account plan, the app placement and (through ownsFleet) the owner of the fleet named. file:ReadAll@11378 c431049c96beaa5858c1aaf58e23fa2f32c40c2319297cac2b42527a3b879359 |
| +@coverage internal/console 9dca8ef71869e2f8700f06d96f0f74049f3bcc569a171b5e8e6aef8e1f39fc7d |
| +@coverage internal/provision 83a571025e988adf3dd9ffa4afcc4ac0d1cbdc9d41f4555886863ab577ed0212 |
| +@coverage internal/schedule f749883df03257d26076da5bb9add4243e453c488688e7ce4324d1c1df8b221f |
diff --git a/internal/schedule/mingap.go b/internal/schedule/mingap.go| new file mode 100644 |
| index 000000000..fb5385add |
| --- /dev/null |
| +++ b/internal/schedule/mingap.go |
| @@ -0,0 +1,93 @@ |
| +package schedule |
| + |
| +import ( |
| + "errors" |
| + "time" |
| +) |
| + |
| +// ShortestInterval is the least time between two runs of s: its every: |
| +// duration, or for a cron line the shortest gap between two of its firings. |
| +// Plans 3b uses it to hold schedules to a plan's minimum. |
| +// |
| +// The cron gap is exact over the whole Gregorian calendar, not sampled from |
| +// a window: "*/1 * * 4 *" fires every minute in April only, and |
| +// "0,5 0 29 2 *" bursts on a leap day (Astra plans-3b finding 1), while |
| +// "0,55 0,23 * * 1" never fires on consecutive days, so its gap is 55 |
| +// minutes, not the 5 a day-blind bound would claim (Astra plans-3b round 2 |
| +// finding 3). Cron runs in UTC here, so there is no daylight-saving shift. |
| +func (s Schedule) ShortestInterval() (time.Duration, error) { |
| + sp, err := s.Spec() |
| + if err != nil { |
| + return 0, err |
| + } |
| + switch { |
| + case sp.Every > 0: |
| + return sp.Every, nil |
| + case sp.Cron != nil: |
| + return sp.Cron.shortestGap(), nil |
| + } |
| + return 0, errors.New("schedule has no interval") |
| +} |
| + |
| +// gregorianCycleDays is the length of the Gregorian calendar's 400-year |
| +// cycle. It is a whole number of weeks, so dates, weekdays and leap days all |
| +// repeat after it. |
| +const gregorianCycleDays = 146097 |
| + |
| +// shortestGap is the smallest gap between two firings: within a matching |
| +// day, or from one matching day's last firing to the next matching day's |
| +// first, with the days taken from a whole 400-year cycle. |
| +func (c *CronExpr) shortestGap() time.Duration { |
| + var times []int // minutes of the day, ascending |
| + for h := 0; h < 24; h++ { |
| + if c.hour&(1<<uint(h)) == 0 { |
| + continue |
| + } |
| + for m := 0; m < 60; m++ { |
| + if c.min&(1<<uint(m)) != 0 { |
| + times = append(times, h*60+m) |
| + } |
| + } |
| + } |
| + days := c.closestMatchingDays() |
| + if len(times) == 0 || days == 0 { |
| + return 24 * time.Hour // never fires |
| + } |
| + shortest := days*24*60 - times[len(times)-1] + times[0] // last firing to the next matching day's first |
| + for i := 1; i < len(times); i++ { |
| + if gap := times[i] - times[i-1]; gap < shortest { |
| + shortest = gap |
| + } |
| + } |
| + return time.Duration(shortest) * time.Minute |
| +} |
| + |
| +// closestMatchingDays is the fewest days between two days the line fires on |
| +// (1 for consecutive days), or 0 if it fires on no day at all. A line that |
| +// matches one day per cycle is that cycle's length apart from itself. |
| +func (c *CronExpr) closestMatchingDays() int { |
| + t := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) |
| + first, prev, closest := -1, -1, 0 |
| + for i := 0; i < gregorianCycleDays; i, t = i+1, t.Add(24*time.Hour) { |
| + if c.month&(1<<uint(t.Month())) == 0 || !c.dayMatches(t) { |
| + continue |
| + } |
| + if prev >= 0 && (closest == 0 || i-prev < closest) { |
| + closest = i - prev |
| + if closest == 1 { |
| + return 1 |
| + } |
| + } |
| + if first < 0 { |
| + first = i |
| + } |
| + prev = i |
| + } |
| + if first < 0 { |
| + return 0 |
| + } |
| + if wrap := gregorianCycleDays - prev + first; closest == 0 || wrap < closest { |
| + closest = wrap |
| + } |
| + return closest |
| +} |
diff --git a/internal/schedule/mingap_test.go b/internal/schedule/mingap_test.go| new file mode 100644 |
| index 000000000..60efe6295 |
| --- /dev/null |
| +++ b/internal/schedule/mingap_test.go |
| @@ -0,0 +1,43 @@ |
| +package schedule |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestCronMinGap(t *testing.T) { |
| + for _, c := range []struct { |
| + sc Schedule |
| + want time.Duration |
| + }{ |
| + {Schedule{Name: "a", Cron: "*/1 * * * *", Path: "/x"}, time.Minute}, |
| + {Schedule{Name: "a", Cron: "*/15 * * * *", Path: "/x"}, 15 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "0,5 * * * *", Path: "/x"}, 5 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "0 3 * * *", Path: "/x"}, 24 * time.Hour}, |
| + // Day fields count, over the whole 400-year calendar: seasonal, |
| + // leap-day and weekday lines are held to their real gaps, neither |
| + // shorter (round 2 finding 3) nor longer (round 1 finding 1). |
| + {Schedule{Name: "a", Cron: "0 9 * * 1", Path: "/x"}, 7 * 24 * time.Hour}, |
| + {Schedule{Name: "a", Cron: "0,55 0,23 * * 1", Path: "/x"}, 55 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "0,55 0,23 * * 1,2", Path: "/x"}, 5 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "0,55 0,23 13 * 5", Path: "/x"}, 5 * time.Minute}, // the 13th or a Friday: Thursday 13th, Friday 14th |
| + {Schedule{Name: "a", Cron: "0,59 0,23 28,29 2 *", Path: "/x"}, time.Minute}, // consecutive only in leap years |
| + {Schedule{Name: "a", Cron: "0,59 0,23 29 2 *", Path: "/x"}, 59 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "0,55 0,23 31 * *", Path: "/x"}, 55 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "0 0 30 2 *", Path: "/x"}, 24 * time.Hour}, // never fires |
| + {Schedule{Name: "a", Cron: "*/1 * * 4 *", Path: "/x"}, time.Minute}, |
| + {Schedule{Name: "a", Cron: "0,5 0 29 2 *", Path: "/x"}, 5 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "*/1 * 13 * 5", Path: "/x"}, time.Minute}, |
| + {Schedule{Name: "a", Cron: "55 23,0 * * *", Path: "/x"}, 60 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "5,55 0,23 * * *", Path: "/x"}, 10 * time.Minute}, |
| + {Schedule{Name: "a", Every: "10m", Path: "/x"}, 10 * time.Minute}, |
| + } { |
| + got, err := c.sc.ShortestInterval() |
| + if err != nil || got != c.want { |
| + t.Errorf("%+v: ShortestInterval = %v, %v; want %v", c.sc, got, err, c.want) |
| + } |
| + } |
| + if _, err := (Schedule{Name: "a", Cron: "nope", Path: "/x"}).ShortestInterval(); err == nil { |
| + t.Error("an invalid cron line had a shortest interval") |
| + } |
| +} |