ADR-0016: Two-Tier Feature Flags — Global Kill-Switch + Per-Org Toggle

Status

Accepted

Tags

fieldforce, feature-flags, multi-tenancy, admin

Decision

Fieldforce route access is gated by two independent flag checks in sequence: (1) the global admin_feature_flags row with key = 'fieldforce' (managed by platform admins), and (2) the per-org org_feature_flags row with key = 'fieldforce' for the requesting org (managed by platform support). Effective access = global.enabled AND org.enabled. A missing row in either table defaults to enabled.

Why

Platform admins need to disable fieldforce for all orgs simultaneously (billing suspension, security incident, maintenance). Support teams need to disable fieldforce for a specific org without affecting others. Neither table alone satisfies both requirements. Rejected alternatives:
  • Single global flag only: Cannot disable fieldforce for one org without touching unrelated orgs. Rejected.
  • Single per-org flag only: Disabling globally requires updating every org’s flag row — a multi-row write with no atomic guarantee. Rejected.
  • Hard-coded per-org feature in org settings: org_feature_flags is a general-purpose table reusable by future modules. Rejected.

How it works

GET /api/organizations/:org_id/fieldforce/...

FieldforceFeatureFlagMiddleware
  1. Query admin_feature_flags WHERE key = 'fieldforce'
     → row exists AND is_enabled = false → 403 "Fieldforce is globally disabled"
     → row missing OR is_enabled = true → continue
  2. Query org_feature_flags WHERE org_id = ? AND key = 'fieldforce'
     → row exists AND enabled = false → 403 "Fieldforce not enabled for this organization"
     → row missing OR enabled = true → allow request
  3. DB error at any point → log error, allow request (fail-open)
Schema:
-- Global (existing table)
admin_feature_flags(key text PK, is_enabled bool NOT NULL)

-- Per-org (new table, migration 004)
org_feature_flags(id uuid PK, org_id text, key text, enabled bool DEFAULT true)
UNIQUE (org_id, key)
The middleware is registered on the fieldforce route group in fieldforce/module.go. Missing rows in both tables default to enabled — no row means the feature is on.

Known limitations

  • Fail-open on DB error means a database outage does not take fieldforce offline, but a bad flag row cannot be enforced during the outage window.
  • Disabling and re-enabling fieldforce mid-request is not atomic — in-flight requests complete with the old flag state.

Rules for agents

  • The middleware MUST check global flag first, then org flag — never only one
  • DB errors in flag checks MUST be logged but MUST NOT return 5xx — fail open to allow the request
  • Missing flag rows MUST be treated as enabled (default on) — never treat missing as disabled
  • org_feature_flags is general-purpose — future modules add their own key rows without schema changes; do not fieldforce-specific-ify the table

Bad pattern (do not generate)

// Checking only the org flag — misses the global kill-switch
func FeatureFlagMiddleware(db *gorm.DB) echo.MiddlewareFunc {
    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            orgID := c.Param("org_id")
            var flag OrgFeatureFlag
            db.Where("org_id = ? AND key = 'fieldforce'", orgID).First(&flag)
            if !flag.Enabled { return echo.ErrForbidden } // wrong — skips global check
            return next(c)
        }
    }
}

// Failing closed on DB error
if err := db.Find(&flag).Error; err != nil {
    return echo.ErrInternalServerError // wrong — should fail open and log
}

Good pattern

func FieldforceFeatureFlagMiddleware(db *gorm.DB, log *logger.Logger) echo.MiddlewareFunc {
    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            // 1. Global flag
            var global AdminFeatureFlag
            if err := db.Where("key = 'fieldforce'").First(&global).Error; err == nil {
                if !global.IsEnabled {
                    return echo.NewHTTPError(403, "Fieldforce is globally disabled")
                }
            } else if !errors.Is(err, gorm.ErrRecordNotFound) {
                log.Error("flag check failed", zap.Error(err)) // fail open
            }

            // 2. Org flag
            orgID := c.Param("org_id")
            var orgFlag OrgFeatureFlag
            if err := db.Where("org_id = ? AND key = 'fieldforce'", orgID).First(&orgFlag).Error; err == nil {
                if !orgFlag.Enabled {
                    return echo.NewHTTPError(403, "Fieldforce not enabled for this organization")
                }
            } else if !errors.Is(err, gorm.ErrRecordNotFound) {
                log.Error("org flag check failed", zap.Error(err)) // fail open
            }

            return next(c)
        }
    }
}