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

1""" 

2HevolveSocial — Feature flag substrate. 

3 

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. 

8 

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. 

13 

14Flat / regional deploys never reach (1) — `tenant_id` is None so the 

15DB lookup is skipped and env vars + defaults rule. 

16 

17This module has zero dependencies beyond logging + os; it is safe to 

18import from auth.py at request time without circular imports. 

19 

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 

26 

27logger = logging.getLogger('hevolve_social') 

28 

29 

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 

38 

39 # Phase 7b 

40 'mentions': False, # parse @-mentions on post/comment create 

41 'agent_members': False, # agents addable as community members 

42 

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 

50 

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 

55 

56 # Phase 7e 

57 'moderation_v2': False, # ContentClassifier post-DLP layer 

58 'nunba_desktop_v2': False, # web parity edits applied 

59 

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) 

64 

65 # Phase 9 

66 'e2e_dms': False, # libsignal-style DM ratchet (optional) 

67 'electron_build': False, # Electron desktop build alongside cx_Freeze 

68} 

69 

70 

71def _env_override(name: str) -> Optional[bool]: 

72 """Read HEVOLVE_FLAG_<NAME> env var. Returns None if unset. 

73 

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 

90 

91 

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. 

94 

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 

115 

116 

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. 

120 

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 

134 

135 

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} 

143 

144 

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)