Files
sure/docs/feature-gating.md
Guillem Arias 7fdc205f25 feat(modules): v1 feature-module gating + Investments
Exploration spike following expert-panel synthesis. Codifies the 3-tier
gating model (instance / per-user / per-family) without adding a
framework. Investments is the first family-scoped module.

- Family#disabled_modules: string[] column, opt-out semantics. No
  per-module DB column. Existing families default to [] = enabled.
- Family::AVAILABLE_MODULES = %w[investments] (the registry).
- ModuleGateable concern (auto-included in ApplicationController):
  require_module! class macro + module_enabled? helper.
- Api::V1::BaseController#require_module!: 403 feature_disabled JSON,
  mirrors require_ai_enabled.
- NavigationHelper extracts mobile/desktop nav into a single source
  with module: key support; mobile shrinks via justify-around, never
  auto-fills empty slots.
- Settings → Preferences gains a family-scoped module toggle card.
- 4 HTML controllers (Investments, Holdings, Trades, Securities) +
  3 API controllers gated. Investment/Crypto account types hidden in
  the new-account modal when off.
- docs/feature-gating.md codifies the rule for future modules.

Background-job layer not wired (no Investments-specific scheduled job
to gate; flagged as TODO in docs). Run db:migrate before bin/rails
test. No PR yet — awaiting decisions in open-questions list.
2026-05-22 15:01:57 +02:00

110 lines
5.5 KiB
Markdown

# Feature Gating
Sure already has three layered mechanisms for turning features on and off. Use them. Do not introduce a new framework (no Flipper, no plugin loader, no `app/modules/*/` autoload tree).
## The three gating tiers
| Tier | Where | When to use | Precedent |
|------|-------|-------------|-----------|
| **Instance** | `Rails.application.config.app_mode` (`config/application.rb`) + env vars like `SIMPLEFIN_INCLUDE_PENDING` | Whole-deploy switches: managed vs self-hosted, opt-in integrations that require credentials. | `app_mode.self_hosted?`, `Rails.configuration.x.simplefin.*` |
| **Per-user** | `User#preferences` jsonb | Personal preferences that affect only that user's UI. | `User#preview_features_enabled?` (`app/models/user.rb`), `User#ai_enabled?` |
| **Per-family ("modules")** | `Family#disabled_modules` text[] + `Family#module_enabled?(:name)` | Toggling whole feature verticals (Investments, future: Budgets, Goals, AI). Affects everyone in the family. | `Family#recurring_transactions_disabled?` (existing), `Family#module_enabled?(:investments)` (this doc) |
## The "module" rule
A module is just a string in `families.disabled_modules`. It opts a feature vertical OUT — default is enabled, presence in the array means disabled. Backwards-compatible: existing families ship with `[]` and see no change.
```ruby
family.module_enabled?(:investments) # => true (default)
family.update!(disabled_modules: ["investments"])
family.module_enabled?(:investments) # => false
```
No migrations per module. Add an entry to `Family::AVAILABLE_MODULES` and wire the three enforcement layers below.
## Three enforcement layers — required for every module
The biggest risk with feature gating is **half-enforcement**: hiding the UI but leaving controllers, jobs, or model writes open. A user disables the module, the surface disappears, background jobs keep writing data, and re-enabling produces surprise state. **For every module, all three layers must call `Family#module_enabled?`.**
### 1. View / nav
Inside ERB and helpers, call `module_enabled?(:investments)`. The method is provided as a `helper_method` by `ModuleGateable` (auto-included in `ApplicationController`).
```erb
<% if module_enabled?(:investments) %>
<%= render "account_type", accountable: Investment.new %>
<%= render "account_type", accountable: Crypto.new %>
<% end %>
```
For nav items, opt in via the `module:` key in `NavigationHelper#main_nav_items`; disabled modules are filtered out and the bottom-mobile nav redistributes via `justify-around`. **Never auto-fill empty slots** with promoted sidebar items — empty slot is the affordance that the module is off.
### 2. Controller
HTML controllers use the `require_module!` class macro from `ModuleGateable`:
```ruby
class InvestmentsController < ApplicationController
require_module! :investments
end
```
This redirects to `root_path` with `flash[:alert] = t("modules.not_enabled")` when the module is off.
API controllers (under `Api::V1`) use the instance method on `Api::V1::BaseController`:
```ruby
class Api::V1::HoldingsController < Api::V1::BaseController
before_action -> { require_module!(:investments) }
end
```
This renders `{ error: "feature_disabled" }` with status `403`. Matches the existing `require_ai_enabled` pattern.
### 3. Background jobs / model writes
If the module has scheduled jobs or model callbacks that write data, guard the entry point:
```ruby
class SomeInvestmentJob < ApplicationJob
def perform(family_id)
family = Family.find(family_id)
return unless family.module_enabled?(:investments)
# ...
end
end
```
Without this, toggling off creates silent data accumulation, and toggling back on produces a populated module the user never approved.
## Choosing the right tier
- **Instance**: needs a deploy/restart. Right for integrations bound to env keys (Plaid, SimpleFIN, OpenAI), and for self-hosters who want a feature off at the docker-compose layer.
- **Per-user**: right for opt-in/opt-out personal preferences that don't affect family-shared data (AI sidebar visibility, preview features, dark mode).
- **Per-family module**: right for "we don't want this vertical at all" — affects all family members, the toggle lives in Settings → Preferences, and surfaces across web + API + jobs.
If you find yourself wanting all three for one feature, you probably want per-family with a `Rails.configuration` instance kill-switch. Don't build "the module system" — there isn't one.
## Naming and conventions
- Module names are snake_case strings: `"investments"`, `"goals"`. Plural for verticals, singular for single features.
- Add to `Family::AVAILABLE_MODULES` so the Settings UI picks them up.
- Add locale entries under `modules.<name>.title` and `modules.<name>.description` in `config/locales/views/modules/<locale>.yml`.
- Same string is used in `disabled_modules` array, controller `require_module!` arg, view `module_enabled?` arg, and locale key.
## Not modules
These are foundational primitives. They are not candidates for module gating because too much else FKs into them:
- Account, Transaction, Entry, Trade, Valuation, Balance, Holding, Security
- Category, Merchant, Transfer
If you think one of these should be a module, you are wrong; redesign the feature instead.
## What about a "modules registry"?
Don't build one. Two reasons:
1. You don't have three modules yet, and premature abstraction here is fatal because the surface is forever (per Discourse/WordPress prior art).
2. `Family::AVAILABLE_MODULES` is the registry. It's an array. That's the framework.