{ "agent": "03", "title": "Plan permission contract", "status": "design_only", "version": "1.0.0", "coordinates_with": [ "docs/plan-permissions/01-dashboard-feature-catalog.md", "docs/plan-permissions/01-feature-keys.json", "docs/plan-permissions/02-plans-permissions-current.md", "docs/plan-permissions/02-extension-points.json" ], "assumptions": [ "Package equals plans row; no separate packages table", "is_custom=true (including seeded Enterprise) defaults all features ON unless override false", "Meters (max_products, credits, can_use_ai) remain; feature keys gate surfaces/actions", "Client canFeature fail-open when features map missing until API cutover" ], "runtime_formula": { "effective": "plan_allows(key) AND global_section_enabled(section(key)) AND global_feature_enabled(key)", "plan_allows": [ "if key in plans.features -> plans.features[key]", "else if plans.is_custom -> true", "else -> DefaultMatrix[normalizePlanName(name)][key] (unknown -> false unless catalog All-plans)" ], "global_missing_default": true, "parent_prefix_rule": "UI treats disabled parent as disabling children; Assert uses specific action key" }, "data_model": { "plans_features_column": { "table": "plans", "column": "features", "type": "JSONB NOT NULL DEFAULT '{}'", "semantics": "sparse overrides only; not full expanded matrix" }, "platform_feature_gates": { "table": "platform_feature_gates", "columns": { "gate_key": "TEXT PRIMARY KEY", "kind": "TEXT CHECK IN ('section','feature')", "enabled": "BOOLEAN NOT NULL DEFAULT true", "updated_at": "TIMESTAMPTZ", "updated_by": "UUID NULL REFERENCES users(id)" }, "section_keys": [ "shell", "dashboard", "catalog", "feeds", "stores", "processing", "marketing", "integrations", "billing", "settings", "support", "capabilities" ] }, "do_not_add": [ "packages table", "parallel permissions package outside billing", "v1 company-level feature overrides" ] }, "feature_key_namespace": { "authority": "docs/plan-permissions/01-feature-keys.json", "pattern": "section.leaf(.subleaf)* snake_case", "capability_prefix": "capability.", "exclude": [ "/admin/*" ] }, "default_matrix_sketch": { "custom_and_enterprise_is_custom": "all_true_unless_override", "free_denies": [ "catalog.products.process_ai_titles", "catalog.products.process_ai_descriptions", "marketing.campaigns.generate_ai", "marketing.campaigns.send", "marketing.brand_ai_apply", "marketing.seo.ai_rewrite", "integrations.ai.byok", "settings.api_keys", "capability.ai_processing", "capability.campaign_ai", "capability.email_live_send", "capability.brand_ai_apply", "capability.seo_ai_rewrite", "capability.api_access", "capability.byok" ], "starter_plus_allows_ai_surfaces": true, "growth_byok_default": true, "all_plans_core": [ "shell.navigation", "dashboard.overview", "catalog.products", "catalog.products.process_categories", "catalog.products.process_attributes", "feeds.list", "feeds.export_feeds", "stores.hub", "processing.monitor", "billing.overview", "settings.profile", "support.center", "capability.normalize_specs_fill", "capability.eprel", "capability.sku_cap", "capability.ai_credits" ], "notes": [ "Expand leaves from 01 catalog; parent deny implies children deny for UI", "capability.sku_cap / ai_credits enable hooks; numeric limits stay on plans meters" ] }, "api": { "admin_plans": { "list": "GET /api/admin/plans", "upsert": "POST /api/admin/plans", "plan_fields_added": [ "features", "resolved_features" ], "features_write_semantics": "replace stored overrides object" }, "admin_feature_gates": { "list": "GET /api/admin/feature-gates", "put": "PUT /api/admin/feature-gates", "put_section": "PUT /api/admin/feature-gates/sections/{section}", "auth": "RequirePlatformAdmin", "response_shape": { "sections": { "marketing": true }, "features": { "capability.byok": false } } }, "user_capabilities": { "embed_in_credits_overview": true, "endpoints": [ "GET /api/billing/credits", "GET /api/auth/me", "GET /api/billing/capabilities" ], "credits_overview_fields_added": [ "features", "disabled_features", "feature_etag" ], "capabilities_response": { "plan_id": "int64", "plan_name": "string", "is_custom": "bool", "features": "map[string]bool effective full registry", "sections": "map[string]bool global section enables", "entitlements": { "can_use_ai": "bool", "can_use_eprel": "bool", "is_free_plan": "bool", "is_paid_plan": "bool" } } }, "enforcement_error": { "http_status": 402, "body": { "error": "feature_disabled", "code": "plan_gate", "feature": "marketing.campaigns.generate_ai", "upgrade_url": "/plans" } } }, "backend_symbols_to_extend": { "reuse": [ "apps/api/internal/billing/entitlements.go#EntitlementsForCompany", "apps/api/internal/billing/entitlements.go#ComputeEntitlements", "apps/api/internal/billing/service.go#Plan", "apps/api/internal/billing/service.go#CreditsOverview", "apps/api/internal/billing/service.go#UpsertPlan", "apps/api/internal/billing/service.go#EnsureDefaultPlans", "apps/api/internal/billing/service.go#AssertCanStartProcessing", "apps/api/internal/httpapi/billing_handlers.go", "apps/web/src/routes/admin/billing/+page.svelte", "apps/web/src/lib/billing-display.ts", "apps/web/src/lib/plan-gates.ts", "apps/web/src/lib/components/Nav.svelte" ], "new_symbols": [ "DefaultPlanFeatures", "ResolveFeatures", "FeaturesForCompany", "AssertFeature", "canFeature" ] }, "migration": { "file_suggestion": "apps/api/sql/schema/026_plan_features.sql", "idempotent": true, "destructive": false, "steps": [ "ALTER TABLE plans ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '{}'", "CREATE TABLE IF NOT EXISTS platform_feature_gates (...)", "EnsureDefaultPlans must not clobber non-empty features", "Migrator inserts '{}' when source lacks column" ] }, "frontend_gating": { "helper": "canFeature(credits, key)", "nav_filter": "Nav.svelte primaryItems + moreItemsAll by parent feature_key", "fail_open_until_cutover": true, "optional_env": "FEATURES_ENFORCE=1" }, "breaking_changes": [], "breaking_changes_policy": "prefer_none_additive_only", "preserve_contracts": [ "can_use_ai", "can_use_eprel", "is_free_plan", "is_paid_plan", "public plan names Free/Starter/Growth/Business/Enterprise", "Stripe checkout public-ladder-only", "402 plan_gate for existing credit/SKU errors" ] }