mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
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.
110 lines
5.5 KiB
Markdown
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.
|