Entitlement Simplification — Target Architecture
Status: Proposed (design only — no code yet)
Date: 2026-06-06
Owner: Jack Nelson
Supersedes the commercial/config model in: 2026-05-30-peak-rearchitecture.md, 2026-06-02-peak-rearchitecture-epic-execution-plan.md
Builds on: PR #1573 (made the current system work; this makes it simple)
1. Why
The current entitlement + feature system is technically working (post-#1573) but commercially over-modeled and operationally rotten:
- 7 products × 5 statuses (
peakpos,peaksimple,peakterminal,peakpro,peakai,peakgateway,peakmarketing×available/trialing/active/suspended/cancelled). - Two parallel stored-config surfaces that humans hand-edit and that silently drift: the PeakPOS config (
feature_suite_org_configurations.configuration_modules, 12 module flags) and the Feature Suite config (feature_suite_tenants.configuration_modules, 19 module flags + preset). - A "feature level / package" vocabulary (
package:peakpos+feature-suite,package:none) layered on top of "product access," so the same idea is named three ways. - Manual provisioning steps, config that can drift from the sold plan, and a resolver whose
moduleDeniedgate reads hand-maintained state.
The owner's model is far simpler and is the target below.
2. Target model (locked)
An entitlement is the commercial truth, and it is just:
PeakPOS: tier × vertical ← required base (the SKU)
+ PeakAI: tier ← optional addon
+ PeakMarketing: tier ← optional addon
+ PeakKitchen: tier ← optional addon
PeakGateway: (flat, always-on) ← the floor; nothing works without it
Module visibility = f(tier, vertical, addons) ← DERIVED, never hand-configured
Per-module overrides = NONE ← config pages deleted entirely
Resolver = invisible plumbing: modules(tier,vertical,addons) ∩ user IAM
Support UI = pick tier · pick vertical · toggle addons(+tier). That is the whole surface.
Principles
- Entitlement is the single input. Tier, vertical, and addons are the only things a human ever sets.
- Modules are derived, not stored.
f(tier, vertical, addons) → enabled modulesis a static, version-controlled table. There is no per-org module state to drift, no config page to maintain, no manual provisioning. - The resolver survives only as plumbing. It still computes
enabledModules ∩ userIAMper request, but it is never a user-facing concept, page, or vocabulary. - Gateway is the floor. Always present, never tiered (for v1). If we ever charge for "online" capability, that is a separate addon (gated) or usage billing (metered) — explicitly deferred, see §8.
- One vocabulary. "Plan" =
{tier, vertical} + addons. Retire "product access," "feature level / package," and "feature suite" as user-facing terms.
Tiers (per product)
PeakPOS tiers are device/form-factor surfaces, not a nested feature ladder. Full canonical definitions + capability matrix: docs/private-docs/docs/peak-commercial-model.md §2 (the source of truth). Summary:
tier_peakterminal— "Peak Terminal" (Nexgo, card-present only): payments, receipts, reports, dual pricing, tips (suggested + adjust), static/bypassable tax. No EBT. No inventory/scheduling/invoices.tier_peaksimple— "Peak Simple" (Android/iPad tablet + Tap to Pay): everything in Terminal plus inventory, scheduling, invoices, full tax — all Peak POS features except EBT.tier_peakpos— "Peak POS" (2-screen tablet + separate reader, default): everything in Simple plus EBT (the only tier with EBT/PIN debit).
It is a clean capability ladder: Terminal ⊂ Simple ⊂ POS — Simple adds back-office (inventory/scheduling/invoices), POS adds EBT (capstone, needs the separate PIN-capable reader). Different hardware per rung. (SDK PeakPosProductTier.surfaceSubtitle predates this and omits the EBT distinction — reconcile during implementation; see commercial-model doc §8.)
- PeakAI / PeakMarketing / PeakKitchen: each absent, or present at one of its own tiers (these are capability tiers, unlike the PeakPOS device tiers).
Verticals
Reuse the existing SUPPORTED_FEATURE_MODES: convenience_retail (default), standard_retail, vape_shop, liquor_store, beauty_salon, grocery, qsr_foodservice, service, hybrid.
3. Current state (ground truth, with refs)
| Concept | Today | Where |
|---|---|---|
| Products | peakpos, peaksimple, peakterminal, peakpro(=feature-suite), peakai, peakgateway, peakmarketing | ProductProvisioningService.kt:329-337, AccountProductService.kt:226 |
| Statuses | available/trialing/active/suspended/cancelled | AccountProductService.kt:187-191; DDL AccountProduct.sq:16 |
| Tier | first-class but separate product-ish: tier_peakpos/tier_peaksimple/tier_peakterminal in merchant-api TierService | TierService.kt:14; mapping ProductProvisioningService.posProductForExistingTier():67-75, tierForProduct():304-306 |
| Vertical (Feature Mode) | feature_mode_key, default convenience_retail, 9 modes | PeakPosConfigurationService.kt:506-517; stored in feature_suite_org_configurations / feature_suite_store_configurations |
| Feature Level / package | derived join package:<segments> / package:none | FeatureResolutionService.kt:106-114, 270-275 |
| PeakPOS module config (stored) | 12 flags JSONB: organization, staff, catalog, inventory, sales, customers, operations, compliance, integrations, marketing, kitchen, cash | PeakPosConfigurationService.kt:369-384; MerchantIamPermissions.kt:6-17 |
| Feature Suite module config (stored) | preset + 19 flags JSONB: accounts, countinghouse, appointments, documentBuilder, tax, reporting, aiAssistant, serviceCatalog, resources, checkins, serviceTickets, engagement, reviews, loyalty, giftCards, marketing, memberships, customerPortal, serviceReporting | FeatureSuiteConfigurationService.kt:340-360; presets 308-338 |
| Resolver gates | productDenied → feature_level_denial; moduleDenied → org/store_configuration_denial; permissionDenied → user_iam_denial; plus migration_fallback_unavailable | FeatureResolutionService.kt:190-244, 452-458 |
| Contract version | 2026-06-24.feature-resolution.v2 | FeatureResolutionService.kt:464 |
The good news: both axes (tier, vertical) already exist. The expensive, rot-prone thing is the stored module config — which the target deletes and replaces with a derived table.
4. Delta: current → target
| # | Change | Risk |
|---|---|---|
| D1 | Collapse peakpos/peaksimple/peakterminal (3 products) → 1 peakpos product + tier attribute | Medium — billing + existing rows |
| D2 | Delete peakpro/feature-suite as a product; "Full" becomes top PeakPOS tier | Medium — billing |
| D3 | Delete stored module config (both configuration_modules JSONB sets + the PeakPOS/Feature-Suite config pages) → modules = f(tier, vertical, addons) | High — the core change |
| D4 | moduleDenied gate reads the derived table, not stored config | High |
| D5 | Make peakgateway an implicit always-on floor, not a grantable product | Low |
| D6 | peakai already entitlement-driven (#1573); make peakmarketing + new peakkitchen follow the same {present, tier} shape | Low–Medium |
| D7 | Retire "product access" + "feature level/package" + "feature suite" vocabulary; Support UI = tier · vertical · addons | Low (UX) |
| D8 | Backfill: derive each existing org's {tier, vertical} from current entitlements/config; snapshot pre-migration config for rollback | Medium |
5. The heart: f(tier, vertical, addons) → modules
Replace the two stored configuration_modules JSONB blobs with one pure, version-controlled resolution function. Proposed shape:
enabledModules(tier, vertical, addons) =
tierFloor(tier) // always-on for that POS tier
∪ ( verticalModules(vertical) // what this industry wants
∩ tierCeiling(tier) ) // capped by what the tier allows
∪ addonModules(addons) // AI / Marketing / Kitchen unlock their own modules
tierFloor— modules the device tier provides; a clean ladder Terminal ⊂ Simple ⊂ POS (see commercial-model doc §2):terminal(Nexgo, payment-only) →organization, staff, sales, cash, operations, reporting, tax(+ payment capabilities: dual pricing, tips; static/bypassable tax; no EBT)simple(tablet + TTP) →terminal ∪ {catalog, customers, inventory, appointments(scheduling), accounts, documentBuilder(invoices), countinghouse}(adds back-office; full tax; no EBT)full(Peak POS, 2-screen + reader) →simple ∪ {serviceReporting}+ EBT/PIN-debit capability (capstone)
verticalModules— industry module set. Examples:convenience_retail/liquor_store/vape_shop→inventory, compliance(age-gate),countinghousebeauty_salon/service→appointments, resources, serviceCatalog, serviceTickets, checkins, customerPortalqsr_foodservice→ kitchen surfaces (only realized if PeakKitchen addon present),tablesgrocery→inventory, countinghouse, reporting
tierCeiling— the device cap, linear along the ladder:terminalcaps at the payment-only surface (no back-office, no EBT);simpleadds back-office but still no EBT (TTP hardware);fullcaps at "all" and is the only tier exposing the EBT/PIN-debit capability (requires the separate reader).addonModules— addon → modules it unlocks:- PeakAI →
aiAssistant(already entitlement-driven, ignores config) - PeakKitchen →
kitchen/ KDS surfaces - PeakMarketing →
marketing, loyalty, giftCards, engagement, reviews, memberships, customerPortal
- PeakAI →
The full 3-tier × 9-vertical matrix (each cell a module set) is product-owned data, authored once, code-reviewed, and unit-tested. It lives next to the resolver (a Kotlin table / resource), not in the database.
Resolver after the change
productDenied→ "is the POS entitled / tier high enough / is this addon present" (no stored status matrix beyond{product → tier}).moduleDenied→definition.moduleKey !in enabledModules(tier, vertical, addons)— derived, no JSONB reads.permissionDenied→ unchanged (user IAM).- Drop
migration_fallback_unavailableonce config tables are gone. - Bump
CONTRACT_VERSIONand overlap one release (Android + support read both shapes during rollout).
6. Migration path (reviewable slices)
- Derived table + resolver read-path (no deletes). Author
f(tier,vertical,addons); make the resolver compute it and compare against current stored config behind a feature flag / shadow-log divergences. No user-visible change. - Collapse POS products → tier attribute (D1). Introduce
peakpos.tier ∈ {simple,terminal,full}; mappeaksimple/peakterminalrows intopeakpos@tier. Keep old keys as read aliases one release. - Fold FeatureSuite into tier (D2).
peakproactive →peakpos@full. Deletepeakproas a sellable product; keeppackage:feature-suiteemission for one contract version. - Gateway floor (D5) + addon shape for Marketing/Kitchen (D6).
- Flip resolver
moduleDeniedto derived (D4); delete config pages + storedconfiguration_modules(D3). Snapshot tables first for rollback. - Support UI to tier·vertical·addons; retire old vocabulary (D7). (Mockup accompanies this doc.)
- Backfill (D8) + Android authoritative-resolver re-check + epic closure.
- Former-PeakPro parity (#1577) — see §6a; runs in parallel (it ports surfaces, this re-arch governs gating). Each ported surface must land behind the correct matrix module key so the new resolver governs it.
Per CLAUDE.md: commit as separate product-lane branches; all automated/third-party execution (billing sync, exports, notifications) stays in tx-bundler — this change is config/derivation only and adds none.
6a. Former-PeakPro parity workstream (#1577)
Issue #1577 closes the last pixel-for-pixel gaps from the retired PeakPro portal. It is surface work that must be wired to the module keys this re-architecture defines — port each page behind its matrix module so the new f(tier,vertical) resolver governs visibility (never an ad-hoc nav flag). Each item maps to a module/vertical from the commercial-model matrix (§3.1 of peak-commercial-model.md):
| #1577 item | Recover from 2bfc32ed1^ | Lands behind module key | Verticals that light it up | Automation → tx-bundler? |
|---|---|---|---|---|
| PDF template/block engine (largest) | …/src/pdf/ | workspace.document_builder (Invoices) | standard_retail, beauty_salon, service, hybrid | Interactive preview/download = client-side; batch/email PDF delivery → tx-bundler |
Credit-note detail (/credit-notes/:id) | CreditNoteDetailPage.tsx | workspace.accounts (Invoices) | invoicing verticals | No |
Organization settings (/organization) | OrganizationPage.tsx | core organization (always-on) | all | No |
| Jurisdictions + Exemptions views | JurisdictionsPage.tsx, ExemptionsPage.tsx | workspace.tax (tabs on AccountTaxPage) | age/tax-sensitive (conv, vape, liquor, grocery) | No |
Check-ins page (/check-ins) | (PeakPro stub — build fresh) | workspace.checkins | beauty_salon, service, hybrid (per §3.1) | No |
Notes:
- Check-ins is fully backend-ready:
account_appointments.statusincludeschecked_in; resolver featureworkspace.checkins; IAMservice_workflow.checkins.{read,write,admin};AccountServiceWorkflowControllerexposes the route. Build the real day-view + one-tap check-in (confirmed → checked_in) + complete/no-show, gatedfeatureKey:'workspace.checkins'/requiredAnyPermissions:['service_workflow.checkins.read',…], mutation needs…write. This is the first new surface that exercises a vertical-selected matrix module end-to-end — good Phase-1 validation that the derived model lights up the right pages. - Sequencing: #1577 surfaces can ship independently of the billing-collapse phases, but wire them to module keys (not bespoke flags) so Phase 5 (derived
moduleDenied) governs them with no rework. - Acceptance (from #1577): every former-PeakPro page is present-with-parity or explicitly recorded obsolete (no silent gaps); retail
:test+:lintgreen; migrated-route browser smoke; automated PDF delivery via tx-bundler.
7. Support UI target (what the mockup shows)
One "Plan" editor per org:
- Tier — segmented control: Simple · Terminal · Full
- Vertical — dropdown of the 9 feature modes
- Addons — PeakAI / PeakMarketing / PeakKitchen, each Off or a tier
- Live preview — derived module list (
enabledModules(...)) updates instantly; read-only, proves what the customer will see - Gateway — shown as an always-on floor chip, not editable
No "product access" matrix. No config page. No "feature level / package" string. No manual provisioning button.
8. Deferred / open
- Online billing for Gateway — possibility noted, not in v1. When needed, decide per-capability: gate (model as a tiered addon, resolver-gated) vs meter (always-on, usage-billed in Gateway/tx-bundler, not an entitlement). The Gateway floor stays flat regardless.
- Exact 3×9 module matrix values — product-owned; this doc defines the structure, not every cell.
- Tier = device tier on a capability ladder. Per product-owner definitions (commercial-model doc §2): Terminal ⊂ Simple ⊂ POS on different hardware — Simple adds back-office, POS adds EBT (the only EBT tier). The SDK
PeakPosProductTier.surfaceSubtitlepredates this and omits the EBT distinction — reconcile during implementation.
9. Risks
- Billing-shaped changes (D1/D2): collapsing products and deleting
peakprotouches how plans are sold — coordinate before flipping. - Deleting stored config (D3) is irreversible-ish: snapshot + shadow-compare (slice 1) before any delete; keep rollback.
- Contract bump: verify no other consumer (Android, customer portal) breaks on removed
workspace.*/packageshapes before deletion. - Vertical defaults: existing orgs with no explicit vertical default to
convenience_retail; backfill must not silently change a salon's surface — derive vertical from current module config where possible.