Files
descrybe/docs/plan-permissions/03-permission-contract.md
T
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

10 KiB

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)

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:

{
  "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):

{
  "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

{
  "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.