insika 0.8.0 → 1.0.1

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -1,94 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # Builds a live MCP client for an McpStore record. The ONE seam
5
- # between Insika and the transport gem: `ruby_llm-mcp` speaks stdio,
6
- # Streamable HTTP and SSE with the full lifecycle (initialize handshake,
7
- # session-id, notifications) — Insika stops maintaining its own protocol
8
- # client. Swapping the gem later (e.g. to modelcontextprotocol/ruby-sdk)
9
- # touches only this file.
10
- #
11
- # `require "ruby_llm/mcp"` is LAZY, done here rather than at `require
12
- # "insika"` time (spec/insika/load_guard_spec.rb): the gem drags in
13
- # ruby_llm + httpx, the same opt-in-cost discipline as every other provider
14
- # surface.
15
- #
16
- # Client LIFECYCLE (start/stop, registering the child process with
17
- # Insika::Shutdown so it dies on SIGTERM) belongs to whoever holds the
18
- # client across calls — the live tool registry, later work. `for` itself
19
- # returns an unstarted client (`start: false`); building one is cheap and
20
- # side-effect-free.
4
+ # Maps persisted MCP configuration to RubyLLM's native, lazy client.
5
+ # Insika retains the stdio opt-in and deployment egress policy.
21
6
  module McpClient
22
- # A stdio instance is arbitrary command execution by config — refused
23
- # unless the operator opted in (config-over-convention, same pattern as
24
- # the egress envs).
25
7
  class StdioDisabled < Insika::Error; end
26
8
 
27
9
  module_function
28
10
 
29
- # `record` — the RAW McpStore record (get_raw/all_raw; never the masked
30
- # one — a masked record's credentials are the __OCULTO__ sentinel, not
31
- # something a client can connect with).
32
- # -> RubyLLM::MCP::Client, not started. Raises StdioDisabled (stdio, gate
33
- # off), Insika::Error (http/sse, egress-blocked url) or
34
- # Insika::ValidationError (unknown transport).
35
11
  def for(record, env_reader: Insika::EnvSchema, egress: Insika::EgressGuard)
36
- require "ruby_llm/mcp"
37
- apply_origin_header_fix!
12
+ require "ruby_llm"
38
13
 
39
14
  case record["transport"].to_s
40
15
  when "stdio" then stdio_client(record, env_reader: env_reader)
41
- when "http" then http_client(record, :streamable_http, egress: egress)
42
- when "sse" then http_client(record, :sse, egress: egress)
16
+ when "http" then http_client(record, egress: egress, env_reader: env_reader)
17
+ when "sse"
18
+ raise Insika::ValidationError,
19
+ "MCP instance '#{record['name']}' uses legacy SSE; configure its Streamable HTTP endpoint instead"
43
20
  else
44
21
  raise Insika::ValidationError,
45
- "MCP instance '#{record["name"]}' has an unknown transport: #{record["transport"].inspect}"
22
+ "MCP instance '#{record['name']}' has an unknown transport: #{record['transport'].inspect}"
46
23
  end
47
24
  end
48
25
 
26
+ # The operator registers an MCP URL and the model cannot change it, so the
27
+ # host allowlist (which pins model-shaped data-tool requests) only pins
28
+ # private destinations: a public server needs no redeploy to be added.
29
+ def egress_options(env_reader)
30
+ hosts = env_reader.read("INSIKA_EGRESS_HOSTS").to_s.split(",").map(&:strip).reject(&:empty?)
31
+ { allow_http: env_reader.truthy?(env_reader.read("INSIKA_EGRESS_ALLOW_HTTP")),
32
+ allow_private: env_reader.truthy?(env_reader.read("INSIKA_EGRESS_ALLOW_PRIVATE")),
33
+ host_allowlist: (hosts unless hosts.empty?), pin_private_only: true }.compact
34
+ end
35
+
49
36
  def stdio_client(record, env_reader:)
50
37
  unless env_reader.truthy?(env_reader.read("INSIKA_MCP_STDIO"))
51
38
  raise StdioDisabled,
52
- "MCP instance '#{record["name"]}' is stdio (arbitrary command execution by config) — " \
39
+ "MCP instance '#{record['name']}' is stdio (arbitrary command execution by config) — " \
53
40
  "set INSIKA_MCP_STDIO=1 to allow it to start"
54
41
  end
55
42
 
56
- RubyLLM::MCP.client(
57
- name: record["name"], transport_type: :stdio, start: false,
58
- config: { command: record["command"], args: Array(record["args"]), env: record["env"] || {} }
59
- )
43
+ RubyLLM.mcp(name: record["name"], command: [record["command"], *Array(record["args"])],
44
+ env: record["env"] || {})
60
45
  end
61
46
 
62
- def http_client(record, transport_type, egress:)
47
+ def http_client(record, egress:, env_reader:)
63
48
  url = record["url"]
64
- reason = egress.violation(url)
49
+ reason = egress.violation(url, **egress_options(env_reader))
65
50
  raise Insika::Error, "MCP target blocked: #{reason}" if reason
66
-
67
- RubyLLM::MCP.client(
68
- name: record["name"], transport_type: transport_type, start: false,
69
- config: { url: url, headers: record["headers"] || {} }
70
- )
71
- end
72
-
73
- # https://github.com/patvice/ruby_llm-mcp/issues/140 (open, unfixed as of
74
- # 1.0.1): StreamableHTTP#build_common_headers sets `Origin` to the MCP
75
- # endpoint's OWN url (path included) on every request. That's invalid per
76
- # RFC 6454 §7 (Origin is scheme+host[+port], never a path) and unneeded —
77
- # a server-to-server client has no browser Origin to report, and the
78
- # reference TS/Python SDKs never send one. No allowlist can ever match a
79
- # value that carries a path, so any server enforcing the spec's
80
- # DNS-rebinding Origin check (GitHub's remote MCP, Grafana, Metabase, ...)
81
- # rejects every request. Same prepend the issue's own author ships in
82
- # production. `Module#prepend` no-ops if the module is already in the
83
- # ancestor chain, so calling this on every `for` is cheap and safe.
84
- def apply_origin_header_fix!
85
- RubyLLM::MCP::Native::Transports::StreamableHTTP.prepend(OriginHeaderFix)
86
- end
87
-
88
- module OriginHeaderFix
89
- def build_common_headers
90
- super.except("Origin")
51
+ unless RubyLLM::MCP::HTTP.secure?(url)
52
+ raise Insika::ValidationError, "Native MCP requires HTTPS without URL credentials, except on loopback hosts"
91
53
  end
54
+
55
+ RubyLLM.mcp(name: record["name"], url: url, headers: record["headers"] || {})
92
56
  end
93
57
  end
94
58
  end
@@ -1,43 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "ruby_llm"
4
5
 
5
6
  module Insika
6
- # One live MCP tool. `name`/`description`/`params_schema`
7
+ # One live MCP tool. `name`/`description`/`parameters_schema`
7
8
  # come from the CACHED descriptor (McpStore#tools_cache — no I/O to build
8
9
  # this instance, same cost as any other tool the registry hands out).
9
10
  # `#execute` is the only thing that touches the network, calling through
10
- # `client_for` (Insika::McpToolRegistry's memoized, started client — this
11
- # class never builds or starts one itself) into the gem's own
12
- # `RubyLLM::MCP::Tool#execute`, which already unwraps `content[].text` and
11
+ # `client_for` (Insika::McpToolRegistry's memoized, lazy, per-conversation client) into
12
+ # `RubyLLM::MCP::Tool#call`, which already unwraps `content[].text` and
13
13
  # turns `isError` into `{error:}`.
14
14
  #
15
15
  # Like every tool in this codebase, `#execute` NEVER raises: a connection
16
16
  # or protocol failure becomes `{error:}`, the model's normal failure path
17
17
  # (see Tools::DataDefinedTool's own rule).
18
18
  class McpLiveTool < RubyLLM::Tool
19
- def initialize(instance_name:, tool:, client_for:)
19
+ # `name:` is what the model calls (the registry's instance-qualified name);
20
+ # the server is still asked for its own tool name.
21
+ def initialize(instance_name:, tool:, client_for:, overrides: {}, name: tool["name"])
20
22
  @instance_name = instance_name
21
23
  @tool = tool
24
+ @name = name
22
25
  @client_for = client_for
26
+ @evidence = overrides["evidence"] && Insika::Evidence::Spec.parse(overrides["evidence"])
27
+ @requires_evidence = Insika::ToolDefinition.normalize_requires_evidence(
28
+ overrides["requires_evidence"], top_level_params
29
+ )
23
30
  super()
24
31
  end
25
32
 
26
- def name = @tool["name"]
33
+ def name = @name
27
34
  def description = @tool["description"].to_s
28
35
 
29
- def params_schema
36
+ # The two questions the envelope asks any tool: what of this result is
37
+ # evidence, and which of these parameters may only carry an id some tool
38
+ # already returned. A server cannot answer either — it does not know what the
39
+ # deployment trusts — so they come from the instance's own configuration and
40
+ # are nil for every tool nobody configured, which is what they were before.
41
+ attr_reader :evidence, :requires_evidence
42
+
43
+ def parameters_schema
30
44
  schema = @tool["inputSchema"]
31
45
  schema.nil? || schema.empty? ? { "type" => "object", "properties" => {} } : schema
32
46
  end
33
47
 
48
+ # The conversation this instance serves, deposited by ToolAssembly. An MCP
49
+ # server keeps state per MCP session (a target store, a pending
50
+ # confirmation token), so each conversation gets its own client.
51
+ def turn_context=(ctx)
52
+ @session_id = ctx && (ctx[:session_id] || ctx["session_id"])
53
+ end
54
+
34
55
  def execute(**params)
35
- live = @client_for.call.tool(@tool["name"])
56
+ live = @client_for.call(@session_id).tools.find { |tool| tool.name == @tool["name"] }
36
57
  raise Insika::NotFoundError, "tool '#{@tool["name"]}' no longer offered" if live.nil?
37
58
 
38
- live.execute(**params)
59
+ result = live.call(**params)
60
+ # Hand the envelope what a chat would send the model (text, JSON of the
61
+ # structured content, or text plus attachments), not the gem's Result.
62
+ parsed(result.is_a?(RubyLLM::MCP::Result) ? result.content : result)
39
63
  rescue StandardError => e
40
64
  { error: "MCP instance '#{@instance_name}' tool '#{@tool["name"]}' failed: #{e.message}" }
41
65
  end
66
+
67
+ private
68
+
69
+ # An MCP result arrives as a String, text plus attachments (an Array), or
70
+ # {error:}. Only a tool someone declared evidence for is
71
+ # parsed into the object the extractor can dig into; everywhere else the result
72
+ # reaches the model exactly as it did before.
73
+ def parsed(result)
74
+ return result unless @evidence && result.is_a?(String)
75
+
76
+ object = begin
77
+ JSON.parse(result)
78
+ rescue JSON::ParserError
79
+ nil
80
+ end
81
+ object.is_a?(Hash) ? object : result
82
+ end
83
+
84
+ def top_level_params
85
+ properties = parameters_schema["properties"] || parameters_schema[:properties] || {}
86
+ properties.keys.map(&:to_s)
87
+ end
42
88
  end
43
89
  end
@@ -94,12 +94,43 @@ module Insika
94
94
  "headers" => reconcile_hash(h[:headers], existing && existing["headers"]),
95
95
  # editing an instance's connection details doesn't change its discovered
96
96
  # tools -> preserved across upsert; only set_tools_cache writes it.
97
- "tools_cache" => (existing && existing["tools_cache"]) || []
97
+ "tools_cache" => (existing && existing["tools_cache"]) || [],
98
+ # WHAT EACH TOOL MEANS to the engine: which of its results are evidence,
99
+ # and which of its parameters may only carry an id some tool returned.
100
+ # A server describes what its tools DO; only the deployment can say what
101
+ # they are worth trusting with. An edit that does not mention them keeps
102
+ # what is there — the same rule `env` and `headers` follow.
103
+ "tool_overrides" => (h.key?(:tools) ? tool_overrides(h[:tools]) : nil) ||
104
+ (existing && existing["tool_overrides"]) || {}
98
105
  }
99
106
  @cs.put(SCOPE, name, record)
100
107
  mask(record)
101
108
  end
102
109
 
110
+ # { "add_to_cart" => { "requires_evidence" => ["product_id"] },
111
+ # "search_products" => { "evidence" => { "kind" => "products", … } } }
112
+ #
113
+ # Refused HERE, never at the turn: an `evidence:` typo that survived to turn
114
+ # time would extract nothing and the gate below it would block every write.
115
+ def tool_overrides(raw)
116
+ return nil if raw.nil?
117
+ raise Insika::ValidationError, "mcp tools must be a mapping of tool name -> overrides" unless raw.is_a?(Hash)
118
+
119
+ Coercion.deep_stringify(raw).to_h do |tool, over|
120
+ raise Insika::ValidationError, "mcp tools['#{tool}'] must be a mapping" unless over.is_a?(Hash)
121
+
122
+ unknown = over.keys - %w[evidence requires_evidence]
123
+ unless unknown.empty?
124
+ raise Insika::ValidationError,
125
+ "mcp tools['#{tool}']: unknown key(s) #{unknown.join(", ")} — known: evidence, requires_evidence"
126
+ end
127
+
128
+ params = Array(over["requires_evidence"]).map(&:to_s).reject(&:empty?)
129
+ [tool.to_s, { "evidence" => over["evidence"] && Insika::Evidence::Spec.parse(over["evidence"]).to_h,
130
+ "requires_evidence" => (params unless params.empty?) }.compact]
131
+ end
132
+ end
133
+
103
134
  # -> bool (did it exist?).
104
135
  def delete(name) = @cs.delete(SCOPE, name.to_s)
105
136
 
@@ -135,7 +166,7 @@ module Insika
135
166
 
136
167
  def http_like?(transport) = %w[http sse].include?(transport.to_s)
137
168
 
138
- # -> [{"name","description","inputSchema"}] string-keyed, dropping any
169
+ # -> [{"name","description","inputSchema"[,"annotations"]}] string-keyed, dropping any
139
170
  # entry without a name (nothing to register a Registry::Entry under).
140
171
  def normalize_tools_cache(tools)
141
172
  Array(tools).filter_map do |t|
@@ -143,7 +174,10 @@ module Insika
143
174
  name = presence(h["name"])
144
175
  next nil if name.nil?
145
176
 
146
- { "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
177
+ cached = { "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
178
+ # the server's own hints (readOnlyHint decides side_effect at registration)
179
+ cached["annotations"] = h["annotations"] if h["annotations"].is_a?(Hash)
180
+ cached
147
181
  end
148
182
  end
149
183
 
@@ -11,14 +11,30 @@ module Insika
11
11
  # `refresh` is the ONLY thing that talks to a server ahead of time: it
12
12
  # connects, lists live, and writes the result back to tools_cache (display/
13
13
  # doctor). Calling a tool never depends on that cache — Insika::McpLiveTool
14
- # always goes through the live, memoized client (and RubyLLM::MCP::Client
14
+ # always goes through the live, memoized client (and RubyLLM::MCP
15
15
  # does its own real `tools/list` on ITS first use per instance, regardless
16
16
  # of whether `refresh` ever ran).
17
17
  class McpToolRegistry
18
- def initialize(mcp_store:, client_factory: Insika::McpClient.method(:for))
18
+ # Providers reject a tool name longer than this.
19
+ MAX_TOOL_NAME = 64
20
+
21
+ # The name the model and the agent's allowlist see: the instance, then the
22
+ # server's tool. Two servers offering the same tool (a store's prod and
23
+ # staging) stay two tools, each reaching its own instance.
24
+ def self.tool_name(instance, tool)
25
+ "#{instance.to_s.downcase.gsub(/[^a-z0-9]+/, '_')}__#{tool}"
26
+ end
27
+
28
+ # Live clients kept per process. Past this, the least recently used one is
29
+ # closed; its conversation reconnects on its next call, as a NEW MCP session.
30
+ # ponytail: count cap, not idle TTL — add a TTL if idle sockets pile up.
31
+ MAX_CLIENTS = 200
32
+
33
+ def initialize(mcp_store:, client_factory: Insika::McpClient.method(:for), max_clients: MAX_CLIENTS)
19
34
  @mcp_store = mcp_store
20
35
  @client_factory = client_factory
21
- @clients = {}
36
+ @max_clients = max_clients
37
+ @clients = {} # [instance, conversation] -> client, oldest use first
22
38
  @mutex = Mutex.new
23
39
  end
24
40
 
@@ -29,7 +45,7 @@ module Insika
29
45
 
30
46
  # Connects to `name` LIVE, lists its tools, and writes McpStore#tools_cache.
31
47
  # ALWAYS drops any memoized client first and rebuilds from the current
32
- # record: a stdio process can be `alive?` (still running) yet permanently
48
+ # record: a stdio process can still be running yet permanently
33
49
  # broken (e.g. wrong transport args — see grafana-stg gotcha), and an
34
50
  # edited command/url/env must take effect without a process restart —
35
51
  # there's no SSH into a Railway dyno to do that by hand.
@@ -43,7 +59,10 @@ module Insika
43
59
 
44
60
  evict(record["name"])
45
61
  client = client_for(record)
46
- discovered = client.tools.map { |t| { "name" => t.name, "description" => t.description, "inputSchema" => t.params_schema } }
62
+ discovered = client.tools.map do |tool|
63
+ { "name" => tool.name, "description" => tool.description, "inputSchema" => tool.parameters_schema,
64
+ "annotations" => { "readOnlyHint" => tool.read_only? } }
65
+ end
47
66
  @mcp_store.set_tools_cache(name, discovered)
48
67
  discovered
49
68
  end
@@ -55,46 +74,77 @@ module Insika
55
74
  # NEXT tool call or refresh, no process restart required (there's no way
56
75
  # to restart a process by hand on a Railway dyno).
57
76
  def evict(name)
58
- client = @mutex.synchronize { @clients.delete(name.to_s) }
59
- client&.stop
60
- rescue StandardError
61
- nil # best-effort teardown of a possibly already-dead process
77
+ gone = @mutex.synchronize do
78
+ keys = @clients.keys.select { |instance, _| instance == name.to_s }
79
+ keys.map { |key| @clients.delete(key) }
80
+ end
81
+ gone.each { |client| close_quietly(client) }
62
82
  end
63
83
 
64
84
  private
65
85
 
66
86
  def entries_for(record)
67
- Array(record["tools_cache"]).map { |tool| entry_for(record, tool) }
87
+ Array(record["tools_cache"]).filter_map do |tool|
88
+ name = self.class.tool_name(record["name"], tool["name"])
89
+ next entry_for(record, tool, name) if name.length <= MAX_TOOL_NAME
90
+
91
+ warn "[mcp] skipping '#{name}': longer than #{MAX_TOOL_NAME} characters; shorten the instance name"
92
+ end
68
93
  end
69
94
 
70
- def entry_for(record, tool)
95
+ def entry_for(record, tool, name)
71
96
  instance = record["name"]
72
97
  Insika::Registry::Entry.new(
73
- name: tool["name"], plugin: "mcp:#{instance}",
74
- metadata: { optional: false, side_effect: true, group: "mcp:#{instance}", tags: [] },
75
- factory: -> { build_tool(record, tool) }
98
+ name: name, plugin: "mcp:#{instance}",
99
+ metadata: { optional: false, side_effect: !read_only?(tool), group: "mcp:#{instance}", tags: [] },
100
+ factory: -> { build_tool(record, tool, name) }
76
101
  )
77
102
  end
78
103
 
104
+ # An MCP tool is a side effect unless its server says otherwise
105
+ # (`annotations.readOnlyHint`): a write is never re-run on resume and runs
106
+ # serially within the session; a declared read keeps `tool_concurrency`.
107
+ def read_only?(tool)
108
+ tool.dig("annotations", "readOnlyHint") == true
109
+ end
110
+
79
111
  # Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
80
112
  # of insika.rb load-time, loaded on the 1st instance (turn time), same
81
113
  # discipline as OverlayToolRegistry#build_tool for data-tools.
82
- def build_tool(record, tool)
114
+ def build_tool(record, tool, name)
83
115
  require_relative "mcp_live_tool"
84
- Insika::McpLiveTool.new(instance_name: record["name"], tool: tool, client_for: -> { client_for(record) })
116
+ Insika::McpLiveTool.new(instance_name: record["name"], tool: tool, name: name,
117
+ overrides: record.dig("tool_overrides", tool["name"]) || {},
118
+ client_for: ->(session_id = nil) { client_for(record, session_id) })
85
119
  end
86
120
 
87
- # A started, MEMOIZED client for `record` — one real connection per
88
- # instance name, reused across calls/turns. Raises on a gated/unreachable
89
- # instance; only called from `refresh` and from a running McpLiveTool's
90
- # `#execute` (which rescues) — never from `entries`/`build_tool`, so a
91
- # downed server never breaks turn ASSEMBLY, only that tool's own call.
92
- def client_for(record)
93
- @mutex.synchronize do
94
- client = (@clients[record["name"]] ||= @client_factory.call(record))
95
- client.start unless client.alive?
96
- client
121
+ # A lazy, MEMOIZED client for `record` — one per (instance, conversation),
122
+ # reused across that conversation's turns. The server ties its state to the
123
+ # MCP session (Mcp-Session-Id): a client shared by every conversation leaked
124
+ # one chat's target store and confirmation tokens into another. A stdio
125
+ # instance stays one shared process — a process per chat is too costly.
126
+ # Raises on a gated/unreachable instance; only called from `refresh` and
127
+ # from a running McpLiveTool's `#execute` (which rescues) — never from
128
+ # `entries`/`build_tool`, so a downed server never breaks turn ASSEMBLY,
129
+ # only that tool's own call.
130
+ def client_for(record, session_id = nil)
131
+ session_id = nil if record["transport"].to_s == "stdio"
132
+ key = [record["name"], session_id&.to_s]
133
+ stale = nil
134
+ client = @mutex.synchronize do
135
+ found = @clients.delete(key) || @client_factory.call(record)
136
+ @clients[key] = found # re-insert: most recently used goes last
137
+ stale = @clients.shift.last if @clients.size > @max_clients
138
+ found
97
139
  end
140
+ close_quietly(stale) if stale
141
+ client
142
+ end
143
+
144
+ def close_quietly(client)
145
+ client.close
146
+ rescue StandardError
147
+ nil # best-effort teardown of a possibly already-dead process
98
148
  end
99
149
  end
100
150
  end
data/lib/insika/media.rb CHANGED
@@ -93,9 +93,9 @@ module Insika
93
93
  # when the turn first carries audio. `stt_prompt` is the Whisper-family
94
94
  # vocabulary hint (product names, brand terms) — OPERATOR config
95
95
  # (agent profile / deployment env), never customer input.
96
- def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil)
96
+ def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil, context: nil)
97
97
  lambda do |url|
98
- fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt)
98
+ fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt, context: context)
99
99
  end
100
100
  end
101
101
 
@@ -110,7 +110,7 @@ module Insika
110
110
  # RubyLLM raises ArgumentError when it's true without an explicit
111
111
  # `provider` (see ModelSelection#assume_model_exists?), and `stt_model` here
112
112
  # is a bare ref like `utility_model` elsewhere — the registry resolves it.
113
- def self.fetch_and_transcribe(url, model:, language:, prompt: nil)
113
+ def self.fetch_and_transcribe(url, model:, language:, prompt: nil, context: nil)
114
114
  require "net/http"
115
115
  require "uri"
116
116
  require "ruby_llm" # lazy — the core loads without it (load-guard)
@@ -120,6 +120,7 @@ module Insika
120
120
  options = { model: model }
121
121
  options[:language] = language if language
122
122
  options[:prompt] = prompt if prompt
123
+ options[:context] = context if context
123
124
  Tempfile.create(["insika-media-", File.extname(filename_for(url).to_s)]) do |file|
124
125
  file.binmode
125
126
  file.write(bytes)
@@ -128,6 +129,23 @@ module Insika
128
129
  end
129
130
  end
130
131
 
132
+ # Durable attachment bytes from a checkpoint; hydrate only at the gem boundary.
133
+ def self.hydrate_attachment(value)
134
+ return value unless value.is_a?(Hash)
135
+ require 'base64'
136
+ require 'stringio'
137
+ require 'ruby_llm'
138
+ unless (value.keys - %w[base64 filename]).empty? && value['base64'].is_a?(String) && value['filename'].is_a?(String)
139
+ raise Insika::MediaError, 'invalid stored attachment'
140
+ end
141
+ raise Insika::MediaError, 'stored attachment too large' if value['base64'].bytesize > 14*1024*1024
142
+ bytes = Base64.strict_decode64(value['base64'])
143
+ raise Insika::MediaError, 'stored attachment too large' if bytes.bytesize > MAX_DOCUMENT_BYTES
144
+ RubyLLM::Attachment.new(StringIO.new(bytes), filename: value['filename'])
145
+ rescue ArgumentError
146
+ raise Insika::MediaError, 'invalid stored attachment'
147
+ end
148
+
131
149
  # An inbound URL -> a RubyLLM::Attachment over bytes WE fetched (egress-
132
150
  # guarded, size-capped — the `media_attachment` recipe). Shared by the
133
151
  # Executor (inbound image/document parts) and `Output.generate_image`
@@ -201,8 +219,7 @@ module Insika
201
219
  # behind lazy requires:
202
220
  # · image — RubyLLM.paint (the gem has vision AND painting), billed
203
221
  # tokens merged into the turn's usage like any ask;
204
- # · tts — RubyLLM still has NO speech API (as of 1.16.0), so the default
205
- # is a thin POST to the OpenAI-compatible `<base>/audio/speech`
222
+ # · tts — a capped POST to the OpenAI-compatible `<base>/audio/speech`
206
223
  # endpoint (base + key from the provider config the chat uses — a
207
224
  # deployment pointing OpenAI at a gateway keeps TTS pointing there).
208
225
  # OpenAI's speech API reports no token usage; the part carries the
@@ -256,8 +273,8 @@ module Insika
256
273
 
257
274
  enforce_embedded_size!(data, "generated image")
258
275
  mime = image.respond_to?(:mime_type) ? image.mime_type : nil
259
- model_id = image.respond_to?(:model_id) ? image.model_id : nil
260
- usage = image.respond_to?(:usage) ? token_usage(image.usage) : {}
276
+ model_id = image.model
277
+ usage = { input_tokens: image.tokens.input, output_tokens: image.tokens.output }.compact
261
278
  part = { "type" => "image", "mime_type" => presence(mime) || "image/png",
262
279
  "base64" => data, "model" => presence(model_id) }
263
280
  [part.compact, usage]
@@ -292,20 +309,24 @@ module Insika
292
309
  req.body = JSON.generate(model: model, voice: voice, input: text.to_s,
293
310
  response_format: format)
294
311
  opts = { use_ssl: uri.scheme == "https", open_timeout: 30, read_timeout: 60 }
312
+ # RubyLLM 2.0 buffers non-audio/error responses without yielding chunks.
313
+ # Keep this bounded transport until native speech caps every response.
295
314
  bytes = Net::HTTP.start(uri.host, uri.port, opts) do |http|
296
- resp = http.request(req)
297
- raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
298
-
299
- # stream into the cap — a rogue/broken endpoint must not grow the
300
- # process past MAX_EMBEDDED_BYTES before the refusal.
301
315
  buf = +"".b
302
- resp.read_body do |chunk|
303
- buf << chunk
304
- break if buf.bytesize > MAX_EMBEDDED_BYTES
316
+ http.request(req) do |resp|
317
+ raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
318
+
319
+ resp.read_body do |chunk|
320
+ size = buf.bytesize + chunk.bytesize
321
+ if size > MAX_EMBEDDED_BYTES
322
+ raise Insika::MediaError,
323
+ "synthesized speech too large to embed (#{size} bytes > #{MAX_EMBEDDED_BYTES})"
324
+ end
325
+ buf << chunk
326
+ end
305
327
  end
306
328
  buf
307
329
  end
308
- enforce_embedded_size!(bytes, "synthesized speech")
309
330
  part = { "type" => "audio", "mime_type" => mime_for(format), "base64" => Base64.strict_encode64(bytes), "model" => model }
310
331
  [part.compact, {}]
311
332
  rescue URI::InvalidURIError
@@ -352,14 +373,6 @@ module Insika
352
373
  Insika::Media.url_attachment(url, max_bytes: Insika::Media::MAX_IMAGE_BYTES)
353
374
  end
354
375
 
355
- def token_usage(raw)
356
- usage = raw.is_a?(Hash) ? raw : {}
357
- {
358
- input_tokens: usage[:input_tokens] || usage["input_tokens"] || usage["prompt_tokens"],
359
- output_tokens: usage[:output_tokens] || usage["output_tokens"] || usage["completion_tokens"]
360
- }.compact
361
- end
362
-
363
376
  def mime_for(format)
364
377
  { "mp3" => "audio/mpeg", "opus" => "audio/opus", "aac" => "audio/aac",
365
378
  "wav" => "audio/wav", "flac" => "audio/flac" }[format.to_s] || "audio/mpeg"
@@ -379,4 +392,4 @@ module Insika
379
392
  end
380
393
  end
381
394
  end
382
- end
395
+ end
@@ -42,6 +42,18 @@ module Insika
42
42
  # ids live in a different namespace in practice.
43
43
  SESSION_TAG = "chat"
44
44
 
45
+ # The ONE memory-cell rule, read by the turn (Executor) and by whoever
46
+ # writes a cell the turn must find (SeedSession): a customer -> the
47
+ # "[tenant:]customer" cell; otherwise the tenant's cell; otherwise the marked
48
+ # per-session cell "chat:<session id>" (nil for a one-shot turn with no
49
+ # session — the store applies _default).
50
+ def self.scope_for(tenant:, customer:, session_id:)
51
+ return [tenant, customer].compact.join(":") if customer
52
+ return tenant if tenant
53
+
54
+ "#{SESSION_TAG}:#{session_id}" if session_id
55
+ end
56
+
45
57
  Fact = Data.define(:key, :value, :origin, :created_at, :updated_at, :expires_at)
46
58
  Note = Data.define(:id, :text, :created_at)
47
59
 
@@ -143,9 +155,10 @@ module Insika
143
155
  # "memory:chat:<tenant>:<id>"). The scope enumeration is the Store's — no
144
156
  # session-derived list, so a customer cell whose session was already
145
157
  # deleted is still purged. ONE transaction for the whole tenant (the repo
146
- # rule): the scope enumeration and every delete see the same snapshot, so a
147
- # customer cell born mid-purge cannot slip through a deletion that reported
148
- # success. -> count of records purged.
158
+ # rule): the purge commits or fails whole. On SQLite, whose transactions
159
+ # run one at a time, a cell cannot be written mid-purge at all; on a backend
160
+ # with concurrent writers a cell written while the purge runs lands after it
161
+ # (a later write, which a later purge removes). -> count of records purged.
149
162
  def purge_tenant(tenant)
150
163
  cell = scope_for(tenant)
151
164
  @store.transaction do