265 lines
10 KiB
Markdown
265 lines
10 KiB
Markdown
# 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.
|