# 03 — Plan permission CONTRACT (agent 3/10) **Status:** Design only — implementers own schema/API/UI. **Coordinates with:** `01-dashboard-feature-catalog.md`, `01-feature-keys.json`, `02-plans-permissions-current.md`, `02-extension-points.json`. **Machine-readable twin:** `03-permission-contract.json`. --- ## PROBLEM Admins need to enable/disable every dashboard tab/section/functionality **per plan**, seed sensible defaults for the public ladder, default **all-on** for custom deals, flip **global section master switches**, and have runtime checks enforce: ``` effective(feature) = plan_allows(feature) AND global_section_enabled(section(feature)) AND global_feature_enabled(feature) ``` UI may hide; **API must fail closed** (reuse 402 `plan_gate` where an action is blocked). --- ## CONTEXT (tools / sources) | Source | Finding | |--------|---------| | codehelper kickoff/investigate | Reuse `Entitlements` / `ComputeEntitlements` / `EntitlementsForCompany`, `Plan` / `UpsertPlan` / `EnsureDefaultPlans`, `CreditsOverview`, `AssertCanStartProcessing` | | Agent 1 | Canonical `feature_key` catalog + section groups + default-by-plan hints | | Agent 2 | No permission matrix today; capabilities derived; `plans.is_custom` exists; unused `company_plans.custom_*`; extension points list | | Schema | `plans` has meters only — no features column yet | | Platform settings | SystemCompanyID JSON exists, but **dedicated gate table** preferred for typed admin UX | **ASSUMPTION:** "Package" equals a `plans` row (no separate packages table) — per agent 2. **ASSUMPTION:** Enterprise remains public ladder + `is_custom=true`; custom-package "all features on" applies to any `is_custom=true` including Enterprise unless a key is explicitly `false` in `plans.features`. **ASSUMPTION:** Metering (`max_products`, credit wallet, `can_use_ai`) stays as today; feature keys gate access/visibility and compose with entitlement booleans. --- ## 1. Runtime resolution (normative) ``` plan_allows(key) = if key present in plans.features -> plans.features[key] else if plans.is_custom -> true else -> DefaultMatrix[normalizePlanName(plans.name)][key] (missing matrix entry -> false for unknown keys; true only if catalog marks "All plans") global_section_enabled(section) = if section present in platform_feature_gates (kind=section) -> enabled else -> true # migration-safe: globals default ON global_feature_enabled(key) = if key present in platform_feature_gates (kind=feature) -> enabled else -> true effective(key) = plan_allows(key) AND global_section_enabled(section_of(key)) AND global_feature_enabled(key) ``` **Parent implication (UI convenience, not storage):** If a parent key is disabled (e.g. `catalog.products`), children under that prefix SHOULD be treated as disabled for nav/tabs even if a child row is `true`. Server enforcement uses the **specific** action key; also check the nearest nav parent for route-level capabilities. **Orthogonal (unchanged):** company role / `is_platform_admin`, Stripe state, env/platform enricher flags. --- ## 2. Data model ### 2.1 `plans.features` (per-plan overrides) | Column | Type | Default | Notes | |--------|------|---------|-------| | `features` | `JSONB NOT NULL` | `'{}'` | Sparse map `feature_key -> boolean` overrides only | Extend Go `Plan` with `Features map[string]bool \`json:"features,omitempty"\``. `UpsertPlan` / list handlers read/write the column. Do **not** store the full expanded matrix on every row. ### 2.2 `platform_feature_gates` (global master switches) ```sql CREATE TABLE IF NOT EXISTS platform_feature_gates ( gate_key TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK (kind IN ('section', 'feature')), enabled BOOLEAN NOT NULL DEFAULT true, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_by UUID NULL REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS platform_feature_gates_kind_idx ON platform_feature_gates (kind); ``` - **Section master:** `kind='section'`, keys from agent 1: `shell`, `dashboard`, `catalog`, `feeds`, `stores`, `processing`, `marketing`, `integrations`, `billing`, `settings`, `support`, `capabilities`. - **Optional feature master:** `kind='feature'`, full feature key kill-switch across all plans. - Missing row => enabled (non-breaking). ### 2.3 Do not add | Avoid | Why | |-------|-----| | Separate `packages` table | packages ≡ `plans` | | Parallel permission service outside `billing` | Keep entitlements as SOT | | Per-company feature overrides in v1 | plan ∩ global only | | Replacing meters with feature keys alone | SKU/credits stay | --- ## 3. Feature key namespace **Authority:** `01-feature-keys.json` + `01-dashboard-feature-catalog.md`. - Dotted segments, `snake_case` leaves (`marketing.campaigns.generate_ai`) - First segment = section - Nav/route parents are first-class keys - Cross-cutting gates under `capability.*` - `/admin/*` excluded from tenant matrix - Admin write rejects unknown keys (`400`); unknown stored keys ignored at resolve, stripped on validated upsert --- ## 4. Default matrix sketch Custom (`is_custom`) / Enterprise (seeded `is_custom=true`) = **all ON** unless override. | Key group | Free | Starter | Growth | Business | Custom | |-----------|------|---------|--------|----------|--------| | `shell.*`, `dashboard.*` | ON | ON | ON | ON | ON | | `catalog.products` + non-AI process | ON | ON | ON | ON | ON | | `catalog.products.process_ai_*` | OFF | ON | ON | ON | ON | | `catalog.categories` / attributes / standard_fields | ON | ON | ON | ON | ON | | `feeds.*`, `stores.*`, `processing.monitor` | ON | ON | ON | ON | ON | | `marketing.campaigns` list/edit | ON | ON | ON | ON | ON | | `marketing.campaigns.generate_ai` / live `send` | OFF | ON | ON | ON | ON | | `marketing.brand_ai_apply` / `seo.ai_rewrite` | OFF | ON | ON | ON | ON | | `integrations.ai` / `email` | ON | ON | ON | ON | ON | | `integrations.ai.byok` | OFF | OFF | ON | ON | ON | | `billing.*`, `support.*` | ON | ON | ON | ON | ON | | `settings.profile` / company / alerts / team | ON | ON | ON | ON | ON | | `settings.api_keys` | OFF | ON | ON | ON | ON | | `capability.normalize_specs_fill` / `eprel` / `sku_cap` / `ai_credits` | ON | ON | ON | ON | ON | | `capability.ai_processing` / `campaign_ai` / `email_live_send` / `brand_ai_apply` / `seo_ai_rewrite` | OFF | ON | ON | ON | ON | | `capability.api_access` | OFF | ON | ON | ON | ON | | `capability.byok` | OFF | OFF | ON | ON | ON | AI feature keys compose with existing `can_use_ai` + wallet. Numeric SKU/credit limits remain on `plans` meters. `DefaultPlanFeatures(name, isCustom)` lives next to `defaultPublicPlans`. **`EnsureDefaultPlans` must not clobber non-empty `features`.** --- ## 5. API shapes ### Admin plans (extend existing) `GET /api/admin/plans` / `POST /api/admin/plans` — add: ```json { "features": { "settings.api_keys": true }, "resolved_features": { "catalog.products": true, "marketing.campaigns.generate_ai": true } } ``` - `features` = stored overrides (may be `{}`) - `resolved_features` = `plan_allows` only (ignore globals so admin sees package intent) - Upsert **replaces** the overrides object; validate registry keys ### Admin global gates (new) | Method | Path | |--------|------| | `GET` | `/api/admin/feature-gates` | | `PUT` | `/api/admin/feature-gates` | | `PUT` | `/api/admin/feature-gates/sections/{section}` | Response/body: `{ "sections": { "marketing": true }, "features": { "capability.byok": false } }`. Auth: `RequirePlatformAdmin`. ### User capabilities Extend `CreditsOverview` (`GET /api/billing/credits`, `/api/auth/me`): ```json { "can_use_ai": false, "features": { "catalog.products": true, "marketing.campaigns.generate_ai": false }, "disabled_features": ["marketing.campaigns.generate_ai"], "feature_etag": "sha256:…" } ``` Also: `GET /api/billing/capabilities` with full effective registry + `sections` + nested `entitlements`. ### Enforcement error ```json { "error": "feature_disabled", "code": "plan_gate", "feature": "marketing.campaigns.generate_ai", "upgrade_url": "/plans" } ``` HTTP 402; map beside existing plan_gate codes. --- ## 6. Backend placement | Concern | Where | |---------|--------| | Resolve | `billing.ResolveFeatures` | | Load | Wire into `CreditsOverview` / `EntitlementsForCompany` assembly (additive fields) | | Defaults | `DefaultPlanFeatures` beside `defaultPublicPlans` | | Assert | `AssertFeature(ctx, companyID, key)` at agent-2 extension points | | Admin HTTP | New handlers under `/api/admin` | UI-only checks without server `AssertFeature` are out of contract. --- ## 7. Migration (idempotent, non-destructive) File suggestion: `apps/api/sql/schema/026_plan_features.sql` 1. `ALTER TABLE plans ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '{}'::jsonb` 2. `CREATE TABLE IF NOT EXISTS platform_feature_gates (...)` 3. No meter rewrites; ETL inserts `'{}'` if source lacks column 4. Down migration optional; prefer leave additive artifacts --- ## 8. Frontend gating pattern 1. Read `credits.features` from `/api/auth/me` 2. Helper `canFeature(credits, key)` beside `plan-gates.ts` / `billing-display.ts` 3. Filter `Nav.svelte` items via parent feature keys (`href -> feature_key` map) 4. Gate tabs/actions; show upgrade CTA when false 5. Fail-open in helper when `features` missing (pre-cutover); optional `FEATURES_ENFORCE=1` for new 402s --- ## 9. BREAKING changes **None preferred.** Additive columns, fields, and routes only. Preserve `can_use_ai`, `can_use_eprel`, free/paid flags, public plan names, Stripe public-ladder checkout. --- ## 10. Verification checklist - [ ] Migrate empty + existing DB - [ ] `TestResolveFeatures_*` for Free / custom / global section off - [ ] Admin upsert round-trip; `EnsureDefaultPlans` does not clobber overrides - [ ] Global section off affects all plans - [ ] Custom plan => all registry keys effective true - [ ] `/api/auth/me` carries effective `features` - [ ] Mutating AI/campaign/SEO/API-key paths call `AssertFeature` plus existing meters --- ## Open questions (defaults chosen) 1. Full registry on `/me` for v1 (not sparse). 2. Enterprise follows custom-all-on via `is_custom`. 3. Client fail-open until features payload exists.