# 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:"running_things,omitempty"`
      SidecarsPerApp      int    `json:"sidecars_per_app,omitempty"`
      IdleSeconds         int    `json:"idle_seconds,omitempty"`
      MemoryTier          string `json:"memory_tier,omitempty"` // "small" | "default"
      AwakeHoursMonth     int    `json:"awake_hours_month,omitempty"`
      DiskBytes           int64  `json:"disk_bytes,omitempty"` // per running thing
      StorageBytes        int64  `json:"storage_bytes,omitempty"` // per account
      StorageSelfServe    bool   `json:"storage_self_serve,omitempty"`
      EmailPerDay         int    `json:"email_per_day,omitempty"`
      BandwidthBytesMonth int64  `json:"bandwidth_bytes_month,omitempty"`
      CustomDomains       bool   `json:"custom_domains,omitempty"`
      Fleets              bool   `json:"fleets,omitempty"`
      MinScheduleSeconds  int    `json:"min_schedule_seconds,omitempty"`
      HistoryDays         int    `json:"history_days,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:"max_quantity"`
  }
  type Catalog struct {
      Version               string          `json:"version"`
      DefaultPlan           string          `json:"default_plan"`
      FreeAccountsPerPerson int             `json:"free_accounts_per_person"`
      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`, running_things 3, sidecars_per_app 1, idle_seconds 90, memory_tier "small", awake_hours_month 100, disk_bytes 1073741824, storage_bytes 1073741824, email_per_day 50, bandwidth_bytes_month 10737418240, min_schedule_seconds 900, no packs); plan `personal` (`paid:true`, running_things 25, sidecars_per_app 5, idle_seconds 900, memory_tier "default", awake_hours_month 1500, disk_bytes 10737418240, storage_bytes 26843545600, storage_self_serve true, email_per_day 1000, bandwidth_bytes_month 214748364800, custom_domains true, fleets true, min_schedule_seconds 60, history_days 7, packs `["apps","storage","email","bandwidth"]`); packs `apps` (+5 running_things), `storage` (+10737418240 disk_bytes, +53687091200 storage_bytes), `email` (+1000 email_per_day), `bandwidth` (+214748364800 bandwidth_bytes_month), each `max_quantity: 20`; ceilings running_things 200, email_per_day 10000, idle_seconds 900.

- [ ] **Step 2: Write the failing tests** in `catalog_test.go`:

```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**

```bash
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`:

```go
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()`:

```sql
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**

```bash
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:"catalog_id"`
      Plan           string    `json:"plan"`
      Source         string    `json:"source"`
      Revision       int64     `json:"revision"`
      Limits         Limits    `json:"limits"`
      IssuedAt       time.Time `json:"issued_at"`
  }
  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:

```go
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`:

```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**

```bash
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")`:

```go
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`:

```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**

```bash
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 `cliRoute`s 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:"catalog_id"`
        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:"published_revision"`
        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):

```go
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:

```go
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**

```bash
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`).
