plans 2: fix Astra's whole-branch findings
- PublishPlansMarker retries with backoff until the marker is right, so a bucket blip at start cannot leave boxes applying limits after the catalog was removed (TestPublishPlansMarkerRetriesUntilDisabled) - plansPass is serialised, so a stalled pass cannot restore limits a newer pass cleared (TestPlansPassesDoNotRestoreDisabledLimits) - the regenerated census overlay describes the sweep's idle hook Findings committed unedited as CODEX-FINDINGS-PLANS-2-CODE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+170
−14
docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md+35 −0internal/console/plansmarker.go+28 −3internal/console/plansmarker_test.go+42 −0internal/host/plans_pass.go+8 −0internal/host/plans_pass_test.go+46 −0internal/provision/plan14_root_reads_test.go+1 −1internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv+10 −10
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (19 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (154 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.321s
ok github.com/carlosframework/platform/cmd/moveprobe 11.772s
ok github.com/carlosframework/platform/cmd/zddprobe 0.759s
ok github.com/carlosframework/platform/internal/activator 62.716s
ok github.com/carlosframework/platform/internal/agentsock 0.040s
ok github.com/carlosframework/platform/internal/alerting 0.009s
ok github.com/carlosframework/platform/internal/analytics 0.025s
ok github.com/carlosframework/platform/internal/appconfig 2.796s
ok github.com/carlosframework/platform/internal/apperr 0.164s
ok github.com/carlosframework/platform/internal/applog 0.268s
ok github.com/carlosframework/platform/internal/awsinv 0.008s
ok github.com/carlosframework/platform/internal/boxchoice 0.007s
ok github.com/carlosframework/platform/internal/boxid 0.757s
? 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.010s
ok github.com/carlosframework/platform/internal/caps 0.046s
ok github.com/carlosframework/platform/internal/certwarm 0.004s
ok github.com/carlosframework/platform/internal/cgroup 0.792s
ok github.com/carlosframework/platform/internal/changelog 0.461s
ok github.com/carlosframework/platform/internal/claimactivation 1.029s
ok github.com/carlosframework/platform/internal/clamd 0.066s
? 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.858s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.014s
ok github.com/carlosframework/platform/internal/dbverify 0.010s
ok github.com/carlosframework/platform/internal/dnsrec 0.007s
ok github.com/carlosframework/platform/internal/docsite 0.023s
ok github.com/carlosframework/platform/internal/domains 2.677s
ok github.com/carlosframework/platform/internal/doorkey 0.011s
ok github.com/carlosframework/platform/internal/economics 0.010s
ok github.com/carlosframework/platform/internal/edge 45.758s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.167s
ok github.com/carlosframework/platform/internal/gate 1.689s
ok github.com/carlosframework/platform/internal/genselect 0.027s
ok github.com/carlosframework/platform/internal/geo 0.013s
ok github.com/carlosframework/platform/internal/geoip 0.032s
ok github.com/carlosframework/platform/internal/historyreadback 0.233s
ok github.com/carlosframework/platform/internal/host 124.302s
ok github.com/carlosframework/platform/internal/hostactors 6.038s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.018s
ok github.com/carlosframework/platform/internal/hostbox 2.873s
ok github.com/carlosframework/platform/internal/hostclaim 0.716s
ok github.com/carlosframework/platform/internal/hostenv 0.018s
ok github.com/carlosframework/platform/internal/hostevidence 0.018s
ok github.com/carlosframework/platform/internal/hostexec 5.335s
ok github.com/carlosframework/platform/internal/hostfence 0.046s
ok github.com/carlosframework/platform/internal/hostfiles 10.418s
ok github.com/carlosframework/platform/internal/hostfloor 0.025s
ok github.com/carlosframework/platform/internal/hostguard 1.026s
ok github.com/carlosframework/platform/internal/hostops 1.878s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.018s
ok github.com/carlosframework/platform/internal/hostquarantine 0.018s
ok github.com/carlosframework/platform/internal/hostrelocate 0.655s
ok github.com/carlosframework/platform/internal/hostresolve 0.255s
ok github.com/carlosframework/platform/internal/hostwrite 0.018s
ok github.com/carlosframework/platform/internal/infraview 0.016s
ok github.com/carlosframework/platform/internal/instancectl 5.277s
ok github.com/carlosframework/platform/internal/instanceexec 2.790s
ok github.com/carlosframework/platform/internal/instancefs 0.025s
ok github.com/carlosframework/platform/internal/instancekey 0.018s
ok github.com/carlosframework/platform/internal/instanceupdates 0.027s
ok github.com/carlosframework/platform/internal/layout 1.707s
ok github.com/carlosframework/platform/internal/lbhealth 0.018s
ok github.com/carlosframework/platform/internal/lease 10.340s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.594s
ok github.com/carlosframework/platform/internal/ledger 0.036s
ok github.com/carlosframework/platform/internal/mail 0.245s
ok github.com/carlosframework/platform/internal/maintenance 0.098s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.556s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.113s
ok github.com/carlosframework/platform/internal/metrics 0.014s
ok github.com/carlosframework/platform/internal/moves 22.752s
ok github.com/carlosframework/platform/internal/moves/archive 1.017s
ok github.com/carlosframework/platform/internal/objstore 0.314s
ok github.com/carlosframework/platform/internal/ops 0.180s
ok github.com/carlosframework/platform/internal/park 0.276s
ok github.com/carlosframework/platform/internal/plans 0.008s
ok github.com/carlosframework/platform/internal/privatefile 0.007s
ok github.com/carlosframework/platform/internal/provision 22.896s
ok github.com/carlosframework/platform/internal/publicrelease 0.011s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.561s
ok github.com/carlosframework/platform/internal/relay 7.227s
ok github.com/carlosframework/platform/internal/release 26.265s
ok github.com/carlosframework/platform/internal/rollout 3.823s
ok github.com/carlosframework/platform/internal/rolloutwords 0.006s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.058s
ok github.com/carlosframework/platform/internal/rootstate 0.028s
ok github.com/carlosframework/platform/internal/runs 0.622s
ok github.com/carlosframework/platform/internal/s3compat 0.011s
ok github.com/carlosframework/platform/internal/safefile 0.006s
ok github.com/carlosframework/platform/internal/schedule 2.637s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 12.086s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.655s
ok github.com/carlosframework/platform/internal/status 0.075s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.299s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.005s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.008s
ok github.com/carlosframework/platform/internal/usage 0.007s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.664s
=== internal_console@0 (209 s)
ok github.com/carlosframework/platform/internal/console 207.308s
=== internal_console@1 (40 s)
ok github.com/carlosframework/platform/internal/console 38.044s
=== internal_console@2 (33 s)
ok github.com/carlosframework/platform/internal/console 30.837s
=== internal_console@3 (37 s)
ok github.com/carlosframework/platform/internal/console 34.788s
=== internal_console@4 (36 s)
ok github.com/carlosframework/platform/internal/console 34.367s
=== internal_console@5 (37 s)
ok github.com/carlosframework/platform/internal/console 34.730s
=== internal_console@6 (38 s)
ok github.com/carlosframework/platform/internal/console 36.497s
=== internal_console@7 (41 s)
ok github.com/carlosframework/platform/internal/console 39.272s
=== root@0 (34 s)
ok github.com/carlosframework/platform 32.547s
=== root@1 (45 s)
ok github.com/carlosframework/platform 42.971s
=== root@2 (42 s)
ok github.com/carlosframework/platform 40.317s
=== 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: 49938594 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.025s
=== RUN TestT4StorageProbeMinio
2026/10/21 18:05:27 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E09D97E18797ED, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
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/internal/console/plansmarker.go b/internal/console/plansmarker.go| index 494d21eab..4493771bd 100644 |
| --- a/internal/console/plansmarker.go |
| +++ b/internal/console/plansmarker.go |
| @@ -8,6 +8,12 @@ import ( |
| "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 |
| @@ -18,21 +24,40 @@ 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 |
| + 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 |
| + return true |
| } |
| want.Enabled, want.CatalogID = true, cat.ID() |
| } else if cur == nil || !cur.Enabled { |
| - return |
| + 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| index 7cea85954..ab19bf98a 100644 |
| --- a/internal/console/plansmarker_test.go |
| +++ b/internal/console/plansmarker_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/plans" |
| ) |
| @@ -67,3 +68,44 @@ func TestPublishPlansMarkerDisablesWhenCatalogRemoved(t *testing.T) { |
| 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/host/plans_pass.go b/internal/host/plans_pass.go| index 041e2bf63..e45961e89 100644 |
| --- a/internal/host/plans_pass.go |
| +++ b/internal/host/plans_pass.go |
| @@ -14,6 +14,7 @@ import ( |
| "log" |
| "os" |
| "strings" |
| + "sync" |
| "sync/atomic" |
| "time" |
| @@ -30,6 +31,11 @@ type planLimitsSnapshot struct { |
| 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 |
| @@ -55,6 +61,8 @@ func planLimitsFor(sqid string) (plans.Limits, bool) { |
| } |
| 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 |
| } |
diff --git a/internal/host/plans_pass_test.go b/internal/host/plans_pass_test.go| index 4793a6f3c..843bbbe06 100644 |
| --- a/internal/host/plans_pass_test.go |
| +++ b/internal/host/plans_pass_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "slices" |
| "sync" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -187,3 +188,48 @@ func TestPlansPassClearsWhenDisabled(t *testing.T) { |
| 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/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 457833a8f..df0341c9c 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -1130,7 +1130,7 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| 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 = "2f933cf2a1e44e4b6bb81b139390d14476598df44c5e3eb648014cf4ce8f6801" |
| + seal = "a239c9251a823a009fb7af19449bfaa16540dc579d7620916e73d6f24c8a5940" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
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| index 69cb19d97..221bb75ce 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv |
| @@ -1,23 +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 securelisten-337. 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). Changed legacy units serve, Service.sweepOnce 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 4a3f9123a40261f216c850a48eca32d98415bb3ddb8ebfa5b075f3490a5ee048 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 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 |
| +# 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 79c61e0f627f8a0cbfa7c839bdd0469d1ba07b1628ce9f0a2a4dc683c748892f 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. data-literal@4077,data-literal@5178,data-literal@5415 cd00e0a21af9c34596afafc4f13a423c32c29f5a73b5661538e01fca7abc215c |
| -@legacy b8b1ea768b17492f8f99849ae8628234dde71f63b49744289216faaa6d00f6d2 internal/activator/activator.go Service.sweepOnce legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed;basis=census-ruling-FS-R6; diagnostic throttling only; registry remains legacy 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 d2cade56a6bc0ca1548606b498dc790d64c56ef9c6e262311402694b035c4e84 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 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@730,whole:Route@716,whole:Route@799 bff074ac29590786b241088b74b85bd4955da411c5abfa5ae514e7b9e207215f |
| 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. data-literal@4077,data-literal@5178,data-literal@5415 cd00e0a21af9c34596afafc4f13a423c32c29f5a73b5661538e01fca7abc215c |
| -internal/activator/activator.go Service.sweepOnce legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed;basis=census-ruling-FS-R6; diagnostic throttling only; registry remains legacy 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 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 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 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@730,whole:Route@716,whole:Route@799 bff074ac29590786b241088b74b85bd4955da411c5abfa5ae514e7b9e207215f |
| -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/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 2806528d2f4a9d8394ed1f1ac8cea4723508ae2ad92589679beeeca577160de9 |
| @coverage internal/activator 18c8b9ef915ac60ccb296a1b4f8fce5ce1c02ee2643b361a0db0a3020d68395b |
| -@coverage internal/console 28f91f42431fcaf42f9012fa61bcc629a4b1e2639248185365712938f410f960 |
| +@coverage internal/console 3ad887215087d8d060669f70bbdffb09a52f059a301bb2102b521a66dc57529d |
| @coverage internal/edge 62bb20932803aa2acdb45b2aab0db6540adc5a5a2f4dc954b1338c30fb73ce2b |
| -@coverage internal/host d6b108015efdef220eb57a90e14ad07869031d1d14bd2d03a9f70ddbd4052e04 |
| +@coverage internal/host fa5d72a5867ca16bba98ed80dcb24982c3171d571c0dec9e3726224fe7f7f756 |
| @coverage internal/hostenv 3981869abecec66e019134cc6485b14282920929032271bc2012c6d582acf1b5 |
| @coverage internal/hostops 7c833e33c02faa9c7a04000f708b9d9583ad0c1590ff9c8b51994f29450caf17 |
| @coverage internal/plans 7de3345c04504076ff5ac23f823f17d5901db261ba52db269c08d4b8d51b0f00 |