insika 0.2.0 → 0.7.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
data/lib/insika/doctor.rb CHANGED
@@ -57,9 +57,55 @@ module Insika
57
57
  end
58
58
  end
59
59
 
60
+ # one entry of the domain inventory. The engine never GUESSES
61
+ # a store (D3) — every entry is something a deployment explicitly declared,
62
+ # or the built-in corpus still in effect (source "gem-default"). A broken
63
+ # enumerator degrades to one error-marked entry, never raises.
64
+ DomainEntry = Data.define(:kind, :agent, :detail, :source, :how_to_clear) do
65
+ def to_h
66
+ { "kind" => kind, "agent" => agent, "detail" => detail,
67
+ "source" => source, "how_to_clear" => how_to_clear }.compact
68
+ end
69
+ end
70
+
71
+ # The domain section of the doctor: the E2 proof surface. Read-only
72
+ # and informational — it never fails the exit code (a pilot reporting its
73
+ # own artifacts exits 0).
74
+ DomainReport = Data.define(:generated_at, :gem_version, :entries) do
75
+ def empty? = entries.empty?
76
+ def count = entries.length
77
+
78
+ def to_h
79
+ { "generated_at" => generated_at, "gem_version" => gem_version,
80
+ "count" => count, "entries" => entries.map(&:to_h) }
81
+ end
82
+
83
+ # The human section (prints AFTER the findings — a section, not a finding).
84
+ def to_s
85
+ header =
86
+ if count.zero?
87
+ "domain: 0 artifacts — a bare install declares no store and ships no corpus in use"
88
+ else
89
+ "domain: #{count} artifact(s) — declared by the deployment or inherited from the gem default"
90
+ end
91
+ lines = entries.map do |e|
92
+ agent = e.agent ? "#{e.agent}: " : ""
93
+ source = e.source ? " (#{e.source})" : ""
94
+ clear = e.how_to_clear ? " — clear: #{e.how_to_clear}" : ""
95
+ " [#{e.kind}] #{agent}#{e.detail}#{source}#{clear}"
96
+ end
97
+ ([header] + lines).join("\n")
98
+ end
99
+ end
100
+
60
101
  def initialize(env: ENV, settings_store: nil, llm_provider_store: nil, tool_store: nil,
61
102
  agent_file_store: nil, skill_store: nil, skill_catalog: nil,
62
- profile_source: nil, backend: nil, extra_env_specs: [])
103
+ profile_source: nil, backend: nil, extra_env_specs: [],
104
+ shadow_pair_store: nil, soak_envelope_path: nil, context_providers: nil,
105
+ memory_store: nil, agent_ids: nil, funnel_store: nil, outcome_store: nil,
106
+ followup_store: nil, contact_store: nil, proposal_store: nil,
107
+ schedule_store: nil,
108
+ harvest_store: nil, harvest_criterion: nil, mcp_store: nil)
63
109
  @env = env
64
110
  @settings_store = settings_store
65
111
  @llm_provider_store = llm_provider_store
@@ -70,6 +116,37 @@ module Insika
70
116
  @profile_source = profile_source
71
117
  @backend = backend
72
118
  @extra_env_specs = extra_env_specs
119
+ @shadow_pair_store = shadow_pair_store
120
+ @soak_envelope_path = soak_envelope_path
121
+ # [ContextProvider | Class] — classes accepted so the CLI
122
+ # can pass the builtin set without deps; the check only reads .layer /
123
+ # .name. nil = skip.
124
+ @context_providers = context_providers
125
+ # the memory-scopes check — Insika::MemoryStore | nil = skip;
126
+ # agent_ids excuse bare cells named like an agent (the agent-memory tab's).
127
+ @memory_store = memory_store
128
+ @agent_ids = Array(agent_ids).map(&:to_s)
129
+ # the outcome-funnel check — nil collaborators = the check
130
+ # reports declarations only (env-only callers stay cheap).
131
+ @funnel_store = funnel_store
132
+ @outcome_store = outcome_store
133
+ # the follow-up check — nil collaborators = the check
134
+ # reports declarations only.
135
+ @followup_store = followup_store
136
+ @contact_store = contact_store
137
+ # the distillation check — nil collaborator = the check
138
+ # reports declarations only (counts skipped).
139
+ @proposal_store = proposal_store
140
+ # the schedules check — nil collaborator = the check
141
+ # reports declarations only (skip counts skipped).
142
+ @schedule_store = schedule_store
143
+ # the harvest check — nil collaborators = the check
144
+ # reports declarations only (counts skipped).
145
+ @harvest_store = harvest_store
146
+ @harvest_criterion = harvest_criterion
147
+ # the mcp check — nil collaborator = the check reports nothing
148
+ # (env-only callers stay cheap).
149
+ @mcp_store = mcp_store
73
150
  end
74
151
 
75
152
  # -> Report. Never raises (a broken check degrades to an :error Finding).
@@ -84,11 +161,25 @@ module Insika
84
161
  [before, run]
85
162
  end
86
163
 
164
+ # — the domain inventory: what a deployment declares (personas
165
+ # with a metadata.domain tag, outcome funnels, tool evidence) plus the
166
+ # built-in pt-BR corpora still in effect. Read-only: informational, never
167
+ # a gate — a pilot reporting its own artifacts must exit 0.
168
+ def domain
169
+ entries = %i[personas corpora funnels evidence].flat_map { |section| safe_domain(section) }.compact
170
+ DomainReport.new(generated_at: Time.now.utc.iso8601,
171
+ gem_version: Insika::VERSION,
172
+ entries: entries.freeze)
173
+ end
174
+
87
175
  private
88
176
 
89
177
  def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
90
- check_admin_token check_data_tools check_prompt_files check_relay_channel
91
- check_web_widget check_skill_eager check_skill_drift]
178
+ check_admin_token check_web_concurrency check_data_tools check_mcp check_prompt_files
179
+ check_relay_channel check_web_widget check_skill_eager check_skill_drift check_shadow_parity
180
+ check_soak_envelope check_turn_timing check_grounding check_cache_layers
181
+ check_memory_scopes check_funnel_declarations check_followup check_distill
182
+ check_harvest check_schedules check_guardrail_corpora]
92
183
 
93
184
  def safe(check)
94
185
  Array(send(check))
@@ -97,6 +188,99 @@ module Insika
97
188
  [Finding.new(check: id, severity: :error, message: "check crashed: #{e.class}: #{e.message}", fix: nil)]
98
189
  end
99
190
 
191
+ # one domain section, wrapped — a broken read degrades to one
192
+ # error-marked entry, never raises (the same discipline as `safe`).
193
+ def safe_domain(section)
194
+ send(:"domain_#{section}")
195
+ rescue StandardError => e
196
+ [DomainEntry.new(kind: "error", agent: nil,
197
+ detail: "#{section}: read failed — #{e.class}: #{e.message}",
198
+ source: nil, how_to_clear: nil)]
199
+ end
200
+
201
+ # 1/4 personas/packs — `profile.metadata["domain"]` (pack `agent.config.json`
202
+ # passes it through; the DSL sets it via `metadata domain: "…"`).
203
+ def domain_personas
204
+ return [] unless @profile_source
205
+
206
+ @profile_source.all.filter_map do |p|
207
+ m = p.metadata
208
+ tag = m && m["domain"]
209
+ next if !Coercion.present?(tag)
210
+
211
+ DomainEntry.new(kind: "persona", agent: p.id, detail: "domain=#{tag}",
212
+ source: "deployment", how_to_clear: nil)
213
+ end
214
+ end
215
+
216
+ # 2/4 corpora — the built-in pt-BR corpus still in effect (gem default) and
217
+ # the built-in pt-BR safe replies that are not fully overridden.
218
+ def domain_corpora
219
+ return [] unless @profile_source
220
+
221
+ @profile_source.all.each_with_object([]) do |p, acc|
222
+ config = Insika::Safety::Config.from_profile(p)
223
+ next unless config.enabled?
224
+
225
+ if config.corpus_languages.include?("pt-BR")
226
+ acc << DomainEntry.new(kind: "guardrail-corpus", agent: p.id,
227
+ detail: "languages=#{config.corpus_languages.join(',')}",
228
+ source: "gem-default",
229
+ how_to_clear: "docs/domain.md#guardrails")
230
+ end
231
+ fallback = builtin_response_categories(config)
232
+ next if fallback.empty?
233
+
234
+ acc << DomainEntry.new(kind: "safe-responses", agent: p.id,
235
+ detail: "categories=#{fallback.join(',')}",
236
+ source: "gem-default",
237
+ how_to_clear: "docs/domain.md#guardrails")
238
+ end
239
+ end
240
+
241
+ # The safe-reply categories that still resolve to the built-in pt-BR
242
+ # DEFAULTS (a category the agent overrode — or a catch-all `default` that
243
+ # replaces every category — contributes none).
244
+ def builtin_response_categories(config)
245
+ Insika::Safety::SafeResponses::DEFAULTS.keys.select do |cat|
246
+ Insika::Safety::SafeResponses.for(cat, overrides: config.responses) == Insika::Safety::SafeResponses::DEFAULTS[cat]
247
+ end
248
+ end
249
+
250
+ # 3/4 funnels — the declaration shape. Absent in the tree (or on a
251
+ # profile) -> absent in the report: a bare install shows no funnel and no
252
+ # stage names at all (the vocabulary note).
253
+ def domain_funnels
254
+ return [] unless @profile_source
255
+
256
+ @profile_source.all.filter_map do |p|
257
+ next unless p.respond_to?(:funnel) && p.funnel.is_a?(Hash) && !p.funnel.empty?
258
+
259
+ stages = Array(p.funnel["stages"]).join(",")
260
+ primary = p.funnel["primary"]
261
+ detail = +"stages=#{stages}"
262
+ detail << ", primary=#{primary}" if Coercion.present?(primary)
263
+ DomainEntry.new(kind: "funnel", agent: p.id, detail: detail,
264
+ source: "deployment", how_to_clear: nil)
265
+ end
266
+ end
267
+
268
+ # 4/4 evidence — tool manifests carrying the `evidence: <kind>`
269
+ # vocabulary. Kinds are pack vocabulary, never gem constants.
270
+ def domain_evidence
271
+ return [] unless @tool_store
272
+
273
+ @tool_store.all_raw.filter_map do |raw|
274
+ ev = raw["evidence"]
275
+ kind = ev.is_a?(Hash) ? ev["kind"] : ev
276
+ next if !Coercion.present?(kind)
277
+
278
+ DomainEntry.new(kind: "evidence", agent: nil,
279
+ detail: "#{raw["name"]}: #{kind}",
280
+ source: "deployment", how_to_clear: nil)
281
+ end
282
+ end
283
+
100
284
  # -- checks --------------------------------------------------------
101
285
 
102
286
  def check_env
@@ -159,6 +343,82 @@ module Insika
159
343
  message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
160
344
  end
161
345
 
346
+ # WEB_CONCURRENCY>1 is a legitimate config (docs/DEPLOY.md "The process
347
+ # model") — but it silently drops per-worker session guarantees
348
+ # (FIFO/collect/steer) unless the operator has sticky routing per session in
349
+ # front, and nothing else in the deployment surfaces that drift. This is
350
+ # exactly how a staging service ended up at N=4 unnoticed and leaked a reply
351
+ # across sessions. The engine cannot see whether sticky routing exists off
352
+ # Railway, so that case only warns — N>1 stays a valid choice for an
353
+ # operator who put a real sticky proxy in front. On Railway it is not a
354
+ # valid choice TODAY: Railway's own docs say it "does not support sticky
355
+ # sessions" and randomly distributes traffic — there is no way to satisfy
356
+ # the precondition on this platform, so this errors instead of warning.
357
+ # RAILWAY_ENVIRONMENT_NAME is injected into every Railway deployment
358
+ # (docs.railway.com/variables/reference), so its presence is a reliable
359
+ # "are we on Railway" signal.
360
+ def check_web_concurrency
361
+ n = @env["WEB_CONCURRENCY"].to_i
362
+ return [ok("web-concurrency", "WEB_CONCURRENCY=1 (default) — session semantics hold cluster-wide")] if n <= 1
363
+
364
+ if router_detected?
365
+ return [ok("web-concurrency", "WEB_CONCURRENCY=#{n} behind a detected insika-router " \
366
+ "sticky routing satisfies the precondition")]
367
+ end
368
+
369
+ if Insika::Coercion.present?(@env["RAILWAY_ENVIRONMENT_NAME"])
370
+ return [Finding.new(check: "web-concurrency", severity: :error,
371
+ message: "WEB_CONCURRENCY=#{n} on Railway — Railway does not support sticky " \
372
+ "sessions (docs.railway.com/deployments/scaling), so per-session " \
373
+ "FIFO/collect/steer WILL break across workers with no way to fix it " \
374
+ "at the routing layer; set WEB_CONCURRENCY=1, or run insika-router " \
375
+ "in front of N local workers instead", fix: nil)]
376
+ end
377
+
378
+ [Finding.new(check: "web-concurrency", severity: :warn,
379
+ message: "WEB_CONCURRENCY=#{n} — per-session FIFO/collect/steer only hold on the worker " \
380
+ "that owns the session; make sure sticky routing per session sits in front (e.g. " \
381
+ "insika-router, set INSIKA_ROUTER_BACKENDS/_DNS), or accept " \
382
+ "per-worker best-effort (docs/DEPLOY.md \"The process model\")", fix: nil)]
383
+ end
384
+
385
+ # A router is "detected", not verified — the doctor cannot see whether a
386
+ # process at these addresses actually runs `insika-router`, only that the
387
+ # operator configured one. Same precedent as every other
388
+ # env-based capability check in this file.
389
+ def router_detected?
390
+ Insika::Coercion.present?(@env["INSIKA_ROUTER_BACKENDS"]) ||
391
+ Insika::Coercion.present?(@env["INSIKA_ROUTER_BACKENDS_DNS"])
392
+ end
393
+
394
+ # the soak envelope (a deployment-side file). Absent is :info — not every
395
+ # deployment soaks, and the tooling is optional. Present-and-broken is
396
+ # :error — an envelope that exists but does not parse is a pre-declaration
397
+ # somebody wrote down that the runner will refuse, and only this check says
398
+ # so before the run is attempted.
399
+ def check_soak_envelope
400
+ return [Finding.new(check: "soak-envelope", severity: :info, fix: nil,
401
+ message: "no soak envelope (#{@soak_envelope_path || 'unset'}) — `insika soak` is available, not required")] if @soak_envelope_path.nil? || !File.file?(@soak_envelope_path)
402
+
403
+ Insika::Soak::Envelope.load(@soak_envelope_path)
404
+ [ok("soak-envelope", "soak envelope parses (#{@soak_envelope_path})")]
405
+ rescue Insika::ConfigError => e
406
+ [Finding.new(check: "soak-envelope", severity: :error, fix: nil,
407
+ message: "soak envelope present but broken: #{e.message}")]
408
+ end
409
+
410
+ # the soak's prep_p95 gate needs INSIKA_TURN_TIMING on the
411
+ # target. Off is :info normally, and the message names what a soak would
412
+ # refuse — the preflight failure must never be a surprise found 72 hours in.
413
+ def check_turn_timing
414
+ if Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_TURN_TIMING", @env))
415
+ [ok("turn-timing", "INSIKA_TURN_TIMING on — the soak's prep_p95 gate is measurable")]
416
+ else
417
+ [Finding.new(check: "turn-timing", severity: :info, fix: nil,
418
+ message: "INSIKA_TURN_TIMING off — a soak would refuse at preflight (P2: no timing block)")]
419
+ end
420
+ end
421
+
162
422
  # A half-configured relay is the silent kind of broken: with only
163
423
  # the deliver URL set, nothing is mounted and every inbound POST 404s; with only
164
424
  # the token, the engine accepts turns it can never answer and the customer waits
@@ -316,8 +576,8 @@ module Insika
316
576
  end
317
577
 
318
578
  # D2. A skill in more than one allowlist that names one of its OWN holders in its
319
- # text is specialized text in shared clothing — the pilot served the Cacau Show
320
- # agent three shared skills that each said "na Natura". Specialize it per agent
579
+ # text is specialized text in shared clothing — a pilot deployment served every
580
+ # holder three shared skills that each named one of them. Specialize it per agent
321
581
  # (write_skill with `agent:`) instead of leaving one store's policy in a shared body.
322
582
  #
323
583
  # Merchant vocabulary beyond the holders' identities is deliberately out of scope:
@@ -489,11 +749,554 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
489
749
  def check_data_tools
490
750
  return [] unless @tool_store
491
751
 
492
- broken = @tool_store.all_raw.filter_map { |raw| broken_tool(raw) }
493
- total = @tool_store.names.length
494
- return [ok("data-tools", "#{total} data tool(s): every definition valid")] if broken.empty?
752
+ raws = @tool_store.all_raw
753
+ broken = raws.filter_map { |raw| broken_tool(raw) }
754
+ legacy = raws.select { |raw| raw["group"].to_s.start_with?("mcp:") }.map { |raw| legacy_mcp_snapshot(raw) }
755
+ base = broken.empty? ? [ok("data-tools", "#{raws.length} data tool(s): every definition valid")] : broken
756
+ base + legacy
757
+ end
758
+
759
+ # A data-tool in an `mcp:*` group was ingested by the
760
+ # RETIRED snapshot path (McpToolIngestor) — the live registry now serves
761
+ # that same MCP instance's tools directly. Still executes fine (never
762
+ # removed automatically), just a duplicate worth cleaning up.
763
+ def legacy_mcp_snapshot(raw)
764
+ Finding.new(check: "data-tools", severity: :info, fix: nil,
765
+ message: "data tool '#{raw["name"]}' (group #{raw["group"]}) is a legacy MCP " \
766
+ "snapshot — that instance's tools now run LIVE via the MCP registry; " \
767
+ "this frozen copy still works but duplicates it, safe to remove.")
768
+ end
769
+
770
+ # An http/sse instance whose credentials still sit under `env`
771
+ # (the pre-RFC meaning) is READ as `headers` (McpStore#raw), but the
772
+ # record on disk is unchanged until the operator re-saves it — flag it so
773
+ # it doesn't linger silently. A stdio instance that is enabled but
774
+ # INSIKA_MCP_STDIO is not set will save fine but refuse to start.
775
+ def check_mcp
776
+ return [] unless @mcp_store
777
+
778
+ raws = @mcp_store.all_raw
779
+ gated = raws.select do |r|
780
+ r["transport"].to_s == "stdio" && r["enabled"] &&
781
+ !Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_MCP_STDIO", @env))
782
+ end
783
+ findings = @mcp_store.legacy_header_names.map do |name|
784
+ Finding.new(check: "mcp", severity: :warn, fix: nil,
785
+ message: "MCP instance '#{name}' still stores credentials under 'env' — " \
786
+ "re-save it so they move to 'headers' (env is now stdio-only).")
787
+ end
788
+ findings += gated.map do |r|
789
+ Finding.new(check: "mcp", severity: :info, fix: nil,
790
+ message: "MCP instance '#{r["name"]}' is stdio and enabled, but INSIKA_MCP_STDIO " \
791
+ "is not set — it will refuse to start.")
792
+ end
793
+ return [ok("mcp", "#{raws.length} MCP instance(s): no legacy env-as-headers records")] if findings.empty?
794
+
795
+ findings
796
+ end
797
+
798
+ # the doctor cannot run a turn, so it cannot prove purity — it
799
+ # CAN verify the declaration. An identity-layer provider that is not one of
800
+ # the engine's three known-safe classes is :warn; one of the engine's
801
+ # known-volatile classes overriding to :identity is :error (a volatile
802
+ # identity block bills a cache write every turn).
803
+ IDENTITY_BUILTINS = %w[
804
+ Insika::Context::Providers::Prompt
805
+ Insika::Context::Providers::Skill
806
+ Insika::Context::Providers::ToolSearch
807
+ ].freeze
808
+ VOLATILE_BUILTINS = %w[
809
+ Insika::Context::Providers::Request
810
+ Insika::Context::Providers::Session
811
+ Insika::Context::Providers::Memory
812
+ Insika::Context::Providers::SkillTrigger
813
+ ].freeze
814
+
815
+ def check_cache_layers
816
+ return [] unless @context_providers
817
+
818
+ findings = @context_providers.flat_map do |p|
819
+ name = p.is_a?(Class) ? p.name : p.class.name
820
+ layer = declared_layer(p)
821
+ next [] unless layer == :identity
822
+ next [] if builtin_of?(p, IDENTITY_BUILTINS)
823
+
824
+ known_volatile = builtin_of?(p, VOLATILE_BUILTINS)
825
+ [Finding.new(check: "cache-layers", severity: known_volatile ? :error : :warn, fix: nil,
826
+ message: "context provider '#{name}' declares layer :identity but is " \
827
+ "#{known_volatile ? 'engine-known turn-dependent' : 'not engine-verified'} — " \
828
+ "a volatile block above the cache boundary bills a cache WRITE every turn. " \
829
+ "Verify the output is byte-stable across turns (no timestamps, no per-turn data).")]
830
+ end
831
+ return findings if findings.any?
832
+
833
+ [ok("cache-layers", "context layers: #{@context_providers.size} provider(s), identity partition verified")]
834
+ end
835
+
836
+ # The declaration is an INSTANCE method, so a class passed as-is does not
837
+ # respond to .layer. Evaluate it on a bare instance (allocate skips
838
+ # initialize — the declaration must not depend on constructor state; a
839
+ # method that does degrades to :volatile, the conservative side). This is
840
+ # what makes an explicit `def layer = :volatile` read as volatile instead of
841
+ # a warning.
842
+ def declared_layer(provider)
843
+ return provider.layer unless provider.is_a?(Class)
844
+ return :volatile unless provider.instance_methods.include?(:layer)
845
+
846
+ provider.instance_method(:layer).bind_call(provider.allocate)
847
+ rescue StandardError
848
+ :volatile
849
+ end
850
+
851
+ # The declaration check is by CLASS: a subclass of an engine-known provider
852
+ # inherits its data source (a Memory subclass is still turn-dependent no
853
+ # matter what it overrides). Accepts instances OR classes; anonymous
854
+ # classes (Class.new(...)) have no .name, so the ancestor walk is what
855
+ # catches them.
856
+ def builtin_of?(provider, consts)
857
+ klass_of = provider.is_a?(Class) ? provider : provider.class
858
+
859
+ consts.any? do |c|
860
+ klass = Object.const_get(c)
861
+ klass_of == klass || klass_of < klass
862
+ rescue NameError
863
+ false
864
+ end
865
+ end
866
+
867
+ # grounding with a matcher that matches NOTHING (no sku) is
868
+ # harmless but useless — every claim passes and the audit reads zero. A
869
+ # warning, never an error: the pack owns matcher quality; the engine refuses
870
+ # only uncompileable data.
871
+ def check_grounding
872
+ return [] unless @profile_source
873
+
874
+ findings = @profile_source.all.flat_map do |profile|
875
+ grounding = profile.respond_to?(:grounding) ? profile.grounding : nil
876
+ next [] if grounding.nil? || grounding == false
877
+ next [] if Coercion.present?(grounding["matcher"].is_a?(Hash) ? grounding["matcher"]["sku"] : nil)
878
+
879
+ [Finding.new(check: "grounding", severity: :warn, fix: nil,
880
+ message: "agent '#{profile.id}' has grounding enabled but no matcher.sku — " \
881
+ "it matches nothing, so no claim is ever flagged or cut. " \
882
+ "Add the store's SKU regex to grounding.matcher.sku.")]
883
+ end
884
+ findings.empty? ? [ok("grounding", "grounding: no agent with an empty matcher")] : findings
885
+ end
886
+
887
+ # the memory-scopes check. Reads the cells via C1's enumeration.
888
+ # Warn-only: the doctor never moves data across cells (D2).
889
+ #
890
+ # What it flags, and what it deliberately does NOT:
891
+ # - "memory:chat:<session id>" cells are the engine's OWN per-session shape
892
+ # — never flagged.
893
+ # - a BARE cell is the DESIGNED single-tenant customer shape ("nil tenant +
894
+ # customer -> memory:<customer>, NEVER _default") — with no tenant to
895
+ # migrate to, warning is a false positive, so the check only fires in a
896
+ # multi_tenant deployment (INSIKA_TENANCY), where every customer must
897
+ # live in a [tenant:]customer cell.
898
+ # - `agent_ids:` excuses the agent-memory tab's cells (a bare cell named
899
+ # like an agent is a profile, not a customer).
900
+ def check_memory_scopes
901
+ return [] unless @memory_store
902
+
903
+ findings = []
904
+ @memory_store.cells.each do |cell|
905
+ next if Insika::MemoryStore.session_cell?(cell) # the engine's per-session cell
906
+ next if cell[:customer].nil? # _default — the shared cell
907
+ next if cell[:tenant] # [tenant:]customer — scoped
908
+ next if single_tenant? # bare = the designed single-tenant customer shape
909
+ next if @agent_ids.include?(cell[:customer].to_s)
910
+
911
+ findings << Finding.new(check: "memory-scopes", severity: :warn, fix: nil,
912
+ message: "memory cell '#{cell[:scope]}' is unscoped — in a multi-tenant " \
913
+ "deployment customer memory must live in a [tenant:]customer cell. " \
914
+ "Migrate it, or confirm it is an agent-memory cell " \
915
+ "(a bare cell named like an agent is excused).")
916
+ end
917
+ return findings if findings.any?
918
+
919
+ [ok("memory-scopes", "memory cells: #{@memory_store.cells.size}, all scoped")]
920
+ end
921
+
922
+ def single_tenant?
923
+ Insika::EnvSchema.read("INSIKA_TENANCY", @env) != "multi_tenant"
924
+ end
925
+
926
+ # the outcome-funnel check — declarations on every pilot store,
927
+ # the plan's "no 1.0 target without a remeasure" backstop. Warn/error only:
928
+ # the doctor never rewrites a declaration (the pack is authoritative;
929
+ # `--fix` has nothing to fix here).
930
+ def check_funnel_declarations
931
+ return [] unless @profile_source
932
+
933
+ findings = @profile_source.all.flat_map do |profile|
934
+ next [] if profile.funnel.nil?
935
+
936
+ decl = Insika::FunnelDeclaration.parse(profile.funnel)
937
+ if decl.nil?
938
+ # D8: the fold skips this agent until it is fixed — the doctor is the
939
+ # only report of why.
940
+ [Finding.new(check: "outcome-funnel", severity: :error, fix: nil,
941
+ message: "agent '#{profile.id}': malformed funnel declaration — " \
942
+ "#{funnel_defect(profile.funnel)}. The fold skips it " \
943
+ "until this is fixed.")]
944
+ else
945
+ [ok("outcome-funnel",
946
+ "agent '#{profile.id}': outcome funnel declared — #{decl.stages.length} " \
947
+ "stages, primary '#{decl.primary}', window #{decl.attribution_window}")] +
948
+ funnel_data_findings(profile, decl)
949
+ end
950
+ end
951
+
952
+ # visibility is the feature — a profile with outcomes but no
953
+ # funnel shows the hole. Warn only; the pack owns the vocabulary.
954
+ without = outcomes_without_funnel
955
+ without.each do |agent, count|
956
+ findings << Finding.new(check: "outcome-funnel", severity: :warn, fix: nil,
957
+ message: "agent '#{agent}' records #{count} outcomes and " \
958
+ "declares no funnel: nothing folds — the funnel " \
959
+ "shows the hole. Add a `funnel:` block to the pack.")
960
+ end
961
+
962
+ return findings unless findings.empty?
495
963
 
496
- broken
964
+ [ok("outcome-funnel", "outcome funnels: every declared funnel valid")]
965
+ end
966
+
967
+ # -> { agent_id => outcome count } — profiles with records but no funnel.
968
+ # ONE scan of the outcome store, grouped by agent (a per-profile `all`
969
+ # would scan it once PER profile — the store is not that big, but the
970
+ # key shape exists precisely so this stays a single pass).
971
+ def outcomes_without_funnel
972
+ return {} unless @outcome_store && @profile_source
973
+
974
+ counts = @outcome_store.all.group_by(&:agent).transform_values(&:size)
975
+ @profile_source.all.each_with_object({}) do |p, acc|
976
+ next unless p.funnel.nil?
977
+
978
+ n = counts[p.id.to_s].to_i
979
+ acc[p.id.to_s] = n if n.positive?
980
+ end
981
+ end
982
+
983
+ # The named defect, for the error message. `parse!` gives the exact field.
984
+ def funnel_defect(hash)
985
+ Insika::FunnelDeclaration.parse!(hash).to_s
986
+ rescue Insika::ValidationError => e
987
+ e.message
988
+ end
989
+
990
+ # Data-age findings, gated on the optional collaborators (nil = skip, env-only
991
+ # callers stay cheap). Tenant-agnostic: folds are per (tenant, agent), so the
992
+ # check reads the store's `pairs` and aggregates over every tenant of the agent
993
+ # (a single-tenant deployment reads the "platform" pair).
994
+ def funnel_data_findings(profile, decl)
995
+ return [] unless @funnel_store
996
+
997
+ pairs = @funnel_store.pairs.select { |p| p[:agent] == profile.id.to_s }
998
+ return [] if pairs.empty?
999
+
1000
+ findings = []
1001
+ folded_days = 0
1002
+ primary_count = 0
1003
+ baseline = false
1004
+ pairs.each do |pair|
1005
+ days = @funnel_store.days(tenant: pair[:tenant], agent: pair[:agent])
1006
+ folded_days += days.size
1007
+ primary_count += days.values.sum { |c| c[decl.primary].to_i }
1008
+ baseline ||= !@funnel_store.baseline(tenant: pair[:tenant], agent: pair[:agent]).nil?
1009
+ end
1010
+
1011
+ if primary_count.zero?
1012
+ findings << Finding.new(check: "outcome-funnel", severity: :info, fix: nil,
1013
+ message: "agent '#{profile.id}': the primary event " \
1014
+ "'#{decl.primary}' was never observed over the " \
1015
+ "folded days — check the integration.")
1016
+ end
1017
+ if folded_days >= 28 && !baseline
1018
+ findings << Finding.new(check: "outcome-funnel", severity: :info, fix: nil,
1019
+ message: "agent '#{profile.id}': #{folded_days} folded " \
1020
+ "days and no baseline frozen — " \
1021
+ "read the baseline. Freeze it in the Studio.")
1022
+ end
1023
+ findings
1024
+ end
1025
+
1026
+ # the follow-up check — declarations validated where they are
1027
+ # declared (D9), plus the two data-age reads that answer "whose follow-ups
1028
+ # will fire" and "what is blocked right now" without reading the store:
1029
+ # · a pending record whose `at` is more than one claim window in the past
1030
+ # will NEVER fire — a blocked rule or a broken policy is holding it
1031
+ # (warn; the Follow-ups page is the drill);
1032
+ # · revoked contact cells per tenant (info — the human opt-out bar).
1033
+ def check_followup
1034
+ return [] unless @profile_source
1035
+
1036
+ declared = @profile_source.all.select do |profile|
1037
+ profile.respond_to?(:followup) && !profile.followup.nil?
1038
+ end
1039
+ # a bare install (no follow-up on any profile) reports NOTHING — no
1040
+ # follow-up vocabulary leaks into the doctor of a store that never
1041
+ # scheduled one.
1042
+ return [] if declared.empty?
1043
+
1044
+ findings = declared.flat_map do |profile|
1045
+ followup = profile.followup
1046
+ decl = Insika::FollowupPolicy.parse(followup)
1047
+ if decl.nil?
1048
+ [Finding.new(check: "follow-up", severity: :error, fix: nil,
1049
+ message: "agent '#{profile.id}': malformed follow-up declaration — " \
1050
+ "#{followup_defect(followup)}. The engine will never fire its " \
1051
+ "follow-ups until this is fixed.")]
1052
+ else
1053
+ [ok("follow-up",
1054
+ "agent '#{profile.id}': follow-up declared — arm #{decl.arm}, " \
1055
+ "#{decl.quiet_hours ? "quiet hours #{decl.quiet_hours.start}-#{decl.quiet_hours.end} " \
1056
+ "<#{decl.quiet_hours.timezone}>" : 'no quiet hours'}, " \
1057
+ "#{decl.cancel_keywords.size} keyword(s), " \
1058
+ "silence after #{decl.silence_after_sends} send(s)")] +
1059
+ followup_data_findings(profile, decl)
1060
+ end
1061
+ end
1062
+
1063
+ findings
1064
+ end
1065
+
1066
+ # The named defect, for the error message — `parse!` gives the exact field.
1067
+ def followup_defect(hash)
1068
+ Insika::FollowupPolicy.parse!(hash).to_s
1069
+ rescue Insika::ValidationError => e
1070
+ e.message
1071
+ end
1072
+
1073
+ # the recurring-schedule check — declarations
1074
+ # validated where they are declared (each one through the SAME
1075
+ # Insika::Schedule parser the engine fires on, never a second opinion),
1076
+ # plus the data read that answers "what is being skipped right now".
1077
+ # A bare install (no schedule on any profile) reports NOTHING.
1078
+ def check_schedules
1079
+ return [] unless @profile_source
1080
+
1081
+ declared = @profile_source.all.select do |profile|
1082
+ profile.respond_to?(:schedules) && Array(profile.schedules).any?
1083
+ end
1084
+ return [] if declared.empty?
1085
+
1086
+ declared.flat_map do |profile|
1087
+ Array(profile.schedules).flat_map do |schedule|
1088
+ decl = Insika::Schedule.parse(schedule)
1089
+ if decl.nil?
1090
+ [Finding.new(check: "schedules", severity: :error, fix: nil,
1091
+ message: "agent '#{profile.id}': malformed schedule — " \
1092
+ "#{schedule_defect(schedule)}. The engine will never " \
1093
+ "fire it until this is fixed.")]
1094
+ else
1095
+ ok_finding = schedule_ok(profile, decl)
1096
+ [ok_finding] + schedule_data_findings(profile, decl)
1097
+ end
1098
+ end
1099
+ end
1100
+ end
1101
+
1102
+ # The ok finding for one valid declaration — plus the warning that
1103
+ # names the cadence ceiling: the engine claims ONE window per pass, so an
1104
+ # `every` shorter than the claim window cannot fire more often than it.
1105
+ def schedule_ok(profile, decl)
1106
+ trigger = decl.cron ? "cron #{decl.cron} <#{decl.tz}>" : "every #{decl.every}s"
1107
+ floor = Insika::ScheduleEngine::DEFAULT_WINDOW
1108
+ if !decl.cron.nil? || decl.every >= floor
1109
+ ok("schedules",
1110
+ "agent '#{profile.id}': schedule '#{decl.id}' — #{trigger}, " \
1111
+ "#{decl.fixed_session? ? "a fixed standing session" : 'a new session per run'}, " \
1112
+ "#{decl.enabled ? 'enabled' : 'disabled'}")
1113
+ else
1114
+ Finding.new(check: "schedules", severity: :warn, fix: nil,
1115
+ message: "agent '#{profile.id}': schedule '#{decl.id}' declares " \
1116
+ "every #{decl.every}s, but the engine fires at most once per " \
1117
+ "#{floor}s claim window — the effective cadence is one run " \
1118
+ "per ~#{floor}s, not one per #{decl.every}s.")
1119
+ end
1120
+ end
1121
+
1122
+ # The named defect, for the error message — the engine's parser's words.
1123
+ def schedule_defect(hash)
1124
+ Insika::Schedule.parse!(hash).to_s
1125
+ rescue Insika::ValidationError => e
1126
+ e.message
1127
+ end
1128
+
1129
+ # Data-age findings, gated on the optional collaborator (nil = skip,
1130
+ # env-only callers stay cheap): the row's last skip, when the schedule
1131
+ # was created and has one — visible in the Studio, named here too.
1132
+ def schedule_data_findings(profile, decl)
1133
+ return [] unless @schedule_store
1134
+
1135
+ row = @schedule_store.find(tenant: Insika::ScheduleEngine.tenant_for(profile),
1136
+ agent: profile.id.to_s, id: decl.id)
1137
+ return [] unless row&.last_skip
1138
+
1139
+ [Finding.new(check: "schedules", severity: :info, fix: nil,
1140
+ message: "agent '#{profile.id}' schedule '#{decl.id}': the last run was " \
1141
+ "SKIPPED at #{row.last_skip['at']} — reason " \
1142
+ "#{row.last_skip['reason'].inspect} (overlap = the previous " \
1143
+ "run still live; budget = a hard cap reached; late = the " \
1144
+ "window was missed by the no-catch-up policy).")]
1145
+ end
1146
+
1147
+ # Data-age findings, gated on the optional collaborators (nil = skip,
1148
+ # env-only callers stay cheap).
1149
+ def followup_data_findings(profile, decl)
1150
+ return [] unless @followup_store && @contact_store
1151
+
1152
+ findings = []
1153
+ # a pending record more than one claim window PAST its `at` (the sign:
1154
+ # due(now - window)) is a promise the tick will never honor. A
1155
+ # quiet-hours deferral is EXCLUDED — that record still fires next pass;
1156
+ # only a record the tick could fire RIGHT NOW and doesn't is stuck.
1157
+ now = Time.now.utc
1158
+ window = Insika::FollowupEngine::DEFAULT_WINDOW
1159
+ deferred_now = decl.quiet?(now)
1160
+ stale = @followup_store.due(now: now - window)
1161
+ .reject { |r| r.agent != profile.id.to_s }
1162
+ .reject { deferred_now }
1163
+ unless stale.empty?
1164
+ findings << Finding.new(check: "follow-up", severity: :warn, fix: nil,
1165
+ message: "agent '#{profile.id}': #{stale.size} pending " \
1166
+ "follow-up(s) past their scheduled time by more than " \
1167
+ "one claim window — the tick will never fire them. A " \
1168
+ "blocked rule or a broken policy is pending; see the " \
1169
+ "Follow-ups page.")
1170
+ end
1171
+ revoked_for(profile).each do |tenant, count|
1172
+ findings << Finding.new(check: "follow-up", severity: :info, fix: nil,
1173
+ message: "agent '#{profile.id}': #{count} revoked contact cell(s) " \
1174
+ "in tenant #{tenant.inspect} — the human opt-out bar " \
1175
+ "(opt-outs are per tenant/customer, shared across agents).")
1176
+ end
1177
+ findings
1178
+ end
1179
+
1180
+ # { tenant => revoked-cell count } among the cells this agent may message.
1181
+ # The contact cell is per (tenant, customer) — shared across agents (D2's
1182
+ # multi-agent note), so the count is a per-tenant tally of revoked cells.
1183
+ def revoked_for(_profile)
1184
+ return {} unless @contact_store
1185
+
1186
+ @contact_store.cells.each_with_object(Hash.new(0)) do |(key, cell), tally|
1187
+ next unless cell["state"] == "revoked"
1188
+
1189
+ tally[key.split(":", 2).first] += 1
1190
+ end
1191
+ end
1192
+
1193
+ # the distillation check — per profile WITH a `distill`
1194
+ # hash: a declared-and-enabled distiller with NO resolvable model (no
1195
+ # distill.model, no platform utility_model) can never run — the warn is
1196
+ # the "declared but dead" signal (D4 — the engine never guesses a model).
1197
+ # A bare install reports one ok ("off"), unlike follow-up's silence: the
1198
+ # Facts page exists with or without declarations and the doctor names why
1199
+ # it stays empty. nil proposal_store = declarations only (counts skipped).
1200
+ def check_distill
1201
+ return [] unless @profile_source
1202
+
1203
+ declared = @profile_source.all.select do |profile|
1204
+ profile.respond_to?(:distill) && !profile.distill.nil?
1205
+ end
1206
+ return [ok("distill", "distillation off — no agent declares it")] if declared.empty?
1207
+
1208
+ settings = @settings_store ? @settings_store.get : {}
1209
+ # one scan pair per run, not per declared profile (the counts are the
1210
+ # store's, printed identically on each line).
1211
+ counts = @proposal_store ? distill_counts : nil
1212
+ declared.flat_map do |profile|
1213
+ config = profile.distill
1214
+ if Coercion.truthy?(config["enabled"]) &&
1215
+ Coercion.presence(config["model"]).nil? &&
1216
+ Coercion.presence(settings["utility_model"]).nil?
1217
+ [Finding.new(check: "distill", severity: :warn, fix: nil,
1218
+ message: "agent '#{profile.id}': the distiller is declared but has " \
1219
+ "no model slot — distillation will never run (set " \
1220
+ "distill.model or the platform utility_model).")]
1221
+ else
1222
+ suffix = counts ? " — #{counts[:pending]} proposal(s) pending, #{counts[:stale]} stale" : ""
1223
+ [ok("distill", "agent '#{profile.id}': distillation declared#{suffix}")]
1224
+ end
1225
+ end
1226
+ end
1227
+
1228
+ def distill_counts
1229
+ { pending: @proposal_store.pending(limit: 10_000).size,
1230
+ stale: @proposal_store.stale(limit: 10_000).size }
1231
+ end
1232
+
1233
+ # the harvest check — per profile WITH a harvest hash:
1234
+ # declared-without-model warn (D12), no grounding matcher warn (D3),
1235
+ # malformed negative list error (D4), else ok with the pending counts.
1236
+ # With @harvest_criterion: the loaded criterion line + a warn when the
1237
+ # file at its path no longer loads (the frozen rule moved).
1238
+ def check_harvest
1239
+ return [] unless @profile_source
1240
+
1241
+ declared = @profile_source.all.select do |profile|
1242
+ profile.respond_to?(:harvest) && !profile.harvest.nil?
1243
+ end
1244
+ findings = []
1245
+ if declared.empty?
1246
+ findings << ok("harvest", "harvest off — no agent declares it")
1247
+ else
1248
+ settings = @settings_store ? @settings_store.get : {}
1249
+ declared.each do |profile|
1250
+ config = profile.harvest
1251
+ id = profile.id
1252
+ if Coercion.truthy?(config["enabled"]) &&
1253
+ Coercion.presence(config.dig("miner", "model")).nil? &&
1254
+ Coercion.presence(settings["utility_model"]).nil?
1255
+ findings << Finding.new(check: "harvest", severity: :warn, fix: nil,
1256
+ message: "agent '#{id}': the harvester is declared but has no " \
1257
+ "model slot — mining will never run (set " \
1258
+ "harvest.miner.model or the platform utility_model).")
1259
+ end
1260
+ grounding = begin
1261
+ Insika::Grounding.parse(profile.grounding)
1262
+ rescue Insika::ValidationError
1263
+ nil
1264
+ end
1265
+ if Coercion.truthy?(config["enabled"]) && (grounding.nil? || !grounding.matcher.sku?)
1266
+ findings << Finding.new(check: "harvest", severity: :warn, fix: nil,
1267
+ message: "agent '#{id}': product claims cannot be verified — " \
1268
+ "mining is skipped (set grounding.matcher.sku).")
1269
+ end
1270
+ if config["negative_list"].is_a?(Array) &&
1271
+ Insika::Harvest::NegativeList.parse(config["negative_list"]).nil?
1272
+ findings << Finding.new(check: "harvest", severity: :error, fix: nil,
1273
+ message: "agent '#{id}': the harvest.negative_list is malformed " \
1274
+ "— the whole list is refused (half a list silently " \
1275
+ "admits what the store banned).")
1276
+ end
1277
+ suffix = harvest_counts(id) if @harvest_store
1278
+ findings << ok("harvest", "agent '#{id}': harvest declared#{suffix}")
1279
+ end
1280
+ end
1281
+ if @harvest_criterion
1282
+ findings << ok("harvest-criterion",
1283
+ "criterion #{@harvest_criterion.rule.metric} / #{@harvest_criterion.rule.window} " \
1284
+ "threshold #{@harvest_criterion.rule.threshold} (#{@harvest_criterion.sha})")
1285
+ begin
1286
+ Insika::Harvest::Criterion.load(@harvest_criterion.path)
1287
+ rescue Insika::ConfigError, Insika::ValidationError
1288
+ findings << Finding.new(check: "harvest-criterion", severity: :warn, fix: nil,
1289
+ message: "the criterion file at #{@harvest_criterion.path} no longer " \
1290
+ "loads — the frozen rule moved since boot.")
1291
+ end
1292
+ end
1293
+ findings
1294
+ end
1295
+
1296
+ def harvest_counts(agent_id)
1297
+ awaiting = @harvest_store.candidates(agent_id: agent_id, status: "awaiting_approval").size
1298
+ pending = @harvest_store.candidates(agent_id: agent_id, status: "pending").size
1299
+ " — #{awaiting} awaiting, #{pending} pending"
497
1300
  end
498
1301
 
499
1302
  def broken_tool(raw)
@@ -511,8 +1314,6 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
511
1314
  Finding.new(check: "data-tools", severity: :error, fix: fix, message: message)
512
1315
  end
513
1316
 
514
- # A fix ONLY when spelling the legacy bare `array` explicitly is enough to make the
515
- # definition build — never a guess at a broken definition we don't understand.
516
1317
  def array_sugar_fix(raw)
517
1318
  params = raw.is_a?(Hash) ? raw["parameters"] : nil
518
1319
  return nil unless params.is_a?(Array) && params.any? { |p| p.is_a?(Hash) && p["type"].to_s == "array" }
@@ -528,6 +1329,83 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
528
1329
 
529
1330
  # -- helpers -------------------------------------------------------
530
1331
 
1332
+ # shadow parity. Shadow holds raw customer conversations, so
1333
+ # every dangerous configuration says so BEFORE the experiment starts — and
1334
+ # the one automated reminder to turn it off. The pairs themselves are only
1335
+ # loaded when shadow is ON (a full scan pays for customer text); the off
1336
+ # path needs just a count, which the store answers from its keys.
1337
+ def check_shadow_parity
1338
+ shadow_on = Insika::EnvSchema.truthy?(@env["INSIKA_RELAY_SHADOW"])
1339
+ path = Insika::EnvSchema.read("INSIKA_PARITY_CRITERION", @env)
1340
+
1341
+ unless shadow_on
1342
+ stored = @shadow_pair_store ? @shadow_pair_store.size : 0
1343
+ return [] if stored.zero?
1344
+
1345
+ return [ok("shadow-parity",
1346
+ "shadow off — #{stored} pair(s) still stored, evidence is not forgotten")]
1347
+ end
1348
+
1349
+ if path.nil?
1350
+ return [Finding.new(check: "shadow-parity", severity: :error, fix: nil,
1351
+ message: "shadow on but INSIKA_PARITY_CRITERION is unset — the frozen criterion is required to record pairs")]
1352
+ end
1353
+
1354
+ pairs = @shadow_pair_store ? @shadow_pair_store.each.to_a : []
1355
+
1356
+ criterion = begin
1357
+ Insika::Parity::Criterion.load(path)
1358
+ rescue Insika::Error => e
1359
+ return [Finding.new(check: "shadow-parity", severity: :error, fix: nil,
1360
+ message: "shadow on but the criterion did not load from #{path}: #{e.message}")]
1361
+ end
1362
+
1363
+ findings = [ok("shadow-parity", "shadow on — criterion frozen (#{criterion.sha}) at #{path}")]
1364
+ if Insika::EnvSchema.present?(@env["INSIKA_RELAY_DELIVER_URL"])
1365
+ findings << Finding.new(check: "shadow-parity", severity: :warn, fix: nil,
1366
+ message: "INSIKA_RELAY_DELIVER_URL is set — the URL is INERT: a shadow relay never delivers")
1367
+ end
1368
+ judges = ((@settings_store&.get || {})["evals"] || {})
1369
+ if Array(judges["judges"]).reject { |j| j["model"].to_s.strip.empty? }.empty?
1370
+ findings << Finding.new(check: "shadow-parity", severity: :warn, fix: nil,
1371
+ message: "no judges in settings['evals'] — pairs will accumulate and nothing can judge them")
1372
+ end
1373
+ window = criterion.rule.window_days
1374
+ oldest = pairs.filter_map { |p| parse_pair_time(p) }.min
1375
+ if oldest && oldest < Time.now.utc - (2 * window * 86_400)
1376
+ findings << Finding.new(check: "shadow-parity", severity: :warn, fix: nil,
1377
+ message: "the oldest pair is older than 2 × window_days — shadow is not a permanent mode; judge and turn it off")
1378
+ end
1379
+ findings
1380
+ end
1381
+
1382
+ def parse_pair_time(pair)
1383
+ Time.iso8601(pair.created_at.to_s)
1384
+ rescue ArgumentError
1385
+ nil
1386
+ end
1387
+
1388
+ # the BOOT gate for a malformed guardrail corpus. A typo'd
1389
+ # language/family or a broken pattern source raises ValidationError inside
1390
+ # Safety::Config on the FIRST TURN — mid-conversation, unrecoverable. This
1391
+ # check makes `insika doctor` the place it surfaces instead: an :error
1392
+ # finding (non-zero exit), so a deployment learns at boot, never mid-turn.
1393
+ def check_guardrail_corpora
1394
+ return [] unless @profile_source
1395
+
1396
+ findings = @profile_source.all.each_with_object([]) do |p, acc|
1397
+ Insika::Safety::Config.from_profile(p) # compiles the corpus — raises on a bad declaration
1398
+ rescue Insika::ValidationError => e
1399
+ acc << Finding.new(check: "guardrail-corpora", severity: :error, fix: nil,
1400
+ message: "agent '#{p.id}': malformed guardrails.corpora — #{e.message}. " \
1401
+ "Every message would fail the guardrail; fix the declaration " \
1402
+ "(docs/domain.md#guardrails).")
1403
+ end
1404
+ return findings unless findings.empty?
1405
+
1406
+ [ok("guardrail-corpora", "guardrail corpora: every declaration compiles")]
1407
+ end
1408
+
531
1409
  def ok(check, message) = Finding.new(check: check, severity: :ok, message: message, fix: nil)
532
1410
 
533
1411
  # A model to seed the platform default from: DEEPSEEK_MODEL env, else the first