lex-agentic-self 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/Gemfile +5 -0
  4. data/LICENSE +21 -0
  5. data/README.md +13 -0
  6. data/lex-agentic-self.gemspec +31 -0
  7. data/lib/legion/extensions/agentic/self/agency/client.rb +21 -0
  8. data/lib/legion/extensions/agentic/self/agency/helpers/constants.rb +77 -0
  9. data/lib/legion/extensions/agentic/self/agency/helpers/efficacy_model.rb +136 -0
  10. data/lib/legion/extensions/agentic/self/agency/helpers/outcome_event.rb +52 -0
  11. data/lib/legion/extensions/agentic/self/agency/runners/agency.rb +117 -0
  12. data/lib/legion/extensions/agentic/self/agency/version.rb +13 -0
  13. data/lib/legion/extensions/agentic/self/agency.rb +19 -0
  14. data/lib/legion/extensions/agentic/self/anchor/client.rb +15 -0
  15. data/lib/legion/extensions/agentic/self/anchor/helpers/anchor.rb +92 -0
  16. data/lib/legion/extensions/agentic/self/anchor/helpers/anchor_engine.rb +123 -0
  17. data/lib/legion/extensions/agentic/self/anchor/helpers/chain.rb +93 -0
  18. data/lib/legion/extensions/agentic/self/anchor/helpers/constants.rb +46 -0
  19. data/lib/legion/extensions/agentic/self/anchor/runners/cognitive_anchor.rb +70 -0
  20. data/lib/legion/extensions/agentic/self/anchor/version.rb +13 -0
  21. data/lib/legion/extensions/agentic/self/anchor.rb +22 -0
  22. data/lib/legion/extensions/agentic/self/anosognosia/client.rb +28 -0
  23. data/lib/legion/extensions/agentic/self/anosognosia/helpers/anosognosia_engine.rb +153 -0
  24. data/lib/legion/extensions/agentic/self/anosognosia/helpers/cognitive_deficit.rb +71 -0
  25. data/lib/legion/extensions/agentic/self/anosognosia/helpers/constants.rb +29 -0
  26. data/lib/legion/extensions/agentic/self/anosognosia/runners/anosognosia.rb +98 -0
  27. data/lib/legion/extensions/agentic/self/anosognosia/version.rb +13 -0
  28. data/lib/legion/extensions/agentic/self/anosognosia.rb +19 -0
  29. data/lib/legion/extensions/agentic/self/architecture/client.rb +19 -0
  30. data/lib/legion/extensions/agentic/self/architecture/helpers/architecture_engine.rb +167 -0
  31. data/lib/legion/extensions/agentic/self/architecture/helpers/connection.rb +57 -0
  32. data/lib/legion/extensions/agentic/self/architecture/helpers/constants.rb +37 -0
  33. data/lib/legion/extensions/agentic/self/architecture/helpers/subsystem.rb +80 -0
  34. data/lib/legion/extensions/agentic/self/architecture/runners/cognitive_architecture.rb +125 -0
  35. data/lib/legion/extensions/agentic/self/architecture/version.rb +13 -0
  36. data/lib/legion/extensions/agentic/self/architecture.rb +20 -0
  37. data/lib/legion/extensions/agentic/self/default_mode_network/actors/idle.rb +45 -0
  38. data/lib/legion/extensions/agentic/self/default_mode_network/client.rb +28 -0
  39. data/lib/legion/extensions/agentic/self/default_mode_network/helpers/constants.rb +53 -0
  40. data/lib/legion/extensions/agentic/self/default_mode_network/helpers/dmn_engine.rb +221 -0
  41. data/lib/legion/extensions/agentic/self/default_mode_network/helpers/wandering_thought.rb +60 -0
  42. data/lib/legion/extensions/agentic/self/default_mode_network/runners/default_mode_network.rb +122 -0
  43. data/lib/legion/extensions/agentic/self/default_mode_network/version.rb +13 -0
  44. data/lib/legion/extensions/agentic/self/default_mode_network.rb +20 -0
  45. data/lib/legion/extensions/agentic/self/fingerprint/client.rb +28 -0
  46. data/lib/legion/extensions/agentic/self/fingerprint/helpers/cognitive_trait.rb +73 -0
  47. data/lib/legion/extensions/agentic/self/fingerprint/helpers/constants.rb +60 -0
  48. data/lib/legion/extensions/agentic/self/fingerprint/helpers/fingerprint_engine.rb +169 -0
  49. data/lib/legion/extensions/agentic/self/fingerprint/runners/cognitive_fingerprint.rb +86 -0
  50. data/lib/legion/extensions/agentic/self/fingerprint/version.rb +13 -0
  51. data/lib/legion/extensions/agentic/self/fingerprint.rb +19 -0
  52. data/lib/legion/extensions/agentic/self/identity/actors/credential_refresh.rb +49 -0
  53. data/lib/legion/extensions/agentic/self/identity/actors/orphan_check.rb +52 -0
  54. data/lib/legion/extensions/agentic/self/identity/client.rb +27 -0
  55. data/lib/legion/extensions/agentic/self/identity/helpers/dimensions.rb +75 -0
  56. data/lib/legion/extensions/agentic/self/identity/helpers/fingerprint.rb +170 -0
  57. data/lib/legion/extensions/agentic/self/identity/helpers/graph_client.rb +29 -0
  58. data/lib/legion/extensions/agentic/self/identity/helpers/graph_token.rb +36 -0
  59. data/lib/legion/extensions/agentic/self/identity/helpers/token_cache.rb +59 -0
  60. data/lib/legion/extensions/agentic/self/identity/helpers/vault_secrets.rb +80 -0
  61. data/lib/legion/extensions/agentic/self/identity/local_migrations/20260316000030_create_fingerprint.rb +20 -0
  62. data/lib/legion/extensions/agentic/self/identity/runners/entra.rb +402 -0
  63. data/lib/legion/extensions/agentic/self/identity/runners/identity.rb +90 -0
  64. data/lib/legion/extensions/agentic/self/identity/version.rb +13 -0
  65. data/lib/legion/extensions/agentic/self/identity.rb +28 -0
  66. data/lib/legion/extensions/agentic/self/metacognition/client.rb +27 -0
  67. data/lib/legion/extensions/agentic/self/metacognition/helpers/constants.rb +377 -0
  68. data/lib/legion/extensions/agentic/self/metacognition/helpers/narrator_bridge.rb +85 -0
  69. data/lib/legion/extensions/agentic/self/metacognition/helpers/registry_store.rb +70 -0
  70. data/lib/legion/extensions/agentic/self/metacognition/helpers/self_model.rb +160 -0
  71. data/lib/legion/extensions/agentic/self/metacognition/helpers/snapshot_store.rb +82 -0
  72. data/lib/legion/extensions/agentic/self/metacognition/runners/metacognition.rb +116 -0
  73. data/lib/legion/extensions/agentic/self/metacognition/runners/registry.rb +180 -0
  74. data/lib/legion/extensions/agentic/self/metacognition/version.rb +13 -0
  75. data/lib/legion/extensions/agentic/self/metacognition.rb +22 -0
  76. data/lib/legion/extensions/agentic/self/metacognitive_monitoring/client.rb +25 -0
  77. data/lib/legion/extensions/agentic/self/metacognitive_monitoring/helpers/calibration_tracker.rb +96 -0
  78. data/lib/legion/extensions/agentic/self/metacognitive_monitoring/helpers/constants.rb +47 -0
  79. data/lib/legion/extensions/agentic/self/metacognitive_monitoring/helpers/monitoring_engine.rb +141 -0
  80. data/lib/legion/extensions/agentic/self/metacognitive_monitoring/helpers/monitoring_judgment.rb +79 -0
  81. data/lib/legion/extensions/agentic/self/metacognitive_monitoring/runners/metacognitive_monitoring.rb +151 -0
  82. data/lib/legion/extensions/agentic/self/metacognitive_monitoring/version.rb +13 -0
  83. data/lib/legion/extensions/agentic/self/metacognitive_monitoring.rb +20 -0
  84. data/lib/legion/extensions/agentic/self/narrative_arc/client.rb +29 -0
  85. data/lib/legion/extensions/agentic/self/narrative_arc/helpers/arc.rb +137 -0
  86. data/lib/legion/extensions/agentic/self/narrative_arc/helpers/arc_engine.rb +119 -0
  87. data/lib/legion/extensions/agentic/self/narrative_arc/helpers/beat_event.rb +59 -0
  88. data/lib/legion/extensions/agentic/self/narrative_arc/helpers/constants.rb +66 -0
  89. data/lib/legion/extensions/agentic/self/narrative_arc/runners/narrative.rb +101 -0
  90. data/lib/legion/extensions/agentic/self/narrative_arc/version.rb +13 -0
  91. data/lib/legion/extensions/agentic/self/narrative_arc.rb +20 -0
  92. data/lib/legion/extensions/agentic/self/narrative_identity/actors/narrative_decay.rb +45 -0
  93. data/lib/legion/extensions/agentic/self/narrative_identity/client.rb +22 -0
  94. data/lib/legion/extensions/agentic/self/narrative_identity/helpers/chapter.rb +48 -0
  95. data/lib/legion/extensions/agentic/self/narrative_identity/helpers/constants.rb +62 -0
  96. data/lib/legion/extensions/agentic/self/narrative_identity/helpers/episode.rb +67 -0
  97. data/lib/legion/extensions/agentic/self/narrative_identity/helpers/narrative_engine.rb +187 -0
  98. data/lib/legion/extensions/agentic/self/narrative_identity/helpers/theme.rb +50 -0
  99. data/lib/legion/extensions/agentic/self/narrative_identity/runners/narrative_identity.rb +158 -0
  100. data/lib/legion/extensions/agentic/self/narrative_identity/version.rb +13 -0
  101. data/lib/legion/extensions/agentic/self/narrative_identity.rb +21 -0
  102. data/lib/legion/extensions/agentic/self/narrative_self/client.rb +27 -0
  103. data/lib/legion/extensions/agentic/self/narrative_self/helpers/autobiography.rb +187 -0
  104. data/lib/legion/extensions/agentic/self/narrative_self/helpers/constants.rb +42 -0
  105. data/lib/legion/extensions/agentic/self/narrative_self/helpers/episode.rb +81 -0
  106. data/lib/legion/extensions/agentic/self/narrative_self/helpers/narrative_thread.rb +65 -0
  107. data/lib/legion/extensions/agentic/self/narrative_self/runners/narrative_self.rb +86 -0
  108. data/lib/legion/extensions/agentic/self/narrative_self/version.rb +13 -0
  109. data/lib/legion/extensions/agentic/self/narrative_self.rb +20 -0
  110. data/lib/legion/extensions/agentic/self/personality/client.rb +21 -0
  111. data/lib/legion/extensions/agentic/self/personality/helpers/constants.rb +84 -0
  112. data/lib/legion/extensions/agentic/self/personality/helpers/personality_store.rb +126 -0
  113. data/lib/legion/extensions/agentic/self/personality/helpers/trait_model.rb +147 -0
  114. data/lib/legion/extensions/agentic/self/personality/runners/personality.rb +102 -0
  115. data/lib/legion/extensions/agentic/self/personality/version.rb +13 -0
  116. data/lib/legion/extensions/agentic/self/personality.rb +19 -0
  117. data/lib/legion/extensions/agentic/self/reflection/client.rb +27 -0
  118. data/lib/legion/extensions/agentic/self/reflection/helpers/constants.rb +66 -0
  119. data/lib/legion/extensions/agentic/self/reflection/helpers/llm_enhancer.rb +166 -0
  120. data/lib/legion/extensions/agentic/self/reflection/helpers/monitors.rb +186 -0
  121. data/lib/legion/extensions/agentic/self/reflection/helpers/reflection.rb +54 -0
  122. data/lib/legion/extensions/agentic/self/reflection/helpers/reflection_store.rb +99 -0
  123. data/lib/legion/extensions/agentic/self/reflection/runners/reflection.rb +199 -0
  124. data/lib/legion/extensions/agentic/self/reflection/version.rb +13 -0
  125. data/lib/legion/extensions/agentic/self/reflection.rb +21 -0
  126. data/lib/legion/extensions/agentic/self/self_model/client.rb +19 -0
  127. data/lib/legion/extensions/agentic/self/self_model/helpers/capability.rb +93 -0
  128. data/lib/legion/extensions/agentic/self/self_model/helpers/constants.rb +46 -0
  129. data/lib/legion/extensions/agentic/self/self_model/helpers/knowledge_domain.rb +82 -0
  130. data/lib/legion/extensions/agentic/self/self_model/helpers/self_model.rb +150 -0
  131. data/lib/legion/extensions/agentic/self/self_model/runners/self_model.rb +82 -0
  132. data/lib/legion/extensions/agentic/self/self_model/version.rb +13 -0
  133. data/lib/legion/extensions/agentic/self/self_model.rb +21 -0
  134. data/lib/legion/extensions/agentic/self/self_talk/actors/volume_decay.rb +45 -0
  135. data/lib/legion/extensions/agentic/self/self_talk/client.rb +30 -0
  136. data/lib/legion/extensions/agentic/self/self_talk/helpers/constants.rb +63 -0
  137. data/lib/legion/extensions/agentic/self/self_talk/helpers/dialogue.rb +114 -0
  138. data/lib/legion/extensions/agentic/self/self_talk/helpers/dialogue_turn.rb +43 -0
  139. data/lib/legion/extensions/agentic/self/self_talk/helpers/inner_voice.rb +77 -0
  140. data/lib/legion/extensions/agentic/self/self_talk/helpers/llm_enhancer.rb +135 -0
  141. data/lib/legion/extensions/agentic/self/self_talk/helpers/self_talk_engine.rb +160 -0
  142. data/lib/legion/extensions/agentic/self/self_talk/runners/self_talk.rb +172 -0
  143. data/lib/legion/extensions/agentic/self/self_talk/version.rb +13 -0
  144. data/lib/legion/extensions/agentic/self/self_talk.rb +22 -0
  145. data/lib/legion/extensions/agentic/self/version.rb +11 -0
  146. data/lib/legion/extensions/agentic/self.rb +33 -0
  147. data/spec/legion/extensions/agentic/self/agency/client_spec.rb +67 -0
  148. data/spec/legion/extensions/agentic/self/agency/helpers/constants_spec.rb +73 -0
  149. data/spec/legion/extensions/agentic/self/agency/helpers/efficacy_model_spec.rb +190 -0
  150. data/spec/legion/extensions/agentic/self/agency/helpers/outcome_event_spec.rb +85 -0
  151. data/spec/legion/extensions/agentic/self/agency/runners/agency_spec.rb +132 -0
  152. data/spec/legion/extensions/agentic/self/anchor/client_spec.rb +30 -0
  153. data/spec/legion/extensions/agentic/self/anchor/helpers/anchor_engine_spec.rb +109 -0
  154. data/spec/legion/extensions/agentic/self/anchor/helpers/anchor_spec.rb +124 -0
  155. data/spec/legion/extensions/agentic/self/anchor/helpers/chain_spec.rb +106 -0
  156. data/spec/legion/extensions/agentic/self/anchor/helpers/constants_spec.rb +53 -0
  157. data/spec/legion/extensions/agentic/self/anchor/runners/cognitive_anchor_spec.rb +70 -0
  158. data/spec/legion/extensions/agentic/self/anosognosia/anosognosia_spec.rb +15 -0
  159. data/spec/legion/extensions/agentic/self/anosognosia/client_spec.rb +50 -0
  160. data/spec/legion/extensions/agentic/self/anosognosia/helpers/anosognosia_engine_spec.rb +266 -0
  161. data/spec/legion/extensions/agentic/self/anosognosia/helpers/cognitive_deficit_spec.rb +150 -0
  162. data/spec/legion/extensions/agentic/self/anosognosia/helpers/constants_spec.rb +58 -0
  163. data/spec/legion/extensions/agentic/self/anosognosia/runners/anosognosia_spec.rb +225 -0
  164. data/spec/legion/extensions/agentic/self/architecture/client_spec.rb +51 -0
  165. data/spec/legion/extensions/agentic/self/architecture/helpers/architecture_engine_spec.rb +321 -0
  166. data/spec/legion/extensions/agentic/self/architecture/helpers/connection_spec.rb +118 -0
  167. data/spec/legion/extensions/agentic/self/architecture/helpers/subsystem_spec.rb +189 -0
  168. data/spec/legion/extensions/agentic/self/architecture/runners/cognitive_architecture_spec.rb +181 -0
  169. data/spec/legion/extensions/agentic/self/default_mode_network/client_spec.rb +69 -0
  170. data/spec/legion/extensions/agentic/self/default_mode_network/helpers/constants_spec.rb +76 -0
  171. data/spec/legion/extensions/agentic/self/default_mode_network/helpers/dmn_engine_spec.rb +321 -0
  172. data/spec/legion/extensions/agentic/self/default_mode_network/helpers/wandering_thought_spec.rb +145 -0
  173. data/spec/legion/extensions/agentic/self/default_mode_network/runners/default_mode_network_spec.rb +269 -0
  174. data/spec/legion/extensions/agentic/self/fingerprint/client_spec.rb +54 -0
  175. data/spec/legion/extensions/agentic/self/fingerprint/helpers/cognitive_trait_spec.rb +180 -0
  176. data/spec/legion/extensions/agentic/self/fingerprint/helpers/constants_spec.rb +108 -0
  177. data/spec/legion/extensions/agentic/self/fingerprint/helpers/fingerprint_engine_spec.rb +318 -0
  178. data/spec/legion/extensions/agentic/self/fingerprint/runners/cognitive_fingerprint_spec.rb +232 -0
  179. data/spec/legion/extensions/agentic/self/identity/actors/orphan_check_spec.rb +104 -0
  180. data/spec/legion/extensions/agentic/self/identity/client_spec.rb +32 -0
  181. data/spec/legion/extensions/agentic/self/identity/helpers/dimensions_spec.rb +51 -0
  182. data/spec/legion/extensions/agentic/self/identity/helpers/fingerprint_spec.rb +66 -0
  183. data/spec/legion/extensions/agentic/self/identity/helpers/graph_client_spec.rb +19 -0
  184. data/spec/legion/extensions/agentic/self/identity/helpers/graph_token_spec.rb +31 -0
  185. data/spec/legion/extensions/agentic/self/identity/helpers/token_cache_spec.rb +50 -0
  186. data/spec/legion/extensions/agentic/self/identity/local_persistence_spec.rb +329 -0
  187. data/spec/legion/extensions/agentic/self/identity/runners/entra_spec.rb +655 -0
  188. data/spec/legion/extensions/agentic/self/identity/runners/identity_spec.rb +61 -0
  189. data/spec/legion/extensions/agentic/self/metacognition/client_spec.rb +20 -0
  190. data/spec/legion/extensions/agentic/self/metacognition/helpers/constants_spec.rb +31 -0
  191. data/spec/legion/extensions/agentic/self/metacognition/helpers/narrator_bridge_spec.rb +102 -0
  192. data/spec/legion/extensions/agentic/self/metacognition/helpers/registry_store_spec.rb +227 -0
  193. data/spec/legion/extensions/agentic/self/metacognition/helpers/self_model_spec.rb +117 -0
  194. data/spec/legion/extensions/agentic/self/metacognition/helpers/snapshot_store_spec.rb +128 -0
  195. data/spec/legion/extensions/agentic/self/metacognition/runners/metacognition_spec.rb +110 -0
  196. data/spec/legion/extensions/agentic/self/metacognition/runners/registry_spec.rb +281 -0
  197. data/spec/legion/extensions/agentic/self/metacognitive_monitoring/client_spec.rb +59 -0
  198. data/spec/legion/extensions/agentic/self/metacognitive_monitoring/helpers/calibration_tracker_spec.rb +143 -0
  199. data/spec/legion/extensions/agentic/self/metacognitive_monitoring/helpers/constants_spec.rb +91 -0
  200. data/spec/legion/extensions/agentic/self/metacognitive_monitoring/helpers/monitoring_engine_spec.rb +198 -0
  201. data/spec/legion/extensions/agentic/self/metacognitive_monitoring/helpers/monitoring_judgment_spec.rb +172 -0
  202. data/spec/legion/extensions/agentic/self/metacognitive_monitoring/runners/metacognitive_monitoring_spec.rb +244 -0
  203. data/spec/legion/extensions/agentic/self/narrative_arc/client_spec.rb +22 -0
  204. data/spec/legion/extensions/agentic/self/narrative_arc/helpers/arc_engine_spec.rb +183 -0
  205. data/spec/legion/extensions/agentic/self/narrative_arc/helpers/arc_spec.rb +177 -0
  206. data/spec/legion/extensions/agentic/self/narrative_arc/helpers/beat_event_spec.rb +96 -0
  207. data/spec/legion/extensions/agentic/self/narrative_arc/helpers/constants_spec.rb +75 -0
  208. data/spec/legion/extensions/agentic/self/narrative_arc/runners/narrative_spec.rb +142 -0
  209. data/spec/legion/extensions/agentic/self/narrative_identity/client_spec.rb +69 -0
  210. data/spec/legion/extensions/agentic/self/narrative_identity/helpers/chapter_spec.rb +85 -0
  211. data/spec/legion/extensions/agentic/self/narrative_identity/helpers/constants_spec.rb +83 -0
  212. data/spec/legion/extensions/agentic/self/narrative_identity/helpers/episode_spec.rb +180 -0
  213. data/spec/legion/extensions/agentic/self/narrative_identity/helpers/narrative_engine_spec.rb +307 -0
  214. data/spec/legion/extensions/agentic/self/narrative_identity/helpers/theme_spec.rb +107 -0
  215. data/spec/legion/extensions/agentic/self/narrative_identity/runners/narrative_identity_spec.rb +240 -0
  216. data/spec/legion/extensions/agentic/self/narrative_self/client_spec.rb +67 -0
  217. data/spec/legion/extensions/agentic/self/narrative_self/helpers/autobiography_spec.rb +155 -0
  218. data/spec/legion/extensions/agentic/self/narrative_self/helpers/constants_spec.rb +28 -0
  219. data/spec/legion/extensions/agentic/self/narrative_self/helpers/episode_spec.rb +144 -0
  220. data/spec/legion/extensions/agentic/self/narrative_self/helpers/narrative_thread_spec.rb +87 -0
  221. data/spec/legion/extensions/agentic/self/narrative_self/runners/narrative_self_spec.rb +118 -0
  222. data/spec/legion/extensions/agentic/self/personality/client_spec.rb +20 -0
  223. data/spec/legion/extensions/agentic/self/personality/helpers/constants_spec.rb +41 -0
  224. data/spec/legion/extensions/agentic/self/personality/helpers/personality_store_spec.rb +66 -0
  225. data/spec/legion/extensions/agentic/self/personality/helpers/trait_model_spec.rb +148 -0
  226. data/spec/legion/extensions/agentic/self/personality/runners/personality_spec.rb +67 -0
  227. data/spec/legion/extensions/agentic/self/reflection/client_spec.rb +24 -0
  228. data/spec/legion/extensions/agentic/self/reflection/helpers/llm_enhancer_spec.rb +191 -0
  229. data/spec/legion/extensions/agentic/self/reflection/helpers/monitors_spec.rb +120 -0
  230. data/spec/legion/extensions/agentic/self/reflection/helpers/reflection_spec.rb +49 -0
  231. data/spec/legion/extensions/agentic/self/reflection/helpers/reflection_store_spec.rb +93 -0
  232. data/spec/legion/extensions/agentic/self/reflection/runners/reflection_spec.rb +204 -0
  233. data/spec/legion/extensions/agentic/self/self_model/client_spec.rb +55 -0
  234. data/spec/legion/extensions/agentic/self/self_model/helpers/capability_spec.rb +160 -0
  235. data/spec/legion/extensions/agentic/self/self_model/helpers/knowledge_domain_spec.rb +128 -0
  236. data/spec/legion/extensions/agentic/self/self_model/helpers/self_model_spec.rb +238 -0
  237. data/spec/legion/extensions/agentic/self/self_model/runners/self_model_spec.rb +143 -0
  238. data/spec/legion/extensions/agentic/self/self_talk/actors/volume_decay_spec.rb +46 -0
  239. data/spec/legion/extensions/agentic/self/self_talk/client_spec.rb +26 -0
  240. data/spec/legion/extensions/agentic/self/self_talk/helpers/constants_spec.rb +110 -0
  241. data/spec/legion/extensions/agentic/self/self_talk/helpers/dialogue_spec.rb +191 -0
  242. data/spec/legion/extensions/agentic/self/self_talk/helpers/dialogue_turn_spec.rb +78 -0
  243. data/spec/legion/extensions/agentic/self/self_talk/helpers/inner_voice_spec.rb +172 -0
  244. data/spec/legion/extensions/agentic/self/self_talk/helpers/llm_enhancer_spec.rb +206 -0
  245. data/spec/legion/extensions/agentic/self/self_talk/helpers/self_talk_engine_spec.rb +239 -0
  246. data/spec/legion/extensions/agentic/self/self_talk/runners/self_talk_llm_spec.rb +169 -0
  247. data/spec/legion/extensions/agentic/self/self_talk/runners/self_talk_spec.rb +196 -0
  248. data/spec/spec_helper.rb +46 -0
  249. metadata +347 -0
@@ -0,0 +1,655 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/agentic/self/identity/runners/entra'
4
+
5
+ # Minimal stubs for Legion::Logging so the runner can call it without the full framework
6
+ unless defined?(Legion::Logging)
7
+ module Legion
8
+ module Logging
9
+ module_function
10
+
11
+ def debug(*); end
12
+ def info(*); end
13
+ def warn(*); end
14
+ end
15
+ end
16
+ end
17
+
18
+ RSpec.describe Legion::Extensions::Agentic::Self::Identity::Runners::Entra do
19
+ # Thin host class that includes the runner module so we can call runner methods directly
20
+ let(:host_class) do
21
+ Class.new do
22
+ include Legion::Extensions::Agentic::Self::Identity::Runners::Entra
23
+ end
24
+ end
25
+
26
+ let(:client) { host_class.new }
27
+
28
+ # A reusable worker record hash returned by the model double
29
+ let(:worker_record) do
30
+ {
31
+ worker_id: 'worker-abc',
32
+ entra_app_id: 'app-id-123',
33
+ entra_object_id: 'obj-id-456',
34
+ owner_msid: 'alice@example.com',
35
+ lifecycle_state: 'active'
36
+ }
37
+ end
38
+
39
+ # Helper: build a model double that responds to .first and .where(...).all
40
+ def build_model_double(worker_hash: worker_record, active_all: [])
41
+ worker_double = instance_double('DigitalWorker')
42
+ allow(worker_double).to receive(:to_hash).and_return(worker_hash)
43
+ allow(worker_double).to receive(:update)
44
+
45
+ scope_double = double('Scope')
46
+ allow(scope_double).to receive(:all).and_return(active_all)
47
+ allow(scope_double).to receive(:update)
48
+
49
+ model_double = double('DigitalWorker model')
50
+ allow(model_double).to receive(:first).and_return(worker_double)
51
+ allow(model_double).to receive(:where).and_return(scope_double)
52
+
53
+ model_double
54
+ end
55
+
56
+ # Helper: stub Legion::Data and the DigitalWorker model constant so `defined?` guards pass
57
+ def stub_data_model(model_double)
58
+ stub_const('Legion::Data', Module.new)
59
+ stub_const('Legion::Data::Model', Module.new)
60
+ stub_const('Legion::Data::Model::DigitalWorker', model_double)
61
+ end
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # validate_worker_identity
65
+ # ---------------------------------------------------------------------------
66
+
67
+ describe '#validate_worker_identity' do
68
+ context 'when the worker exists with an entra_app_id' do
69
+ it 'returns valid: true with identity fields' do
70
+ stub_data_model(build_model_double)
71
+
72
+ result = client.validate_worker_identity(worker_id: 'worker-abc')
73
+
74
+ expect(result[:valid]).to be true
75
+ expect(result[:worker_id]).to eq('worker-abc')
76
+ expect(result[:entra_app_id]).to eq('app-id-123')
77
+ expect(result[:owner_msid]).to eq('alice@example.com')
78
+ expect(result[:lifecycle]).to eq('active')
79
+ expect(result[:validated_at]).to be_a(Time)
80
+ end
81
+
82
+ it 'uses the caller-supplied entra_app_id when provided' do
83
+ stub_data_model(build_model_double)
84
+
85
+ result = client.validate_worker_identity(worker_id: 'worker-abc', entra_app_id: 'override-app-id')
86
+
87
+ expect(result[:valid]).to be true
88
+ expect(result[:entra_app_id]).to eq('override-app-id')
89
+ end
90
+ end
91
+
92
+ context 'when the worker does not exist' do
93
+ it 'returns valid: false with an error message' do
94
+ model_double = double('DigitalWorker model')
95
+ allow(model_double).to receive(:first).and_return(nil)
96
+ stub_data_model(model_double)
97
+
98
+ result = client.validate_worker_identity(worker_id: 'no-such-worker')
99
+
100
+ expect(result[:valid]).to be false
101
+ expect(result[:error]).to eq('worker not found')
102
+ end
103
+ end
104
+
105
+ context 'when Legion::Data is not available' do
106
+ it 'returns valid: false because find_worker returns nil' do
107
+ result = client.validate_worker_identity(worker_id: 'worker-abc')
108
+
109
+ expect(result[:valid]).to be false
110
+ expect(result[:error]).to eq('worker not found')
111
+ end
112
+ end
113
+
114
+ context 'when the worker record has no entra_app_id' do
115
+ it 'returns valid: false with no entra_app_id error' do
116
+ worker_without_app = worker_record.merge(entra_app_id: nil)
117
+ stub_data_model(build_model_double(worker_hash: worker_without_app))
118
+
119
+ result = client.validate_worker_identity(worker_id: 'worker-abc')
120
+
121
+ expect(result[:valid]).to be false
122
+ expect(result[:error]).to eq('no entra_app_id')
123
+ end
124
+ end
125
+ end
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # validate_worker_identity — JWKS token validation
129
+ # ---------------------------------------------------------------------------
130
+
131
+ describe '#validate_worker_identity (JWKS)' do
132
+ context 'without token' do
133
+ it 'returns valid without token validation' do
134
+ stub_data_model(build_model_double)
135
+
136
+ result = client.validate_worker_identity(worker_id: 'worker-abc')
137
+ expect(result[:valid]).to be true
138
+ expect(result).not_to have_key(:claims)
139
+ end
140
+ end
141
+
142
+ context 'with token and JWKS support' do
143
+ let(:claims) { { sub: 'worker-abc', iss: 'https://login.microsoftonline.com/tenant-1/v2.0' } }
144
+
145
+ before do
146
+ stub_data_model(build_model_double)
147
+ jwt_mod = Module.new do
148
+ def self.verify_with_jwks(*, **)
149
+ nil
150
+ end
151
+ end
152
+ stub_const('Legion::Crypt::JWT', jwt_mod)
153
+ allow(Legion::Crypt::JWT).to receive(:verify_with_jwks).and_return(claims)
154
+ allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
155
+ end
156
+
157
+ it 'validates the token via JWKS' do
158
+ result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
159
+ expect(result[:valid]).to be true
160
+ expect(result[:claims]).to eq(claims)
161
+ end
162
+
163
+ it 'passes correct JWKS URL and issuer' do
164
+ expect(Legion::Crypt::JWT).to receive(:verify_with_jwks).with(
165
+ 'jwt-token',
166
+ jwks_url: 'https://login.microsoftonline.com/tenant-1/discovery/v2.0/keys',
167
+ issuers: ['https://login.microsoftonline.com/tenant-1/v2.0'],
168
+ audience: 'app-id-123'
169
+ )
170
+ client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
171
+ end
172
+ end
173
+
174
+ context 'when token is expired' do
175
+ before do
176
+ stub_data_model(build_model_double)
177
+ jwt_mod = Module.new do
178
+ def self.verify_with_jwks(*, **)
179
+ nil
180
+ end
181
+ end
182
+ stub_const('Legion::Crypt::JWT', jwt_mod)
183
+ stub_const('Legion::Crypt::JWT::Error', Class.new(StandardError))
184
+ stub_const('Legion::Crypt::JWT::ExpiredTokenError', Class.new(Legion::Crypt::JWT::Error))
185
+ allow(Legion::Crypt::JWT).to receive(:verify_with_jwks)
186
+ .and_raise(Legion::Crypt::JWT::ExpiredTokenError, 'token has expired')
187
+ allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
188
+ end
189
+
190
+ it 'returns valid false with token_expired error' do
191
+ result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'expired-jwt')
192
+ expect(result[:valid]).to be false
193
+ expect(result[:error]).to eq('token_expired')
194
+ end
195
+ end
196
+
197
+ context 'when token signature is invalid' do
198
+ before do
199
+ stub_data_model(build_model_double)
200
+ jwt_mod = Module.new do
201
+ def self.verify_with_jwks(*, **)
202
+ nil
203
+ end
204
+ end
205
+ stub_const('Legion::Crypt::JWT', jwt_mod)
206
+ stub_const('Legion::Crypt::JWT::Error', Class.new(StandardError))
207
+ stub_const('Legion::Crypt::JWT::ExpiredTokenError', Class.new(Legion::Crypt::JWT::Error))
208
+ stub_const('Legion::Crypt::JWT::InvalidTokenError', Class.new(Legion::Crypt::JWT::Error))
209
+ allow(Legion::Crypt::JWT).to receive(:verify_with_jwks)
210
+ .and_raise(Legion::Crypt::JWT::InvalidTokenError, 'signature verification failed')
211
+ allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
212
+ end
213
+
214
+ it 'returns valid false with token_invalid error' do
215
+ result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'bad-jwt')
216
+ expect(result[:valid]).to be false
217
+ expect(result[:error]).to eq('token_invalid')
218
+ end
219
+ end
220
+
221
+ context 'when no tenant_id configured' do
222
+ before do
223
+ stub_data_model(build_model_double)
224
+ jwt_mod = Module.new do
225
+ def self.verify_with_jwks(*, **)
226
+ nil
227
+ end
228
+ end
229
+ stub_const('Legion::Crypt::JWT', jwt_mod)
230
+ allow(client).to receive(:resolve_tenant_id).and_return(nil)
231
+ end
232
+
233
+ it 'returns valid false with no tenant_id error' do
234
+ result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
235
+ expect(result[:valid]).to be false
236
+ expect(result[:error]).to eq('no tenant_id configured')
237
+ end
238
+ end
239
+ end
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # refresh_access_token
243
+ # ---------------------------------------------------------------------------
244
+
245
+ describe '#refresh_access_token' do
246
+ let(:vault_secrets_mod) do
247
+ Module.new do
248
+ def self.read_client_secret(worker_id:) # rubocop:disable Lint/UnusedMethodArgument
249
+ { client_id: 'app-id', client_secret: 'secret-val' }
250
+ end
251
+ end
252
+ end
253
+
254
+ before do
255
+ require 'legion/extensions/agentic/self/identity/helpers/token_cache'
256
+ Legion::Extensions::Agentic::Self::Identity::Helpers::TokenCache.clear_all
257
+ stub_const('Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets', vault_secrets_mod)
258
+ allow(client).to receive(:resolve_tenant_id).and_return('tenant-123')
259
+ end
260
+
261
+ after { Legion::Extensions::Agentic::Self::Identity::Helpers::TokenCache.clear_all }
262
+
263
+ it 'returns cached token when available and not expiring' do
264
+ Legion::Extensions::Agentic::Self::Identity::Helpers::TokenCache.store(worker_id: 'w1', token: 'cached', expires_in: 3600)
265
+ result = client.refresh_access_token(worker_id: 'w1')
266
+ expect(result[:refreshed]).to be false
267
+ expect(result[:source]).to eq(:cache)
268
+ end
269
+
270
+ it 'returns error when vault unavailable' do
271
+ vault_nil = Module.new do
272
+ def self.read_client_secret(**) = nil
273
+ end
274
+ stub_const('Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets', vault_nil)
275
+ result = client.refresh_access_token(worker_id: 'w1')
276
+ expect(result[:error]).to eq('vault_unavailable')
277
+ end
278
+
279
+ it 'returns error when no tenant_id' do
280
+ allow(client).to receive(:resolve_tenant_id).and_return(nil)
281
+ result = client.refresh_access_token(worker_id: 'w1')
282
+ expect(result[:error]).to eq('no_tenant_id')
283
+ end
284
+ end
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # rotate_client_secret
288
+ # ---------------------------------------------------------------------------
289
+
290
+ describe '#rotate_client_secret' do
291
+ before do
292
+ stub_const('Legion::Settings', Class.new do
293
+ def self.dig(*keys)
294
+ map = {
295
+ %i[identity entra rotation_enabled] => false,
296
+ %i[identity entra rotation_buffer_days] => 30
297
+ }
298
+ map[keys]
299
+ end
300
+
301
+ def self.[](_key) = {}
302
+ end)
303
+ end
304
+
305
+ it 'returns no action when no expiry tracked' do
306
+ vault_mod = Module.new do
307
+ def self.read_client_secret(**) = { client_secret: 'val' }
308
+ end
309
+ stub_const('Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets', vault_mod)
310
+
311
+ result = client.rotate_client_secret(worker_id: 'w1')
312
+ expect(result[:action_required]).to be false
313
+ expect(result[:reason]).to eq('no_expiry_tracked')
314
+ end
315
+
316
+ it 'emits warning when rotation not enabled and secret expiring' do
317
+ vault_mod = Module.new do
318
+ def self.read_client_secret(**)
319
+ { client_secret: 'val', client_secret_expires_at: (Time.now + (86_400 * 10)).iso8601 }
320
+ end
321
+ end
322
+ stub_const('Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets', vault_mod)
323
+
324
+ result = client.rotate_client_secret(worker_id: 'w1')
325
+ expect(result[:action_required]).to be true
326
+ expect(result[:days_remaining]).to be_within(0.5).of(10.0)
327
+ end
328
+
329
+ it 'returns no action when secret has plenty of time' do
330
+ vault_mod = Module.new do
331
+ def self.read_client_secret(**)
332
+ { client_secret: 'val', client_secret_expires_at: (Time.now + (86_400 * 60)).iso8601 }
333
+ end
334
+ end
335
+ stub_const('Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets', vault_mod)
336
+
337
+ result = client.rotate_client_secret(worker_id: 'w1')
338
+ expect(result[:action_required]).to be false
339
+ expect(result[:days_remaining]).to be > 30
340
+ end
341
+ end
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # sync_owner
345
+ # ---------------------------------------------------------------------------
346
+
347
+ describe '#sync_owner' do
348
+ context 'when the worker does not exist' do
349
+ it 'returns synced: false with error' do
350
+ model_double = double('DigitalWorker model')
351
+ allow(model_double).to receive(:first).and_return(nil)
352
+ stub_data_model(model_double)
353
+
354
+ result = client.sync_owner(worker_id: 'no-such-worker')
355
+
356
+ expect(result[:synced]).to be false
357
+ expect(result[:error]).to eq('worker not found')
358
+ end
359
+ end
360
+
361
+ context 'when credentials are unavailable' do
362
+ it 'falls back to local source' do
363
+ stub_data_model(build_model_double)
364
+ allow(client).to receive(:resolve_graph_credentials).and_return(nil)
365
+
366
+ result = client.sync_owner(worker_id: 'worker-abc')
367
+
368
+ expect(result[:synced]).to be true
369
+ expect(result[:source]).to eq(:local)
370
+ expect(result[:owner_msid]).to eq('alice@example.com')
371
+ end
372
+ end
373
+
374
+ context 'when Graph API call succeeds' do
375
+ before do
376
+ stub_data_model(build_model_double)
377
+ allow(client).to receive(:resolve_graph_credentials)
378
+ .and_return({ tenant_id: 't', client_id: 'c', client_secret: 's' })
379
+
380
+ graph_token = Legion::Extensions::Agentic::Self::Identity::Helpers::GraphToken
381
+ allow(graph_token).to receive(:fetch).and_return('token')
382
+
383
+ @conn = instance_double(Faraday::Connection)
384
+ allow(Legion::Extensions::Agentic::Self::Identity::Helpers::GraphClient).to receive(:connection).and_return(@conn)
385
+ end
386
+
387
+ it 'returns source: :graph_api on success' do
388
+ allow(@conn).to receive(:get)
389
+ .and_return(double(success?: true, body: { 'value' => [{ 'id' => 'owner-123' }] }))
390
+
391
+ result = client.sync_owner(worker_id: 'worker-abc')
392
+ expect(result[:source]).to eq(:graph_api)
393
+ expect(result[:synced]).to be true
394
+ end
395
+
396
+ it 'returns source: :local when Graph API call fails' do
397
+ allow(@conn).to receive(:get).and_return(double(success?: false, status: 403))
398
+
399
+ result = client.sync_owner(worker_id: 'worker-abc')
400
+ expect(result[:synced]).to be false
401
+ expect(result[:source]).to eq(:local)
402
+ end
403
+ end
404
+
405
+ context 'when worker has no entra_object_id' do
406
+ it 'returns error with source: :local' do
407
+ worker_no_obj = worker_record.merge(entra_object_id: nil)
408
+ stub_data_model(build_model_double(worker_hash: worker_no_obj))
409
+
410
+ result = client.sync_owner(worker_id: 'worker-abc')
411
+ expect(result[:synced]).to be false
412
+ expect(result[:error]).to eq('no entra_object_id')
413
+ end
414
+ end
415
+ end
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # transfer_ownership
419
+ # ---------------------------------------------------------------------------
420
+
421
+ describe '#transfer_ownership' do
422
+ context 'when the worker exists and the new owner differs' do
423
+ it 'returns transferred: true with full audit fields' do
424
+ model_double = build_model_double
425
+ stub_data_model(model_double)
426
+
427
+ result = client.transfer_ownership(
428
+ worker_id: 'worker-abc',
429
+ new_owner_msid: 'bob@example.com',
430
+ transferred_by: 'admin@example.com',
431
+ reason: 'role change'
432
+ )
433
+
434
+ expect(result[:transferred]).to be true
435
+ expect(result[:event]).to eq(:ownership_transferred)
436
+ expect(result[:from_owner]).to eq('alice@example.com')
437
+ expect(result[:to_owner]).to eq('bob@example.com')
438
+ expect(result[:transferred_by]).to eq('admin@example.com')
439
+ expect(result[:reason]).to eq('role change')
440
+ expect(result[:at]).to be_a(Time)
441
+ end
442
+
443
+ it 'emits a Legion::Events event when Legion::Events is available' do
444
+ model_double = build_model_double
445
+ stub_data_model(model_double)
446
+ events_double = double('Legion::Events')
447
+ stub_const('Legion::Events', events_double)
448
+ expect(events_double).to receive(:emit).with('worker.ownership_transferred', hash_including(event: :ownership_transferred))
449
+
450
+ client.transfer_ownership(
451
+ worker_id: 'worker-abc',
452
+ new_owner_msid: 'bob@example.com',
453
+ transferred_by: 'admin@example.com'
454
+ )
455
+ end
456
+
457
+ it 'updates the database record when Legion::Data is available' do
458
+ worker_double = instance_double('DigitalWorker')
459
+ allow(worker_double).to receive(:to_hash).and_return(worker_record)
460
+ expect(worker_double).to receive(:update).with(hash_including(owner_msid: 'bob@example.com'))
461
+
462
+ model_double = double('DigitalWorker model')
463
+ allow(model_double).to receive(:first).and_return(worker_double)
464
+ stub_data_model(model_double)
465
+
466
+ client.transfer_ownership(
467
+ worker_id: 'worker-abc',
468
+ new_owner_msid: 'bob@example.com',
469
+ transferred_by: 'admin@example.com'
470
+ )
471
+ end
472
+ end
473
+
474
+ context 'when new_owner_msid is the same as the current owner' do
475
+ it 'returns transferred: false with same owner error' do
476
+ stub_data_model(build_model_double)
477
+
478
+ result = client.transfer_ownership(
479
+ worker_id: 'worker-abc',
480
+ new_owner_msid: 'alice@example.com',
481
+ transferred_by: 'admin@example.com'
482
+ )
483
+
484
+ expect(result[:transferred]).to be false
485
+ expect(result[:error]).to eq('same owner')
486
+ end
487
+ end
488
+
489
+ context 'when the worker does not exist' do
490
+ it 'returns transferred: false with worker not found error' do
491
+ model_double = double('DigitalWorker model')
492
+ allow(model_double).to receive(:first).and_return(nil)
493
+ stub_data_model(model_double)
494
+
495
+ result = client.transfer_ownership(
496
+ worker_id: 'no-such-worker',
497
+ new_owner_msid: 'bob@example.com',
498
+ transferred_by: 'admin@example.com'
499
+ )
500
+
501
+ expect(result[:transferred]).to be false
502
+ expect(result[:error]).to eq('worker not found')
503
+ end
504
+ end
505
+ end
506
+
507
+ # ---------------------------------------------------------------------------
508
+ # check_orphans
509
+ # ---------------------------------------------------------------------------
510
+
511
+ describe '#check_orphans' do
512
+ context 'when Legion::Data is not available' do
513
+ it 'returns empty orphans with source: :unavailable' do
514
+ result = client.check_orphans
515
+
516
+ expect(result[:orphans]).to eq([])
517
+ expect(result[:checked]).to eq(0)
518
+ expect(result[:source]).to eq(:unavailable)
519
+ end
520
+ end
521
+
522
+ context 'when there are no active workers' do
523
+ it 'returns empty orphans list with checked count of 0' do
524
+ stub_data_model(build_model_double(active_all: []))
525
+ allow(client).to receive(:resolve_graph_credentials).and_return(nil)
526
+
527
+ result = client.check_orphans
528
+
529
+ expect(result[:orphans]).to eq([])
530
+ expect(result[:checked]).to eq(0)
531
+ expect(result[:source]).to eq(:local)
532
+ expect(result[:checked_at]).to be_a(Time)
533
+ end
534
+ end
535
+
536
+ context 'when credentials are unavailable' do
537
+ it 'returns source: :local with workers scanned' do
538
+ active_worker = double('DigitalWorker', worker_id: 'worker-abc', owner_msid: 'alice@example.com',
539
+ entra_app_id: 'entra-app-abc')
540
+ stub_data_model(build_model_double(active_all: [active_worker]))
541
+ allow(client).to receive(:resolve_graph_credentials).and_return(nil)
542
+
543
+ result = client.check_orphans
544
+
545
+ expect(result[:checked]).to eq(1)
546
+ expect(result[:orphans]).to eq([])
547
+ expect(result[:source]).to eq(:local)
548
+ end
549
+ end
550
+
551
+ context 'when Graph API detects orphan (app deleted)' do
552
+ it 'auto-pauses the orphaned worker' do
553
+ active_worker = double('DigitalWorker', worker_id: 'worker-abc', owner_msid: 'alice@example.com',
554
+ entra_app_id: 'entra-app-abc', entra_object_id: 'obj-456')
555
+ allow(active_worker).to receive(:update)
556
+ stub_data_model(build_model_double(active_all: [active_worker]))
557
+
558
+ allow(client).to receive(:resolve_graph_credentials)
559
+ .and_return({ tenant_id: 't', client_id: 'c', client_secret: 's' })
560
+ allow(Legion::Extensions::Agentic::Self::Identity::Helpers::GraphToken).to receive(:fetch).and_return('token')
561
+
562
+ conn = instance_double(Faraday::Connection)
563
+ allow(Legion::Extensions::Agentic::Self::Identity::Helpers::GraphClient).to receive(:connection).and_return(conn)
564
+ allow(conn).to receive(:get).with('applications/obj-456').and_return(double(success?: false))
565
+
566
+ result = client.check_orphans
567
+ expect(result[:orphans].size).to eq(1)
568
+ expect(result[:source]).to eq(:graph_api)
569
+ end
570
+ end
571
+
572
+ context 'when one worker errors during scan' do
573
+ it 'continues scanning remaining workers' do
574
+ w1 = double('DigitalWorker', worker_id: 'w1', owner_msid: 'a@x.com',
575
+ entra_app_id: 'app1', entra_object_id: 'obj1')
576
+ w2 = double('DigitalWorker', worker_id: 'w2', owner_msid: 'b@x.com',
577
+ entra_app_id: 'app2', entra_object_id: 'obj2')
578
+ stub_data_model(build_model_double(active_all: [w1, w2]))
579
+
580
+ allow(client).to receive(:resolve_graph_credentials)
581
+ .and_return({ tenant_id: 't', client_id: 'c', client_secret: 's' })
582
+ allow(Legion::Extensions::Agentic::Self::Identity::Helpers::GraphToken).to receive(:fetch).and_return('token')
583
+
584
+ conn = instance_double(Faraday::Connection)
585
+ allow(Legion::Extensions::Agentic::Self::Identity::Helpers::GraphClient).to receive(:connection).and_return(conn)
586
+ allow(conn).to receive(:get).with('applications/obj1').and_raise(Faraday::ConnectionFailed, 'timeout')
587
+ allow(conn).to receive(:get).with('applications/obj2').and_return(double(success?: true))
588
+ allow(conn).to receive(:get).with('users/b@x.com').and_return(double(success?: true, body: { 'accountEnabled' => true }))
589
+
590
+ result = client.check_orphans
591
+ expect(result[:checked]).to eq(2)
592
+ end
593
+ end
594
+ end
595
+
596
+ # ---------------------------------------------------------------------------
597
+ # resolve_governance_roles
598
+ # ---------------------------------------------------------------------------
599
+
600
+ describe '#resolve_governance_roles' do
601
+ let(:group_map) do
602
+ {
603
+ '00000000-0000-0000-0000-000000000001' => 'admin',
604
+ '00000000-0000-0000-0000-000000000002' => 'governance-council',
605
+ '00000000-0000-0000-0000-000000000003' => 'owner'
606
+ }
607
+ end
608
+
609
+ before do
610
+ stub_const('Legion::Settings', Class.new do
611
+ def self.dig(*keys)
612
+ map = {
613
+ %i[rbac entra group_map] => {
614
+ '00000000-0000-0000-0000-000000000001' => 'admin',
615
+ '00000000-0000-0000-0000-000000000002' => 'governance-council',
616
+ '00000000-0000-0000-0000-000000000003' => 'owner'
617
+ },
618
+ %i[rbac entra default_role] => 'governance-observer'
619
+ }
620
+ map[keys]
621
+ end
622
+
623
+ def self.[](_key) = {}
624
+ end)
625
+ end
626
+
627
+ it 'returns matched role for known group OID' do
628
+ result = client.resolve_governance_roles(groups: ['00000000-0000-0000-0000-000000000001'])
629
+ expect(result[:success]).to be true
630
+ expect(result[:roles]).to eq(['admin'])
631
+ end
632
+
633
+ it 'returns all matched roles for multiple groups' do
634
+ result = client.resolve_governance_roles(
635
+ groups: %w[00000000-0000-0000-0000-000000000001 00000000-0000-0000-0000-000000000002]
636
+ )
637
+ expect(result[:roles]).to contain_exactly('admin', 'governance-council')
638
+ end
639
+
640
+ it 'returns default_role when no groups match' do
641
+ result = client.resolve_governance_roles(groups: ['unknown-oid'])
642
+ expect(result[:roles]).to eq(['governance-observer'])
643
+ end
644
+
645
+ it 'returns default_role when groups is nil' do
646
+ result = client.resolve_governance_roles(groups: nil)
647
+ expect(result[:roles]).to eq(['governance-observer'])
648
+ end
649
+
650
+ it 'returns default_role when groups is empty' do
651
+ result = client.resolve_governance_roles(groups: [])
652
+ expect(result[:roles]).to eq(['governance-observer'])
653
+ end
654
+ end
655
+ end