Coverage for integrations / social / feature_flags.py: 52.5%
40 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial — Feature flag substrate.
4Phase 7+ rollout uses per-flag gating so every new behavior can land
5dark, soak in dev, and flip on per-tenant before going global. The
6plan (sunny-gliding-eich.md, Part A.2 + Part N) specifies the rollout
7order: dev → one tenant → 10% → global.
9Resolution order (highest priority first):
10 1. Per-tenant override row in `tenant_feature_flags` (cloud only).
11 2. Process env var `HEVOLVE_FLAG_<NAME>` (any deploy mode).
12 3. Default value declared in `_DEFAULTS` below.
14Flat / regional deploys never reach (1) — `tenant_id` is None so the
15DB lookup is skipped and env vars + defaults rule.
17This module has zero dependencies beyond logging + os; it is safe to
18import from auth.py at request time without circular imports.
20Transport: N/A — this is read-only and synchronous. Reads happen
21once per authenticated request and the result is cached on Flask `g`.
22"""
23import logging
24import os
25from typing import Dict, Optional
27logger = logging.getLogger('hevolve_social')
30# Default flag values. Every new behavior added by Phase 7+ adds a
31# row here. Default is False so dark-launching is the norm; flip on
32# per-deploy via env var or per-tenant DB row.
33_DEFAULTS: Dict[str, bool] = {
34 # Phase 7a foundation
35 'tenancy_v2': False, # JWT 'tid' claim + query filter
36 'members_v2': False, # polymorphic Membership table reads
37 'mentions_autocomplete': False, # GET /users/autocomplete
39 # Phase 7b
40 'mentions': False, # parse @-mentions on post/comment create
41 'agent_members': False, # agents addable as community members
43 # Phase 7c
44 'friends_v2': False, # symmetric Friendship state machine
45 'invites_v2': False, # first-class community/conversation invites
46 'conversations': False, # internal DM/group chat (separate from external channels)
47 'reactions': False, # emoji reactions on posts/comments/messages
48 'post_privacy': False, # public/friends/community/private privacy levels
49 'sync_v1': False, # /sync delta endpoint for multi-device backfill
51 # Phase 7d
52 'calls_v1': False, # voice/video/screen via LiveKit + WebRTC mesh
53 'agent_voice_bridge': False, # agents in calls via node-side bridge
54 'wear_calls': False, # Wear OS call controls
56 # Phase 7e
57 'moderation_v2': False, # ContentClassifier post-DLP layer
58 'nunba_desktop_v2': False, # web parity edits applied
60 # Phase 8
61 'multi_tenant_cloud': False, # full tenant signup + per-tenant LiveKit creds
62 'tenant_strict_mode': False, # drop NULL pass-through in tenant_filter
63 # (Pass-2 H-NEW-2 + Pass-4 P4-3 hardening)
65 # Phase 9
66 'e2e_dms': False, # libsignal-style DM ratchet (optional)
67 'electron_build': False, # Electron desktop build alongside cx_Freeze
68}
71def _env_override(name: str) -> Optional[bool]:
72 """Read HEVOLVE_FLAG_<NAME> env var. Returns None if unset.
74 Accepts: '1', 'true', 'yes', 'on' → True
75 '0', 'false', 'no', 'off' → False
76 Anything else logs a warning and returns None.
77 """
78 raw = os.environ.get(f'HEVOLVE_FLAG_{name.upper()}')
79 if raw is None:
80 return None
81 val = raw.strip().lower()
82 if val in ('1', 'true', 'yes', 'on'):
83 return True
84 if val in ('0', 'false', 'no', 'off'):
85 return False
86 logger.warning(
87 "feature_flags: HEVOLVE_FLAG_%s has unrecognized value %r — ignoring",
88 name.upper(), raw)
89 return None
92def _tenant_override(db, tenant_id: Optional[str], name: str) -> Optional[bool]:
93 """Read per-tenant override row. Returns None if no row OR table missing.
95 The `tenant_feature_flags` table is created by the multi-tenant
96 cloud migration (Phase 8). Until that ships, this function always
97 returns None. Wrapped in try/except so flat/regional deploys
98 without the table never hit a hard error.
99 """
100 if not tenant_id or db is None:
101 return None
102 try:
103 from sqlalchemy import text
104 result = db.execute(text(
105 "SELECT enabled FROM tenant_feature_flags "
106 "WHERE tenant_id = :tid AND flag_name = :name"),
107 {'tid': tenant_id, 'name': name}
108 ).fetchone()
109 if result is None:
110 return None
111 return bool(result[0])
112 except Exception:
113 # Table missing (pre-Phase-8) or transient DB error — fall through.
114 return None
117def get_flag(name: str, db=None, tenant_id: Optional[str] = None,
118 default: Optional[bool] = None) -> bool:
119 """Resolve a single flag's value.
121 Priority: tenant override > env var > _DEFAULTS > caller-provided default > False.
122 """
123 tenant_val = _tenant_override(db, tenant_id, name)
124 if tenant_val is not None:
125 return tenant_val
126 env_val = _env_override(name)
127 if env_val is not None:
128 return env_val
129 if name in _DEFAULTS:
130 return _DEFAULTS[name]
131 if default is not None:
132 return default
133 return False
136def get_flags_for_tenant(db, tenant_id: Optional[str]) -> Dict[str, bool]:
137 """Resolve every known flag for the given tenant. Returns a dict
138 keyed by flag name. Used by auth.py to populate g.feature_flags
139 once per authenticated request.
140 """
141 return {name: get_flag(name, db=db, tenant_id=tenant_id)
142 for name in _DEFAULTS}
145# Convenience for tests / CLI introspection.
146def list_flags() -> Dict[str, bool]:
147 """Return the static defaults (no env / tenant resolution)."""
148 return dict(_DEFAULTS)