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

5.5 KiB

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.

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).

<% 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:

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:

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:

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.