insika 0.1.0 → 0.3.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
data/lib/insika/doctor.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # `insika doctor` (item 23 / §8.1 — OpenClaw's "strict config + doctor --fix",
4
+ # `insika doctor` (— OpenClaw's "strict config + doctor --fix",
5
5
  # "the repo's best productization discipline"). A read-only diagnosis of a
6
6
  # deployment's configuration that turns the scattered, silent boot warnings into
7
7
  # ONE structured report — and, with `--fix`, applies the safe autofixes.
@@ -57,15 +57,89 @@ 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
- agent_file_store: nil, backend: nil, extra_env_specs: [])
102
+ agent_file_store: nil, skill_store: nil, skill_catalog: nil,
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
+ harvest_store: nil, harvest_criterion: nil)
62
108
  @env = env
63
109
  @settings_store = settings_store
64
110
  @llm_provider_store = llm_provider_store
65
111
  @tool_store = tool_store
66
112
  @agent_file_store = agent_file_store
113
+ @skill_store = skill_store
114
+ @skill_catalog = skill_catalog
115
+ @profile_source = profile_source
67
116
  @backend = backend
68
117
  @extra_env_specs = extra_env_specs
118
+ @shadow_pair_store = shadow_pair_store
119
+ @soak_envelope_path = soak_envelope_path
120
+ # [ContextProvider | Class] — classes accepted so the CLI
121
+ # can pass the builtin set without deps; the check only reads .layer /
122
+ # .name. nil = skip.
123
+ @context_providers = context_providers
124
+ # the memory-scopes check — Insika::MemoryStore | nil = skip;
125
+ # agent_ids excuse bare cells named like an agent (the agent-memory tab's).
126
+ @memory_store = memory_store
127
+ @agent_ids = Array(agent_ids).map(&:to_s)
128
+ # the outcome-funnel check — nil collaborators = the check
129
+ # reports declarations only (env-only callers stay cheap).
130
+ @funnel_store = funnel_store
131
+ @outcome_store = outcome_store
132
+ # the follow-up check — nil collaborators = the check
133
+ # reports declarations only.
134
+ @followup_store = followup_store
135
+ @contact_store = contact_store
136
+ # the distillation check — nil collaborator = the check
137
+ # reports declarations only (counts skipped).
138
+ @proposal_store = proposal_store
139
+ # the harvest check — nil collaborators = the check
140
+ # reports declarations only (counts skipped).
141
+ @harvest_store = harvest_store
142
+ @harvest_criterion = harvest_criterion
69
143
  end
70
144
 
71
145
  # -> Report. Never raises (a broken check degrades to an :error Finding).
@@ -80,11 +154,25 @@ module Insika
80
154
  [before, run]
81
155
  end
82
156
 
157
+ # — the domain inventory: what a deployment declares (personas
158
+ # with a metadata.domain tag, outcome funnels, tool evidence) plus the
159
+ # built-in pt-BR corpora still in effect. Read-only: informational, never
160
+ # a gate — a pilot reporting its own artifacts must exit 0.
161
+ def domain
162
+ entries = %i[personas corpora funnels evidence].flat_map { |section| safe_domain(section) }.compact
163
+ DomainReport.new(generated_at: Time.now.utc.iso8601,
164
+ gem_version: Insika::VERSION,
165
+ entries: entries.freeze)
166
+ end
167
+
83
168
  private
84
169
 
85
170
  def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
86
171
  check_admin_token check_data_tools check_prompt_files check_relay_channel
87
- check_web_widget]
172
+ check_web_widget check_skill_eager check_skill_drift check_shadow_parity
173
+ check_soak_envelope check_turn_timing check_grounding check_cache_layers
174
+ check_memory_scopes check_funnel_declarations check_followup check_distill
175
+ check_harvest check_guardrail_corpora]
88
176
 
89
177
  def safe(check)
90
178
  Array(send(check))
@@ -93,6 +181,99 @@ module Insika
93
181
  [Finding.new(check: id, severity: :error, message: "check crashed: #{e.class}: #{e.message}", fix: nil)]
94
182
  end
95
183
 
184
+ # one domain section, wrapped — a broken read degrades to one
185
+ # error-marked entry, never raises (the same discipline as `safe`).
186
+ def safe_domain(section)
187
+ send(:"domain_#{section}")
188
+ rescue StandardError => e
189
+ [DomainEntry.new(kind: "error", agent: nil,
190
+ detail: "#{section}: read failed — #{e.class}: #{e.message}",
191
+ source: nil, how_to_clear: nil)]
192
+ end
193
+
194
+ # 1/4 personas/packs — `profile.metadata["domain"]` (pack `agent.config.json`
195
+ # passes it through; the DSL sets it via `metadata domain: "…"`).
196
+ def domain_personas
197
+ return [] unless @profile_source
198
+
199
+ @profile_source.all.filter_map do |p|
200
+ m = p.metadata
201
+ tag = m && m["domain"]
202
+ next if !Coercion.present?(tag)
203
+
204
+ DomainEntry.new(kind: "persona", agent: p.id, detail: "domain=#{tag}",
205
+ source: "deployment", how_to_clear: nil)
206
+ end
207
+ end
208
+
209
+ # 2/4 corpora — the built-in pt-BR corpus still in effect (gem default) and
210
+ # the built-in pt-BR safe replies that are not fully overridden.
211
+ def domain_corpora
212
+ return [] unless @profile_source
213
+
214
+ @profile_source.all.each_with_object([]) do |p, acc|
215
+ config = Insika::Safety::Config.from_profile(p)
216
+ next unless config.enabled?
217
+
218
+ if config.corpus_languages.include?("pt-BR")
219
+ acc << DomainEntry.new(kind: "guardrail-corpus", agent: p.id,
220
+ detail: "languages=#{config.corpus_languages.join(',')}",
221
+ source: "gem-default",
222
+ how_to_clear: "docs/domain.md#guardrails")
223
+ end
224
+ fallback = builtin_response_categories(config)
225
+ next if fallback.empty?
226
+
227
+ acc << DomainEntry.new(kind: "safe-responses", agent: p.id,
228
+ detail: "categories=#{fallback.join(',')}",
229
+ source: "gem-default",
230
+ how_to_clear: "docs/domain.md#guardrails")
231
+ end
232
+ end
233
+
234
+ # The safe-reply categories that still resolve to the built-in pt-BR
235
+ # DEFAULTS (a category the agent overrode — or a catch-all `default` that
236
+ # replaces every category — contributes none).
237
+ def builtin_response_categories(config)
238
+ Insika::Safety::SafeResponses::DEFAULTS.keys.select do |cat|
239
+ Insika::Safety::SafeResponses.for(cat, overrides: config.responses) == Insika::Safety::SafeResponses::DEFAULTS[cat]
240
+ end
241
+ end
242
+
243
+ # 3/4 funnels — the declaration shape. Absent in the tree (or on a
244
+ # profile) -> absent in the report: a bare install shows no funnel and no
245
+ # stage names at all (the vocabulary note).
246
+ def domain_funnels
247
+ return [] unless @profile_source
248
+
249
+ @profile_source.all.filter_map do |p|
250
+ next unless p.respond_to?(:funnel) && p.funnel.is_a?(Hash) && !p.funnel.empty?
251
+
252
+ stages = Array(p.funnel["stages"]).join(",")
253
+ primary = p.funnel["primary"]
254
+ detail = +"stages=#{stages}"
255
+ detail << ", primary=#{primary}" if Coercion.present?(primary)
256
+ DomainEntry.new(kind: "funnel", agent: p.id, detail: detail,
257
+ source: "deployment", how_to_clear: nil)
258
+ end
259
+ end
260
+
261
+ # 4/4 evidence — tool manifests carrying the `evidence: <kind>`
262
+ # vocabulary. Kinds are pack vocabulary, never gem constants.
263
+ def domain_evidence
264
+ return [] unless @tool_store
265
+
266
+ @tool_store.all_raw.filter_map do |raw|
267
+ ev = raw["evidence"]
268
+ kind = ev.is_a?(Hash) ? ev["kind"] : ev
269
+ next if !Coercion.present?(kind)
270
+
271
+ DomainEntry.new(kind: "evidence", agent: nil,
272
+ detail: "#{raw["name"]}: #{kind}",
273
+ source: "deployment", how_to_clear: nil)
274
+ end
275
+ end
276
+
96
277
  # -- checks --------------------------------------------------------
97
278
 
98
279
  def check_env
@@ -155,7 +336,35 @@ module Insika
155
336
  message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
156
337
  end
157
338
 
158
- # A half-configured relay is the silent kind of broken (RFC-0011 §6): with only
339
+ # the soak envelope (a deployment-side file). Absent is :info — not every
340
+ # deployment soaks, and the tooling is optional. Present-and-broken is
341
+ # :error — an envelope that exists but does not parse is a pre-declaration
342
+ # somebody wrote down that the runner will refuse, and only this check says
343
+ # so before the run is attempted.
344
+ def check_soak_envelope
345
+ return [Finding.new(check: "soak-envelope", severity: :info, fix: nil,
346
+ message: "no soak envelope (#{@soak_envelope_path || 'unset'}) — `insika soak` is available, not required")] if @soak_envelope_path.nil? || !File.file?(@soak_envelope_path)
347
+
348
+ Insika::Soak::Envelope.load(@soak_envelope_path)
349
+ [ok("soak-envelope", "soak envelope parses (#{@soak_envelope_path})")]
350
+ rescue Insika::ConfigError => e
351
+ [Finding.new(check: "soak-envelope", severity: :error, fix: nil,
352
+ message: "soak envelope present but broken: #{e.message}")]
353
+ end
354
+
355
+ # the soak's prep_p95 gate needs INSIKA_TURN_TIMING on the
356
+ # target. Off is :info normally, and the message names what a soak would
357
+ # refuse — the preflight failure must never be a surprise found 72 hours in.
358
+ def check_turn_timing
359
+ if Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_TURN_TIMING", @env))
360
+ [ok("turn-timing", "INSIKA_TURN_TIMING on — the soak's prep_p95 gate is measurable")]
361
+ else
362
+ [Finding.new(check: "turn-timing", severity: :info, fix: nil,
363
+ message: "INSIKA_TURN_TIMING off — a soak would refuse at preflight (P2: no timing block)")]
364
+ end
365
+ end
366
+
367
+ # A half-configured relay is the silent kind of broken: with only
159
368
  # the deliver URL set, nothing is mounted and every inbound POST 404s; with only
160
369
  # the token, the engine accepts turns it can never answer and the customer waits
161
370
  # forever on a reply that is sitting in the outbox. Both halves or neither.
@@ -176,7 +385,7 @@ module Insika
176
385
  end
177
386
 
178
387
  # The widget is the one PUBLIC channel, so its misconfigurations are the ones that
179
- # cost money rather than just failing (RFC-0011 §5.3/§5.4). Three of them, in the
388
+ # cost money rather than just failing. Three of them, in the
180
389
  # order they bite: half the switch set (mounted nowhere, or mounted addressing
181
390
  # nothing), and a mount with no chat rate limit anywhere — which the channel
182
391
  # refuses with a 503 rather than opening, but which reads to an operator as "the
@@ -202,6 +411,247 @@ module Insika
202
411
  "INSIKA_WIDGET_AGENTS sets limits.chat_rate_limit (a public channel with no ceiling is not served)", fix: nil)]
203
412
  end
204
413
 
414
+ # Eagerness moved from the SKILL.md frontmatter to `profile.skills_eager`, because
415
+ # skills are SHARED and a flag on the skill forced one decision onto every agent
416
+ # holding it. The parser now ignores `eager:` — which is the quiet kind of upgrade:
417
+ # nothing crashes, the body simply stops being in the prompt. This check is the
418
+ # only thing that says so. Its sibling half is the opposite mistake: a name in
419
+ # `skills_eager` that the agent's `skills` allowlist does not contain is
420
+ # intersected away at runtime, so the operator's intent evaporates in silence.
421
+ def check_skill_eager
422
+ findings = stale_eager_frontmatter + unreachable_eager_names
423
+ return findings if findings.any?
424
+ return [] unless @skill_store || @skill_catalog || @profile_source
425
+
426
+ [ok("skill-eager", "skill eagerness: per-agent (profile.skills_eager), no stale frontmatter")]
427
+ end
428
+
429
+ def stale_eager_frontmatter
430
+ skill_sources.filter_map do |name, content|
431
+ next unless frontmatter_of(content).key?("eager")
432
+
433
+ Finding.new(check: "skill-eager", severity: :warn, fix: nil,
434
+ message: "skill '#{name}' still declares `eager:` in its frontmatter — the key is IGNORED. " \
435
+ "Eagerness is per-agent now: put the name in that agent's `skills_eager` " \
436
+ "(Studio > Skills, or `skills_eager \"#{name}\"` in the DSL).")
437
+ end
438
+ end
439
+
440
+ def unreachable_eager_names
441
+ return [] unless @profile_source
442
+
443
+ @profile_source.all.flat_map do |profile|
444
+ eager = profile.skills_eager
445
+ next [] unless eager.is_a?(Array)
446
+ next [] if profile.skills.nil? # nil = every skill allowed, so nothing is unreachable
447
+
448
+ allowed = Array(profile.skills).map(&:to_s)
449
+ (eager.map(&:to_s) - allowed).map do |name|
450
+ Finding.new(check: "skill-eager", severity: :warn, fix: nil,
451
+ message: "agent '#{profile.id}' marks skill '#{name}' eager but does not allow it — " \
452
+ "the name is a no-op. Add it to the agent's skills, or drop it from skills_eager.")
453
+ end
454
+ end
455
+ end
456
+
457
+ # { name => raw SKILL.md } across BOTH sources the runtime reads: the disk roots
458
+ # (seed, via the catalog) and the authored store (wins — same precedence as
459
+ # SkillCatalog). The checks parse frontmatter the catalog deliberately drops
460
+ # (`eager:`), so they need the raw text; for a disk skill that is the file itself,
461
+ # and a store-overlaid skill carries a sentinel path that is not a file.
462
+ def skill_sources
463
+ @skill_sources ||= disk_skill_sources.merge(@skill_store ? @skill_store.all : {})
464
+ end
465
+
466
+ def disk_skill_sources
467
+ return {} unless @skill_catalog
468
+
469
+ @skill_catalog.all.each_with_object({}) do |skill, acc|
470
+ acc[skill.name] = File.read(skill.path, encoding: "UTF-8") if File.file?(skill.path.to_s)
471
+ end
472
+ end
473
+
474
+ # The frontmatter block only; a `eager:` line in the BODY is prose, not config.
475
+ def frontmatter_of(content)
476
+ match = content.to_s.match(/\A---\s*\n(.*?)\n---\s*\n/m)
477
+ match ? Insika::Frontmatter.parse(match[1]) : {}
478
+ end
479
+
480
+ # Drift between a pack's PROSE and the catalog. Three ways it happened on the
481
+ # pilot, all silent, all found by reading a customer conversation afterwards.
482
+ #
483
+ # Every check here takes MECHANICAL inputs only — skill names, allowlists, agent
484
+ # identities. A check that has to parse prose ("this paragraph declares a count of
485
+ # six") false-positives on the first real pack and takes the doctor's credibility
486
+ # with it, which costs more than the drift it caught.
487
+ def check_skill_drift
488
+ return [] unless (@skill_store || @skill_catalog) && @profile_source
489
+
490
+ findings = prompt_files_naming_unallowed_skills + shared_skills_naming_a_holder + broken_companions
491
+ return findings if findings.any?
492
+
493
+ [ok("skill-drift", "skill references: prompt files, shared bodies and companions all consistent")]
494
+ end
495
+
496
+ # D1 residue. The routing table is GENERATED now (SkillCatalog#format_for_prompt
497
+ # renders each skill with its triggers), so the hand-written companion has no
498
+ # reason to exist — but a pack that still carries one keeps instructing the model
499
+ # about skills the agent cannot load. Skill names are known ids, so this is a grep.
500
+ def prompt_files_naming_unallowed_skills
501
+ return [] unless @agent_file_store
502
+
503
+ catalog = skill_sources.keys
504
+ @profile_source.all.flat_map do |profile|
505
+ next [] if profile.skills.nil? # nil = everything allowed, nothing to be outside of
506
+
507
+ allowed = Array(profile.skills).map(&:to_s)
508
+ orphans = catalog - allowed
509
+ next [] if orphans.empty?
510
+
511
+ @agent_file_store.list(profile.id).flat_map do |file|
512
+ body = @agent_file_store.read(profile.id, file).to_s
513
+ orphans.select { |name| mentions?(body, name) }.map do |name|
514
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
515
+ message: "agent '#{profile.id}' file '#{file}' names skill '#{name}', which is NOT in its " \
516
+ "skills allowlist — the model is being told to use something it cannot load. " \
517
+ "Allow the skill, or drop the reference (the skill table is generated).")
518
+ end
519
+ end
520
+ end
521
+ end
522
+
523
+ # D2. A skill in more than one allowlist that names one of its OWN holders in its
524
+ # text is specialized text in shared clothing — a pilot deployment served every
525
+ # holder three shared skills that each named one of them. Specialize it per agent
526
+ # (write_skill with `agent:`) instead of leaving one store's policy in a shared body.
527
+ #
528
+ # Merchant vocabulary beyond the holders' identities is deliberately out of scope:
529
+ # there is no mechanical source for it.
530
+ def shared_skills_naming_a_holder
531
+ holders = skill_holders
532
+ specialized = specialized_by
533
+ skill_sources.flat_map do |name, content|
534
+ owners = holders[name].to_a
535
+ next [] if owners.length < 2
536
+
537
+ # Who still READS this shared body: a holder with its own version reads that
538
+ # instead, so it stops being a victim — but its identity in the shared text
539
+ # keeps poisoning whoever is left. Identity comes from ALL holders; only the
540
+ # readers shrink as specializations land, so the finding clears when the last
541
+ # victim stops reading, never merely because the named holder moved out.
542
+ readers = owners - specialized[name].to_a
543
+ owners.flat_map do |owner|
544
+ victims = (readers - [owner]).sort
545
+ next [] if victims.empty?
546
+
547
+ identity_terms(owner).select { |term| mentions?(content, term) }.map do |term|
548
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
549
+ message: "shared skill '#{name}' names '#{term}', the identity of '#{owner}' — " \
550
+ "#{victims.join(', ')} read(s) #{owner}'s text as their own. Specialize " \
551
+ "'#{owner}' if it needs that text, and remove it from the shared body.")
552
+ end
553
+ end
554
+ end
555
+ end
556
+
557
+ # D3 residue, the half `companions:` cannot prevent: a body that points at another
558
+ # catalog skill without declaring it (so the pair can still break apart), and a
559
+ # declared companion an agent is not allowed to load (so the pair breaks for THAT
560
+ # agent, silently, since the engine will not widen an allowlist on its own).
561
+ def broken_companions
562
+ names = skill_sources.keys
563
+ undeclared = skill_sources.flat_map do |name, content|
564
+ declared = declared_companions(content)
565
+ (names - [name] - declared).select { |other| mentions?(body_of(content), other) }.map do |other|
566
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
567
+ message: "skill '#{name}' references skill '#{other}' in its body without declaring it a " \
568
+ "companion — the two can arrive apart, and half a recipe is worse than none. " \
569
+ "Add `companions: [#{other}]` to '#{name}'.")
570
+ end
571
+ end
572
+ undeclared + companions_outside_allowlists
573
+ end
574
+
575
+ # Same lenient reading as SkillCatalog#parse_list: a YAML list, or the whole value
576
+ # as one comma-separated String when the tolerant parser had to fall back.
577
+ def declared_companions(content)
578
+ raw = frontmatter_of(content)["companions"]
579
+ Array(raw).flat_map { |c| c.to_s.split(",") }.map(&:strip).reject(&:empty?)
580
+ end
581
+
582
+ def companions_outside_allowlists
583
+ declared = skill_sources.each_with_object({}) do |(name, content), acc|
584
+ list = declared_companions(content)
585
+ acc[name] = list unless list.empty?
586
+ end
587
+ return [] if declared.empty?
588
+
589
+ @profile_source.all.flat_map do |profile|
590
+ next [] if profile.skills.nil?
591
+
592
+ allowed = Array(profile.skills).map(&:to_s)
593
+ declared.flat_map do |name, companions|
594
+ next [] unless allowed.include?(name)
595
+
596
+ (companions - allowed).map do |missing|
597
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
598
+ message: "agent '#{profile.id}' allows skill '#{name}' but not its companion '#{missing}' — " \
599
+ "the pair cannot travel together for this agent. Allow '#{missing}' too.")
600
+ end
601
+ end
602
+ end
603
+ end
604
+
605
+ # { skill name => [agent ids that have their OWN version] }.
606
+ def specialized_by
607
+ return {} unless @skill_store.respond_to?(:agents)
608
+
609
+ @skill_store.agents.each_with_object({}) do |agent, acc|
610
+ @skill_store.names(agent: agent).each { |name| (acc[name] ||= []) << agent }
611
+ end
612
+ end
613
+
614
+ # { skill name => Set(agent ids that allow it explicitly) }. An agent with
615
+ # skills=nil allows everything and is not a "holder": it says nothing about which
616
+ # skills were meant to be shared.
617
+ def skill_holders
618
+ @profile_source.all.each_with_object({}) do |profile, acc|
619
+ next if profile.skills.nil?
620
+
621
+ Array(profile.skills).each { |name| (acc[name.to_s] ||= []) << profile.id }
622
+ end
623
+ end
624
+
625
+ # An agent's identity as WORDS: the distinctive tokens of its id plus whatever it
626
+ # calls itself in metadata. Structural tokens (agent/store/bot/…) are dropped and
627
+ # short ones ignored — "store" appears in every retail skill ever written, and one
628
+ # false positive is enough for an operator to stop reading the doctor.
629
+ IDENTITY_STOPWORDS = %w[agent agente store shop loja bot assistant assistente atendimento
630
+ prod staging demo test main default].freeze
631
+
632
+ def identity_terms(agent_id)
633
+ profile = @profile_source.fetch(agent_id)
634
+ meta = profile ? (profile.metadata || {}) : {}
635
+ raw = [agent_id.to_s.split(/[-_.\s]+/), meta["name"], meta["display_name"], meta["store_name"]]
636
+ raw.flatten.compact.map { |t| t.to_s.strip }
637
+ .reject { |t| t.length < 4 || IDENTITY_STOPWORDS.include?(t.downcase) }
638
+ .uniq
639
+ end
640
+
641
+ # Whole-word, case- and accent-insensitive — the same reading the trigger matcher
642
+ # uses, for the same reason: a substring hit inside a longer word is a false
643
+ # positive, and one of those is enough to lose the operator.
644
+ def mentions?(text, term)
645
+ needle = fold(term)
646
+ return false if needle.empty?
647
+
648
+ /(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(fold(text))
649
+ end
650
+
651
+ def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
652
+
653
+ def body_of(content) = content.to_s.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
654
+
205
655
  def platform_chat_rate_limit
206
656
  value = ((@settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
207
657
  value.to_i.positive?
@@ -251,6 +701,436 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
251
701
  broken
252
702
  end
253
703
 
704
+ # the doctor cannot run a turn, so it cannot prove purity — it
705
+ # CAN verify the declaration. An identity-layer provider that is not one of
706
+ # the engine's three known-safe classes is :warn; one of the engine's
707
+ # known-volatile classes overriding to :identity is :error (a volatile
708
+ # identity block bills a cache write every turn).
709
+ IDENTITY_BUILTINS = %w[
710
+ Insika::Context::Providers::Prompt
711
+ Insika::Context::Providers::Skill
712
+ Insika::Context::Providers::ToolSearch
713
+ ].freeze
714
+ VOLATILE_BUILTINS = %w[
715
+ Insika::Context::Providers::Request
716
+ Insika::Context::Providers::Session
717
+ Insika::Context::Providers::Memory
718
+ Insika::Context::Providers::SkillTrigger
719
+ ].freeze
720
+
721
+ def check_cache_layers
722
+ return [] unless @context_providers
723
+
724
+ findings = @context_providers.flat_map do |p|
725
+ name = p.is_a?(Class) ? p.name : p.class.name
726
+ layer = declared_layer(p)
727
+ next [] unless layer == :identity
728
+ next [] if builtin_of?(p, IDENTITY_BUILTINS)
729
+
730
+ known_volatile = builtin_of?(p, VOLATILE_BUILTINS)
731
+ [Finding.new(check: "cache-layers", severity: known_volatile ? :error : :warn, fix: nil,
732
+ message: "context provider '#{name}' declares layer :identity but is " \
733
+ "#{known_volatile ? 'engine-known turn-dependent' : 'not engine-verified'} — " \
734
+ "a volatile block above the cache boundary bills a cache WRITE every turn. " \
735
+ "Verify the output is byte-stable across turns (no timestamps, no per-turn data).")]
736
+ end
737
+ return findings if findings.any?
738
+
739
+ [ok("cache-layers", "context layers: #{@context_providers.size} provider(s), identity partition verified")]
740
+ end
741
+
742
+ # The declaration is an INSTANCE method, so a class passed as-is does not
743
+ # respond to .layer. Evaluate it on a bare instance (allocate skips
744
+ # initialize — the declaration must not depend on constructor state; a
745
+ # method that does degrades to :volatile, the conservative side). This is
746
+ # what makes an explicit `def layer = :volatile` read as volatile instead of
747
+ # a warning.
748
+ def declared_layer(provider)
749
+ return provider.layer unless provider.is_a?(Class)
750
+ return :volatile unless provider.instance_methods.include?(:layer)
751
+
752
+ provider.instance_method(:layer).bind_call(provider.allocate)
753
+ rescue StandardError
754
+ :volatile
755
+ end
756
+
757
+ # The declaration check is by CLASS: a subclass of an engine-known provider
758
+ # inherits its data source (a Memory subclass is still turn-dependent no
759
+ # matter what it overrides). Accepts instances OR classes; anonymous
760
+ # classes (Class.new(...)) have no .name, so the ancestor walk is what
761
+ # catches them.
762
+ def builtin_of?(provider, consts)
763
+ klass_of = provider.is_a?(Class) ? provider : provider.class
764
+
765
+ consts.any? do |c|
766
+ klass = Object.const_get(c)
767
+ klass_of == klass || klass_of < klass
768
+ rescue NameError
769
+ false
770
+ end
771
+ end
772
+
773
+ # grounding with a matcher that matches NOTHING (no sku) is
774
+ # harmless but useless — every claim passes and the audit reads zero. A
775
+ # warning, never an error: the pack owns matcher quality; the engine refuses
776
+ # only uncompileable data.
777
+ def check_grounding
778
+ return [] unless @profile_source
779
+
780
+ findings = @profile_source.all.flat_map do |profile|
781
+ grounding = profile.respond_to?(:grounding) ? profile.grounding : nil
782
+ next [] if grounding.nil? || grounding == false
783
+ next [] if Coercion.present?(grounding["matcher"].is_a?(Hash) ? grounding["matcher"]["sku"] : nil)
784
+
785
+ [Finding.new(check: "grounding", severity: :warn, fix: nil,
786
+ message: "agent '#{profile.id}' has grounding enabled but no matcher.sku — " \
787
+ "it matches nothing, so no claim is ever flagged or cut. " \
788
+ "Add the store's SKU regex to grounding.matcher.sku.")]
789
+ end
790
+ findings.empty? ? [ok("grounding", "grounding: no agent with an empty matcher")] : findings
791
+ end
792
+
793
+ # the memory-scopes check. Reads the cells via C1's enumeration.
794
+ # Warn-only: the doctor never moves data across cells (D2).
795
+ #
796
+ # What it flags, and what it deliberately does NOT:
797
+ # - "memory:chat:<session id>" cells are the engine's OWN per-session shape
798
+ # — never flagged.
799
+ # - a BARE cell is the DESIGNED single-tenant customer shape ("nil tenant +
800
+ # customer -> memory:<customer>, NEVER _default") — with no tenant to
801
+ # migrate to, warning is a false positive, so the check only fires in a
802
+ # multi_tenant deployment (INSIKA_TENANCY), where every customer must
803
+ # live in a [tenant:]customer cell.
804
+ # - `agent_ids:` excuses the agent-memory tab's cells (a bare cell named
805
+ # like an agent is a profile, not a customer).
806
+ def check_memory_scopes
807
+ return [] unless @memory_store
808
+
809
+ findings = []
810
+ @memory_store.cells.each do |cell|
811
+ next if Insika::MemoryStore.session_cell?(cell) # the engine's per-session cell
812
+ next if cell[:customer].nil? # _default — the shared cell
813
+ next if cell[:tenant] # [tenant:]customer — scoped
814
+ next if single_tenant? # bare = the designed single-tenant customer shape
815
+ next if @agent_ids.include?(cell[:customer].to_s)
816
+
817
+ findings << Finding.new(check: "memory-scopes", severity: :warn, fix: nil,
818
+ message: "memory cell '#{cell[:scope]}' is unscoped — in a multi-tenant " \
819
+ "deployment customer memory must live in a [tenant:]customer cell. " \
820
+ "Migrate it, or confirm it is an agent-memory cell " \
821
+ "(a bare cell named like an agent is excused).")
822
+ end
823
+ return findings if findings.any?
824
+
825
+ [ok("memory-scopes", "memory cells: #{@memory_store.cells.size}, all scoped")]
826
+ end
827
+
828
+ def single_tenant?
829
+ Insika::EnvSchema.read("INSIKA_TENANCY", @env) != "multi_tenant"
830
+ end
831
+
832
+ # the outcome-funnel check — declarations on every pilot store,
833
+ # the plan's "no 1.0 target without a remeasure" backstop. Warn/error only:
834
+ # the doctor never rewrites a declaration (the pack is authoritative;
835
+ # `--fix` has nothing to fix here).
836
+ def check_funnel_declarations
837
+ return [] unless @profile_source
838
+
839
+ findings = @profile_source.all.flat_map do |profile|
840
+ next [] if profile.funnel.nil?
841
+
842
+ decl = Insika::FunnelDeclaration.parse(profile.funnel)
843
+ if decl.nil?
844
+ # D8: the fold skips this agent until it is fixed — the doctor is the
845
+ # only report of why.
846
+ [Finding.new(check: "outcome-funnel", severity: :error, fix: nil,
847
+ message: "agent '#{profile.id}': malformed funnel declaration — " \
848
+ "#{funnel_defect(profile.funnel)}. The fold skips it " \
849
+ "until this is fixed.")]
850
+ else
851
+ [ok("outcome-funnel",
852
+ "agent '#{profile.id}': outcome funnel declared — #{decl.stages.length} " \
853
+ "stages, primary '#{decl.primary}', window #{decl.attribution_window}")] +
854
+ funnel_data_findings(profile, decl)
855
+ end
856
+ end
857
+
858
+ # visibility is the feature — a profile with outcomes but no
859
+ # funnel shows the hole. Warn only; the pack owns the vocabulary.
860
+ without = outcomes_without_funnel
861
+ without.each do |agent, count|
862
+ findings << Finding.new(check: "outcome-funnel", severity: :warn, fix: nil,
863
+ message: "agent '#{agent}' records #{count} outcomes and " \
864
+ "declares no funnel: nothing folds — the funnel " \
865
+ "shows the hole. Add a `funnel:` block to the pack.")
866
+ end
867
+
868
+ return findings unless findings.empty?
869
+
870
+ [ok("outcome-funnel", "outcome funnels: every declared funnel valid")]
871
+ end
872
+
873
+ # -> { agent_id => outcome count } — profiles with records but no funnel.
874
+ # ONE scan of the outcome store, grouped by agent (a per-profile `all`
875
+ # would scan it once PER profile — the store is not that big, but the
876
+ # key shape exists precisely so this stays a single pass).
877
+ def outcomes_without_funnel
878
+ return {} unless @outcome_store && @profile_source
879
+
880
+ counts = @outcome_store.all.group_by(&:agent).transform_values(&:size)
881
+ @profile_source.all.each_with_object({}) do |p, acc|
882
+ next unless p.funnel.nil?
883
+
884
+ n = counts[p.id.to_s].to_i
885
+ acc[p.id.to_s] = n if n.positive?
886
+ end
887
+ end
888
+
889
+ # The named defect, for the error message. `parse!` gives the exact field.
890
+ def funnel_defect(hash)
891
+ Insika::FunnelDeclaration.parse!(hash).to_s
892
+ rescue Insika::ValidationError => e
893
+ e.message
894
+ end
895
+
896
+ # Data-age findings, gated on the optional collaborators (nil = skip, env-only
897
+ # callers stay cheap). Tenant-agnostic: folds are per (tenant, agent), so the
898
+ # check reads the store's `pairs` and aggregates over every tenant of the agent
899
+ # (a single-tenant deployment reads the "platform" pair).
900
+ def funnel_data_findings(profile, decl)
901
+ return [] unless @funnel_store
902
+
903
+ pairs = @funnel_store.pairs.select { |p| p[:agent] == profile.id.to_s }
904
+ return [] if pairs.empty?
905
+
906
+ findings = []
907
+ folded_days = 0
908
+ primary_count = 0
909
+ baseline = false
910
+ pairs.each do |pair|
911
+ days = @funnel_store.days(tenant: pair[:tenant], agent: pair[:agent])
912
+ folded_days += days.size
913
+ primary_count += days.values.sum { |c| c[decl.primary].to_i }
914
+ baseline ||= !@funnel_store.baseline(tenant: pair[:tenant], agent: pair[:agent]).nil?
915
+ end
916
+
917
+ if primary_count.zero?
918
+ findings << Finding.new(check: "outcome-funnel", severity: :info, fix: nil,
919
+ message: "agent '#{profile.id}': the primary event " \
920
+ "'#{decl.primary}' was never observed over the " \
921
+ "folded days — check the integration.")
922
+ end
923
+ if folded_days >= 28 && !baseline
924
+ findings << Finding.new(check: "outcome-funnel", severity: :info, fix: nil,
925
+ message: "agent '#{profile.id}': #{folded_days} folded " \
926
+ "days and no baseline frozen — " \
927
+ "read the baseline. Freeze it in the Studio.")
928
+ end
929
+ findings
930
+ end
931
+
932
+ # the follow-up check — declarations validated where they are
933
+ # declared (D9), plus the two data-age reads that answer "whose follow-ups
934
+ # will fire" and "what is blocked right now" without reading the store:
935
+ # · a pending record whose `at` is more than one claim window in the past
936
+ # will NEVER fire — a blocked rule or a broken policy is holding it
937
+ # (warn; the Follow-ups page is the drill);
938
+ # · revoked contact cells per tenant (info — the human opt-out bar).
939
+ def check_followup
940
+ return [] unless @profile_source
941
+
942
+ declared = @profile_source.all.select do |profile|
943
+ profile.respond_to?(:followup) && !profile.followup.nil?
944
+ end
945
+ # a bare install (no follow-up on any profile) reports NOTHING — no
946
+ # follow-up vocabulary leaks into the doctor of a store that never
947
+ # scheduled one.
948
+ return [] if declared.empty?
949
+
950
+ findings = declared.flat_map do |profile|
951
+ followup = profile.followup
952
+ decl = Insika::FollowupPolicy.parse(followup)
953
+ if decl.nil?
954
+ [Finding.new(check: "follow-up", severity: :error, fix: nil,
955
+ message: "agent '#{profile.id}': malformed follow-up declaration — " \
956
+ "#{followup_defect(followup)}. The engine will never fire its " \
957
+ "follow-ups until this is fixed.")]
958
+ else
959
+ [ok("follow-up",
960
+ "agent '#{profile.id}': follow-up declared — arm #{decl.arm}, " \
961
+ "#{decl.quiet_hours ? "quiet hours #{decl.quiet_hours.start}-#{decl.quiet_hours.end} " \
962
+ "<#{decl.quiet_hours.timezone}>" : 'no quiet hours'}, " \
963
+ "#{decl.cancel_keywords.size} keyword(s), " \
964
+ "silence after #{decl.silence_after_sends} send(s)")] +
965
+ followup_data_findings(profile, decl)
966
+ end
967
+ end
968
+
969
+ findings
970
+ end
971
+
972
+ # The named defect, for the error message — `parse!` gives the exact field.
973
+ def followup_defect(hash)
974
+ Insika::FollowupPolicy.parse!(hash).to_s
975
+ rescue Insika::ValidationError => e
976
+ e.message
977
+ end
978
+
979
+ # Data-age findings, gated on the optional collaborators (nil = skip,
980
+ # env-only callers stay cheap).
981
+ def followup_data_findings(profile, decl)
982
+ return [] unless @followup_store && @contact_store
983
+
984
+ findings = []
985
+ # a pending record more than one claim window PAST its `at` (the sign:
986
+ # due(now - window)) is a promise the tick will never honor. A
987
+ # quiet-hours deferral is EXCLUDED — that record still fires next pass;
988
+ # only a record the tick could fire RIGHT NOW and doesn't is stuck.
989
+ now = Time.now.utc
990
+ window = Insika::FollowupEngine::DEFAULT_WINDOW
991
+ deferred_now = decl.quiet?(now)
992
+ stale = @followup_store.due(now: now - window)
993
+ .reject { |r| r.agent != profile.id.to_s }
994
+ .reject { deferred_now }
995
+ unless stale.empty?
996
+ findings << Finding.new(check: "follow-up", severity: :warn, fix: nil,
997
+ message: "agent '#{profile.id}': #{stale.size} pending " \
998
+ "follow-up(s) past their scheduled time by more than " \
999
+ "one claim window — the tick will never fire them. A " \
1000
+ "blocked rule or a broken policy is pending; see the " \
1001
+ "Follow-ups page.")
1002
+ end
1003
+ revoked_for(profile).each do |tenant, count|
1004
+ findings << Finding.new(check: "follow-up", severity: :info, fix: nil,
1005
+ message: "agent '#{profile.id}': #{count} revoked contact cell(s) " \
1006
+ "in tenant #{tenant.inspect} — the human opt-out bar " \
1007
+ "(opt-outs are per tenant/customer, shared across agents).")
1008
+ end
1009
+ findings
1010
+ end
1011
+
1012
+ # { tenant => revoked-cell count } among the cells this agent may message.
1013
+ # The contact cell is per (tenant, customer) — shared across agents (D2's
1014
+ # multi-agent note), so the count is a per-tenant tally of revoked cells.
1015
+ def revoked_for(_profile)
1016
+ return {} unless @contact_store
1017
+
1018
+ @contact_store.cells.each_with_object(Hash.new(0)) do |(key, cell), tally|
1019
+ next unless cell["state"] == "revoked"
1020
+
1021
+ tally[key.split(":", 2).first] += 1
1022
+ end
1023
+ end
1024
+
1025
+ # the distillation check — per profile WITH a `distill`
1026
+ # hash: a declared-and-enabled distiller with NO resolvable model (no
1027
+ # distill.model, no platform utility_model) can never run — the warn is
1028
+ # the "declared but dead" signal (D4 — the engine never guesses a model).
1029
+ # A bare install reports one ok ("off"), unlike follow-up's silence: the
1030
+ # Facts page exists with or without declarations and the doctor names why
1031
+ # it stays empty. nil proposal_store = declarations only (counts skipped).
1032
+ def check_distill
1033
+ return [] unless @profile_source
1034
+
1035
+ declared = @profile_source.all.select do |profile|
1036
+ profile.respond_to?(:distill) && !profile.distill.nil?
1037
+ end
1038
+ return [ok("distill", "distillation off — no agent declares it")] if declared.empty?
1039
+
1040
+ settings = @settings_store ? @settings_store.get : {}
1041
+ # one scan pair per run, not per declared profile (the counts are the
1042
+ # store's, printed identically on each line).
1043
+ counts = @proposal_store ? distill_counts : nil
1044
+ declared.flat_map do |profile|
1045
+ config = profile.distill
1046
+ if Coercion.truthy?(config["enabled"]) &&
1047
+ Coercion.presence(config["model"]).nil? &&
1048
+ Coercion.presence(settings["utility_model"]).nil?
1049
+ [Finding.new(check: "distill", severity: :warn, fix: nil,
1050
+ message: "agent '#{profile.id}': the distiller is declared but has " \
1051
+ "no model slot — distillation will never run (set " \
1052
+ "distill.model or the platform utility_model).")]
1053
+ else
1054
+ suffix = counts ? " — #{counts[:pending]} proposal(s) pending, #{counts[:stale]} stale" : ""
1055
+ [ok("distill", "agent '#{profile.id}': distillation declared#{suffix}")]
1056
+ end
1057
+ end
1058
+ end
1059
+
1060
+ def distill_counts
1061
+ { pending: @proposal_store.pending(limit: 10_000).size,
1062
+ stale: @proposal_store.stale(limit: 10_000).size }
1063
+ end
1064
+
1065
+ # the harvest check — per profile WITH a harvest hash:
1066
+ # declared-without-model warn (D12), no grounding matcher warn (D3),
1067
+ # malformed negative list error (D4), else ok with the pending counts.
1068
+ # With @harvest_criterion: the loaded criterion line + a warn when the
1069
+ # file at its path no longer loads (the frozen rule moved).
1070
+ def check_harvest
1071
+ return [] unless @profile_source
1072
+
1073
+ declared = @profile_source.all.select do |profile|
1074
+ profile.respond_to?(:harvest) && !profile.harvest.nil?
1075
+ end
1076
+ findings = []
1077
+ if declared.empty?
1078
+ findings << ok("harvest", "harvest off — no agent declares it")
1079
+ else
1080
+ settings = @settings_store ? @settings_store.get : {}
1081
+ declared.each do |profile|
1082
+ config = profile.harvest
1083
+ id = profile.id
1084
+ if Coercion.truthy?(config["enabled"]) &&
1085
+ Coercion.presence(config.dig("miner", "model")).nil? &&
1086
+ Coercion.presence(settings["utility_model"]).nil?
1087
+ findings << Finding.new(check: "harvest", severity: :warn, fix: nil,
1088
+ message: "agent '#{id}': the harvester is declared but has no " \
1089
+ "model slot — mining will never run (set " \
1090
+ "harvest.miner.model or the platform utility_model).")
1091
+ end
1092
+ grounding = begin
1093
+ Insika::Grounding.parse(profile.grounding)
1094
+ rescue Insika::ValidationError
1095
+ nil
1096
+ end
1097
+ if Coercion.truthy?(config["enabled"]) && (grounding.nil? || !grounding.matcher.sku?)
1098
+ findings << Finding.new(check: "harvest", severity: :warn, fix: nil,
1099
+ message: "agent '#{id}': product claims cannot be verified — " \
1100
+ "mining is skipped (set grounding.matcher.sku).")
1101
+ end
1102
+ if config["negative_list"].is_a?(Array) &&
1103
+ Insika::Harvest::NegativeList.parse(config["negative_list"]).nil?
1104
+ findings << Finding.new(check: "harvest", severity: :error, fix: nil,
1105
+ message: "agent '#{id}': the harvest.negative_list is malformed " \
1106
+ "— the whole list is refused (half a list silently " \
1107
+ "admits what the store banned).")
1108
+ end
1109
+ suffix = harvest_counts(id) if @harvest_store
1110
+ findings << ok("harvest", "agent '#{id}': harvest declared#{suffix}")
1111
+ end
1112
+ end
1113
+ if @harvest_criterion
1114
+ findings << ok("harvest-criterion",
1115
+ "criterion #{@harvest_criterion.rule.metric} / #{@harvest_criterion.rule.window} " \
1116
+ "threshold #{@harvest_criterion.rule.threshold} (#{@harvest_criterion.sha})")
1117
+ begin
1118
+ Insika::Harvest::Criterion.load(@harvest_criterion.path)
1119
+ rescue Insika::ConfigError, Insika::ValidationError
1120
+ findings << Finding.new(check: "harvest-criterion", severity: :warn, fix: nil,
1121
+ message: "the criterion file at #{@harvest_criterion.path} no longer " \
1122
+ "loads — the frozen rule moved since boot.")
1123
+ end
1124
+ end
1125
+ findings
1126
+ end
1127
+
1128
+ def harvest_counts(agent_id)
1129
+ awaiting = @harvest_store.candidates(agent_id: agent_id, status: "awaiting_approval").size
1130
+ pending = @harvest_store.candidates(agent_id: agent_id, status: "pending").size
1131
+ " — #{awaiting} awaiting, #{pending} pending"
1132
+ end
1133
+
254
1134
  def broken_tool(raw)
255
1135
  Insika::ToolDefinition.from_h(raw)
256
1136
  nil
@@ -266,8 +1146,6 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
266
1146
  Finding.new(check: "data-tools", severity: :error, fix: fix, message: message)
267
1147
  end
268
1148
 
269
- # A fix ONLY when spelling the legacy bare `array` explicitly is enough to make the
270
- # definition build — never a guess at a broken definition we don't understand.
271
1149
  def array_sugar_fix(raw)
272
1150
  params = raw.is_a?(Hash) ? raw["parameters"] : nil
273
1151
  return nil unless params.is_a?(Array) && params.any? { |p| p.is_a?(Hash) && p["type"].to_s == "array" }
@@ -283,6 +1161,83 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
283
1161
 
284
1162
  # -- helpers -------------------------------------------------------
285
1163
 
1164
+ # shadow parity. Shadow holds raw customer conversations, so
1165
+ # every dangerous configuration says so BEFORE the experiment starts — and
1166
+ # the one automated reminder to turn it off. The pairs themselves are only
1167
+ # loaded when shadow is ON (a full scan pays for customer text); the off
1168
+ # path needs just a count, which the store answers from its keys.
1169
+ def check_shadow_parity
1170
+ shadow_on = Insika::EnvSchema.truthy?(@env["INSIKA_RELAY_SHADOW"])
1171
+ path = Insika::EnvSchema.read("INSIKA_PARITY_CRITERION", @env)
1172
+
1173
+ unless shadow_on
1174
+ stored = @shadow_pair_store ? @shadow_pair_store.size : 0
1175
+ return [] if stored.zero?
1176
+
1177
+ return [ok("shadow-parity",
1178
+ "shadow off — #{stored} pair(s) still stored, evidence is not forgotten")]
1179
+ end
1180
+
1181
+ if path.nil?
1182
+ return [Finding.new(check: "shadow-parity", severity: :error, fix: nil,
1183
+ message: "shadow on but INSIKA_PARITY_CRITERION is unset — the frozen criterion is required to record pairs")]
1184
+ end
1185
+
1186
+ pairs = @shadow_pair_store ? @shadow_pair_store.each.to_a : []
1187
+
1188
+ criterion = begin
1189
+ Insika::Parity::Criterion.load(path)
1190
+ rescue Insika::Error => e
1191
+ return [Finding.new(check: "shadow-parity", severity: :error, fix: nil,
1192
+ message: "shadow on but the criterion did not load from #{path}: #{e.message}")]
1193
+ end
1194
+
1195
+ findings = [ok("shadow-parity", "shadow on — criterion frozen (#{criterion.sha}) at #{path}")]
1196
+ if Insika::EnvSchema.present?(@env["INSIKA_RELAY_DELIVER_URL"])
1197
+ findings << Finding.new(check: "shadow-parity", severity: :warn, fix: nil,
1198
+ message: "INSIKA_RELAY_DELIVER_URL is set — the URL is INERT: a shadow relay never delivers")
1199
+ end
1200
+ judges = ((@settings_store&.get || {})["evals"] || {})
1201
+ if Array(judges["judges"]).reject { |j| j["model"].to_s.strip.empty? }.empty?
1202
+ findings << Finding.new(check: "shadow-parity", severity: :warn, fix: nil,
1203
+ message: "no judges in settings['evals'] — pairs will accumulate and nothing can judge them")
1204
+ end
1205
+ window = criterion.rule.window_days
1206
+ oldest = pairs.filter_map { |p| parse_pair_time(p) }.min
1207
+ if oldest && oldest < Time.now.utc - (2 * window * 86_400)
1208
+ findings << Finding.new(check: "shadow-parity", severity: :warn, fix: nil,
1209
+ message: "the oldest pair is older than 2 × window_days — shadow is not a permanent mode; judge and turn it off")
1210
+ end
1211
+ findings
1212
+ end
1213
+
1214
+ def parse_pair_time(pair)
1215
+ Time.iso8601(pair.created_at.to_s)
1216
+ rescue ArgumentError
1217
+ nil
1218
+ end
1219
+
1220
+ # the BOOT gate for a malformed guardrail corpus. A typo'd
1221
+ # language/family or a broken pattern source raises ValidationError inside
1222
+ # Safety::Config on the FIRST TURN — mid-conversation, unrecoverable. This
1223
+ # check makes `insika doctor` the place it surfaces instead: an :error
1224
+ # finding (non-zero exit), so a deployment learns at boot, never mid-turn.
1225
+ def check_guardrail_corpora
1226
+ return [] unless @profile_source
1227
+
1228
+ findings = @profile_source.all.each_with_object([]) do |p, acc|
1229
+ Insika::Safety::Config.from_profile(p) # compiles the corpus — raises on a bad declaration
1230
+ rescue Insika::ValidationError => e
1231
+ acc << Finding.new(check: "guardrail-corpora", severity: :error, fix: nil,
1232
+ message: "agent '#{p.id}': malformed guardrails.corpora — #{e.message}. " \
1233
+ "Every message would fail the guardrail; fix the declaration " \
1234
+ "(docs/domain.md#guardrails).")
1235
+ end
1236
+ return findings unless findings.empty?
1237
+
1238
+ [ok("guardrail-corpora", "guardrail corpora: every declaration compiles")]
1239
+ end
1240
+
286
1241
  def ok(check, message) = Finding.new(check: check, severity: :ok, message: message, fix: nil)
287
1242
 
288
1243
  # A model to seed the platform default from: DEEPSEEK_MODEL env, else the first