carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

Plans 1: Catalog and Account Limits Implementation Plan

> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A deployment can configure a plan catalog; the console records each account's plan and packs, computes its effective limits, and publishes them to the bucket at accounts/<sqid>/limits.json. Nothing enforces them yet.

Architecture: A small stdlib-only package internal/plans holds the catalog, the limits arithmetic and the wire record (boxes will import it in Plans 2). The console stores assignments in a new account_plans table, publishes records from a claim worker (StartPlanLimitsPass) and immediately after an operator change, and exposes an operator-only CLI door. With CARLOS_PLAN_CATALOG unset, the worker and the door do nothing and no record is written: every existing deployment is unchanged.

Tech Stack: Go (repo go.mod version), SQLite via the console's existing Store, the deployment bucket via release.Store.

Spec: docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md (sections 4, 7, 8.7, 9.4 "Sponsorship needs a new path", 10).

Where this plan sits

The spec ships as a sequence of plans, each working software on its own:

  1. Catalog and limits (this plan). Dormant foundation.
  2. Box-side limits: a record reader with last-known-good caching and fail-open; per-route idle window for instances; the small memory tier; the continuous-awake ceiling; the throttle state at the edge.
  3. Creation caps: running things, sidecars per app, one free account per person, fleets, paid-only custom domains, schedule minimum, never-sleep refusals.
  4. Notices and grace: the warning/grace/after-grace engine, notice receipt, measured caps (awake hours, storage, email account-wide, bandwidth), the lapse lifecycle (disable, delete after 90 days via the Trash), and the sponsored-usage measurement tool.
  5. Disk quotas on boxes.
  6. Checkout and packs: harvested Stripe client, multi-item subscription, webhook, portal, slim price book.
  7. Pricing page and public price export.
  8. Abuse tooling: takedown command, outbound port 25 block, cost monitoring.

Plans 2 to 8 are written when their predecessor merges, against the code as it then stands.

Global Constraints

  • No new module dependencies. internal/plans imports only the standard library and internal/release.
  • Cross-compile gate: CI builds darwin and windows; nothing in this plan is Linux-only.
  • No catalog configured means no caps, no records, no behaviour change (spec §2 Goals, §4.1).
  • carloku.com gets no special case anywhere in code (spec §2 Anti-goals). Examples and tests use neutral names (example.com, a plan called free, a pack called apps); never tenant hosts.
  • The console is the single writer of limits records (spec §7).
  • Every user-facing string (CLI output, error messages, docs prose) goes through the copy-review skill before merge. Copy voice: no em dashes; a full stop and a new sentence instead.
  • Local testing: run only the touched packages with -run, e.g. go test ./internal/plans/ and go test ./internal/console/ -run 'Plan|Limits'. Leave GOCACHE at its default. CI runs the full suite and gates the merge.
  • New console routes and claim workers must be added to internal/console/testdata/claim-routes.txt, or claim_routes_test.go fails.
  • Console code outside store.go/store_gate.go reaches SQL only through s.db (claim_sql_census_test.go).

Review Focus

  1. A catalog edited without a version bump. Expect the console to refuse to start, naming the file, rather than serve records whose catalog_version lies about their content. Pinned in Task 1 (TestCatalogIDChangesWithContent) and Task 4 (TestLoadPlanCatalogEnv).
  2. A catalog change that orphans an assignment (plan or pack removed while accounts hold it). Expect the pass to skip that account, log it, keep the last published record, and keep publishing everyone else. Pinned in Task 4 (TestPlanLimitsPassSkipsAnOrphanedAssignment).
  3. A deleted (tombstoned) account. Expect no row ensured and no record written for it. Pinned in Task 2 (TestEnsureDefaultPlansSkipsDeletedAccounts).
  4. An operator change racing the pass. Expect the newer revision to win in the bucket and in published_revision; a stale publish never marks a newer revision published. Pinned in Task 2 (TestMarkPlanPublishedIgnoresAStaleRevision) and Task 4 (TestPublishNeverOverwritesANewerRecord).
  5. Partial sponsored limits. A sponsored override that sets only some fields must leave the rest at the plan's value; in particular min_schedule_seconds: 0 must not mean "no minimum". Pinned in Task 1 (TestEffectiveSponsoredZeroIsUnset).

---

Task 1: The catalog and the limits arithmetic

Files:

  • Create: internal/plans/catalog.go
  • Create: internal/plans/catalog_test.go
  • Create: internal/plans/testdata/example-catalog.json

Interfaces:

  • Produces: ``go type Limits struct { RunningThings int json:"runningthings,omitempty" SidecarsPerApp int json:"sidecarsperapp,omitempty" IdleSeconds int json:"idleseconds,omitempty" MemoryTier string json:"memorytier,omitempty" // "small" | "default" AwakeHoursMonth int json:"awakehoursmonth,omitempty" DiskBytes int64 json:"diskbytes,omitempty" // per running thing StorageBytes int64 json:"storagebytes,omitempty" // per account StorageSelfServe bool json:"storageselfserve,omitempty" EmailPerDay int json:"emailperday,omitempty" BandwidthBytesMonth int64 json:"bandwidthbytesmonth,omitempty" CustomDomains bool json:"customdomains,omitempty" Fleets bool json:"fleets,omitempty" MinScheduleSeconds int json:"minscheduleseconds,omitempty" HistoryDays int json:"historydays,omitempty" } type Plan struct { Paid bool json:"paid" Limits Limits json:"limits" Packs []string json:"packs,omitempty" // pack names this plan may buy } type Pack struct { Adds Limits json:"adds" MaxQuantity int json:"maxquantity" } type Catalog struct { Version string json:"version" DefaultPlan string json:"defaultplan" FreeAccountsPerPerson int json:"freeaccountsperperson" Plans map[string]Plan json:"plans" Packs map[string]Pack json:"packs" Ceilings Limits json:"ceilings" } func ParseCatalog(b []byte) (*Catalog, error) func LoadCatalog(path string) (*Catalog, error) func (c *Catalog) ID() string // "<version>@<first 12 hex of sha256 of the canonical JSON>" func (c *Catalog) Effective(plan string, packs map[string]int, sponsored *Limits) (Limits, error) var ErrUnknownPlan, ErrUnknownPack, ErrPackNotOffered, ErrPackQuantity error ``
  • [ ] Step 1: Write the example catalog internal/plans/testdata/example-catalog.json with the spec's table 4.2 values: version "2026-10-20-v1", default plan free, free_accounts_per_person: 1; plan free (paid:false, runningthings 3, sidecarsperapp 1, idleseconds 90, memorytier "small", awakehoursmonth 100, diskbytes 1073741824, storagebytes 1073741824, emailperday 50, bandwidthbytesmonth 10737418240, minscheduleseconds 900, no packs); plan personal (paid:true, runningthings 25, sidecarsperapp 5, idleseconds 900, memorytier "default", awakehoursmonth 1500, diskbytes 10737418240, storagebytes 26843545600, storageselfserve true, emailperday 1000, bandwidthbytesmonth 214748364800, customdomains true, fleets true, minscheduleseconds 60, historydays 7, packs ["apps","storage","email","bandwidth"]); packs apps (+5 runningthings), storage (+10737418240 diskbytes, +53687091200 storagebytes), email (+1000 emailperday), bandwidth (+214748364800 bandwidthbytesmonth), each `maxquantity: 20`; ceilings runningthings 200, emailperday 10000, idleseconds 900.
  • [ ] Step 2: Write the failing tests in catalog_test.go:
func TestParseExampleCatalog(t *testing.T)            // LoadCatalog("testdata/example-catalog.json") succeeds; Plans["free"].Limits.RunningThings == 3
func TestParseCatalogRefuses(t *testing.T)            // table: empty version; default_plan not in plans; pack listed by a plan but not defined;
                                                      // pack adds a non-additive field (idle_seconds, memory_tier, any bool); max_quantity < 1;
                                                      // memory_tier not "small"/"default"; unknown JSON field (DisallowUnknownFields). Each returns a non-nil error.
func TestCatalogIDChangesWithContent(t *testing.T)    // same version, running_things 3 -> 4: IDs differ; byte-identical re-parse: IDs equal;
                                                      // key order / whitespace changes alone: IDs equal
func TestEffectivePlanOnly(t *testing.T)              // Effective("free", nil, nil) == Plans["free"].Limits
func TestEffectiveAddsPacks(t *testing.T)             // Effective("personal", {"apps":2,"email":1}, nil): RunningThings 35, EmailPerDay 2000
func TestEffectiveClampsToCeilings(t *testing.T)      // Effective("personal", {"apps":20,"email":20}, nil): RunningThings 125 (25+100, under 200);
                                                      // with ceilings.running_things 100: 100; EmailPerDay 10000 (1000+20000 clamped)
func TestEffectiveRefuses(t *testing.T)               // unknown plan -> ErrUnknownPlan; unknown pack -> ErrUnknownPack;
                                                      // {"apps":1} on free -> ErrPackNotOffered; {"apps":21} or {"apps":-1} -> ErrPackQuantity
func TestEffectiveSponsoredTakesTheMoreGenerous(t *testing.T) // sponsored {RunningThings: 300, EmailPerDay: 20} on personal:
                                                      // RunningThings 300 (above the 200 ceiling: sponsored is not clamped), EmailPerDay 1000 (plan wins);
                                                      // sponsored {CustomDomains:true} on free: true; {MemoryTier:"default"} on free: "default"
func TestEffectiveSponsoredZeroIsUnset(t *testing.T)  // sponsored {RunningThings: 40} on free: MinScheduleSeconds stays 900;
                                                      // sponsored {MinScheduleSeconds: 60} on free: 60 (smaller is more generous for this field only)
  • [ ] Step 3: Run them to verify they fail

Run: go test ./internal/plans/ Expected: FAIL (package does not compile: undefined LoadCatalog).

  • [ ] Step 4: Implement catalog.go

ParseCatalog uses a json.Decoder with DisallowUnknownFields, then validates. Additive fields (the only ones a pack may set, and the only ones ceilings clamp): RunningThings, DiskBytes, StorageBytes, EmailPerDay, BandwidthBytesMonth. A ceiling of 0 means no ceiling for that field; IdleSeconds ceiling clamps the plan value too. ID() hashes json.Marshal(c) (map keys marshal sorted, so it is canonical). Effective order: plan limits, plus qty * adds per pack, clamp additive fields and idle to ceilings, then merge sponsored field by field taking the more generous value: larger for numbers, true for bools, "default" over "small", and for MinScheduleSeconds the smaller non-zero value. Zero in sponsored means unset for every field. Errors wrap the sentinels with the offending name (fmt.Errorf("%w: %q", ErrUnknownPack, name)).

  • [ ] Step 5: Run the tests to verify they pass

Run: go test ./internal/plans/ Expected: PASS.

  • [ ] Step 6: Commit
git add internal/plans/
git commit -m "plans: the catalog and the limits arithmetic"

Task 2: Assignments in the console store

Files:

  • Modify: internal/console/store.go (the CREATE TABLE list in migrate(), before line ~385)
  • Create: internal/console/accountplans.go
  • Create: internal/console/accountplans_test.go

Interfaces:

  • Consumes: plans.Limits (Task 1).
  • Produces: ``go type AccountPlanRow struct { AccountID int64 Sqid string Plan string Packs map[string]int Source string // "default" | "operator" | "sponsored" | "billing" Sponsored *plans.Limits Reason string Revision int64 PublishedRevision int64 PublishedCatalog string UpdatedBy string UpdatedAt time.Time } func (s *Store) AccountPlan(accountID int64) (*AccountPlanRow, error) // nil, nil when no row func (s *Store) SetAccountPlan(accountID int64, plan string, packs map[string]int, source string, sponsored *plans.Limits, reason, by string, now time.Time) (int64, error) // returns the new revision func (s *Store) EnsureDefaultPlans(defaultPlan string, now time.Time) (int, error) // rows inserted func (s *Store) PlansToPublish(catalogID string, limit int) ([]AccountPlanRow, error) func (s *Store) MarkPlanPublished(accountID, revision int64, catalogID string) error ``
  • [ ] Step 1: Write the failing tests in accountplans_test.go, opening a store with Open(filepath.Join(t.TempDir(), "console.db")) and creating accounts with CreateAccount:
func TestAccountPlanAbsentIsNil(t *testing.T)                 // AccountPlan on a fresh account: nil, nil
func TestSetAccountPlanBumpsRevision(t *testing.T)            // first Set returns 1, second 2; AccountPlan round-trips Packs, Sponsored, Source, Reason, UpdatedBy
func TestEnsureDefaultPlansInsertsOnlyMissing(t *testing.T)   // 3 accounts, one already set to "personal": returns 2; the set one keeps "personal" and its revision;
                                                              // inserted rows have Source "default", Revision 1, UpdatedBy "plans"
func TestEnsureDefaultPlansSkipsDeletedAccounts(t *testing.T) // an account with deleted_at set gets no row
func TestPlansToPublish(t *testing.T)                         // returns rows where published_revision < revision OR published_catalog != catalogID; respects limit;
                                                              // after MarkPlanPublished(id, rev, "v1@abc") the row is not returned for "v1@abc" but is for "v2@def"
func TestMarkPlanPublishedIgnoresAStaleRevision(t *testing.T) // Set (rev 1), Set (rev 2), MarkPlanPublished(id, 1, c): PublishedRevision stays 0;
                                                              // MarkPlanPublished(id, 2, c): becomes 2
  • [ ] Step 2: Run them to verify they fail

Run: go test ./internal/console/ -run 'AccountPlan|DefaultPlans|PlansToPublish|MarkPlanPublished' Expected: FAIL (undefined AccountPlanRow).

  • [ ] Step 3: Add the table to the CREATE TABLE IF NOT EXISTS list in migrate():
CREATE TABLE IF NOT EXISTS account_plans (
  account_id         INTEGER PRIMARY KEY,
  plan               TEXT NOT NULL,
  packs              TEXT NOT NULL DEFAULT '{}',
  source             TEXT NOT NULL,
  sponsored          TEXT NOT NULL DEFAULT '',
  reason             TEXT NOT NULL DEFAULT '',
  revision           INTEGER NOT NULL,
  published_revision INTEGER NOT NULL DEFAULT 0,
  published_catalog  TEXT NOT NULL DEFAULT '',
  updated_by         TEXT NOT NULL,
  updated_at         INTEGER NOT NULL
);
  • [ ] Step 4: Implement the five methods in accountplans.go through s.db. packs and sponsored are JSON text (sponsored empty for nil). SetAccountPlan is one INSERT ... ON CONFLICT(account_id) DO UPDATE SET ..., revision = revision + 1 RETURNING revision. EnsureDefaultPlans is one INSERT ... SELECT id ... FROM accounts WHERE deleted_at = 0 AND id NOT IN (SELECT account_id FROM account_plans). PlansToPublish joins accounts for Sqid and excludes deleted accounts. MarkPlanPublished updates only WHERE account_id = ? AND revision = ?.
  • [ ] Step 5: Run the tests to verify they pass

Run: go test ./internal/console/ -run 'AccountPlan|DefaultPlans|PlansToPublish|MarkPlanPublished' Expected: PASS.

  • [ ] Step 6: Commit
git add internal/console/store.go internal/console/accountplans.go internal/console/accountplans_test.go
git commit -m "console: account plan assignments"

Task 3: The limits record on the wire

Files:

  • Create: internal/plans/record.go
  • Create: internal/plans/record_test.go
  • Modify: internal/domains/reconcile_test.go (one test)

Interfaces:

  • Consumes: plans.Limits (Task 1), release.Store.
  • Produces: ``go const RecordSchema = 1 type Record struct { Schema int json:"schema" Account string json:"account" // sqid CatalogID string json:"catalogid" Plan string json:"plan" Source string json:"source" Revision int64 json:"revision" Limits Limits json:"limits" IssuedAt time.Time json:"issuedat" } func RecordKey(sqid string) string // "accounts/" + sqid + "/limits.json" func ParseRecord(b []byte, sqid string) (*Record, error) func GetRecord(ctx context.Context, s release.Store, sqid string) (*Record, string, error) // record, etag; os.ErrNotExist-wrapped when absent func PutRecord(ctx context.Context, s release.Store, r *Record, ifMatch string) (string, error) ` Later plans add fields (throttle state, suspended routes) additively with omitempty`; readers ignore unknown fields so an older box reads a newer record.
  • [ ] Step 1: Write the failing tests in record_test.go, using release.NewDirStore(t.TempDir()) as the bucket:
func TestRecordKey(t *testing.T)                        // RecordKey("abc") == "accounts/abc/limits.json"
func TestRecordRoundTrip(t *testing.T)                  // PutRecord(create-only) then GetRecord returns an equal Record and a non-empty etag
func TestParseRecordRefuses(t *testing.T)               // schema 2; account "other" when asked for "abc"; malformed JSON: each errors
func TestParseRecordIgnoresUnknownFields(t *testing.T)  // a record with an extra "throttled": true parses
func TestPutRecordIsCAS(t *testing.T)                   // second create-only Put returns release.ErrCASConflict; Put with a stale etag returns ErrCASConflict

And in internal/domains/reconcile_test.go:

func TestAccountManifestSqidIgnoresLimitsRecord(t *testing.T) // AccountManifestSqid("accounts/abc/limits.json") returns ok == false
  • [ ] Step 2: Run them to verify they fail

Run: go test ./internal/plans/ ./internal/domains/ -run 'Record|IgnoresLimits' Expected: internal/plans FAILS (undefined RecordKey); the domains test PASSES already (it pins existing behaviour so a later change cannot break it).

  • [ ] Step 3: Implement record.go, mirroring objstore.GetEntitlement/PutEntitlement (internal/objstore/entitlement.go): ParseRecord uses plain json.Unmarshal (unknown fields allowed), then checks Schema == RecordSchema and Account == sqid.
  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/plans/ ./internal/domains/ -run 'Record|IgnoresLimits' Expected: PASS.

  • [ ] Step 5: Commit
git add internal/plans/record.go internal/plans/record_test.go internal/domains/reconcile_test.go
git commit -m "plans: the per-account limits record"

Task 4: Publishing, and the catalog in the console's config

Files:

  • Modify: internal/console/server.go (the Config struct near StoreMaxPerApp, line ~717)
  • Modify: cmd/console/main.go (config literal near line 392; a planCatalog() helper beside storeOperators())
  • Modify: internal/console/claim_workers.go (register "StartPlanLimitsPass")
  • Modify: internal/console/testdata/claim-routes.txt (add worker StartPlanLimitsPass)
  • Create: internal/console/planlimits.go
  • Create: internal/console/planlimits_test.go
  • Create: cmd/console/plancatalog_test.go

Interfaces:

  • Consumes: Tasks 1 to 3.
  • Produces: ``go // Config PlanCatalog *plans.Catalog // nil: plans are off on this deployment func (s *Server) StartPlanLimitsPass(ctx context.Context) func (s *Server) planLimitsPass(ctx context.Context, now time.Time) func (s *Server) publishAccountLimits(ctx context.Context, row AccountPlanRow, now time.Time) error func (s *Server) EffectiveLimits(acct *Account) (*plans.Limits, error) // nil, nil when PlanCatalog is nil; default plan when no row // cmd/console func planCatalog() (*plans.Catalog, error) // reads CARLOS_PLAN_CATALOG; "" -> nil, nil ``
  • [ ] Step 1: Write the failing tests in planlimits_test.go, using newConsoleFixture(t) and setting fx.srv.cfg.PlanCatalog from plans.LoadCatalog("../plans/testdata/example-catalog.json"):
func TestPlanLimitsPassOffWithoutACatalog(t *testing.T)       // PlanCatalog nil: planLimitsPass writes nothing under accounts/*/limits.json and inserts no account_plans rows
func TestPlanLimitsPassPublishesDefaults(t *testing.T)        // with the catalog: after one pass the fixture account has a record with Plan "free",
                                                              // Source "default", Limits.RunningThings 3, CatalogID == catalog.ID(); its row is marked published
func TestPlanLimitsPassRepublishesOnCatalogChange(t *testing.T) // pass with catalog A, then with A' (running_things 4, new version): the record's CatalogID
                                                              // and RunningThings update; a third pass with A' writes nothing (etag unchanged)
func TestPlanLimitsPassSkipsAnOrphanedAssignment(t *testing.T) // two accounts; one set to plan "gold" (not in the catalog): the pass publishes the other,
                                                              // leaves the orphan's existing record byte-identical, and does not mark it published
func TestPublishNeverOverwritesANewerRecord(t *testing.T)     // seed the bucket with Revision 5 for the same CatalogID; publish a row at revision 3:
                                                              // the bucket keeps 5, and the row is marked published (the newer write already covers it)
func TestPublishRetriesAfterABucketFailure(t *testing.T)      // wrap fx.rel so Put fails once: the row stays unpublished; the next pass publishes it
func TestEffectiveLimits(t *testing.T)                        // nil catalog: nil, nil; catalog + no row: free limits; row "personal" + {"apps":1}: RunningThings 30

And in cmd/console/plancatalog_test.go:

func TestLoadPlanCatalogEnv(t *testing.T) // unset: nil, nil; path to the example: non-nil; path to an invalid file or a missing file: error
  • [ ] Step 2: Run them to verify they fail

Run: go test ./internal/console/ -run 'PlanLimits|Publish|EffectiveLimits' && go test ./cmd/console/ -run PlanCatalog Expected: FAIL (undefined PlanCatalog).

  • [ ] Step 3: Implement
  • planCatalog() returns plans.LoadCatalog(path); main calls log.Fatalf("CARLOS_PLAN_CATALOG: %v", err) on error: a misconfigured catalog must never silently mean unlimited.
  • StartPlanLimitsPass follows StartExpiryPass (expirypass.go:27): return at once when s.cfg.Releases == nil || s.cfg.PlanCatalog == nil; otherwise run now and every minute.
  • planLimitsPass: EnsureDefaultPlans(catalog.DefaultPlan, now), then PlansToPublish(catalog.ID(), 200), then publishAccountLimits for each, logging and continuing on error.
  • publishAccountLimits: compute Effective; on error log console: plan limits: <sqid>: <err> and return it (record untouched). GetRecord; if it exists with the same CatalogID and Revision >= row.Revision, mark published and return. Otherwise PutRecord with the read etag (create-only if absent); on ErrCASConflict re-read once and repeat the comparison, then give up until the next pass. On success MarkPlanPublished(row.AccountID, row.Revision, catalog.ID()).
  • EffectiveLimits reads AccountPlan; no row means the catalog's default plan with no packs.
  • [ ] Step 4: Register the worker in registerClaimWorkers and add worker StartPlanLimitsPass to testdata/claim-routes.txt in sorted position.
  • [ ] Step 5: Run the tests to verify they pass

Run: go test ./internal/console/ -run 'PlanLimits|Publish|EffectiveLimits|ClaimRoutes' && go test ./cmd/console/ -run PlanCatalog Expected: PASS.

  • [ ] Step 6: Commit
git add internal/console/ cmd/console/
git commit -m "console: publish account limits from the plan catalog"

Task 5: The operator door and carlos accounts plan

Files:

  • Create: internal/console/apiaccountplan.go
  • Create: internal/console/apiaccountplan_test.go
  • Modify: internal/console/authcli.go (register two cliRoutes beside store/approve, line ~931)
  • Modify: internal/console/testdata/claim-routes.txt
  • Modify: apimode.go (two session methods beside storeApprove, line ~2195)
  • Modify: accounts.go (add case "plan": to the dispatch at line ~73)
  • Create: accounts_plan_test.go
  • Modify: docs/site/cli.md (document carlos accounts plan)
  • Create: docs/site/plans.md (operator guide: the catalog file, CARLOS_PLAN_CATALOG, assigning and sponsoring)
  • Modify: docs/site/nav.json (a "Plans" page under Reference)

Interfaces:

  • Consumes: Tasks 1, 2, 4.
  • Produces: - GET /api/cli/accounts/{account}/plan and POST /api/cli/accounts/{account}/plan, fleet operators only (s.store.IsOperator), everyone else 404 (the store-approval doors' convention, apistore_entitlement.go:92). - Reply body: ``go type accountPlanReply struct { Account string json:"account" CatalogID string json:"catalogid" Plan string json:"plan" Packs map[string]int json:"packs" Source string json:"source" Sponsored *plans.Limits json:"sponsored,omitempty" Reason string json:"reason,omitempty" Revision int64 json:"revision" PublishedRevision int64 json:"publishedrevision" Limits *plans.Limits json:"limits,omitempty" // effective; omitted when Effective errors LimitsError string json:"limits_error,omitempty" } ` - POST body: {"plan": "personal", "packs": {"apps": 2}, "sponsored": {...} | null, "reason": "..."}. Source is "sponsored" when sponsored is non-null, else "operator". reason is required. - CLI: carlos accounts plan --account <sqid> prints the reply; carlos accounts plan --account <sqid> --set <plan> [--pack name=qty ...] [--sponsored <file.json>] --reason <text> sets it. - Session: func (s apiSession) accountPlan(ctx context.Context, account string) (accountPlanReply, error) and func (s apiSession) setAccountPlan(ctx context.Context, account string, req setAccountPlanRequest) (accountPlanReply, error) (setAccountPlanRequest` mirrors the POST body).
  • [ ] Step 1: Write the failing console tests in apiaccountplan_test.go with newConsoleFixture, an operator token from fx.st.SetOperator(addr, true) plus cliToken(t, fx, addr, code) (as entitlementOperatorToken does, without the store-operator list):
func TestAccountPlanDoorIsOperatorOnly(t *testing.T)       // a non-operator owner of the account: GET and POST both 404
func TestAccountPlanDoorWithoutACatalog(t *testing.T)      // PlanCatalog nil: 409, error names CARLOS_PLAN_CATALOG
func TestAccountPlanGetDefaults(t *testing.T)              // fresh account: plan "free", source "default", revision 0, limits.running_things 3
func TestAccountPlanSetAndPublish(t *testing.T)            // POST personal + {"apps":1}: reply revision 1, limits.running_things 30;
                                                           // the bucket record at accounts/<sqid>/limits.json has Revision 1 and Plan "personal" (published inline)
func TestAccountPlanSetSponsored(t *testing.T)             // POST free with sponsored {"running_things": 12}: source "sponsored", limits.running_things 12
func TestAccountPlanSetRefuses(t *testing.T)               // 400 for: unknown plan; unknown pack; pack not offered by the plan; quantity 0, -1 or above max;
                                                           // missing reason. Nothing is written (revision unchanged)
func TestAccountPlanSetSurvivesABucketFailure(t *testing.T) // Put fails: POST still 200 with published_revision < revision; the next planLimitsPass publishes
  • [ ] Step 2: Write the failing CLI test in accounts_plan_test.go, following the runStoreApproveWithSession pattern (store.go:405) against a test console session:
func TestAccountsPlanParsesPacks(t *testing.T) // "--pack apps=2 --pack email=1" -> {"apps":2,"email":1}; "--pack apps" and "--pack apps=x" are errors
func TestAccountsPlanShowAndSet(t *testing.T)  // show prints the plan, source and each effective limit; set sends the request body above
  • [ ] Step 3: Run them to verify they fail

Run: go test ./internal/console/ -run AccountPlan && go test . -run AccountsPlan Expected: FAIL (404 / undefined).

  • [ ] Step 4: Implement the door, the routes, the session methods and the CLI command. POST validates with catalog.Effective(plan, packs, sponsored) before writing, maps the plans.Err* sentinels to 400 with a plain message, then SetAccountPlan, then calls publishAccountLimits inline, logging (not failing) a publish error. The CLI prints limits in human units (GB, per day, per month) in the field order of spec table 4.2. Add both routes to testdata/claim-routes.txt.
  • [ ] Step 5: Run the tests to verify they pass

Run: go test ./internal/console/ -run 'AccountPlan|ClaimRoutes' && go test . -run AccountsPlan Expected: PASS.

  • [ ] Step 6: Write the docs docs/site/plans.md (what a catalog is, the example catalog, that no catalog means no limits, how to assign and sponsor) and the cli.md entry. Then run the copy-review skill on every new human-facing string from Tasks 4 and 5 (CLI output, error messages, docs prose) and apply the approved text verbatim. Internal log lines and JSON keys are not reviewed.
  • [ ] Step 7: Commit
git add internal/console/ apimode.go accounts.go accounts_plan_test.go docs/site/
git commit -m "console, cli: the operator door for account plans"

Done when

  • go test ./internal/plans/ ./internal/domains/ -run 'Catalog|Effective|Record|IgnoresLimits', go test ./internal/console/ -run 'AccountPlan|DefaultPlans|PlansToPublish|MarkPlanPublished|PlanLimits|Publish|EffectiveLimits|ClaimRoutes', go test ./cmd/console/ -run PlanCatalog and go test . -run AccountsPlan pass locally.
  • CI is green on the branch tip, after merging current main.
  • With CARLOS_PLAN_CATALOG unset, no console behaviour changes (pinned by TestPlanLimitsPassOffWithoutACatalog and TestAccountPlanDoorWithoutACatalog).