carlos / platform Public

console: publish the plans marker; plan writes need a live account

PublishPlansMarker writes control/plans.json enabled under the catalog's
id once per start, turns a left-over marker off when the catalog is gone,
and writes nothing on a deployment that never had one. hostops classifies
the key (put only); fleet boxes may read it. SetAccountPlan refuses a
deleted account (Astra plans-1 finding 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 30ace3c3329ee28915aea53f9d6132fd643f6e8e parent d055ab9
15 files changed, +212 −3
  • cmd/console/main.go +3 −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 +38 −0
  • internal/console/plansmarker_test.go +69 −0
  • internal/console/testdata/claim-routes.txt +1 −0
  • internal/edge/storedoor.go +2 −1
  • internal/edge/storedoor_test.go +14 −0
  • internal/hostops/limits_classifier_test.go +20 −0
  • internal/hostops/mutation_classifier.go +1 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
index bdee22837..acdb15919 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -526,6 +526,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/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 d09d1d291..fddb0b7c8 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -1853,6 +1853,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 c75eb79e2..2fead5e23 100644
--- a/internal/console/claim_invariant_probes_worker_test.go
+++ b/internal/console/claim_invariant_probes_worker_test.go
@@ -190,6 +190,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..494d21eab
--- /dev/null
+++ b/internal/console/plansmarker.go
@@ -0,0 +1,38 @@
+package console
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+)
+
+// 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
+ }
+ cur, etag, err := plans.GetMarker(ctx, s.cfg.Releases)
+ if err != nil {
+ log.Printf("console: plans marker: read: %v", err)
+ return
+ }
+ 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
+ }
+ want.Enabled, want.CatalogID = true, cat.ID()
+ } else if cur == nil || !cur.Enabled {
+ return
+ }
+ if _, err := plans.PutMarker(ctx, s.cfg.Releases, &want, etag); err != nil {
+ log.Printf("console: plans marker: write: %v", err)
+ }
+}
diff --git a/internal/console/plansmarker_test.go b/internal/console/plansmarker_test.go
new file mode 100644
index 000000000..7cea85954
--- /dev/null
+++ b/internal/console/plansmarker_test.go
@@ -0,0 +1,69 @@
+package console
+
+import (
+ "context"
+ "testing"
+
+ "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)
+ }
+}
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index 932579962..874022711 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -283,6 +283,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/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"},