plan: plans 1, catalog and account limits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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`). |