Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
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_caseleaves (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_allowsonly (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
ALTER TABLE plans ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '{}'::jsonbCREATE TABLE IF NOT EXISTS platform_feature_gates (...)- No meter rewrites; ETL inserts
'{}'if source lacks column - Down migration optional; prefer leave additive artifacts
8. Frontend gating pattern
- Read
credits.featuresfrom/api/auth/me - Helper
canFeature(credits, key)besideplan-gates.ts/billing-display.ts - Filter
Nav.svelteitems via parent feature keys (href -> feature_keymap) - Gate tabs/actions; show upgrade CTA when false
- Fail-open in helper when
featuresmissing (pre-cutover); optionalFEATURES_ENFORCE=1for 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;
EnsureDefaultPlansdoes not clobber overrides - Global section off affects all plans
- Custom plan => all registry keys effective true
/api/auth/mecarries effectivefeatures- Mutating AI/campaign/SEO/API-key paths call
AssertFeatureplus existing meters
Open questions (defaults chosen)
- Full registry on
/mefor v1 (not sparse). - Enterprise follows custom-all-on via
is_custom. - Client fail-open until features payload exists.