carlos / platform Public
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:
- Catalog and limits (this plan). Dormant foundation.
- 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.
- Creation caps: running things, sidecars per app, one free account per person, fleets, paid-only custom domains, schedule minimum, never-sleep refusals.
- 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.
- Disk quotas on boxes.
- Checkout and packs: harvested Stripe client, multi-item subscription, webhook, portal, slim price book.
- Pricing page and public price export.
- 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/plansimports only the standard library andinternal/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 calledfree, a pack calledapps); 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/andgo test ./internal/console/ -run 'Plan|Limits'. LeaveGOCACHEat 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, orclaim_routes_test.gofails. - Console code outside
store.go/store_gate.goreaches SQL only throughs.db(claim_sql_census_test.go).
Review Focus
- A catalog edited without a version bump. Expect the console to refuse to start, naming the file, rather than serve records whose
catalog_versionlies about their content. Pinned in Task 1 (TestCatalogIDChangesWithContent) and Task 4 (TestLoadPlanCatalogEnv). - 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). - A deleted (tombstoned) account. Expect no row ensured and no record written for it. Pinned in Task 2 (
TestEnsureDefaultPlansSkipsDeletedAccounts). - 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). - 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: 0must 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 intjson:"runningthings,omitempty"SidecarsPerApp intjson:"sidecarsperapp,omitempty"IdleSeconds intjson:"idleseconds,omitempty"MemoryTier stringjson:"memorytier,omitempty"// "small" | "default" AwakeHoursMonth intjson:"awakehoursmonth,omitempty"DiskBytes int64json:"diskbytes,omitempty"// per running thing StorageBytes int64json:"storagebytes,omitempty"// per account StorageSelfServe booljson:"storageselfserve,omitempty"EmailPerDay intjson:"emailperday,omitempty"BandwidthBytesMonth int64json:"bandwidthbytesmonth,omitempty"CustomDomains booljson:"customdomains,omitempty"Fleets booljson:"fleets,omitempty"MinScheduleSeconds intjson:"minscheduleseconds,omitempty"HistoryDays intjson:"historydays,omitempty"} type Plan struct { Paid booljson:"paid"Limits Limitsjson:"limits"Packs []stringjson:"packs,omitempty"// pack names this plan may buy } type Pack struct { Adds Limitsjson:"adds"MaxQuantity intjson:"maxquantity"} type Catalog struct { Version stringjson:"version"DefaultPlan stringjson:"defaultplan"FreeAccountsPerPerson intjson:"freeaccountsperperson"Plans map[string]Planjson:"plans"Packs map[string]Packjson:"packs"Ceilings Limitsjson:"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.jsonwith the spec's table 4.2 values: version"2026-10-20-v1", default planfree,free_accounts_per_person: 1; planfree(paid:false, runningthings 3, sidecarsperapp 1, idleseconds 90, memorytier "small", awakehoursmonth 100, diskbytes 1073741824, storagebytes 1073741824, emailperday 50, bandwidthbytesmonth 10737418240, minscheduleseconds 900, no packs); planpersonal(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"]); packsapps(+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(theCREATE TABLElist inmigrate(), 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 withOpen(filepath.Join(t.TempDir(), "console.db"))and creating accounts withCreateAccount:
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 EXISTSlist inmigrate():
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.gothroughs.db.packsandsponsoredare JSON text (sponsoredempty for nil).SetAccountPlanis oneINSERT ... ON CONFLICT(account_id) DO UPDATE SET ..., revision = revision + 1 RETURNING revision.EnsureDefaultPlansis oneINSERT ... SELECT id ... FROM accounts WHERE deleted_at = 0 AND id NOT IN (SELECT account_id FROM account_plans).PlansToPublishjoinsaccountsforSqidand excludes deleted accounts.MarkPlanPublishedupdates onlyWHERE 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 intjson:"schema"Account stringjson:"account"// sqid CatalogID stringjson:"catalogid"Plan stringjson:"plan"Source stringjson:"source"Revision int64json:"revision"Limits Limitsjson:"limits"IssuedAt time.Timejson:"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 withomitempty`; readers ignore unknown fields so an older box reads a newer record.
- [ ] Step 1: Write the failing tests in
record_test.go, usingrelease.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 ErrCASConflictAnd 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, mirroringobjstore.GetEntitlement/PutEntitlement(internal/objstore/entitlement.go):ParseRecorduses plainjson.Unmarshal(unknown fields allowed), then checksSchema == RecordSchemaandAccount == 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(theConfigstruct nearStoreMaxPerApp, line ~717) - Modify:
cmd/console/main.go(config literal near line 392; aplanCatalog()helper besidestoreOperators()) - Modify:
internal/console/claim_workers.go(register"StartPlanLimitsPass") - Modify:
internal/console/testdata/claim-routes.txt(addworker 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, usingnewConsoleFixture(t)and settingfx.srv.cfg.PlanCatalogfromplans.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 30And 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()returnsplans.LoadCatalog(path);maincallslog.Fatalf("CARLOS_PLAN_CATALOG: %v", err)on error: a misconfigured catalog must never silently mean unlimited.StartPlanLimitsPassfollowsStartExpiryPass(expirypass.go:27): return at once whens.cfg.Releases == nil || s.cfg.PlanCatalog == nil; otherwise run now and every minute.planLimitsPass:EnsureDefaultPlans(catalog.DefaultPlan, now), thenPlansToPublish(catalog.ID(), 200), thenpublishAccountLimitsfor each, logging and continuing on error.publishAccountLimits: computeEffective; on error logconsole: plan limits: <sqid>: <err>and return it (record untouched).GetRecord; if it exists with the sameCatalogIDandRevision >= row.Revision, mark published and return. OtherwisePutRecordwith the read etag (create-only if absent); onErrCASConflictre-read once and repeat the comparison, then give up until the next pass. On successMarkPlanPublished(row.AccountID, row.Revision, catalog.ID()).EffectiveLimitsreadsAccountPlan; no row means the catalog's default plan with no packs.
- [ ] Step 4: Register the worker in
registerClaimWorkersand addworker StartPlanLimitsPasstotestdata/claim-routes.txtin 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 twocliRoutes besidestore/approve, line ~931) - Modify:
internal/console/testdata/claim-routes.txt - Modify:
apimode.go(two session methods besidestoreApprove, line ~2195) - Modify:
accounts.go(addcase "plan":to the dispatch at line ~73) - Create:
accounts_plan_test.go - Modify:
docs/site/cli.md(documentcarlos 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}/planandPOST /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 stringjson:"account"CatalogID stringjson:"catalogid"Plan stringjson:"plan"Packs map[string]intjson:"packs"Source stringjson:"source"Sponsored *plans.Limitsjson:"sponsored,omitempty"Reason stringjson:"reason,omitempty"Revision int64json:"revision"PublishedRevision int64json:"publishedrevision"Limits *plans.Limitsjson:"limits,omitempty"// effective; omitted when Effective errors LimitsError stringjson:"limits_error,omitempty"}`- POST body:{"plan": "personal", "packs": {"apps": 2}, "sponsored": {...} | null, "reason": "..."}. Source is"sponsored"whensponsoredis non-null, else"operator".reasonis 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)andfunc (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.gowithnewConsoleFixture, an operator token fromfx.st.SetOperator(addr, true)pluscliToken(t, fx, addr, code)(asentitlementOperatorTokendoes, 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 therunStoreApproveWithSessionpattern (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 theplans.Err*sentinels to 400 with a plain message, thenSetAccountPlan, then callspublishAccountLimitsinline, 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 totestdata/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 thecli.mdentry. 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 PlanCatalogandgo test . -run AccountsPlanpass locally.- CI is green on the branch tip, after merging current main.
- With
CARLOS_PLAN_CATALOGunset, no console behaviour changes (pinned byTestPlanLimitsPassOffWithoutACatalogandTestAccountPlanDoorWithoutACatalog).