carlos / platform Public

plan: plans 1, catalog and account limits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 567600d9a80bb25cae8e2d06a17440de2e38f567 parent 3085d6a
1 file changed, +451 −0
  • docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md +451 −0

CI passed — run details

CI log
=== gate ===
go: downloading github.com/aws/aws-sdk-go-v2 v1.44.0
go: downloading github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
go: downloading github.com/aws/aws-sdk-go-v2/config v1.32.6
go: downloading github.com/benbjohnson/litestream v0.5.14
go: downloading golang.org/x/sys v0.46.0
go: downloading github.com/superfly/ltx v0.5.1
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sns v1.43.0
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
go: downloading github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
go: downloading github.com/oschwald/maxminddb-golang v1.13.1
go: downloading golang.org/x/crypto v0.53.0
go: downloading github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
go: downloading github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.19.6
go: downloading github.com/aws/smithy-go v1.28.1
go: downloading github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0
go: downloading github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
go: downloading github.com/klauspost/compress v1.18.0
go: downloading github.com/pion/logging v0.2.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
go: downloading github.com/pion/stun/v3 v3.0.1
go: downloading golang.org/x/net v0.56.0
go: downloading github.com/pion/turn/v4 v4.1.4
go: downloading github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
go: downloading github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4
go: downloading github.com/aws/aws-sdk-go-v2/service/signin v1.0.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.30.8
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading github.com/pion/randutil v0.1.0
go: downloading github.com/pion/transport/v4 v4.0.1
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading golang.org/x/sync v0.21.0
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18
go: downloading github.com/pion/transport/v3 v3.0.8
go: downloading github.com/pion/dtls/v3 v3.0.7
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/prometheus/common v0.45.0
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading golang.org/x/text v0.38.0
go: downloading modernc.org/libc v1.74.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading github.com/google/uuid v1.6.0
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
go: downloading golang.org/x/tools v0.47.0
go: downloading golang.org/x/mod v0.37.0
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	340.203s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.874s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.362s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.707s
ok  	github.com/carlosframework/platform/internal/activator	252.114s
ok  	github.com/carlosframework/platform/internal/agentsock	0.015s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.479s
ok  	github.com/carlosframework/platform/internal/apperr	0.073s
ok  	github.com/carlosframework/platform/internal/applog	0.129s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.631s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.013s
ok  	github.com/carlosframework/platform/internal/certwarm	0.004s
ok  	github.com/carlosframework/platform/internal/cgroup	0.700s
ok  	github.com/carlosframework/platform/internal/changelog	0.333s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.834s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	420.100s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.938s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.004s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.371s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	45.201s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.135s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.016s
ok  	github.com/carlosframework/platform/internal/geo	0.013s
ok  	github.com/carlosframework/platform/internal/geoip	0.032s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.158s
ok  	github.com/carlosframework/platform/internal/host	195.261s
ok  	github.com/carlosframework/platform/internal/hostactors	6.232s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.013s
ok  	github.com/carlosframework/platform/internal/hostbox	2.890s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.618s
ok  	github.com/carlosframework/platform/internal/hostenv	0.013s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.013s
ok  	github.com/carlosframework/platform/internal/hostexec	5.326s
ok  	github.com/carlosframework/platform/internal/hostfence	0.033s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.982s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.016s
ok  	github.com/carlosframework/platform/internal/hostguard	1.043s
ok  	github.com/carlosframework/platform/internal/hostops	1.691s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.013s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.013s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.511s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.147s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.014s
ok  	github.com/carlosframework/platform/internal/infraview	0.013s
ok  	github.com/carlosframework/platform/internal/instancectl	5.269s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.790s
ok  	github.com/carlosframework/platform/internal/instancefs	0.017s
ok  	github.com/carlosframework/platform/internal/instancekey	0.015s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.023s
ok  	github.com/carlosframework/platform/internal/layout	1.604s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.002s
ok  	github.com/carlosframework/platform/internal/lease	10.122s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.429s
ok  	github.com/carlosframework/platform/internal/ledger	0.016s
ok  	github.com/carlosframework/platform/internal/mail	0.199s
ok  	github.com/carlosframework/platform/internal/maintenance	0.080s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.768s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.099s
ok  	github.com/carlosframework/platform/internal/metrics	0.009s
ok  	github.com/carlosframework/platform/internal/moves	21.930s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.781s
ok  	github.com/carlosframework/platform/internal/objstore	0.202s
ok  	github.com/carlosframework/platform/internal/ops	0.209s
ok  	github.com/carlosframework/platform/internal/park	0.234s
ok  	github.com/carlosframework/platform/internal/privatefile	0.008s
ok  	github.com/carlosframework/platform/internal/provision	97.740s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.008s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.942s
ok  	github.com/carlosframework/platform/internal/relay	7.251s
ok  	github.com/carlosframework/platform/internal/release	27.602s
ok  	github.com/carlosframework/platform/internal/rollout	3.724s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.060s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.583s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.512s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	12.443s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.261s
ok  	github.com/carlosframework/platform/internal/status	0.074s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.288s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.005s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.581s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49807522 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.024s
=== RUN   TestT4StorageProbeMinio
2026/10/20 18:18:39 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E04FBBD6D64D2B, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.023s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md b/docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md
new file mode 100644
index 000000000..09c1119fd
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md
@@ -0,0 +1,451 @@
+# 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`).