Files
descrybe/docs/plan-permissions/03-permission-contract.md
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

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.