Skip to content

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 levelScope
Business archetypearchetype✅
Business industry/domainindustry✅
Subscription planplan✅
Enterprise / groupworkspace_group✅
Individual vendor overrideworkspace✅
(also) platform-wide, per-member, per-consumerplatform, 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 APPLICABLE

Zero 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 — with grace_until; full function, then falls back to block_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.
  • plans still has no features jsonb — plan features remain grants at scope_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 modePrevented by
Irrelevant features exposedDerived applicability — a boutique never sees Recurrence, with no admin action
Conditional logic sprawlD4's lint rule — the only unretrofittable item here
Difficult subscription managementPlan features as grants; on_exceed makes downgrade non-destructive
Poor UXVendor self-configuration (D2) instead of "contact support"
Blocks onboarding a new industryA 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.