carlos / platform Public

plans-2-box-limits

Squashed from plans-2-box-limits into main.
claude agent, operated by paul@keymail.dev 0235046428a9ec7b0314fb23bc420b27490482a6 parent f6ba443
33 files changed, +1329 −9
  • cmd/console/main.go +3 −0
  • docs/superpowers/plans/2026-10-21-plans-2-box-limits.md +227 −0
  • docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md +35 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-2-CODE.md +42 −0
  • internal/activator/activator.go +22 −0
  • internal/activator/activator_idlefor_test.go +113 −0
  • internal/console/accountplans.go +9 −2
  • internal/console/accountplans_test.go +18 −0
  • internal/console/apiaccountplan.go +5 −0
  • internal/console/apiaccountplan_test.go +19 −0
  • internal/console/claim_invariant_fixtures_test.go +1 −0
  • internal/console/claim_invariant_probes_worker_test.go +11 −0
  • internal/console/claim_workers.go +1 −0
  • internal/console/plansmarker.go +63 −0
  • internal/console/plansmarker_test.go +111 −0
  • internal/console/testdata/claim-routes.txt +1 −0
  • internal/edge/storedoor.go +2 −1
  • internal/edge/storedoor_test.go +14 −0
  • internal/host/agent.go +7 −0
  • internal/host/plans_idle_test.go +29 −0
  • internal/host/plans_pass.go +137 −0
  • internal/host/plans_pass_test.go +235 −0
  • internal/hostenv/hostenv.go +1 −0
  • internal/hostops/limits_classifier_test.go +20 −0
  • internal/hostops/mutation_classifier.go +1 −0
  • internal/plans/catalog.go +21 −5
  • internal/plans/catalog_test.go +14 −0
  • internal/plans/marker.go +60 −0
  • internal/plans/marker_test.go +58 −0
  • internal/provision/plan14_root_reads_test.go +13 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/plans2_census_test.go +12 −0
  • internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv +23 −0

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (18 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (165 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.569s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.763s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.793s
ok  	github.com/carlosframework/platform/internal/activator	59.714s
ok  	github.com/carlosframework/platform/internal/agentsock	0.026s
ok  	github.com/carlosframework/platform/internal/alerting	0.010s
ok  	github.com/carlosframework/platform/internal/analytics	0.053s
ok  	github.com/carlosframework/platform/internal/appconfig	2.813s
ok  	github.com/carlosframework/platform/internal/apperr	0.142s
ok  	github.com/carlosframework/platform/internal/applog	0.250s
ok  	github.com/carlosframework/platform/internal/awsinv	0.009s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.006s
ok  	github.com/carlosframework/platform/internal/boxid	0.903s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.014s
ok  	github.com/carlosframework/platform/internal/certwarm	0.005s
ok  	github.com/carlosframework/platform/internal/cgroup	0.805s
ok  	github.com/carlosframework/platform/internal/changelog	0.450s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.074s
ok  	github.com/carlosframework/platform/internal/clamd	0.068s
?   	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	5.676s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.012s
ok  	github.com/carlosframework/platform/internal/dbverify	0.008s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.006s
ok  	github.com/carlosframework/platform/internal/docsite	0.011s
ok  	github.com/carlosframework/platform/internal/domains	2.707s
ok  	github.com/carlosframework/platform/internal/doorkey	0.010s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	45.692s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.011s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.133s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.018s
ok  	github.com/carlosframework/platform/internal/geo	0.013s
ok  	github.com/carlosframework/platform/internal/geoip	0.035s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.155s
ok  	github.com/carlosframework/platform/internal/host	136.789s
ok  	github.com/carlosframework/platform/internal/hostactors	5.724s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.007s
ok  	github.com/carlosframework/platform/internal/hostbox	2.951s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.822s
ok  	github.com/carlosframework/platform/internal/hostenv	0.007s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.011s
ok  	github.com/carlosframework/platform/internal/hostexec	5.206s
ok  	github.com/carlosframework/platform/internal/hostfence	0.043s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.319s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.027s
ok  	github.com/carlosframework/platform/internal/hostguard	1.071s
ok  	github.com/carlosframework/platform/internal/hostops	1.970s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.007s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.007s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.598s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.184s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.007s
ok  	github.com/carlosframework/platform/internal/infraview	0.007s
ok  	github.com/carlosframework/platform/internal/instancectl	5.237s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.794s
ok  	github.com/carlosframework/platform/internal/instancefs	0.011s
ok  	github.com/carlosframework/platform/internal/instancekey	0.007s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.011s
ok  	github.com/carlosframework/platform/internal/layout	1.740s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.003s
ok  	github.com/carlosframework/platform/internal/lease	10.261s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.541s
ok  	github.com/carlosframework/platform/internal/ledger	0.017s
ok  	github.com/carlosframework/platform/internal/mail	0.283s
ok  	github.com/carlosframework/platform/internal/maintenance	0.094s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.596s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.117s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	22.598s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.977s
ok  	github.com/carlosframework/platform/internal/objstore	0.309s
ok  	github.com/carlosframework/platform/internal/ops	0.182s
ok  	github.com/carlosframework/platform/internal/park	0.318s
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	22.685s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.008s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.589s
ok  	github.com/carlosframework/platform/internal/relay	7.243s
ok  	github.com/carlosframework/platform/internal/release	27.004s
ok  	github.com/carlosframework/platform/internal/rollout	3.776s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.008s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.068s
ok  	github.com/carlosframework/platform/internal/rootstate	0.028s
ok  	github.com/carlosframework/platform/internal/runs	0.511s
ok  	github.com/carlosframework/platform/internal/s3compat	0.007s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.600s
ok  	github.com/carlosframework/platform/internal/seeds	0.011s
ok  	github.com/carlosframework/platform/internal/sim/engines	2.560s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	11.575s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.732s
ok  	github.com/carlosframework/platform/internal/status	0.077s
ok  	github.com/carlosframework/platform/internal/steering	0.008s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.310s
ok  	github.com/carlosframework/platform/internal/svcrole	0.004s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.007s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.007s
ok  	github.com/carlosframework/platform/internal/updatewords	0.006s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.003s
ok  	github.com/carlosframework/platform/internal/vet	1.719s
=== internal_console@0 (85 s)
ok  	github.com/carlosframework/platform/internal/console	83.199s
=== internal_console@1 (38 s)
ok  	github.com/carlosframework/platform/internal/console	36.125s
=== internal_console@2 (38 s)
ok  	github.com/carlosframework/platform/internal/console	35.876s
=== internal_console@3 (38 s)
ok  	github.com/carlosframework/platform/internal/console	35.870s
=== internal_console@4 (40 s)
ok  	github.com/carlosframework/platform/internal/console	37.749s
=== internal_console@5 (39 s)
ok  	github.com/carlosframework/platform/internal/console	37.106s
=== internal_console@6 (44 s)
ok  	github.com/carlosframework/platform/internal/console	41.691s
=== internal_console@7 (41 s)
ok  	github.com/carlosframework/platform/internal/console	38.791s
=== root@0 (34 s)
ok  	github.com/carlosframework/platform	32.204s
=== root@1 (45 s)
ok  	github.com/carlosframework/platform	42.973s
=== root@2 (45 s)
ok  	github.com/carlosframework/platform	43.508s
=== 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: 50069666 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.023s
=== RUN   TestT4StorageProbeMinio
2026/10/22 08:26:30 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0CC9492DFFC22, 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.021s
minio contract: PASS
diff --git a/cmd/console/main.go b/cmd/console/main.go
index b2964f92e..3d0d11779 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -530,6 +530,9 @@ func serve() {
// The plan limits pass (internal/console/planlimits.go): publishes each
// account's limits record. A no-op with CARLOS_PLAN_CATALOG unset.
go srv.RunClaimWorker(context.Background(), "StartPlanLimitsPass")
+ // The plans deployment marker (internal/console/plansmarker.go): once per
+ // start. Writes nothing on a deployment that never set a catalog.
+ go srv.RunClaimWorker(context.Background(), "PublishPlansMarker")
// The run sweep (internal/console/runsweep.go): expires queued runs,
// ends lost ones, deletes a day-old run's objects. Same placement rule.
go srv.RunClaimWorker(context.Background(), "StartRunSweep")
diff --git a/docs/superpowers/plans/2026-10-21-plans-2-box-limits.md b/docs/superpowers/plans/2026-10-21-plans-2-box-limits.md
new file mode 100644
index 000000000..30569f2a9
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-21-plans-2-box-limits.md
@@ -0,0 +1,227 @@
+# Plans 2: Boxes Read Account Limits Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Boxes read each account's published limits and apply the plan's idle window to instances and sidecar workers. Self-hosted deployments without a plan catalog see no change.
+
+**Architecture:** The console publishes a deployment marker `control/plans.json` (enabled plus the catalog id) once at start. On every box the host agent's existing full and five-minute passes run a `plansPass` beside `telemetryPass`. It reads the marker, and only when plans are enabled does it read `accounts/<sqid>/limits.json` for the accounts this box serves, into a last-known-good in-memory cache. The activator gets an `IdleFor` hook that can only shorten an entry's idle window. A box-level switch `CARLOS_PLAN_LIMITS=off` turns all of it off with zero bucket reads.
+
+**Tech Stack:** Go; `internal/plans`, `internal/console`, `internal/host`, `internal/activator`, `internal/hostops`, `internal/edge`.
+
+**Spec:** `docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md` §5.2 (idle window), §7 (how limits reach the boxes; fail open), §10 (provider boundary). Builds on Plan 1 (`docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md`, merged f550afa).
+
+## Self-hosters (Paul, 2026-10-21: "make sure this isn't going to interrupt them")
+
+Three guards, each pinned by a test:
+
+1. **No catalog, no change.** Without `CARLOS_PLAN_CATALOG` the console never writes `control/plans.json` as enabled. A box that finds the marker absent or disabled clears its cache and reads no limits record: one small read per pass (the marker), nothing else, and every idle window is exactly today's.
+2. **No record, no limits.** A box with no limits record for an account runs that account exactly as today (fail open, spec §7).
+3. **A box can opt out.** `CARLOS_PLAN_LIMITS=off` (also `false`, `0`, `no`) makes the box ignore plans entirely and read nothing.
+
+A deployment that turns its catalog off again: the console's next start writes the marker disabled, and boxes revert within one pass.
+
+## Scope changes from the spec's plan list (rulings, author's)
+
+- The **small memory tier** moves to Plan 2b. It touches nine allowlists across `internal/cgroup`, `internal/instancectl` and the root claim launch code, plus unit-backed apps' rendered drop-ins, and most of that is root-path code with its own census ruling.
+- **Throttling and the continuous-awake ceiling** move to Plan 4, where the grace engine that triggers them lives. Plan 2 carries no throttle state.
+- Plan 1's two deferred minors are fixed here (Task 1, Task 2).
+
+## Global Constraints
+
+- No new module dependencies.
+- Plans can only **shorten** an idle window: an entry's window is `min(its current window, plan idle)` (spec §5.2 "the smaller of its own and the plan's"). A plan never lengthens a box's `CARLOS_HIBERNATE_IDLE` and never touches an always-on worker.
+- Fail open everywhere on the box: a read error keeps the last good value, an absent record means no limits.
+- New box env vars go in `hostenv.Optional` (`internal/hostenv/hostenv.go:71`).
+- New bucket keys need a hostops classification and, if fleet boxes read them, the storedoor allowlist (`internal/edge/storedoor.go:436`).
+- New console workers: `claim_workers.go`, `cmd/console/main.go` RunClaimWorker, `testdata/claim-routes.txt`, a claim fixture and probe.
+- CI shards tests: no test depends on another top-level test having run first. New `internal/host` tests call `t.Parallel()` unless they set env, cwd, umask, log output or package vars; the cache is a package var, so tests that touch it stay serial. New `internal/activator` tests call `t.Parallel()` too unless they write package vars, env, log output or the umask, or listen on TCP (the rule above activator's TestMain, ci-speed-activator 4f55bfb). The Task 5 overlay loads after `plan14-root-reads-ci-speed-activator`.
+- Every change to covered packages re-pins the #280 root-read overlay; the record-write census layer may need entries too (Task 5).
+- Copy: this plan adds no human-facing text except log lines (not reviewed).
+- Local testing: touched packages only, with `-run`; CI runs the full suite.
+
+## Review Focus
+
+1. **Plans off.** With the marker absent, a box must make exactly one bucket read per pass (the marker) and apply no limits. Pinned in Task 3 (`TestPlansPassOffReadsOnlyTheMarker`).
+2. **Box switch off.** `CARLOS_PLAN_LIMITS=off` must make zero bucket reads and clear any cache. Pinned in Task 3 (`TestPlansPassBoxSwitchOffReadsNothing`).
+3. **Bucket blips.** A marker read error keeps the whole last-good cache; a per-account read error keeps that account's last value. Pinned in Task 3 (`TestPlansPassKeepsLastGoodOnErrors`).
+4. **Plans turned off again.** Marker enabled, then disabled: the next pass clears the cache and idle windows revert. Pinned in Task 3 (`TestPlansPassClearsWhenDisabled`) and Task 4 (`TestIdleForNilOrZeroChangesNothing`).
+5. **A plan idle longer than the box's default.** The box default stands. Pinned in Task 4 (`TestPlanIdleOnlyShortens`).
+
+---
+
+### Task 1: The deployment marker, and checked pack arithmetic
+
+**Files:**
+- Create: `internal/plans/marker.go`, `internal/plans/marker_test.go`
+- Modify: `internal/plans/catalog.go` (`Effective`), `internal/plans/catalog_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ const MarkerSchema = 1
+ type Marker struct {
+ Schema int `json:"schema"`
+ Enabled bool `json:"enabled"`
+ CatalogID string `json:"catalog_id,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+ }
+ func MarkerKey() string // "control/plans.json"
+ func GetMarker(ctx context.Context, s release.Store) (*Marker, string, error) // nil, "", nil when absent
+ func PutMarker(ctx context.Context, s release.Store, m *Marker, ifMatch string) (string, error)
+ ```
+ `GetMarker` refuses an unknown schema (error, so the box keeps its last good state).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestMarkerKey(t *testing.T) // MarkerKey() == "control/plans.json"
+func TestMarkerRoundTrip(t *testing.T) // PutMarker create-only then GetMarker equal; absent -> nil, "", nil
+func TestMarkerRefusesUnknownSchema(t *testing.T) // a stored {"schema": 2} -> GetMarker error
+func TestPutMarkerIsCAS(t *testing.T) // second create-only put -> release.ErrCASConflict
+func TestEffectiveRefusesOverflow(t *testing.T) // pack storage adds 1<<62, quantity 2, ceiling 100 -> error wrapping ErrPackQuantity,
+ // never a negative or wrapped limit (Astra plans-1 finding 5)
+```
+
+- [ ] **Step 2: Run them to verify they fail** — `go test ./internal/plans/` → FAIL (undefined `MarkerKey`).
+
+- [ ] **Step 3: Implement** `marker.go` on the pattern of `record.go`. In `Effective`, compute each pack's contribution with checked multiplication and addition (`math/bits.Mul64`/overflow checks, or compare against `math.MaxInt64 / qty`) and return `fmt.Errorf("%w: %q overflows", ErrPackQuantity, name)` on overflow.
+
+- [ ] **Step 4: Run** `go test ./internal/plans/` → PASS.
+
+- [ ] **Step 5: Commit** `plans: the deployment marker, and checked pack arithmetic`
+
+### Task 2: The console publishes the marker; plan writes need a live account
+
+**Files:**
+- Create: `internal/console/plansmarker.go`, `internal/console/plansmarker_test.go`
+- Modify: `internal/console/accountplans.go` (`SetAccountPlan`), `internal/console/accountplans_test.go`, `internal/console/apiaccountplan.go`, `internal/console/apiaccountplan_test.go`
+- Modify: `internal/console/claim_workers.go`, `cmd/console/main.go`, `internal/console/testdata/claim-routes.txt`, `internal/console/claim_invariant_fixtures_test.go`, `internal/console/claim_invariant_probes_worker_test.go`
+- Modify: `internal/hostops/mutation_classifier.go`, `internal/hostops/limits_classifier_test.go`
+- Modify: `internal/edge/storedoor.go` (`aclRead`), its test file
+
+**Interfaces:**
+- Consumes: Task 1.
+- Produces:
+ ```go
+ func (s *Server) PublishPlansMarker(ctx context.Context) // claim worker "PublishPlansMarker"; runs once
+ var ErrAccountGone = errors.New("account deleted") // console package
+ ```
+ `SetAccountPlan` returns `ErrAccountGone` and writes nothing when the account's `deleted_at` is set (Astra plans-1 finding 3); the plan door answers 404 for it.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// plansmarker_test.go
+func TestPublishPlansMarkerOffAndAbsentWritesNothing(t *testing.T) // no catalog, no marker: bucket unchanged
+func TestPublishPlansMarkerEnables(t *testing.T) // catalog set: marker Enabled, CatalogID == catalog.ID()
+func TestPublishPlansMarkerIsIdempotent(t *testing.T) // second run with the same catalog: etag unchanged
+func TestPublishPlansMarkerDisablesWhenCatalogRemoved(t *testing.T) // marker enabled, then a run with no catalog: Enabled false
+// accountplans_test.go
+func TestSetAccountPlanRefusesADeletedAccount(t *testing.T) // deleted_at set: ErrAccountGone, no row
+// apiaccountplan_test.go
+func TestAccountPlanSetOnADeletedAccountIs404(t *testing.T) // deleted between lookup and write: 404, no record published
+// hostops limits_classifier_test.go
+func TestClassifyPlansMarkerIsExact(t *testing.T) // Put on control/plans.json: non-app; Delete and lookalikes: ErrUnclassified
+// edge storedoor test
+func TestFleetBoxCanReadThePlansMarker(t *testing.T) // aclRead(sqid, fleet, "control/plans.json") == true
+```
+
+- [ ] **Step 2: Run** the touched packages' tests → FAIL.
+
+- [ ] **Step 3: Implement.** `PublishPlansMarker`: with a catalog, `GetMarker`; if absent or different (`!Enabled || CatalogID != cat.ID()`), `PutMarker` with the read etag. Without a catalog, `GetMarker`; only if present and `Enabled`, write it `Enabled: false`. Log, never fail. `SetAccountPlan`: guard the insert and the update with `EXISTS (SELECT 1 FROM accounts WHERE id = ? AND deleted_at = 0)`, returning `ErrAccountGone` when no row changes. hostops: add `{exact: "control/plans.json", reason: "plans deployment marker published by the console; no app state"}` to `nonAppFamilies` (deletes false). storedoor: add `plans.MarkerKey()` to the exact case in `aclRead`.
+
+- [ ] **Step 4: Register and start the worker** (`claim_workers.go`, `cmd/console/main.go` beside `StartPlanLimitsPass`), add `worker PublishPlansMarker` to `claim-routes.txt`, a `claimNonApp` fixture and a probe asserting the marker is written when a catalog is set.
+
+- [ ] **Step 5: Run** `go test ./internal/plans/ ./internal/hostops/ ./internal/edge/ -run 'Marker|Classify|Fleet'`, `go test ./internal/console/ -run 'PlansMarker|AccountPlan|ClaimConsoleRouteCensus'`, `go test ./cmd/console/ -run TestEveryRegisteredClaimWorkerIsStarted` → PASS.
+
+- [ ] **Step 6: Commit** `console: publish the plans marker; plan writes need a live account`
+
+### Task 3: The box's plans pass and cache
+
+**Files:**
+- Create: `internal/host/plans_pass.go`, `internal/host/plans_pass_test.go`
+- Modify: `internal/host/agent.go` (call `plansPass` beside both `telemetryPass` calls, `agent.go:2809` and `:3395`), `internal/hostenv/hostenv.go` (`CARLOS_PLAN_LIMITS`)
+
+**Interfaces:**
+- Consumes: `plans.GetMarker`, `plans.GetLimits` (Task 1, Plan 1).
+- Produces:
+ ```go
+ func plansPass(ctx context.Context, s release.Store, reg *registry.Registry, getenv func(string) string)
+ func planLimitsFor(sqid string) (plans.Limits, bool) // false when plans are off or the account has no record
+ func planLimitsSwitchOn(getenv func(string) string) bool // false for off/false/0/no (case-insensitive)
+ ```
+ The cache is a package-level `atomic.Pointer` to an immutable snapshot `{byAccount map[string]plans.Limits}`; `nil` means off.
+
+- [ ] **Step 1: Write the failing tests** (serial: they touch the package cache), using `release.NewDirStore(t.TempDir())` wrapped in a counting store that records every `Get` key and can fail a key once, and a registry with routes for two accounts:
+
+```go
+func TestPlanLimitsSwitch(t *testing.T) // "", "on": true; "off", "OFF", "false", "0", "no": false
+func TestPlansPassOffReadsOnlyTheMarker(t *testing.T) // no marker: Gets == ["control/plans.json"]; planLimitsFor(any) false
+func TestPlansPassBoxSwitchOffReadsNothing(t *testing.T) // CARLOS_PLAN_LIMITS=off with an enabled marker and records: zero Gets; cache cleared
+func TestPlansPassLoadsServedAccounts(t *testing.T) // enabled marker, records for A and B: planLimitsFor(A).IdleSeconds == 90; an account with no routes on this box is not read
+func TestPlansPassAbsentRecordMeansNoLimits(t *testing.T) // enabled, no record for B: planLimitsFor(B) false
+func TestPlansPassKeepsLastGoodOnErrors(t *testing.T) // after a good pass: marker Get fails -> A and B unchanged; A's record Get fails -> A unchanged, B refreshed
+func TestPlansPassClearsWhenDisabled(t *testing.T) // enabled then marker Enabled false: planLimitsFor(A) false
+```
+
+- [ ] **Step 2: Run** `go test ./internal/host/ -run 'PlansPass|PlanLimitsSwitch'` → FAIL.
+
+- [ ] **Step 3: Implement** `plans_pass.go` after `telemetry_pass.go`'s shape: switch off → store nil, return. `GetMarker` error → log, return (keep). Absent or disabled → store nil, return. Otherwise collect distinct `release.RouteAccount(rt)` from `reg.List()` (skip status routes), `GetLimits` each: error → copy the previous value for that account if any; nil → omit; else set. Store the new snapshot. Log first failure and every tenth, as `homemap.go`'s loop does.
+
+- [ ] **Step 4: Wire** `plansPass(ctx, store, reg, os.Getenv)` after both `telemetryPass` calls; add `"CARLOS_PLAN_LIMITS"` to `hostenv.Optional` in sorted position.
+
+- [ ] **Step 5: Run** `go test ./internal/host/ -run 'PlansPass|PlanLimitsSwitch' ./internal/hostenv/` → PASS.
+
+- [ ] **Step 6: Commit** `host: read account limits each pass, marker-gated, with a box off switch`
+
+### Task 4: The activator's idle hook
+
+**Files:**
+- Modify: `internal/activator/activator.go` (`Service` gains `IdleFor`; the sweep at `:4646`)
+- Modify: `internal/activator/activator_test.go`
+- Modify: `internal/host/agent.go` (set `svc.IdleFor` after `activator.New`, `agent.go:490`)
+- Create: `internal/host/plans_idle_test.go`
+
+**Interfaces:**
+- Consumes: `planLimitsFor` (Task 3).
+- Produces:
+ ```go
+ // Service field, set before the service is published:
+ IdleFor func(registry.Route) time.Duration // 0 = no opinion; nil = today's behaviour
+ func planIdleFor(rt registry.Route) time.Duration // internal/host: Limits.IdleSeconds as a duration, 0 when none
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// activator_test.go, beside TestActivatorIdleSleep and TestWorkerRouteIdleWindowOverridesPlatformDefault
+func TestIdleForShortensAnInstance(t *testing.T) // cfg.Idle 15m, IdleFor returns 90s: the entry sleeps after 90s idle, not before
+func TestPlanIdleOnlyShortens(t *testing.T) // cfg.Idle 5m, IdleFor 15m: sleeps at 5m
+func TestIdleForShortensAWorkerToTheSmaller(t *testing.T) // worker IdleSeconds 600, IdleFor 90s: 90s; worker 60, IdleFor 90s: 60s
+func TestIdleForNilOrZeroChangesNothing(t *testing.T) // nil hook, and a hook returning 0: today's windows
+func TestIdleForLeavesAlwaysOnWorkersAlone(t *testing.T) // Hibernate=false worker with IdleFor 90s: never stopped for idleness
+// plans_idle_test.go (serial)
+func TestPlanIdleFor(t *testing.T) // cache off: 0; account with IdleSeconds 90: 90s; account absent: 0
+```
+
+- [ ] **Step 2: Run** `go test ./internal/activator/ -run IdleFor` and `go test ./internal/host/ -run TestPlanIdleFor` → FAIL.
+
+- [ ] **Step 3: Implement.** In the sweep, after the current `idleWindow` is chosen: `if f := a.IdleFor; f != nil { if d := f(e.route); d > 0 && d < idleWindow { idleWindow = d } }`. The "held for tick" check already uses `idleWindow`. `planIdleFor` reads `planLimitsFor(release.RouteAccount(rt))`. In `agent.go`, set `svc.IdleFor = planIdleFor` right after `activator.New`.
+
+- [ ] **Step 4: Run** the two commands above and `go test ./internal/activator/ -run 'Idle|Worker|AlwaysOn'` → PASS.
+
+- [ ] **Step 5: Commit** `activator: a plan can shorten an entry's idle window`
+
+### Task 5: Census, then the branch gates
+
+**Files:** `internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv` (new), `internal/provision/plan14_root_reads_test.go`, possibly a record-write census layer beside `internal/provision/plans_caps_census_test.go`.
+
+- [ ] **Step 1:** Run `GOFLAGS=-buildvcs=false go test ./internal/provision/ -run 'TestPlan14RootReadCensus|TestPlan9B8'`. For each discrepancy, regenerate the overlay with the scratch-generator recipe used for Plan 1 (load every overlay, print `@legacy`/rows/`@coverage` for changed and new units, compute the seal), with basis text naming the executing identity: the box agent's plans pass reads two bucket objects and makes no root decision; the activator change narrows an idle window. Load it last; mark `rev3=PROPOSED-plans-2-box-limits`.
+- [ ] **Step 2:** Run the full claim invariant once: `nice -n 19 go test ./internal/console/ -run TestClaimConsoleMutationInvariant -timeout 30m` → ok.
+- [ ] **Step 3:** Commit `plans 2: census overlay`, merge current main, push, wait for CI green, ask for the census ruling on #280.
+
+## Done when
+
+- The commands in each task pass locally; CI is green on the tip after merging current main.
+- Review Focus 1–5 each have their named test passing.
+- The branch's final review (Astra, whole branch) has no open Critical or Important finding.
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md
new file mode 100644
index 000000000..a785eb0ec
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md
@@ -0,0 +1,35 @@
+1. **Important — A transient startup failure can leave plans enabled indefinitely.** [internal/console/plansmarker.go:21](internal/console/plansmarker.go:21)
+
+ Remove the catalog and restart the console while the marker GET or PUT fails. The worker logs the error and exits permanently; no later pass retries it. Boxes continue reading the old enabled marker and enforcing limits after the bucket recovers. Initial enablement can likewise remain ineffective indefinitely.
+
+ **Fix:** retry startup reconciliation with backoff until the desired marker state is established, rereading after CAS conflicts. Keep the successful no-catalog/no-marker path inert. Add transient read/write failure tests covering disablement.
+
+2. **Important — Concurrent passes can restore disabled limits or overwrite newer values.** [internal/host/plans_pass.go:107](internal/host/plans_pass.go:107)
+
+ `fastConvergeLoop` and `instancesLoop` independently call `plansPass`. Pass A can read an enabled marker and stall fetching limits. After the marker is disabled, pass B clears the cache; A then finishes and unconditionally installs its enabled snapshot. Limits return until another successful pass. Similarly, A’s captured `prev` can overwrite limits that B refreshed while A encountered a per-account error.
+
+ Atomic pointers prevent data races, but do not order these operations.
+
+ **Fix:** serialize the complete pass, including disable paths, or introduce publication ordering that rejects stale work. Add a blocking-store test that forces these interleavings.
+
+3. **Minor — The refreshed census justification misdescribes the sweep change.** [internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv:9](internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv:9)
+
+ Both the sweep row and its `@legacy` declaration retain “diagnostic throttling only” while freezing a body that now uses account limits to change hibernation and held-for-tick decisions. A reviewer relying on that justification receives an inaccurate account of the behavioral change.
+
+ **Fix:** update both justifications to describe the idle hook, executing identity and retained lifecycle guards; regenerate the affected seal.
+
+**Declined to judge**
+
+- Spec §3’s settled commercial and hibernation policies — explicitly excluded from reconsideration.
+- Small memory tiers — explicitly deferred to Plan 2b.
+- Throttling and continuous-awake ceilings — explicitly deferred to Plan 4.
+- Preserving always-on workers — required by this plan; admission restrictions belong to separate work.
+- The atomic `SetIdleFor` ruling — accepted; it safely publishes the callback.
+- The prescribed full/five-minute refresh cadence — accepted as scope.
+- Final census-owner approval and remote CI status — not verified in this environment.
+
+The five required cases look correct in sequential execution; finding 2 breaks disablement and last-good preservation under overlap. The live-account SQL passed an in-memory SQLite probe covering live inserts/updates and missing/deleted accounts. Changed Go files are formatted, and `git diff --check` passed.
+
+Go tests could not start because the sandbox refused creation of Go’s build directory. Darwin/Windows builds remain unverified. HEAD remains `5e5a13e745a16bd0d44aedaa5d8555685dd06726`; the working tree is unchanged.
+
+**Verdict: ready with fixes. Resolve both Important findings and run the blocked checks before merging.**
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-2-CODE.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-2-CODE.md
new file mode 100644
index 000000000..da1a28407
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-2-CODE.md
@@ -0,0 +1,42 @@
+# Whole-branch code review brief: Plans 2 (boxes read account limits)
+
+You are a senior Go reviewer on the carlos platform. Review branch
+`plans-2-box-limits`, range `4a1e4b29a7196a2a0acb505cd4f8a3dcea054ad0..HEAD` (`git diff 4a1e4b29a7196a2a0acb505cd4f8a3dcea054ad0..HEAD`; the
+docs under docs/superpowers/ are requirements, not code to review). The
+review is read-only: do not modify the working tree, index, HEAD or
+branches. You may run `go test` for packages you want to probe (set
+GOFLAGS=-buildvcs=false).
+
+Requirements, in order of authority:
+1. Spec: docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md
+ (§5.2 idle window, §7 limits reach the boxes and fail open, §10). Its
+ section 3 decisions are not to be argued with.
+2. Plan: docs/superpowers/plans/2026-10-21-plans-2-box-limits.md, especially
+ "Self-hosters": Paul's requirement that deployments without a plan
+ catalog are never interrupted.
+3. Executor rulings: `.superpowers/sdd/2026-10-21-plans-2-box-limits/progress.md`,
+ lines containing `Ruling:`. Weigh each.
+
+The spec is a vision document. For behaviour it is silent on, judge by what a
+reasonable operator, self-hoster or customer would expect, and grade by effect.
+
+Check these deliberately (the plan's Review Focus):
+1. Plans off (marker absent): exactly one bucket read per pass, no limits.
+2. CARLOS_PLAN_LIMITS=off: zero bucket reads, cache cleared.
+3. Bucket blips: marker read error keeps the whole cache; a per-account
+ error keeps that account's last value.
+4. Plans turned off again: marker disabled clears the cache; windows revert.
+5. A plan idle longer than the box default: the box default stands.
+
+Also look hard at: any path where a self-hosted box (no catalog ever) changes
+behaviour or cost; the activator sweep change (always-on workers, held-for-tick,
+probe entries, sidecar workers); races on the package-level cache and the
+atomic hook; the console marker publisher (once per start, disable path,
+CAS); SetAccountPlan's live-account guard SQL; the hostops and storedoor
+entries for control/plans.json; the census overlay rows' honesty; and
+darwin/windows builds.
+
+Output: findings most severe first, each Critical / Important / Minor with
+file:line, the concrete failure scenario and a suggested fix; then "Declined
+to judge" (every behaviour set aside, one line each with the reason); then a
+verdict: ready to merge, ready with fixes, or not ready.
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index a0d56891b..dbd881d66 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -1139,6 +1139,11 @@ type Service struct {
// edge chose before recovery moved their upstream.
startupSeenAt atomic.Int64
+ // idleFor is the plans idle hook (plans 2), set through SetIdleFor: given an
+ // entry's route it names the plan's idle window, or 0 for no opinion. It
+ // can only shorten a window. nil means today's behaviour.
+ idleFor atomic.Pointer[func(registry.Route) time.Duration]
+
cfg Config
backend Backend
due DueStore
@@ -4647,6 +4652,11 @@ func (a *Service) sweepOnce() {
if e.workerIdle > 0 {
idleWindow = e.workerIdle
}
+ if f := a.idleFor.Load(); f != nil {
+ if d := (*f)(e.route); d > 0 && d < idleWindow {
+ idleWindow = d
+ }
+ }
if hibernates && e.inflight == 0 && e.cmd == nil && now.Sub(e.lastActive) >= idleWindow {
// Don't sleep into an imminent tick. A wake/sleep pair is a full
// checkpoint-and-restore round trip; spending one to be absent
@@ -5446,3 +5456,15 @@ func (a *Service) serveWakeError(w http.ResponseWriter, r *http.Request, rt regi
}
return true
}
+
+// SetIdleFor installs the plans idle hook (plans 2). A plan can only shorten
+// an entry's idle window: the sweep takes the smaller of the entry's own
+// window and the hook's answer, and ignores 0. Always-on workers have no idle
+// clock, so the hook never reaches them. nil removes the hook.
+func (a *Service) SetIdleFor(f func(registry.Route) time.Duration) {
+ if f == nil {
+ a.idleFor.Store(nil)
+ return
+ }
+ a.idleFor.Store(&f)
+}
diff --git a/internal/activator/activator_idlefor_test.go b/internal/activator/activator_idlefor_test.go
new file mode 100644
index 000000000..7eb8f2835
--- /dev/null
+++ b/internal/activator/activator_idlefor_test.go
@@ -0,0 +1,113 @@
+package activator
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// The plans idle hook (plans 2): a plan may only shorten an entry's idle
+// window, never lengthen it, and never touches an always-on worker.
+
+func idleFor(d time.Duration) func(registry.Route) time.Duration {
+ return func(registry.Route) time.Duration { return d }
+}
+
+func wakeInstance(t *testing.T, a *Service, host string) {
+ t.Helper()
+ release, err := a.beginRequest(context.Background(), testRoute(host))
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+}
+
+func TestIdleForShortensAnInstance(t *testing.T) {
+ t.Parallel()
+ a, _, clk, _ := testActivator(t, 15*time.Minute)
+ a.SetIdleFor(idleFor(90 * time.Second))
+ wakeInstance(t, a, "one.example.com")
+ clk.advance(80 * time.Second)
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatal("slept before the plan's 90s window")
+ }
+ clk.advance(11 * time.Second)
+ a.sweepOnce()
+ waitFor(t, "plan idle sleep", func() bool { return a.entryCount() == 0 })
+}
+
+func TestPlanIdleOnlyShortens(t *testing.T) {
+ t.Parallel()
+ a, _, clk, _ := testActivator(t, 5*time.Minute)
+ a.SetIdleFor(idleFor(15 * time.Minute))
+ wakeInstance(t, a, "one.example.com")
+ clk.advance(5*time.Minute + time.Second)
+ a.sweepOnce()
+ waitFor(t, "box-default sleep", func() bool { return a.entryCount() == 0 })
+}
+
+func TestIdleForShortensAWorkerToTheSmaller(t *testing.T) {
+ t.Parallel()
+ for _, c := range []struct {
+ name string
+ worker int
+ plan, sleep time.Duration
+ }{
+ {"plan shorter than the worker's own", 600, 90 * time.Second, 90 * time.Second},
+ {"worker's own shorter than the plan", 60, 90 * time.Second, 60 * time.Second},
+ } {
+ a, _, clk, _ := testActivator(t, time.Hour)
+ a.SetIdleFor(idleFor(c.plan))
+ rt := testWorkerRoute("bot.x.internal")
+ rt.IdleSeconds = c.worker
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("%s: WakeWorker: %v", c.name, err)
+ }
+ clk.advance(c.sleep - 5*time.Second)
+ a.sweepOnce()
+ if !a.entryAwake("bot.x.internal") {
+ t.Fatalf("%s: slept early", c.name)
+ }
+ clk.advance(6 * time.Second)
+ a.sweepOnce()
+ waitFor(t, c.name, func() bool { return a.entryCount() == 0 })
+ }
+}
+
+func TestIdleForNilOrZeroChangesNothing(t *testing.T) {
+ t.Parallel()
+ for name, hook := range map[string]func(registry.Route) time.Duration{"nil": nil, "zero": idleFor(0)} {
+ a, _, clk, _ := testActivator(t, 10*time.Second)
+ a.SetIdleFor(hook)
+ wakeInstance(t, a, "one.example.com")
+ clk.advance(9 * time.Second)
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatalf("%s hook: slept before the box default", name)
+ }
+ clk.advance(2 * time.Second)
+ a.sweepOnce()
+ waitFor(t, name+" hook: default sleep", func() bool { return a.entryCount() == 0 })
+ }
+}
+
+func TestIdleForLeavesAlwaysOnWorkersAlone(t *testing.T) {
+ t.Parallel()
+ a, _, clk, _ := testActivator(t, time.Hour)
+ a.SetIdleFor(idleFor(90 * time.Second))
+ rt := testWorkerRoute("bot.x.internal")
+ rt.Hibernate = false
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ for i := 0; i < 5; i++ {
+ clk.advance(time.Hour)
+ a.sweepOnce()
+ }
+ if a.entryCount() != 1 {
+ t.Fatal("an always-on worker was stopped by the plan's idle window")
+ }
+}
diff --git a/internal/console/accountplans.go b/internal/console/accountplans.go
index 2f5a5ce7c..2a2cac308 100644
--- a/internal/console/accountplans.go
+++ b/internal/console/accountplans.go
@@ -18,6 +18,10 @@ const (
PlanSourceBilling = "billing"
)
+// ErrAccountGone refuses a plan write for an account that does not exist or
+// has been deleted.
+var ErrAccountGone = errors.New("account deleted")
+
// planDefaultActor is who a default row names as its writer.
const planDefaultActor = "plans"
@@ -93,12 +97,15 @@ func (s *Store) SetAccountPlan(accountID int64, plan string, packs map[string]in
var rev int64
err = s.db.QueryRow(`INSERT INTO account_plans
(account_id, plan, packs, source, sponsored, reason, revision, updated_by, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
+ SELECT ?, ?, ?, ?, ?, ?, 1, ?, ? WHERE EXISTS (SELECT 1 FROM accounts WHERE id = ? AND deleted_at = 0)
ON CONFLICT(account_id) DO UPDATE SET plan = excluded.plan, packs = excluded.packs,
source = excluded.source, sponsored = excluded.sponsored, reason = excluded.reason,
updated_by = excluded.updated_by, updated_at = excluded.updated_at, revision = revision + 1
RETURNING revision`,
- accountID, plan, string(pj), source, sj, reason, by, now.Unix()).Scan(&rev)
+ accountID, plan, string(pj), source, sj, reason, by, now.Unix(), accountID).Scan(&rev)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, ErrAccountGone
+ }
return rev, err
}
diff --git a/internal/console/accountplans_test.go b/internal/console/accountplans_test.go
index bd099ba56..4b1553c22 100644
--- a/internal/console/accountplans_test.go
+++ b/internal/console/accountplans_test.go
@@ -1,6 +1,7 @@
package console
import (
+ "errors"
"path/filepath"
"reflect"
"testing"
@@ -165,3 +166,20 @@ func TestMarkPlanPublishedIgnoresAStaleRevision(t *testing.T) {
t.Fatalf("published %d / %q", row.PublishedRevision, row.PublishedCatalog)
}
}
+
+// Astra plans-1 finding 3: a plan write must not land on a deleted account.
+func TestSetAccountPlanRefusesADeletedAccount(t *testing.T) {
+ st, ids := planStore(t, "gone")
+ if _, err := st.db.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ?`, planNow.Unix(), ids["gone"]); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.SetAccountPlan(ids["gone"], "free", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); !errors.Is(err, ErrAccountGone) {
+ t.Fatalf("err %v, want ErrAccountGone", err)
+ }
+ if row, _ := st.AccountPlan(ids["gone"]); row != nil {
+ t.Fatalf("wrote %+v", row)
+ }
+ if _, err := st.SetAccountPlan(999999, "free", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); !errors.Is(err, ErrAccountGone) {
+ t.Fatalf("unknown account: err %v, want ErrAccountGone", err)
+ }
+}
diff --git a/internal/console/apiaccountplan.go b/internal/console/apiaccountplan.go
index 88c5cc853..d21fceb2b 100644
--- a/internal/console/apiaccountplan.go
+++ b/internal/console/apiaccountplan.go
@@ -1,6 +1,7 @@
package console
import (
+ "errors"
"fmt"
"log"
"net/http"
@@ -119,6 +120,10 @@ func (s *Server) handleAPIAccountPlanSet(w http.ResponseWriter, r *http.Request)
}
now := time.Now()
if _, err := s.store.SetAccountPlan(acct.ID, req.Plan, req.Packs, source, req.Sponsored, reason, addr, now); err != nil {
+ if errors.Is(err, ErrAccountGone) {
+ http.NotFound(w, r)
+ return
+ }
log.Printf("console: api account plan %s: save: %v", acct.Sqid, err)
jsonError(w, http.StatusInternalServerError, planCopySaveFailed)
return
diff --git a/internal/console/apiaccountplan_test.go b/internal/console/apiaccountplan_test.go
index cfa1382a4..5084b18ab 100644
--- a/internal/console/apiaccountplan_test.go
+++ b/internal/console/apiaccountplan_test.go
@@ -200,3 +200,22 @@ func TestAccountPlanSetRefusesUnknownFields(t *testing.T) {
t.Fatalf("a refused POST wrote %+v", row)
}
}
+
+// Astra plans-1 finding 3: an account deleted between the door's lookup and
+// its write gets no plan row and no published record.
+func TestAccountPlanSetOnADeletedAccountIs404(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-deleted")
+ if _, err := fx.st.db.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ?`, planNow.Unix(), acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ status, _ := postRaw(t, accountPlanURL(fx, acct.Sqid), map[string]any{"plan": "free", "reason": "r"}, token)
+ if status != http.StatusNotFound {
+ t.Fatalf("POST on a deleted account = %d, want 404", status)
+ }
+ if r, _ := readLimits(t, fx, acct.Sqid); r != nil {
+ t.Fatalf("published %+v for a deleted account", r)
+ }
+}
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index 6553bfad9..f409dfd1e 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -1926,6 +1926,7 @@ var claimFixtures = map[string]claimFixture{
"worker StartHostReports": claimNonApp("this console's census report (status/consoles) and roster activation release"),
"worker StartInfraPass": claimNonApp("the deployment's infrastructure inventory snapshot"),
"worker StartPlanLimitsPass": claimNonApp("account plan rows and their limits records under accounts/<sqid>/limits.json; names no app"),
+ "worker PublishPlansMarker": claimNonApp("the plans deployment marker (control/plans.json): whether boxes read limits; names no app"),
"worker StartClaimRecoveryPass": {door: &claimDoor{
apps: claimRefs("bab/titogo"),
setup: func(t *testing.T, cx *claimCase) { cx.crashedRestore(t) },
diff --git a/internal/console/claim_invariant_probes_worker_test.go b/internal/console/claim_invariant_probes_worker_test.go
index 765f8db9a..a79737061 100644
--- a/internal/console/claim_invariant_probes_worker_test.go
+++ b/internal/console/claim_invariant_probes_worker_test.go
@@ -194,6 +194,17 @@ var claimWorkerProbes = map[string]claimProbe{
return ""
},
},
+ "worker PublishPlansMarker": {
+ setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) },
+ do: claimWorkerReply("PublishPlansMarker"),
+ effect: func(t *testing.T, cx *claimCase, r claimReply) string {
+ var m plans.Marker
+ if !cx.bucketJSON(plans.MarkerKey(), &m) || !m.Enabled {
+ return "no enabled plans marker was published"
+ }
+ return ""
+ },
+ },
"worker StartPlanLimitsPass": {
setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) },
do: claimWorkerReply("StartPlanLimitsPass"),
diff --git a/internal/console/claim_workers.go b/internal/console/claim_workers.go
index 803e6bbc7..ed947d88c 100644
--- a/internal/console/claim_workers.go
+++ b/internal/console/claim_workers.go
@@ -15,6 +15,7 @@ func (s *Server) registerClaimWorkers() {
"StartScanDrain": s.StartScanDrain,
"StartExpiryPass": s.StartExpiryPass,
"StartPlanLimitsPass": s.StartPlanLimitsPass,
+ "PublishPlansMarker": s.PublishPlansMarker,
"StartRunSweep": s.StartRunSweep,
"StartSeedsPass": s.StartSeedsPass,
"StartRelay": s.StartRelay,
diff --git a/internal/console/plansmarker.go b/internal/console/plansmarker.go
new file mode 100644
index 000000000..4493771bd
--- /dev/null
+++ b/internal/console/plansmarker.go
@@ -0,0 +1,63 @@
+package console
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+)
+
+// plansMarkerRetry is the first wait between attempts; it doubles up to
+// plansMarkerRetryMax. A var so a test can shrink it.
+var plansMarkerRetry = 5 * time.Second
+
+const plansMarkerRetryMax = 5 * time.Minute
+
+// PublishPlansMarker tells boxes whether plans are on (plans 2). It runs once
+// per console start. With a catalog it makes sure control/plans.json is
+// enabled under this catalog's id; without one it touches nothing unless an
+// enabled marker is left over, which it turns off so boxes stop applying
+// limits within a pass. A self-hosted deployment that never configured a
+// catalog never gets a marker.
+func (s *Server) PublishPlansMarker(ctx context.Context) {
+ if s.cfg.Releases == nil {
+ return
+ }
+ // Retry until the marker is right: a bucket blip at start must not leave
+ // boxes applying limits after the catalog was removed, or never applying
+ // them after it was added (Astra plans-2 finding 1).
+ wait := plansMarkerRetry
+ for !s.publishPlansMarkerOnce(ctx) {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(wait):
+ }
+ wait = min(2*wait, plansMarkerRetryMax)
+ }
+}
+
+// publishPlansMarkerOnce makes one attempt and reports whether the marker is
+// now as it should be (including "nothing to do").
+func (s *Server) publishPlansMarkerOnce(ctx context.Context) bool {
+ cur, etag, err := plans.GetMarker(ctx, s.cfg.Releases)
+ if err != nil {
+ log.Printf("console: plans marker: read: %v", err)
+ return false
+ }
+ want := plans.Marker{UpdatedAt: time.Now().UTC().Truncate(time.Second)}
+ if cat := s.cfg.PlanCatalog; cat != nil {
+ if cur != nil && cur.Enabled && cur.CatalogID == cat.ID() {
+ return true
+ }
+ want.Enabled, want.CatalogID = true, cat.ID()
+ } else if cur == nil || !cur.Enabled {
+ return true
+ }
+ if _, err := plans.PutMarker(ctx, s.cfg.Releases, &want, etag); err != nil {
+ log.Printf("console: plans marker: write: %v", err)
+ return false
+ }
+ return true
+}
diff --git a/internal/console/plansmarker_test.go b/internal/console/plansmarker_test.go
new file mode 100644
index 000000000..ab19bf98a
--- /dev/null
+++ b/internal/console/plansmarker_test.go
@@ -0,0 +1,111 @@
+package console
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+)
+
+func readMarker(t *testing.T, fx *consoleFixture) (*plans.Marker, string) {
+ t.Helper()
+ m, etag, err := plans.GetMarker(context.Background(), fx.rel)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m, etag
+}
+
+func TestPublishPlansMarkerOffAndAbsentWritesNothing(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.PublishPlansMarker(context.Background())
+ if m, _ := readMarker(t, fx); m != nil {
+ t.Fatalf("wrote a marker with no catalog: %+v", m)
+ }
+}
+
+func TestPublishPlansMarkerEnables(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ fx.srv.PublishPlansMarker(context.Background())
+ m, _ := readMarker(t, fx)
+ if m == nil || !m.Enabled || m.CatalogID != fx.srv.cfg.PlanCatalog.ID() {
+ t.Fatalf("marker %+v", m)
+ }
+}
+
+func TestPublishPlansMarkerIsIdempotent(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ fx.srv.PublishPlansMarker(context.Background())
+ _, first := readMarker(t, fx)
+ fx.srv.PublishPlansMarker(context.Background())
+ if _, second := readMarker(t, fx); second != first {
+ t.Fatal("an up-to-date marker was rewritten")
+ }
+}
+
+func TestPublishPlansMarkerFollowsACatalogChange(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ fx.srv.PublishPlansMarker(context.Background())
+ next := bumpedCatalog(t, "2026-10-22-v2", "4")
+ fx.srv.cfg.PlanCatalog = next
+ fx.srv.PublishPlansMarker(context.Background())
+ if m, _ := readMarker(t, fx); m.CatalogID != next.ID() {
+ t.Fatalf("marker %+v, want catalog %s", m, next.ID())
+ }
+}
+
+func TestPublishPlansMarkerDisablesWhenCatalogRemoved(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ fx.srv.PublishPlansMarker(context.Background())
+ fx.srv.cfg.PlanCatalog = nil
+ fx.srv.PublishPlansMarker(context.Background())
+ if m, _ := readMarker(t, fx); m == nil || m.Enabled {
+ t.Fatalf("marker %+v, want disabled", m)
+ }
+}
+
+// Astra plans-2 finding 1: a bucket failure at start must not leave the
+// marker in the wrong state for the life of the console. The worker retries
+// until the marker is right.
+func TestPublishPlansMarkerRetriesUntilDisabled(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ fx.srv.PublishPlansMarker(context.Background())
+ defer func(d time.Duration) { plansMarkerRetry = d }(plansMarkerRetry)
+ plansMarkerRetry = time.Millisecond
+ fp := &planFailingPuts{Store: fx.rel}
+ fp.n.Store(3) // the first three writes fail
+ fx.srv.cfg.Releases = fp
+ fx.srv.cfg.PlanCatalog = nil
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ fx.srv.PublishPlansMarker(ctx)
+ fx.srv.cfg.Releases = fp.Store
+ if m, _ := readMarker(t, fx); m == nil || m.Enabled {
+ t.Fatalf("marker %+v after transient write failures, want disabled", m)
+ }
+}
+
+func TestPublishPlansMarkerStopsWithItsContext(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ defer func(d time.Duration) { plansMarkerRetry = d }(plansMarkerRetry)
+ plansMarkerRetry = time.Millisecond
+ fp := &planFailingPuts{Store: fx.rel}
+ fp.n.Store(1 << 30) // never succeeds
+ fx.srv.cfg.Releases = fp
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ done := make(chan struct{})
+ go func() { fx.srv.PublishPlansMarker(ctx); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("PublishPlansMarker kept retrying after its context ended")
+ }
+}
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index 3a3c4ada4..bb81d7f5f 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -287,6 +287,7 @@ route PUT /api/cli/system/geoip
route PUT /api/cli/system/upload
worker LogPlacementDiscrepancies
worker PublishMovesConsole
+worker PublishPlansMarker
worker ReportApexSquats
worker StartAlertWatcher
worker StartClaimRecoveryPass
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go
index e341b9712..2d014501e 100644
--- a/internal/edge/storedoor.go
+++ b/internal/edge/storedoor.go
@@ -30,6 +30,7 @@ import (
"strings"
"github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/plans"
"github.com/carlosframework/platform/internal/release"
)
@@ -433,7 +434,7 @@ func aclRead(sqid, fleet, key string) bool {
return false
}
switch key {
- case "control/serial", "bootstrap/carlos", release.LayoutIntentKey():
+ case "control/serial", "bootstrap/carlos", release.LayoutIntentKey(), plans.MarkerKey():
return true
}
if systemReadKey(key) || promotionRecordRead(sqid, key) {
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go
index c67b38de6..9b7ae7e08 100644
--- a/internal/edge/storedoor_test.go
+++ b/internal/edge/storedoor_test.go
@@ -23,6 +23,7 @@ import (
"testing"
"github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/plans"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"time"
@@ -1009,3 +1010,16 @@ func TestStoreDoorClaimsAreOwnAccountAndRedacted(t *testing.T) {
t.Errorf("dirs under domains/ = %s, want []", w.Body.String())
}
}
+
+// TestFleetBoxCanReadThePlansMarker: a fleet box reads the plans marker
+// (plans 2) to learn whether to read limits at all; the same two-identity
+// guard applies.
+func TestFleetBoxCanReadThePlansMarker(t *testing.T) {
+ key := plans.MarkerKey()
+ if !aclRead(testSqid, testFleet, key) {
+ t.Errorf("aclRead(%q, %q, %q) = false, want true", testSqid, testFleet, key)
+ }
+ if aclRead("", testFleet, key) || aclRead(testSqid, "", key) {
+ t.Error("the plans marker was readable without both identities")
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 559c0a27c..3229da5c2 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -495,6 +495,10 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// (design §6.8). The registry satisfies RouteLookup with its own
// Get, exactly as DueStore above.
svc.Routes = reg
+ // Plans 2: an account's plan can shorten its entries' idle window. With
+ // plans off (no marker, CARLOS_PLAN_LIMITS=off, or no record) the hook
+ // answers 0 and every window is the box's own.
+ svc.SetIdleFor(planIdleFor)
// Settings spec §4.2: before any process runs a build against an
// instance's database, the build state names it, under the lease. A
// store that failed to start refuses every spawn (never "run
@@ -2808,6 +2812,8 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
}
telemetryPass(ctx, store, reg)
mark("telemetry")
+ plansPass(ctx, store, reg, os.Getenv)
+ mark("plans")
runMovesPass(ctx, store)
mark("moves")
runRunsPass(ctx)
@@ -3393,6 +3399,7 @@ func instancesLoop(reg *registry.Registry, writes hostwrite.Config, bs *buildSta
bs.refreshAll(ctx)
}
telemetryPass(ctx, store, reg)
+ plansPass(ctx, store, reg, os.Getenv)
runMovesPass(ctx, store)
runRunsPass(ctx)
deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now())
diff --git a/internal/host/plans_idle_test.go b/internal/host/plans_idle_test.go
new file mode 100644
index 000000000..867033b43
--- /dev/null
+++ b/internal/host/plans_idle_test.go
@@ -0,0 +1,29 @@
+package host
+
+// Serial: it writes the package-level plans cache.
+
+import (
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestPlanIdleFor(t *testing.T) {
+ t.Cleanup(func() { planLimits.Store(nil) })
+ rt := registry.Route{Host: "a.example", Kind: "instance", Account: "bab", App: "web"}
+ planLimits.Store(nil)
+ if d := planIdleFor(rt); d != 0 {
+ t.Fatalf("plans off: %v, want 0", d)
+ }
+ planLimits.Store(&planLimitsSnapshot{byAccount: map[string]plans.Limits{"bab": {IdleSeconds: 90}}})
+ if d := planIdleFor(rt); d != 90*time.Second {
+ t.Fatalf("bab: %v, want 90s", d)
+ }
+ other := rt
+ other.Account = "bdk"
+ if d := planIdleFor(other); d != 0 {
+ t.Fatalf("account with no record: %v, want 0", d)
+ }
+}
diff --git a/internal/host/plans_pass.go b/internal/host/plans_pass.go
new file mode 100644
index 000000000..e45961e89
--- /dev/null
+++ b/internal/host/plans_pass.go
@@ -0,0 +1,137 @@
+// The plans pass (plans-caps-packs spec §7, plans 2): learn whether plans are
+// on for this deployment and, only if they are, read the limits record of
+// every account this box serves into a last-known-good cache the activator
+// asks. Rides the same passes as telemetryPass.
+//
+// Self-hosters are never interrupted: with no marker (no catalog on the
+// console) the pass reads one object and applies nothing, a box with
+// CARLOS_PLAN_LIMITS=off reads nothing at all, and an account with no record
+// runs exactly as it always has.
+package host
+
+import (
+ "context"
+ "log"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// planLimitsSnapshot is immutable once stored; nil in planLimits means plans
+// are off on this box.
+type planLimitsSnapshot struct {
+ byAccount map[string]plans.Limits
+}
+
+var planLimits atomic.Pointer[planLimitsSnapshot]
+
+// plansPassMu serialises whole passes: the full pass and the five-minute pass
+// both run plansPass, and a stalled pass must not publish over a newer one
+// (Astra plans-2 finding 2). Readers never take it.
+var plansPassMu sync.Mutex
+
+// planLimitsFailures counts consecutive failing passes for log throttling.
+var planLimitsFailures atomic.Int64
+
+// planLimitsSwitchOn reads CARLOS_PLAN_LIMITS: off, false, 0 or no turn plans
+// off on this box; anything else, including unset, leaves them to the marker.
+func planLimitsSwitchOn(getenv func(string) string) bool {
+ switch strings.ToLower(strings.TrimSpace(getenv("CARLOS_PLAN_LIMITS"))) {
+ case "off", "false", "0", "no":
+ return false
+ }
+ return true
+}
+
+// planLimitsFor is sqid's limits, or false when plans are off or the account
+// has no record.
+func planLimitsFor(sqid string) (plans.Limits, bool) {
+ snap := planLimits.Load()
+ if snap == nil {
+ return plans.Limits{}, false
+ }
+ l, ok := snap.byAccount[sqid]
+ return l, ok
+}
+
+func plansPass(ctx context.Context, s release.Store, reg *registry.Registry, getenv func(string) string) {
+ plansPassMu.Lock()
+ defer plansPassMu.Unlock()
+ if getenv == nil {
+ getenv = os.Getenv
+ }
+ if !planLimitsSwitchOn(getenv) {
+ planLimits.Store(nil)
+ return
+ }
+ marker, _, err := plans.GetMarker(ctx, s)
+ if err != nil {
+ planLimitsFailed("marker: %v", err)
+ return // keep the last good cache
+ }
+ if marker == nil || !marker.Enabled {
+ planLimits.Store(nil)
+ planLimitsFailures.Store(0)
+ return
+ }
+ routes, err := reg.List()
+ if err != nil {
+ planLimitsFailed("routes: %v", err)
+ return
+ }
+ prev := planLimits.Load()
+ next := &planLimitsSnapshot{byAccount: map[string]plans.Limits{}}
+ seen := map[string]bool{}
+ failed := false
+ for _, rt := range routes {
+ if rt.Kind == "status" {
+ continue
+ }
+ sqid := release.RouteAccount(rt)
+ if sqid == "" || seen[sqid] {
+ continue
+ }
+ seen[sqid] = true
+ rec, _, err := plans.GetLimits(ctx, s, sqid)
+ switch {
+ case err != nil:
+ failed = true
+ planLimitsFailed("%s: %v", sqid, err)
+ if prev != nil {
+ if l, ok := prev.byAccount[sqid]; ok {
+ next.byAccount[sqid] = l
+ }
+ }
+ case rec != nil:
+ next.byAccount[sqid] = rec.Limits
+ }
+ }
+ planLimits.Store(next)
+ if !failed {
+ planLimitsFailures.Store(0)
+ }
+}
+
+// planLimitsFailed logs the first failure of a run and every tenth after it.
+func planLimitsFailed(format string, args ...any) {
+ if n := planLimitsFailures.Add(1); n == 1 || n%10 == 0 {
+ log.Printf("agent: plans pass: "+format+" (keeping the last good limits)", args...)
+ }
+}
+
+// planIdleFor is the activator's plans idle hook: the route's account's idle
+// window from its limits, or 0 (no opinion) when plans are off or the account
+// has no record.
+func planIdleFor(rt registry.Route) time.Duration {
+ l, ok := planLimitsFor(release.RouteAccount(rt))
+ if !ok || l.IdleSeconds <= 0 {
+ return 0
+ }
+ return time.Duration(l.IdleSeconds) * time.Second
+}
diff --git a/internal/host/plans_pass_test.go b/internal/host/plans_pass_test.go
new file mode 100644
index 000000000..843bbbe06
--- /dev/null
+++ b/internal/host/plans_pass_test.go
@@ -0,0 +1,235 @@
+package host
+
+// These tests share the package-level plans cache, so they stay serial.
+
+import (
+ "context"
+ "errors"
+ "slices"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// plansCountingStore records every Get and can fail chosen keys.
+type plansCountingStore struct {
+ release.Store
+ mu sync.Mutex
+ gets []string
+ fail map[string]bool
+}
+
+func (c *plansCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.mu.Lock()
+ c.gets = append(c.gets, key)
+ failing := c.fail[key]
+ c.mu.Unlock()
+ if failing {
+ return nil, "", errors.New("bucket unavailable")
+ }
+ return c.Store.Get(ctx, key)
+}
+
+func (c *plansCountingStore) reset() {
+ c.mu.Lock()
+ c.gets, c.fail = nil, map[string]bool{}
+ c.mu.Unlock()
+}
+
+func plansWorld(t *testing.T) (*plansCountingStore, *registry.Registry) {
+ t.Helper()
+ t.Cleanup(func() { planLimits.Store(nil) })
+ s := &plansCountingStore{Store: release.NewDirStore(t.TempDir()), fail: map[string]bool{}}
+ reg := edgesTestRegistry(t)
+ for _, rt := range []registry.Route{
+ {Host: "a.example", Kind: "instance", Account: "bab", App: "web", Status: "ready"},
+ {Host: "b.example", Kind: "instance", Account: "bdk", App: "shop", Status: "ready"},
+ {Host: "status.example", Kind: "status", Status: "ready"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return s, reg
+}
+
+func putMarker(t *testing.T, s release.Store, enabled bool) {
+ t.Helper()
+ ctx := context.Background()
+ _, etag, err := plans.GetMarker(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := plans.PutMarker(ctx, s, &plans.Marker{Enabled: enabled, CatalogID: "v1@0123456789ab"}, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func putAccountLimits(t *testing.T, s release.Store, sqid string, idle int) {
+ t.Helper()
+ ctx := context.Background()
+ _, etag, err := plans.GetLimits(ctx, s, sqid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := &plans.Record{Account: sqid, CatalogID: "v1@0123456789ab", Plan: "free", Revision: 1, Limits: plans.Limits{IdleSeconds: idle}}
+ if _, err := plans.PutLimits(ctx, s, r, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func plansNoEnv(string) string { return "" }
+
+func TestPlanLimitsSwitch(t *testing.T) {
+ for v, want := range map[string]bool{"": true, "on": true, "off": false, "OFF": false, "false": false, "0": false, "no": false, " off ": false} {
+ if got := planLimitsSwitchOn(func(string) string { return v }); got != want {
+ t.Errorf("CARLOS_PLAN_LIMITS=%q: on = %v, want %v", v, got, want)
+ }
+ }
+}
+
+func TestPlansPassOffReadsOnlyTheMarker(t *testing.T) {
+ s, reg := plansWorld(t)
+ putAccountLimits(t, s, "bab", 90)
+ s.reset()
+ plansPass(context.Background(), s, reg, plansNoEnv)
+ if !slices.Equal(s.gets, []string{plans.MarkerKey()}) {
+ t.Fatalf("reads %v, want only the marker", s.gets)
+ }
+ if _, ok := planLimitsFor("bab"); ok {
+ t.Fatal("limits applied with no marker")
+ }
+}
+
+func TestPlansPassBoxSwitchOffReadsNothing(t *testing.T) {
+ s, reg := plansWorld(t)
+ putMarker(t, s, true)
+ putAccountLimits(t, s, "bab", 90)
+ plansPass(context.Background(), s, reg, plansNoEnv)
+ if _, ok := planLimitsFor("bab"); !ok {
+ t.Fatal("setup: limits not loaded")
+ }
+ s.reset()
+ plansPass(context.Background(), s, reg, func(string) string { return "off" })
+ if len(s.gets) != 0 {
+ t.Fatalf("switched-off box read %v", s.gets)
+ }
+ if _, ok := planLimitsFor("bab"); ok {
+ t.Fatal("switched-off box kept limits")
+ }
+}
+
+func TestPlansPassLoadsServedAccounts(t *testing.T) {
+ s, reg := plansWorld(t)
+ putMarker(t, s, true)
+ putAccountLimits(t, s, "bab", 90)
+ putAccountLimits(t, s, "bdk", 900)
+ putAccountLimits(t, s, "zzz", 60)
+ s.reset()
+ plansPass(context.Background(), s, reg, plansNoEnv)
+ if l, ok := planLimitsFor("bab"); !ok || l.IdleSeconds != 90 {
+ t.Fatalf("bab: %+v, %v", l, ok)
+ }
+ if l, ok := planLimitsFor("bdk"); !ok || l.IdleSeconds != 900 {
+ t.Fatalf("bdk: %+v, %v", l, ok)
+ }
+ if slices.Contains(s.gets, plans.LimitsKey("zzz")) {
+ t.Fatal("read the limits of an account with no routes on this box")
+ }
+}
+
+func TestPlansPassAbsentRecordMeansNoLimits(t *testing.T) {
+ s, reg := plansWorld(t)
+ putMarker(t, s, true)
+ putAccountLimits(t, s, "bab", 90)
+ plansPass(context.Background(), s, reg, plansNoEnv)
+ if _, ok := planLimitsFor("bdk"); ok {
+ t.Fatal("an account with no record got limits")
+ }
+}
+
+func TestPlansPassKeepsLastGoodOnErrors(t *testing.T) {
+ s, reg := plansWorld(t)
+ putMarker(t, s, true)
+ putAccountLimits(t, s, "bab", 90)
+ putAccountLimits(t, s, "bdk", 900)
+ plansPass(context.Background(), s, reg, plansNoEnv)
+
+ s.fail[plans.MarkerKey()] = true
+ plansPass(context.Background(), s, reg, plansNoEnv)
+ if l, ok := planLimitsFor("bab"); !ok || l.IdleSeconds != 90 {
+ t.Fatalf("a marker read error dropped bab: %+v, %v", l, ok)
+ }
+
+ s.reset()
+ putAccountLimits(t, s, "bdk", 600)
+ s.fail[plans.LimitsKey("bab")] = true
+ plansPass(context.Background(), s, reg, plansNoEnv)
+ if l, ok := planLimitsFor("bab"); !ok || l.IdleSeconds != 90 {
+ t.Fatalf("a record read error dropped bab: %+v, %v", l, ok)
+ }
+ if l, _ := planLimitsFor("bdk"); l.IdleSeconds != 600 {
+ t.Fatalf("bdk not refreshed: %+v", l)
+ }
+}
+
+func TestPlansPassClearsWhenDisabled(t *testing.T) {
+ s, reg := plansWorld(t)
+ putMarker(t, s, true)
+ putAccountLimits(t, s, "bab", 90)
+ plansPass(context.Background(), s, reg, plansNoEnv)
+ putMarker(t, s, false)
+ plansPass(context.Background(), s, reg, plansNoEnv)
+ if _, ok := planLimitsFor("bab"); ok {
+ t.Fatal("limits kept after plans were disabled")
+ }
+}
+
+// blockingLimitsStore blocks reads of one limits key until released, and
+// says when a read has reached it.
+type blockingLimitsStore struct {
+ release.Store
+ key string
+ reached chan struct{}
+ release chan struct{}
+ once sync.Once
+}
+
+func (b *blockingLimitsStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == b.key {
+ b.once.Do(func() { close(b.reached); <-b.release })
+ }
+ return b.Store.Get(ctx, key)
+}
+
+// Astra plans-2 finding 2: the full pass and the five-minute pass both run
+// plansPass. A pass that read an enabled marker and stalled must not install
+// its snapshot after a newer pass saw plans disabled.
+func TestPlansPassesDoNotRestoreDisabledLimits(t *testing.T) {
+ s, reg := plansWorld(t)
+ putMarker(t, s, true)
+ putAccountLimits(t, s, "bab", 90)
+ b := &blockingLimitsStore{Store: s.Store, key: plans.LimitsKey("bab"), reached: make(chan struct{}), release: make(chan struct{})}
+
+ aDone := make(chan struct{})
+ go func() { plansPass(context.Background(), b, reg, plansNoEnv); close(aDone) }()
+ <-b.reached // pass A read the enabled marker and is stalled on bab's record
+
+ putMarker(t, s, false)
+ bDone := make(chan struct{})
+ go func() { plansPass(context.Background(), s.Store, reg, plansNoEnv); close(bDone) }()
+ select { // without ordering, pass B finishes now and clears the cache
+ case <-bDone:
+ case <-time.After(200 * time.Millisecond):
+ }
+ close(b.release)
+ <-aDone
+ <-bDone
+ if _, ok := planLimitsFor("bab"); ok {
+ t.Fatal("a stalled pass restored limits after plans were disabled")
+ }
+}
diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go
index 26458e0ee..e89154183 100644
--- a/internal/hostenv/hostenv.go
+++ b/internal/hostenv/hostenv.go
@@ -84,6 +84,7 @@ var Optional = []string{
"CARLOS_LAYOUT_DEFAULT",
"CARLOS_MOVES_PHASE1",
"CARLOS_MOVE_READY_DELTA_BYTES",
+ "CARLOS_PLAN_LIMITS",
"CARLOS_REQUIRE_POINTER_DIGEST",
"CARLOS_S3_ENDPOINT",
"CARLOS_S3_QUIRKS",
diff --git a/internal/hostops/limits_classifier_test.go b/internal/hostops/limits_classifier_test.go
index 2e714c110..189a3919e 100644
--- a/internal/hostops/limits_classifier_test.go
+++ b/internal/hostops/limits_classifier_test.go
@@ -43,3 +43,23 @@ func TestClassifyAccountLimitsRecordIsExact(t *testing.T) {
}
}
}
+
+// TestClassifyPlansMarkerIsExact: the console's deployment marker (plans 2)
+// is a reviewed non-app family, put only.
+func TestClassifyPlansMarkerIsExact(t *testing.T) {
+ ctx := context.Background()
+ key := "control/plans.json"
+ got, err := ClassifyMutation(ctx, nil, nil, MutationTarget{Surface: "bucket", Method: "Put", Key: key, After: []byte(`{}`)})
+ if err != nil || got.NonApp == "" || len(got.Scopes) != 0 {
+ t.Fatalf("put: %+v, %v; want a non-app family", got, err)
+ }
+ for _, c := range []MutationTarget{
+ {Surface: "bucket", Method: "Delete", Key: key},
+ {Surface: "bucket", Method: "Put", Key: "control/plans.json.bak", After: []byte(`{}`)},
+ {Surface: "bucket", Method: "Put", Key: "control/plans", After: []byte(`{}`)},
+ } {
+ if got, err := ClassifyMutation(ctx, nil, nil, c); !errors.Is(err, ErrUnclassified) {
+ t.Errorf("%s %s: classified %+v, err %v; want ErrUnclassified", c.Method, c.Key, got, err)
+ }
+ }
+}
diff --git a/internal/hostops/mutation_classifier.go b/internal/hostops/mutation_classifier.go
index 430496ec5..298489074 100644
--- a/internal/hostops/mutation_classifier.go
+++ b/internal/hostops/mutation_classifier.go
@@ -48,6 +48,7 @@ var nonAppFamilies = []nonAppFamily{
{exact: "control/serial", reason: "deployment convergence notification counter; contains no app state"},
{exact: "control/relay-writer.json", reason: "deployment relay writer lease; app effects remain separately admitted"},
{exact: "control/seeds-protocol.json", reason: "operator-published seeds floor; a protocol number and a release, no app state"},
+ {exact: "control/plans.json", reason: "plans deployment marker published by the console; says whether boxes read limits; no app state"},
{prefix: "status/boxes/", deletes: true, reason: "per-box heartbeats; no app state"},
{exact: "status/fleet.json", reason: "deployment fleet status summary"},
{exact: "status/index.html", reason: "deployment status page"},
diff --git a/internal/plans/catalog.go b/internal/plans/catalog.go
index cd9c2734b..a9a9592cb 100644
--- a/internal/plans/catalog.go
+++ b/internal/plans/catalog.go
@@ -14,6 +14,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "math"
"os"
"slices"
)
@@ -211,11 +212,15 @@ func (c *Catalog) Effective(plan string, packs map[string]int, sponsored *Limits
if qty < 1 || qty > pk.MaxQuantity {
return Limits{}, fmt.Errorf("%w: %q has %d, allowed 1 to %d", ErrPackQuantity, name, qty, pk.MaxQuantity)
}
- l.RunningThings += qty * pk.Adds.RunningThings
- l.DiskBytes += int64(qty) * pk.Adds.DiskBytes
- l.StorageBytes += int64(qty) * pk.Adds.StorageBytes
- l.EmailPerDay += qty * pk.Adds.EmailPerDay
- l.BandwidthBytesMonth += int64(qty) * pk.Adds.BandwidthBytesMonth
+ fits := true
+ l.RunningThings = addPack(l.RunningThings, qty, pk.Adds.RunningThings, &fits)
+ l.DiskBytes = addPack(l.DiskBytes, qty, pk.Adds.DiskBytes, &fits)
+ l.StorageBytes = addPack(l.StorageBytes, qty, pk.Adds.StorageBytes, &fits)
+ l.EmailPerDay = addPack(l.EmailPerDay, qty, pk.Adds.EmailPerDay, &fits)
+ l.BandwidthBytesMonth = addPack(l.BandwidthBytesMonth, qty, pk.Adds.BandwidthBytesMonth, &fits)
+ if !fits {
+ return Limits{}, fmt.Errorf("%w: %q overflows", ErrPackQuantity, name)
+ }
}
ce := c.Ceilings
l.RunningThings = clamp(l.RunningThings, ce.RunningThings)
@@ -261,3 +266,14 @@ func (l Limits) generous(o Limits) Limits {
}
return l
}
+
+// addPack is v + qty*add, refusing (ok = false) anything that would overflow
+// int64. Values are non-negative (validate), so only the upper bound matters;
+// int is 64-bit on every platform this builds for.
+func addPack[T int | int64](v T, qty int, add T, fits *bool) T {
+ if add != 0 && int64(qty) > (math.MaxInt64-int64(v))/int64(add) {
+ *fits = false
+ return v
+ }
+ return v + T(qty)*add
+}
diff --git a/internal/plans/catalog_test.go b/internal/plans/catalog_test.go
index ce6c86c0f..3728f2649 100644
--- a/internal/plans/catalog_test.go
+++ b/internal/plans/catalog_test.go
@@ -202,3 +202,17 @@ func TestEffectiveSponsoredZeroIsUnset(t *testing.T) {
t.Fatalf("min_schedule_seconds %d, want 60", got.MinScheduleSeconds)
}
}
+
+// Astra plans-1 finding 5: an accepted catalog with a huge pack increment
+// must never wrap to a negative limit that the ceiling cannot correct.
+func TestEffectiveRefusesOverflow(t *testing.T) {
+ c := exampleCatalog(t)
+ pk := c.Packs["storage"]
+ pk.Adds.StorageBytes = 1 << 62
+ c.Packs["storage"] = pk
+ c.Ceilings.StorageBytes = 100
+ got, err := c.Effective("personal", map[string]int{"storage": 2}, nil)
+ if !errors.Is(err, ErrPackQuantity) {
+ t.Fatalf("err %v (limits %+v), want ErrPackQuantity", err, got)
+ }
+}
diff --git a/internal/plans/marker.go b/internal/plans/marker.go
new file mode 100644
index 000000000..d39ee9e6c
--- /dev/null
+++ b/internal/plans/marker.go
@@ -0,0 +1,60 @@
+package plans
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// MarkerSchema is the deployment marker's wire version.
+const MarkerSchema = 1
+
+// Marker says whether plans are on for the deployment (plans 2). The console
+// writes it once at start; boxes read it on every pass and read no limits
+// record while it is absent or disabled, so a deployment without a catalog
+// costs a box one small read per pass and changes nothing.
+type Marker struct {
+ Schema int `json:"schema"`
+ Enabled bool `json:"enabled"`
+ CatalogID string `json:"catalog_id,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// MarkerKey is where the deployment marker lives.
+func MarkerKey() string { return "control/plans.json" }
+
+// GetMarker reads the marker and its etag. Absent is nil, "", nil. An unknown
+// schema is an error, so a reader keeps whatever it last knew.
+func GetMarker(ctx context.Context, s release.Store) (*Marker, string, error) {
+ raw, etag, err := s.Get(ctx, MarkerKey())
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, "", nil
+ }
+ return nil, "", err
+ }
+ var m Marker
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return nil, "", fmt.Errorf("plans: %s: %w", MarkerKey(), err)
+ }
+ if m.Schema != MarkerSchema {
+ return nil, "", fmt.Errorf("plans: %s: schema %d, want %d", MarkerKey(), m.Schema, MarkerSchema)
+ }
+ return &m, etag, nil
+}
+
+// PutMarker CAS-writes the marker: ifMatch "" is create-only, otherwise
+// replace-only. Schema is stamped here.
+func PutMarker(ctx context.Context, s release.Store, m *Marker, ifMatch string) (string, error) {
+ m.Schema = MarkerSchema
+ body, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ return "", err
+ }
+ return s.Put(ctx, MarkerKey(), append(body, '\n'), ifMatch)
+}
diff --git a/internal/plans/marker_test.go b/internal/plans/marker_test.go
new file mode 100644
index 000000000..b01b1ee20
--- /dev/null
+++ b/internal/plans/marker_test.go
@@ -0,0 +1,58 @@
+package plans
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestMarkerKey(t *testing.T) {
+ if got := MarkerKey(); got != "control/plans.json" {
+ t.Fatalf("MarkerKey = %q", got)
+ }
+}
+
+func TestMarkerRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if m, etag, err := GetMarker(ctx, s); m != nil || etag != "" || err != nil {
+ t.Fatalf("absent: %+v, %q, %v", m, etag, err)
+ }
+ want := &Marker{Enabled: true, CatalogID: "v1@0123456789ab", UpdatedAt: time.Unix(1_800_000_000, 0).UTC()}
+ if _, err := PutMarker(ctx, s, want, ""); err != nil {
+ t.Fatal(err)
+ }
+ got, etag, err := GetMarker(ctx, s)
+ if err != nil || etag == "" {
+ t.Fatalf("get: %q, %v", etag, err)
+ }
+ want.Schema = MarkerSchema
+ if *got != *want {
+ t.Fatalf("got %+v, want %+v", got, want)
+ }
+}
+
+func TestMarkerRefusesUnknownSchema(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, err := s.Put(ctx, MarkerKey(), []byte(`{"schema": 2, "enabled": true}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if m, _, err := GetMarker(ctx, s); err == nil {
+ t.Fatalf("accepted schema 2: %+v", m)
+ }
+}
+
+func TestPutMarkerIsCAS(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, err := PutMarker(ctx, s, &Marker{Enabled: true}, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := PutMarker(ctx, s, &Marker{Enabled: false}, ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("second create-only put: %v", err)
+ }
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 40cb11596..770724ec2 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -1297,6 +1297,19 @@ func TestPlan14RootReadCensus(t *testing.T) {
t.Fatal(err)
}
seal = "b25dfda5a68d2183af759b42398f79bb471749d46e68d267545647d1a2e6046b"
+ // Census overlay plans-2-box-limits (boxes read account limits behind the
+ // console's plans marker; the activator's idle hook), loaded last, after
+ // email-signin. PROPOSED-plans-2-box-limits, asked of the census owner on
+ // #280. The new agent units read routes and two bucket objects and only
+ // narrow idle windows. Frozen declarations are added, so the seal moves;
+ // email-signin's is checked first.
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ seal = "d044224434b0b375599a076e573c7c4164e3777ae6295abf95d9d9b9ed69e482"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 094df27bd..3694cedd5 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 := plan14ClaimRosterBindingWriteCensusInventory()
+ inventory := plans2WriteCensusInventory()
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/plans2_census_test.go b/internal/provision/plans2_census_test.go
new file mode 100644
index 000000000..6828edf4a
--- /dev/null
+++ b/internal/provision/plans2_census_test.go
@@ -0,0 +1,12 @@
+package provision
+
+// Record-write census entry for plans-2-box-limits, layered on
+// movesOfflinePrecopyWriteCensusInventory. Reviewed by the branch author; the
+// census owner rules on #280 (PROPOSED-plans-2-box-limits).
+// - plans.PutMarker writes control/plans.json, the deployment's plans
+// marker, never an instance or sidecar record: nonrecord.
+func plans2WriteCensusInventory() []censusEntry {
+ return append(append([]censusEntry(nil), plan14ClaimRosterBindingWriteCensusInventory()...),
+ censusEntry{"internal/plans/marker.go", "PutMarker", "nonrecord", "sel:Put", "28e544ee616f632d5ea97c3c29247d3afd37de80192413e00c69f6bf449a1eaf"},
+ )
+}
diff --git a/internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv b/internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv
new file mode 100644
index 000000000..c2b97fdd4
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv
@@ -0,0 +1,23 @@
+# Census overlay plans-2-box-limits (boxes read account limits, marker-gated; the activator's plans idle hook; the console's plans marker), loaded LAST, after email-signin. PROPOSED-plans-2-box-limits, asked of the census owner on #280.
+# New units plansPass and planIdleFor run in the box agent, read their own routes' accounts and two bucket objects, and only narrow idle windows (accepted legacy at telemetrySwitches' J1 root-route disposition); plansPass is serialised by its own mutex. Service.sweepOnce is re-frozen with a basis that names the idle hook; serve and Store.SetAccountPlan are re-frozen; runAgent, fastConvergeLoop and type Service are re-pinned at their authority class. Frozen declarations are added, so the seal moves.
+@legacy 1b1ec6ba471451bfb94da5a1e1bbebad0eef99735f95f4376edbb53e28f11743 internal/activator/activator.go Service.sweepOnce legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed;basis=census-ruling-FS-R6; registry remains legacy; PROPOSED-plans-2-box-limits: the sweep also narrows an entry's idle window to its account's plan idle through the SetIdleFor hook (never lengthens it; always-on workers and held probe entries are untouched; the hibernate, inflight and held-for-tick guards are unchanged). registry:Hibernate@12328,registry:Hibernate@21499,registry:Hibernate@5124,registry:Hibernate@6794,registry:Hibernate@9319,registry:Host@21568,registry:Host@21626,registry:Host@21998,registry:Host@22091,registry:Host@22826,registry:Host@22908,registry:Host@23973,registry:Host@24152,registry:Host@24201,registry:NextDue@22781,registry:Status@21515,whole:Route@12330,whole:Route@14263,whole:Route@15973,whole:Route@16163,whole:Route@16456,whole:Route@16628,whole:Route@16775,whole:Route@20986,whole:Route@22495,whole:Route@22835,whole:Route@22842,whole:Route@23788,whole:Route@24185,whole:Route@5126,whole:Route@6796,whole:Route@7230,whole:Route@9321 fa0ced864fd598142b3f93ecd05a8f28ea85da83a0e8f9f056f37f1a6def42fb
+@legacy 1c311d4b2c462944cbed211a693f04e7752526ca8a871330a5269ee0d4e1a6e2 internal/host/plans_pass.go planIdleFor legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-2-box-limits;basis=root-route; the agent's plans pass, serialised by its own mutex, reads its own routes' accounts and two bucket objects (the plans marker, each account's limits record) into a cache; planIdleFor only narrows the activator's idle window. No root decision. whole:Route@97 eea0d90d956f443a16446d17ba76312f07a26a4f5421b33cbd9c0f385801e4cc
+@legacy 25dde7240be6c5fa3f45ef1764cd03ea4f0f261359f0610e7788eaf78d86378b internal/host/plans_pass.go plansPass legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-2-box-limits;basis=root-route; the agent's plans pass, serialised by its own mutex, reads its own routes' accounts and two bucket objects (the plans marker, each account's limits record) into a cache; planIdleFor only narrows the activator's idle window. No root decision. registry:Kind@778,whole:Route@764,whole:Route@847 47aa10719db662b78dec476944ac91745bf61ab60cc24283531e61ae8bee87c3
+@legacy 55cbbc6af84bef1c0f30e8ce2b923641a4596dda7a2e251d87179e9e23268464 internal/console/accountplans.go Store.SetAccountPlan legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:QueryRow@442 36db76c1951df91bff3f8f5960434235b87b1e526c4ff91ade2c8cb8f4b01e06
+@legacy 80bb2ce8fb1489f707b71d5523b708b2026e8580ee56c1614e379200442df514 cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=PROPOSED-scan-demand-revival;basis=Console service wires the optional callback signer and starts the claim-guarded queue worker. The same three data path literals move; it does not add a privileged reader.;rev3=PROPOSED-email-signin;identity=the console (cmd/console serve under console.service, user carlos-console);basis=passes CARLOS_CONSOLE_NAME to NewServer as Config.ConsoleName, the name the console's emails use; the same three /data literals at the same offsets; no read added data-literal@4077,data-literal@5178,data-literal@5415 edeae271d6037cab9ccbd63ea281787de6c23b2cc8295fca95bdb19658460098
+cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=PROPOSED-scan-demand-revival;basis=Console service wires the optional callback signer and starts the claim-guarded queue worker. The same three data path literals move; it does not add a privileged reader.;rev3=PROPOSED-email-signin;identity=the console (cmd/console serve under console.service, user carlos-console);basis=passes CARLOS_CONSOLE_NAME to NewServer as Config.ConsoleName, the name the console's emails use; the same three /data literals at the same offsets; no read added data-literal@4077,data-literal@5178,data-literal@5415 edeae271d6037cab9ccbd63ea281787de6c23b2cc8295fca95bdb19658460098
+internal/activator/activator.go Service.sweepOnce legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed;basis=census-ruling-FS-R6; registry remains legacy; PROPOSED-plans-2-box-limits: the sweep also narrows an entry's idle window to its account's plan idle through the SetIdleFor hook (never lengthens it; always-on workers and held probe entries are untouched; the hibernate, inflight and held-for-tick guards are unchanged). registry:Hibernate@12328,registry:Hibernate@21499,registry:Hibernate@5124,registry:Hibernate@6794,registry:Hibernate@9319,registry:Host@21568,registry:Host@21626,registry:Host@21998,registry:Host@22091,registry:Host@22826,registry:Host@22908,registry:Host@23973,registry:Host@24152,registry:Host@24201,registry:NextDue@22781,registry:Status@21515,whole:Route@12330,whole:Route@14263,whole:Route@15973,whole:Route@16163,whole:Route@16456,whole:Route@16628,whole:Route@16775,whole:Route@20986,whole:Route@22495,whole:Route@22835,whole:Route@22842,whole:Route@23788,whole:Route@24185,whole:Route@5126,whole:Route@6796,whole:Route@7230,whole:Route@9321 fa0ced864fd598142b3f93ecd05a8f28ea85da83a0e8f9f056f37f1a6def42fb
+internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED-seed; existing activator lifecycle and due-wake state plus owned seed exclusion and incarnation-keyed diagnostics; normal refusal and preparation ordering remain authoritative; PROPOSED-plans-2-box-limits adds the idleFor hook field, which only narrows idle windows 861dda6f11a641d800eadd5fbc368275ef7fe80e35e644dd01d4e0818c38f90e
+internal/console/accountplans.go Store.SetAccountPlan legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:QueryRow@442 36db76c1951df91bff3f8f5960434235b87b1e526c4ff91ade2c8cb8f4b01e06
+internal/host/agent.go fastConvergeLoop authority B3 rev3=census-ruling-moves-offline;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body (census-ruling-carlos-run) with the offline keep view read before the first reconcile pass (refreshOfflineView); read sites unchanged, offsets moved; main's B3 authority retained whole:Route@8952,whole:Route@9515 ed8b3b75914b7cfaf62e694556f6cbcf0b85bbfb8a662b6da38cf1df6f94eafb
+internal/host/agent.go runAgent authority B,C,D,F rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with wireSeedPlacement called in the activator start's Configure step, before armMovesPass, and the startup sweep's result stored for the import rollback in Recover; read sites unchanged, offsets moved; main's B,C,D,F authority retained PROPOSED E46: captured claim selection and root journal mutation; root RPC outside service host exclusion; SQL/route data never grants root authority. Executing identity: service controller; root for inventory/retirement callbacks.;rev3=PROPOSED-325;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=passes the agent's log sink to armRunsPass; reads unchanged file:Exec@13802,file:Open@731,registry:App@10065,whole:Route@10057,whole:Route@23351 49319d24c62ebd37444d3e7d9a2e78f2dab25ff803eeb10bfa88d4a8ca7775fd
+internal/host/plans_pass.go planIdleFor legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-2-box-limits;basis=root-route; the agent's plans pass, serialised by its own mutex, reads its own routes' accounts and two bucket objects (the plans marker, each account's limits record) into a cache; planIdleFor only narrows the activator's idle window. No root decision. whole:Route@97 eea0d90d956f443a16446d17ba76312f07a26a4f5421b33cbd9c0f385801e4cc
+internal/host/plans_pass.go plansPass legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-2-box-limits;basis=root-route; the agent's plans pass, serialised by its own mutex, reads its own routes' accounts and two bucket objects (the plans marker, each account's limits record) into a cache; planIdleFor only narrows the activator's idle window. No root decision. registry:Kind@778,whole:Route@764,whole:Route@847 47aa10719db662b78dec476944ac91745bf61ab60cc24283531e61ae8bee87c3
+@coverage cmd/console ae123181abe153851c1f1546e9b609f8502cbaa36d5ff64667fa158bc1480dad
+@coverage internal/activator 18c8b9ef915ac60ccb296a1b4f8fce5ce1c02ee2643b361a0db0a3020d68395b
+@coverage internal/console 40a44fc3a28c5769a84ca81d2916dd97685604ae04d780661a1dcd5aa1503401
+@coverage internal/edge 62bb20932803aa2acdb45b2aab0db6540adc5a5a2f4dc954b1338c30fb73ce2b
+@coverage internal/host d5c86ad049089ddcdcb97685e185164d8012a1b8788734962eb7021a2c87c8dc
+@coverage internal/hostenv 3981869abecec66e019134cc6485b14282920929032271bc2012c6d582acf1b5
+@coverage internal/hostops cff0ae84199421ad3e46eb1b853b47a6f8ec0005c250b3d17d21e154de0494e0
+@coverage internal/plans 7de3345c04504076ff5ac23f823f17d5901db261ba52db269c08d4b8d51b0f00