diff --git a/plans/plans.go b/plans/plans.go index 7db7bbc..3efdfa6 100644 --- a/plans/plans.go +++ b/plans/plans.go @@ -52,6 +52,26 @@ type Limits struct { // unbounded queue creation is an operational risk against the cluster. QueueCount int `yaml:"queue_count"` + // PostgresCount / VectorCount / RedisCount / MongoCount / StorageCount are the + // per-service active-resource-count caps. Each mirrors QueueCount exactly: + // -1 means unlimited; 0 means the tier cannot provision that service at all. + // + // Task #55 (strict-≥80%-margin redesign, 2026-06-05): before this, only + // queue_count was capped, so a tenant could create MANY postgres/redis/mongo + // resources each at the per-resource size cap and blow the saturated-COGS + // bound. The invariant is `count × per-resource-size-cap × unit-COGS ≤ the + // tier's 20%-of-price budget` per service, with Redis the binding constraint + // ($6.50/GB-mo — see docs/.../PRICING-MARGIN-MODEL-AND-TEAM-REDESIGN.md). + // + // Enforcement in api/ is FLAG-GATED behind RESOURCE_COUNT_CAPS_ENABLED + // (default false) — these fields are inert until the operator flips the flag, + // so shipping them cannot surprise-break existing heavy tenants with a 402. + PostgresCount int `yaml:"postgres_count"` + VectorCount int `yaml:"vector_count"` + RedisCount int `yaml:"redis_count"` + MongoCount int `yaml:"mongodb_count"` + StorageCount int `yaml:"storage_count"` + // StorageStorageMB is the maximum object storage per R2 prefix in megabytes. StorageStorageMB int `yaml:"storage_storage_mb"` @@ -492,6 +512,83 @@ func (r *Registry) QueueCountLimit(tier string) int { return p.Limits.QueueCount } +// ResourceCountLimit returns the per-tier active-resource-count cap for a +// provisionable service ("postgres", "vector", "redis", "mongodb", "storage", +// "queue"). It is the single registry-driven accessor the api enforcement +// blocks call so that adding a new service is one switch arm here, not a new +// bespoke accessor + handler pattern (rule 18 — registry-driven, not hand-typed). +// +// Semantics mirror QueueCountLimit exactly: +// - -1 means unlimited; +// - a zero struct value (the YAML field was absent on an older plans.yaml) is +// treated as unlimited (-1) so a stale config never blocks an existing +// customer — once plans.yaml has every *_count, this zero-fallback is inert; +// - any positive value is the hard cap. +// +// An unknown service name returns -1 (fail open) — a typo in a caller must never +// block a provision. The mapping is the single place that knows which struct +// field backs which service string. +func (r *Registry) ResourceCountLimit(tier, service string) int { + switch service { + case "queue": + return r.QueueCountLimit(tier) + case "postgres": + return r.PostgresCountLimit(tier) + case "vector": + return r.VectorCountLimit(tier) + case "redis": + return r.RedisCountLimit(tier) + case "mongodb": + return r.MongoCountLimit(tier) + case "storage": + return r.StorageCountLimit(tier) + default: + return -1 // unknown service — fail open + } +} + +// PostgresCountLimit returns the max active Postgres resources for the tier. +// -1 unlimited; 0 → unlimited fallback (absent YAML field). See ResourceCountLimit. +func (r *Registry) PostgresCountLimit(tier string) int { + return r.countLimit(tier, func(l Limits) int { return l.PostgresCount }) +} + +// VectorCountLimit returns the max active pgvector resources for the tier. +func (r *Registry) VectorCountLimit(tier string) int { + return r.countLimit(tier, func(l Limits) int { return l.VectorCount }) +} + +// RedisCountLimit returns the max active Redis resources for the tier. Redis is +// the binding COGS constraint ($6.50/GB-mo), so this is the most conservative cap. +func (r *Registry) RedisCountLimit(tier string) int { + return r.countLimit(tier, func(l Limits) int { return l.RedisCount }) +} + +// MongoCountLimit returns the max active MongoDB resources for the tier. +func (r *Registry) MongoCountLimit(tier string) int { + return r.countLimit(tier, func(l Limits) int { return l.MongoCount }) +} + +// StorageCountLimit returns the max active object-storage resources for the tier. +func (r *Registry) StorageCountLimit(tier string) int { + return r.countLimit(tier, func(l Limits) int { return l.StorageCount }) +} + +// countLimit is the shared zero-as-unlimited fallback used by every *CountLimit +// accessor. Mirrors QueueCountLimit's semantics so all count caps behave +// identically: unknown tier or absent field → -1 (unlimited / fail open). +func (r *Registry) countLimit(tier string, pick func(Limits) int) int { + p := r.Get(tier) + if p == nil { + return -1 // unknown tier — fail open + } + v := pick(p.Limits) + if v == 0 { + return -1 // absent YAML field — treat as unlimited (inert zero-fallback) + } + return v +} + // BackupRetentionDays returns how long the worker keeps Postgres backups for // the given tier. 0 means no backups are taken. func (r *Registry) BackupRetentionDays(tier string) int { @@ -579,6 +676,13 @@ plans: # queue_count -1 → 1 (0 means unlimited via QueueCountLimit, so 1). queue_storage_mb: 64 queue_count: 1 + # Task #55 resource-count caps (flag-gated, default OFF in api). anonymous + # is also fingerprint-dedup-gated; 1 each keeps the saturated-COGS bound. + postgres_count: 1 + vector_count: 1 + redis_count: 1 + mongodb_count: 1 + storage_count: 1 storage_storage_mb: 10 webhook_requests_stored: 100 team_members: 1 @@ -617,6 +721,12 @@ plans: # strict-80% margin redesign (2026-06-05): mirror anonymous. queue_storage_mb: 64 queue_count: 1 + # Task #55 resource-count caps — mirror anonymous (claim flip must not widen). + postgres_count: 1 + vector_count: 1 + redis_count: 1 + mongodb_count: 1 + storage_count: 1 storage_storage_mb: 10 webhook_requests_stored: 100 team_members: 1 @@ -650,6 +760,13 @@ plans: # strict-80% margin redesign (2026-06-05): queue 5120 → 2048 MB. queue_storage_mb: 2048 queue_count: 3 + # Task #55 resource-count caps. hobby budget=$1.80 (20% of $9); redis + # 2×50MB×$6.50/GB=$0.65 — well within budget, redis kept conservative. + postgres_count: 2 + vector_count: 2 + redis_count: 2 + mongodb_count: 2 + storage_count: 2 storage_storage_mb: 512 webhook_requests_stored: 1000 team_members: 1 @@ -690,6 +807,13 @@ plans: mongodb_ops_per_minute: 1000 queue_storage_mb: 5120 queue_count: 5 + # Task #55 resource-count caps. hobby_plus budget=$3.80; redis + # 3×50MB×$6.50/GB=$0.98 — conservative; every service ≤ its budget max. + postgres_count: 3 + vector_count: 3 + redis_count: 3 + mongodb_count: 3 + storage_count: 3 storage_storage_mb: 5120 webhook_requests_stored: 5000 team_members: 1 @@ -731,6 +855,13 @@ plans: mongodb_ops_per_minute: 1000 queue_storage_mb: 5120 queue_count: 5 + # Task #55 resource-count caps. hobby_plus budget=$3.80; redis + # 3×50MB×$6.50/GB=$0.98 — conservative; every service ≤ its budget max. + postgres_count: 3 + vector_count: 3 + redis_count: 3 + mongodb_count: 3 + storage_count: 3 storage_storage_mb: 5120 webhook_requests_stored: 5000 team_members: 1 @@ -776,6 +907,13 @@ plans: # strict-80% margin redesign (2026-06-05): queue 5120 → 2048 MB (mirror hobby). queue_storage_mb: 2048 queue_count: 3 + # Task #55 resource-count caps. hobby budget=$1.80 (20% of $9); redis + # 2×50MB×$6.50/GB=$0.65 — well within budget, redis kept conservative. + postgres_count: 2 + vector_count: 2 + redis_count: 2 + mongodb_count: 2 + storage_count: 2 storage_storage_mb: 512 webhook_requests_stored: 1000 team_members: 1 @@ -810,6 +948,14 @@ plans: # strict-80% margin redesign (2026-06-05): queue 10240 → 5120 MB. queue_storage_mb: 5120 queue_count: 20 + # Task #55 resource-count caps. pro budget=$9.80; redis is binding: + # 512MB×$6.50/GB=$3.25/res → max 3 in budget, so redis_count=3. pg/vec + # 10GB×$0.15=$1.50/res → 5 ≤ 6.5 budget-max. storage 50GB×$0.02=$1/res → 5. + postgres_count: 5 + vector_count: 5 + redis_count: 3 + mongodb_count: 5 + storage_count: 5 storage_storage_mb: 51200 webhook_requests_stored: 10000 team_members: 5 @@ -845,6 +991,14 @@ plans: # strict-80% margin redesign (2026-06-05): queue 10240 → 5120 MB (mirror pro). queue_storage_mb: 5120 queue_count: 20 + # Task #55 resource-count caps. pro budget=$9.80; redis is binding: + # 512MB×$6.50/GB=$3.25/res → max 3 in budget, so redis_count=3. pg/vec + # 10GB×$0.15=$1.50/res → 5 ≤ 6.5 budget-max. storage 50GB×$0.02=$1/res → 5. + postgres_count: 5 + vector_count: 5 + redis_count: 3 + mongodb_count: 5 + storage_count: 5 storage_storage_mb: 51200 webhook_requests_stored: 10000 team_members: 5 @@ -880,6 +1034,15 @@ plans: mongodb_ops_per_minute: 50000 queue_storage_mb: 40960 queue_count: 100 + # Task #55 resource-count caps. team budget=$39.80; redis binding: + # 1.5GB×$6.50/GB=$9.75/res → max 4 in budget, redis_count=4. pg 50GB× + # $0.15=$7.50/res → 5 ≤ 5.3 budget-max; mongo 40GB×$0.15=$6 → 6 ≤ 6.6; + # storage 300GB×$0.02=$6 → 6 ≤ 6.6; vector 30GB×$0.15=$4.5 → 8 ≤ 8.8. + postgres_count: 5 + vector_count: 8 + redis_count: 4 + mongodb_count: 6 + storage_count: 6 storage_storage_mb: 307200 webhook_requests_stored: 100000 team_members: 25 @@ -916,6 +1079,15 @@ plans: mongodb_ops_per_minute: 50000 queue_storage_mb: 40960 queue_count: 100 + # Task #55 resource-count caps. team budget=$39.80; redis binding: + # 1.5GB×$6.50/GB=$9.75/res → max 4 in budget, redis_count=4. pg 50GB× + # $0.15=$7.50/res → 5 ≤ 5.3 budget-max; mongo 40GB×$0.15=$6 → 6 ≤ 6.6; + # storage 300GB×$0.02=$6 → 6 ≤ 6.6; vector 30GB×$0.15=$4.5 → 8 ≤ 8.8. + postgres_count: 5 + vector_count: 8 + redis_count: 4 + mongodb_count: 6 + storage_count: 6 storage_storage_mb: 307200 webhook_requests_stored: 100000 team_members: 25 @@ -952,6 +1124,15 @@ plans: mongodb_ops_per_minute: 50000 queue_storage_mb: 20480 queue_count: 50 + # Task #55 resource-count caps. growth budget=$19.80; redis binding: + # 1GB×$6.50/GB=$6.50/res → max 3 in budget, redis_count=3. pg 20GB× + # $0.15=$3/res → 6 ≤ 6.6 budget-max; mongo 20GB×$0.15=$3 → 6 ≤ 6.6; + # storage 150GB×$0.02=$3 → 6 ≤ 6.6; vector 10GB×$0.15=$1.5 → 6 ≤ 13.2. + postgres_count: 6 + vector_count: 6 + redis_count: 3 + mongodb_count: 6 + storage_count: 6 storage_storage_mb: 153600 webhook_requests_stored: 100000 team_members: 10 diff --git a/plans/resource_count_test.go b/plans/resource_count_test.go new file mode 100644 index 0000000..3a3616c --- /dev/null +++ b/plans/resource_count_test.go @@ -0,0 +1,174 @@ +package plans_test + +// resource_count_test.go — coverage for the per-service resource-count cap +// accessors added in Task #55 (strict-≥80%-margin redesign, 2026-06-05). +// +// Before this, only queue_count was capped, so a tenant could create MANY +// postgres/redis/mongo resources each at the per-resource size cap and blow the +// saturated-COGS bound. These tests pin the accessor semantics (mirrors +// QueueCountLimit exactly) and assert — via a registry iteration, not a +// hand-typed list (rule 18) — that no tier's count×size×unit-COGS exceeds its +// 20%-of-price budget for the binding resources. + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/common/plans" +) + +// TestResourceCountLimit_Dispatch asserts the registry-driven dispatcher routes +// each service string to the right per-service accessor and fails open (-1) for +// an unknown service name. +func TestResourceCountLimit_Dispatch(t *testing.T) { + r := plans.Default() + + cases := []struct { + service string + direct func(string) int + }{ + {"postgres", r.PostgresCountLimit}, + {"vector", r.VectorCountLimit}, + {"redis", r.RedisCountLimit}, + {"mongodb", r.MongoCountLimit}, + {"storage", r.StorageCountLimit}, + {"queue", r.QueueCountLimit}, + } + for _, tier := range []string{"anonymous", "hobby", "pro", "growth", "team"} { + for _, c := range cases { + assert.Equal(t, c.direct(tier), r.ResourceCountLimit(tier, c.service), + "ResourceCountLimit(%q,%q) must match the direct accessor", tier, c.service) + } + } + + // Unknown service → fail open. + assert.Equal(t, -1, r.ResourceCountLimit("pro", "kafka"), + "unknown service must fail open (-1) so a caller typo never blocks a provision") +} + +// TestResourceCountLimit_UnknownTierFallsBackToAnonymous asserts an unknown +// tier resolves to the anonymous plan (Registry.Get's established fallback) for +// every count service — so a bad tier string lands on the most-restrictive cap, +// never an unbounded one. +func TestResourceCountLimit_UnknownTierFallsBackToAnonymous(t *testing.T) { + r := plans.Default() + for _, svc := range []string{"postgres", "vector", "redis", "mongodb", "storage", "queue"} { + assert.Equal(t, r.ResourceCountLimit("anonymous", svc), r.ResourceCountLimit("does-not-exist", svc), + "unknown tier must resolve to anonymous fallback for %s", svc) + } +} + +// TestResourceCountLimit_ZeroFallbackIsUnlimited asserts that a tier whose +// *_count field is absent (struct zero value 0) is treated as unlimited (-1), +// mirroring QueueCountLimit so a stale plans.yaml never blocks a customer. +func TestResourceCountLimit_ZeroFallbackIsUnlimited(t *testing.T) { + // Build a registry from a minimal YAML that omits every *_count field. + const minimalYAML = ` +plans: + anonymous: + display_name: "Anonymous" + price_monthly_cents: 0 + limits: + provisions_per_day: 5 +` + dir := t.TempDir() + path := filepath.Join(dir, "minimal.yaml") + require.NoError(t, os.WriteFile(path, []byte(minimalYAML), 0o600)) + r, err := plans.Load(path) + require.NoError(t, err) + for _, svc := range []string{"postgres", "vector", "redis", "mongodb", "storage", "queue"} { + assert.Equal(t, -1, r.ResourceCountLimit("anonymous", svc), + "absent %s_count field must fall back to unlimited (-1)", svc) + } +} + +// TestResourceCountLimit_NilPlanFailsOpen exercises the defensive nil-plan +// branch in every count accessor: a zero-value Registry (empty plan map) makes +// Get return a nil *Plan, and each accessor must then fail open (-1) rather than +// panic. Mirrors QueueCountLimit's nil guard. +func TestResourceCountLimit_NilPlanFailsOpen(t *testing.T) { + var r plans.Registry // zero value: nil plan map → Get returns nil + for _, svc := range []string{"postgres", "vector", "redis", "mongodb", "storage", "queue"} { + assert.Equal(t, -1, r.ResourceCountLimit("anything", svc), + "nil plan must fail open (-1) for %s", svc) + } +} + +// TestResourceCount_PinnedTierValues pins the exact per-tier counts so a future +// edit that loosens a cap (and breaks the COGS bound) trips a visible diff. +func TestResourceCount_PinnedTierValues(t *testing.T) { + r := plans.Default() + type want struct{ pg, vector, redis, mongo, storage int } + cases := map[string]want{ + "anonymous": {1, 1, 1, 1, 1}, + "free": {1, 1, 1, 1, 1}, + "hobby": {2, 2, 2, 2, 2}, + "hobby_plus": {3, 3, 3, 3, 3}, + "pro": {5, 5, 3, 5, 5}, + "growth": {6, 6, 3, 6, 6}, + "team": {5, 8, 4, 6, 6}, + } + for tier, w := range cases { + assert.Equal(t, w.pg, r.PostgresCountLimit(tier), "%s postgres_count", tier) + assert.Equal(t, w.vector, r.VectorCountLimit(tier), "%s vector_count", tier) + assert.Equal(t, w.redis, r.RedisCountLimit(tier), "%s redis_count", tier) + assert.Equal(t, w.mongo, r.MongoCountLimit(tier), "%s mongodb_count", tier) + assert.Equal(t, w.storage, r.StorageCountLimit(tier), "%s storage_count", tier) + } +} + +// TestResourceCount_WithinBudget is the rule-18 registry-iterating COGS guard: +// for every paid tier, count × per-resource-size-cap × unit-COGS must stay +// within the tier's 20%-of-price budget for the binding cost resources. This is +// the invariant that closes the strict-margin hole — re-introducing a too-large +// count (or a new tier with an unbudgeted count) fails here. +func TestResourceCount_WithinBudget(t *testing.T) { + r := plans.Default() + + // Strict marginal COGS in $/GB-mo (see PRICING-MARGIN-MODEL doc). + const ( + usdPGPerGB = 0.15 // Postgres / vector / Mongo storage (DO Volume + backups) + usdRedisPerGB = 6.50 // RAM — the binding constraint at every tier + usdStoragePerGB = 0.02 // DO Spaces object storage + ) + + // service → (unit $/GB, storage-limit service key for StorageLimitMB) + type svc struct { + name string + unit float64 + count func(string) int + sizeMB func(tier string) int + } + services := []svc{ + {"postgres", usdPGPerGB, r.PostgresCountLimit, func(t string) int { return r.StorageLimitMB(t, "postgres") }}, + {"vector", usdPGPerGB, r.VectorCountLimit, func(t string) int { return r.StorageLimitMB(t, "vector") }}, + {"mongodb", usdPGPerGB, r.MongoCountLimit, func(t string) int { return r.StorageLimitMB(t, "mongodb") }}, + {"redis", usdRedisPerGB, r.RedisCountLimit, func(t string) int { return r.StorageLimitMB(t, "redis") }}, + {"storage", usdStoragePerGB, r.StorageCountLimit, func(t string) int { return r.StorageLimitMB(t, "storage") }}, + } + + // Paid tiers only — anonymous/free are $0 (CAC) and fingerprint-dedup-gated. + prices := map[string]int{ // dollars/mo + "hobby": 9, "hobby_plus": 19, "pro": 49, "growth": 99, "team": 199, + } + + for tier, price := range prices { + budget := float64(price) * 0.20 + for _, s := range services { + count := s.count(tier) + if count < 0 { + t.Errorf("tier %q service %q: count is unlimited (-1) — every count cap must be finite to bound COGS", tier, s.name) + continue + } + sizeGB := float64(s.sizeMB(tier)) / 1024.0 + satCOGS := float64(count) * sizeGB * s.unit + assert.LessOrEqualf(t, satCOGS, budget, + "tier %q service %q: count(%d)×size(%.3fGB)×$%.2f/GB = $%.2f exceeds 20%%-of-price budget $%.2f", + tier, s.name, count, sizeGB, s.unit, satCOGS, budget) + } + } +}