carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 8bfdbc5b60a84cec8d1ac1832987b348bbb32d27 parent 5e5a13e
7 files changed, +170 −14
  • docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md +35 −0
  • internal/console/plansmarker.go +28 −3
  • internal/console/plansmarker_test.go +42 −0
  • internal/host/plans_pass.go +8 −0
  • internal/host/plans_pass_test.go +46 −0
  • internal/provision/plan14_root_reads_test.go +1 −1
  • internal/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