Appearance
ADR-0021 · Feature entitlement & control plane
Status: 🟡 Proposed — authored 2026-08-07, awaiting owner approval · Supersedes: ADR-0007 (storage; the domain×tier×feature concept stands) · Depends on: ADR-0020 (schema), ADR-0009 (archetypes/industries), ADR-0006 (platform vs tenant role) · Related: QRS-379, QRS-389, business-domain coverage
The one-line thesis
Granular control is necessary and is not sufficient. Feature defaults must be DERIVED from an industry's primitive composition, and the grant table must hold only SPARSE deviations. A control plane whose correctness depends on an operator filling in a 30-industry × 40-feature matrix has not solved the archetype-granularity problem — it has replaced a wrong default with an unmaintained one.
Context
The owner's example is exact and it breaks archetype-level mapping:
A dairy and a boutique are both
inventory_listing. The dairy needs recurring customer subscriptions, repeat orders and per-customer billing cycles. The boutique needs none of them.
So archetype cannot be the unit of feature availability. The owner's requirement — control at archetype · industry · plan · enterprise/group · vendor level, administered from the Admin Panel — is correct, and the stated failure modes are the right ones to design against: irrelevant features exposed, conditional logic sprawling through the app, unmanageable subscriptions, poor UX, and architectural blocks when onboarding a new industry.
All five levels already exist in ADR-0020's feature_grants model, which spans eight scopes:
| Owner's level | Scope | |
|---|---|---|
| Business archetype | archetype | ✅ |
| Business industry/domain | industry | ✅ |
| Subscription plan | plan | ✅ |
| Enterprise / group | workspace_group | ✅ |
| Individual vendor override | workspace | ✅ |
| (also) platform-wide, per-member, per-consumer | platform, workspace_member, user | ✅ |
This ADR therefore does not add the mechanism. It fixes how the mechanism gets its defaults, adds three missing pieces, and records the one gate that actually prevents conditional-logic sprawl.
Decision 1 — Applicability DEFAULTS are derived from primitive composition; grants are sparse deviations
Rejected: a dense (industry × feature) configuration matrix, which is the intuitive reading of "granular admin control" and is the wrong design.
At ~30 industries × ~40 features that is ~1,200 cells an operator must populate and keep correct. Consequences, all of them worse than the problem being solved:
- A new industry starts with every cell unset — so either everything is off (the vertical is dead on arrival) or everything is on (irrelevant features exposed, the exact failure the owner names).
- The product decision "does a dairy do subscriptions?" becomes a config-entry task, so it gets made by whoever is filling cells, once, silently, with no review.
- Correctness depends on operator diligence at scale, which is the failure mode this repo has measured repeatedly (QRS-180: deferred reconciliation completes at ~0%).
Instead: an industries row declares its primitive composition (QRS-389 — Catalogue · Party · Schedule · Recurrence · Fulfilment · Ledger · Balance · Location · Resource). Each features row declares the primitive it belongs to. Applicability then derives:
dairy → composition includes Recurrence ⇒ every Recurrence-backed feature is APPLICABLE
boutique → composition omits Recurrence ⇒ every Recurrence-backed feature is NOT APPLICABLEZero admin action, correct by default, and a new industry is correct the moment its composition is declared. The dairy/boutique distinction is expressed once, as one array on one row — not as forty cells on each of two rows.
feature_grants then holds only deviations from that derived default. It stays sparse forever, which is what makes it auditable: every row in it is an intentional decision with a reason, rather than one entry in a wall of defaults nobody reads.
This is the third time this exact pattern has been the right answer in this platform — ADR-0016's recurrence rule + sparse exception rows, ADR-0009's override → default → false, and now derived applicability + sparse grants. Treat "dense configuration" as a design smell here by default.
Decision 2 — A grant records its SOURCE, and a vendor may configure their own business
The gap this closes: two dairies differ. One does home delivery on standing orders; the other is a counter-sale milk booth. Same industry, same composition, different needs. Under an admin-only model, every such vendor needs a manual override — and "file a support ticket to enable a feature you already pay for" is an unacceptable experience for a non-technical merchant, which is this platform's primary audience.
So feature_grants.source is one of platform_admin | org_admin | vendor | system, and precedence within the workspace scope is:
platform_admin > org_admin > vendor > (derived default)- Vendor self-configuration during onboarding and in Settings: "Do you serve regular customers on a monthly cycle?" → the vendor enables Recurrence for themselves, inside what their composition permits. The merchant configures their business; the operator does not.
- A platform-admin grant wins over vendor choice, which is what support, abuse handling and compliance require.
- An org-admin grant wins over its member's choice (category 2 — the org governs, per the three-user- category principle), and is confined to workspaces its organization owns.
- A vendor may never grant themselves something outside their composition or above their plan. Self- configuration operates on the applicability axis only; entitlement stays commercial.
Without source, an admin action and a vendor preference are the same row, so support cannot tell an intentional override from a merchant's own setting — and one overwrites the other silently.
Decision 3 — Every limit carries an over-limit POLICY. Exceeding a quota is never destructive
limit_value/limit_period exist; what a breach does was undefined. The scenario that forces it: a dairy on Pro with 500 subscription customers downgrades to Free with a limit of 50. Deleting 450 customer relationships is not a downgrade, it is data loss.
feature_grants.on_exceed ∈ block_new | read_only | grace_period:
block_new— existing data intact, no new records. The correct default for almost everything.read_only— visible, exportable, not mutable.grace_period— withgrace_until; full function, then falls back toblock_new.
Nothing may ever delete or hide vendor data on a quota breach. This is the same principle already settled for template plan-lapse (D6 mitigation 1: never overwrite the vendor's choice — a lapse changes only the effective state), applied to quotas. Reusing it a second time indicates it is the right rule.
Decision 4 — ONE gate is what actually prevents conditional-logic sprawl, and it does not exist yet
The owner's stated fear — "complex conditional logic throughout the application" — is not prevented by a good resolver. It is prevented by making the wrong thing impossible to write.
Today nothing stops a screen containing if (archetype === 'dairy') or if (industry === 'boutique'). The moment one exists, industry knowledge leaks into presentation, and every new vertical requires editing screens — which is the architectural block the owner is trying to avoid, arriving through the front door.
New lint guardrail (@qrsetu/eslint-config/guardrails.js): no comparison against an archetype key, industry key, or plan key anywhere in apps/**. Ask for a feature; never branch on a business type. This is the same shape as the existing no-hard-coded-colours and tier-boundary rules, and the same reasoning that made useFeature('store') right rather than capabilities.catalog — the core-vs-gated decision lives in exactly one place.
Cost: ~1 hour. Value: it is the only thing on this page that cannot be retrofitted, because by the time the rule is added, the violations are the code.
Decision 5 — The admin matrix renders DERIVED + DEVIATION, never raw grant rows
A UI listing feature_grants shows an operator a sparse, unreadable table. The Feature Control screen must render, per cell, the effective value plus where it came from and whether it is a deviation — which is exactly what resolve_features' three provenance columns already return.
This is also what makes the RBAC design's four cell states meaningful on this screen: explicit grant · inherited (derived from composition/archetype/plan) · not granted · not applicable (composition excludes it). "Not applicable" and "not granted" must be visually distinct — conflating them is how an operator "fixes" a dairy by enabling a feature a boutique's composition legitimately excludes.
What this ADR deliberately does NOT change
- The eight scopes and three axes stand as designed in ADR-0020 D3.
plansstill has nofeatures jsonb— plan features remain grants atscope_kind='plan'.- Entitlement stays commercial and separate from applicability. A free-tier dairy has subscriptions and is capped; it is not "missing the feature". Conflating these is ADR-0009's documented collision.
- The resolver signature stays
resolve_features(p_user_id, p_workspace_id default null).
Consequences
Added to the schema (all additive, all in the baseline):industries.enabled_primitives text[] · features.primitive text · feature_grants.source · feature_grants.on_exceed + grace_until · one lint rule · the admin matrix reads provenance rather than rows.
Cost: ~+1.0d on top of the primitives work (~+3.5d design / ~+3.0d implementation), because the mechanism was already designed — this changes where its defaults come from and adds three columns.
What it buys, against the owner's five stated failure modes:
| Failure mode | Prevented by |
|---|---|
| Irrelevant features exposed | Derived applicability — a boutique never sees Recurrence, with no admin action |
| Conditional logic sprawl | D4's lint rule — the only unretrofittable item here |
| Difficult subscription management | Plan features as grants; on_exceed makes downgrade non-destructive |
| Poor UX | Vendor self-configuration (D2) instead of "contact support" |
| Blocks onboarding a new industry | A new industry is one row + a composition; correct by default |
Accepted cost, stated plainly: deriving defaults from composition means the composition becomes a load-bearing product decision. Declaring dairy's primitives wrongly mis-configures every dairy at once. That is the right trade — one reviewable array per industry beats forty cells per industry — but it means an industry's composition belongs in the discovery document and its ADR-0009 capability mapping, under review, not typed into an admin form.
Open decision this ADR still needs
The plan/tier vocabulary. plans.key is the one platform-wide list every entitlement grant references, and three vocabularies exist in-repo (Free/Pro/Enterprise verbally; free/starter/pro/business in templates.required_tier; profiles.subscription_tier's own enum). This is now the longest-standing blocker on the critical path and it gates the entitlement axis entirely.