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
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # The circuit breaker's durable state (WS3): one cell per
7
+ # (tenant, provider/model ref) holding the recent failures and when the
8
+ # circuit opened. The read-modify-write rides `@store.transaction` — the same
9
+ # discipline as the dispatch claim (A1): two concurrent failures of the same
10
+ # cell serialize, so the "10 failures in 60s" count is never lost to a race.
11
+ #
12
+ # States, standard semantics:
13
+ # :closed fewer than `after` failures in the `within` window — attempts flow.
14
+ # :open the window count is met AND the cooldown hasn't elapsed — the
15
+ # edge FAIL-FASTS (no provider call) with retry_after = remaining
16
+ # cooldown. Reached at the `after`-th failure, which stamps
17
+ # opened_at.
18
+ # :half_open the cooldown elapsed — the next attempt is the TRIAL: a success
19
+ # closes (record_success clears the cell), a failure reopens
20
+ # (a new failure count / opened_at).
21
+ class CircuitState
22
+ SCOPE = "circuit_state"
23
+
24
+ # Timestamps are pruned to COUNT_LIMIT — a pathological loop cannot grow the
25
+ # cell unboundedly (the window is 60s-wide; 100 reads and writes bounded).
26
+ COUNT_LIMIT = 100
27
+
28
+ Record = Data.define(:failures, :opened_at)
29
+
30
+ def initialize(store:)
31
+ @store = store
32
+ end
33
+
34
+ # Records ONE failure for (tenant, ref). If this failure makes the window
35
+ # reach `after` and the circuit is not already open, it stamps opened_at
36
+ # (the instant the breaker trips). -> :closed (still closed) | :open (JUST
37
+ # tripped — closed->open, the ONLY transition that alerts) | :reopened (an
38
+ # already-tripped cell re-stamped: a half-open trial failed; the circuit is
39
+ # open again but the node already told the operator it is sick — no NEW
40
+ # alert (WS3)).
41
+ def record_failure(tenant:, ref:, after: 10, within: 60, now: Time.now)
42
+ key = key_for(tenant, ref)
43
+ @store.transaction do
44
+ record = load(key)
45
+ cutoff = (now.to_i - within)
46
+ retained = record.failures.select { |t| t > cutoff }.last(COUNT_LIMIT)
47
+ failures = (retained + [now.to_i]).last(COUNT_LIMIT)
48
+ opened_at = record.opened_at
49
+ tripped = :closed
50
+ if opened_at.nil?
51
+ if failures.size >= after
52
+ opened_at = now.to_i
53
+ tripped = :open
54
+ end
55
+ else
56
+ # the circuit was tripped before; a failure here can only be a HALF-OPEN
57
+ # trial that failed. Re-stamp opened_at so it RE-OPENS — a breaker that
58
+ # never restamps is one-shot: after the first cooldown every later turn
59
+ # is an unlocked half-open trial (WS3).
60
+ opened_at = now.to_i
61
+ tripped = :reopened
62
+ end
63
+ @store.set(SCOPE, key, { "failures" => failures, "opened_at" => opened_at })
64
+ tripped
65
+ end
66
+ end
67
+
68
+ # A successful attempt CLOSES the circuit: the cell is cleared so the
69
+ # failure window starts fresh (half-open trial success included).
70
+ def record_success(tenant:, ref:)
71
+ @store.delete(SCOPE, key_for(tenant, ref))
72
+ end
73
+
74
+ # -> :closed | :open | :half_open
75
+ def state(tenant:, ref:, after: 10, within: 60, cooldown: 300, now: Time.now)
76
+ record = load(key_for(tenant, ref))
77
+ opened = record.opened_at.to_i
78
+ # :closed needs BOTH doors shut: the window count is under `after` AND the
79
+ # circuit never tripped. A surviving opened_at tombstone keeps the circuit
80
+ # governed by the cooldown (open, then half-open) even after its original
81
+ # failures age out of the rolling window — only a SUCCESS clears it (WS3:
82
+ # a failed half-open trial must reopen, not silently close).
83
+ return :closed if record.failures.size < after && opened.zero?
84
+
85
+ return :open if opened.zero? || (now.to_i - opened) < cooldown
86
+
87
+ :half_open
88
+ end
89
+
90
+ # Seconds until the circuit can be retried (the remaining cooldown). nil
91
+ # while closed.
92
+ def retry_after(tenant:, ref:, cooldown: 300, now: Time.now)
93
+ record = load(key_for(tenant, ref))
94
+ return nil if record.failures.empty?
95
+
96
+ opened = record.opened_at.to_i
97
+ return nil if opened.zero?
98
+
99
+ remaining = cooldown - (now.to_i - opened)
100
+ remaining.positive? ? remaining : nil
101
+ end
102
+
103
+ private
104
+
105
+ def key_for(tenant, ref) = "#{tenant || 'platform'}:#{ref}"
106
+
107
+ def load(key)
108
+ record = @store.get(SCOPE, key)
109
+ return Record.new([], nil) if record.nil?
110
+
111
+ Record.new(Array(record["failures"]), record["opened_at"])
112
+ end
113
+ end
114
+ end
@@ -19,6 +19,14 @@ module Insika
19
19
  # Inverse of blank? — a usable, non-empty value.
20
20
  def present?(value) = !blank?(value)
21
21
 
22
+ # The operator-facing truthy: what a form checkbox ("1"), a JSON round-trip
23
+ # ("true") or the DSL (true) produce for an opt-in. One list, one reading —
24
+ # AgentProfile#stream_public?, SkillCatalog#blanket? and the Studio all consult
25
+ # it, so a value that opts in on one surface cannot opt out on another.
26
+ TRUTHY = [true, "true", "1", "yes", "on"].freeze
27
+
28
+ def truthy?(value) = TRUTHY.include?(value)
29
+
22
30
  # Bytes that come from outside the engine (sockets, pipes, subprocesses)
23
31
  # arrive tagged BINARY, or as UTF-8 carrying invalid sequences. Both break
24
32
  # `JSON.generate` the moment the text has an accent or an emoji — today a
@@ -10,10 +10,12 @@ module Insika
10
10
  module AgentPayload
11
11
  # Fields accepted by AgentProfile.build (order irrelevant).
12
12
  FIELDS = %i[id model provider base_prompt prompt_files tools_allow tools_deny
13
- tools_allow_groups skills context_providers workflows_allow policies
13
+ tools_allow_groups skills skills_eager context_providers workflows_allow policies
14
14
  prompt_refs limits approvals_required capabilities subagents tools_deferred
15
- memory params model_policy guardrails refinement capabilities_declared
16
- edge_stream metadata].freeze
15
+ memory prompt_caching tool_persistence tool_output_compression budget reliability alerts
16
+ routes stuck_signal outputs briefing_fields grounding funnel followup
17
+ params model_policy guardrails refinement capabilities_declared
18
+ edge_stream metadata distill harvest].freeze
17
19
 
18
20
  module_function
19
21
 
@@ -25,7 +27,7 @@ module Insika
25
27
  out[:provider] = presence(out[:provider])&.to_sym if out.key?(:provider)
26
28
  out[:policies] = Array(out[:policies]).map(&:to_sym) if out.key?(:policies)
27
29
  out[:limits] = out[:limits].transform_keys(&:to_sym) if out[:limits].is_a?(Hash)
28
- # generation params (v2, §10) consumed by symbol key (temperature/max_tokens/thinking).
30
+ # generation params consumed by symbol key (temperature/max_tokens/thinking).
29
31
  out[:params] = out[:params].transform_keys(&:to_sym) if out[:params].is_a?(Hash)
30
32
  out
31
33
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # the operator cancels ONE pending follow-up record (the
8
+ # Studio's Cancel button dispatches this). Synchronous control command (no
9
+ # task, like RecordOutcome); idempotent — an already-cancelled record
10
+ # returns as-is (a repeat of the same call is not an error). Tenant-scoped
11
+ # (WS1): a tenant principal can only cancel its own tenant's records.
12
+ class CancelFollowup
13
+ def initialize(followup_store:, event_stream:)
14
+ @followup_store = followup_store
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ # -> Record. Emits :followup_cancelled { id:, cancelled_by: }.
19
+ def call(command)
20
+ id = Coercion.presence(command.payload[:followup_id] || command.payload["followup_id"])
21
+ raise ValidationError, "followup_id is required" if id.nil?
22
+
23
+ record = @followup_store.find(id)
24
+ raise NotFoundError, "follow-up not found: #{id}" if record.nil?
25
+
26
+ # WS1: the principal's tenant is the scope — a tenant token cannot
27
+ # cancel another tenant's record by id.
28
+ tenant = command.meta[:tenant]
29
+ unless record.tenant == (tenant.to_s.empty? ? "platform" : tenant.to_s)
30
+ raise ValidationError, "follow-up #{id} belongs to another tenant"
31
+ end
32
+
33
+ # A non-pending record (the tick fired it between the render and the
34
+ # click) is a domain error the Studio can flash — never a 500.
35
+ cancelled = begin
36
+ @followup_store.cancel(id: id)
37
+ rescue ArgumentError => e
38
+ raise ValidationError, e.message
39
+ end
40
+ @event_stream.emit(Insika::Event.new(
41
+ type: :followup_cancelled,
42
+ data: { id: cancelled.id, cancelled_by: "operator" },
43
+ meta: { tenant: command.meta[:tenant], at: Time.now.utc.iso8601 }
44
+ ))
45
+ cancelled
46
+ end
47
+ end
48
+ end
49
+ end
@@ -17,13 +17,13 @@ module Insika
17
17
  attrs = AgentPayload.attrs(command.payload)
18
18
  id = AgentPayload.presence(attrs[:id])
19
19
  raise Insika::ValidationError, "id is required" if id.nil?
20
- # model is OPTIONAL as of v2 (§10): omitted -> resolves the platform
20
+ # model is OPTIONAL as of v2: omitted -> resolves the platform
21
21
  # default_model (Settings) at turn start. An agent with neither its own
22
22
  # model nor a platform default fails clearly at the first turn.
23
23
  raise Insika::ValidationError, "agent '#{id}' already exists" if @profile_source.fetch(id)
24
24
 
25
25
  profile = Insika::AgentProfile.build(**attrs)
26
- # RFC-0010 §4.4: definition-time cycle + depth check. Only a profile that
26
+ # definition-time cycle + depth check. Only a profile that
27
27
  # introduces edges (non-empty subagents) can create a violation — a
28
28
  # childless agent is always a safe leaf. Raises SubagentError (a
29
29
  # ValidationError) BEFORE persisting, so a bad graph never lands.
@@ -21,7 +21,7 @@ module Insika
21
21
  vars = command.payload[:vars] || command.payload["vars"] || {}
22
22
  raise Insika::ValidationError, "vars must be a Hash" unless vars.is_a?(Hash)
23
23
 
24
- # Per-chat model pin (v2, §10): `model`/`provider` on the payload become a
24
+ # Per-chat model pin: `model`/`provider` on the payload become a
25
25
  # reserved, collision-safe slot in vars (never rendered in the prompt — the
26
26
  # Request provider skips "__"-prefixed vars). The ModelResolver reads it as
27
27
  # the highest-precedence layer (Chat > Agent > platform default).
@@ -6,7 +6,7 @@ module Insika
6
6
  module Commands
7
7
  # Control command: removes an LLM provider from the
8
8
  # LLMProviderStore. Idempotent (`existed: false` if there was none). UNDOES the
9
- # config in RubyLLM at runtime (§9.5): when the provider existed, calls
9
+ # config in RubyLLM at runtime: when the provider existed, calls
10
10
  # `configurator.unapply(api)` clearing the global key/base — without a restart. A provider
11
11
  # that RubyLLM does not recognize degrades gracefully (unapply: false, nothing to
12
12
  # undo). -> { existed: bool }.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: removes an authored skill from the SkillStore and RELOADS the
8
+ # catalog. -> { name, agent, deleted }.
9
+ #
10
+ # With `agent`, it deletes only that agent's SPECIALIZATION — the shared skill
11
+ # stays exactly where it was, and the agent falls back to it on the next turn.
12
+ # "Stop specializing this" is the inverse of the specialize action, and it must not
13
+ # be expressible as "delete the skill".
14
+ #
15
+ # Deleting a shared skill does NOT touch any allowlist: an agent left naming a
16
+ # skill that no longer exists sees nothing (the catalog resolves nothing), which is
17
+ # the same outcome as never having allowed it.
18
+ class DeleteSkill
19
+ def initialize(skill_store:, skill_catalog:, event_stream:)
20
+ @skill_store = skill_store
21
+ @skill_catalog = skill_catalog
22
+ @event_stream = event_stream
23
+ end
24
+
25
+ def call(command)
26
+ p = AgentPayload.symbolize(command.payload)
27
+ name = AgentPayload.presence(p[:name])
28
+ agent = AgentPayload.presence(p[:agent])
29
+ raise Insika::ValidationError, "name is required" if name.nil?
30
+
31
+ deleted = @skill_store.delete(name, agent: agent)
32
+ raise Insika::NotFoundError, "skill '#{name}' not found#{" for agent '#{agent}'" if agent}" unless deleted
33
+
34
+ @skill_catalog.reload
35
+ @event_stream.emit(Insika::Event.new(
36
+ type: :skill_deleted, data: { name: name, agent: agent }.compact,
37
+ meta: { at: Time.now.utc.iso8601 }
38
+ ))
39
+ { name: name, agent: agent, deleted: true }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (WS8, phase 2 — LGPD): purges everything the engine holds
8
+ # about ONE TENANT — its sessions (the "<tenant>:" namespace) and everything
9
+ # those sessions left behind (traces, tasks, checkpoints, outbox deliveries
10
+ # — see SessionPurge), every memory cell under the tenant (its own + the
11
+ # customer cells), its outcome records (WS7) and its API CREDENTIALS (every
12
+ # active token of the tenant is revoked first — an offboarded tenant must
13
+ # not authenticate). The tenant string IS the
14
+ # isolation boundary, so zeroing it cannot touch another tenant's data.
15
+ # Operator-only BY CONSTRUCTION: the generic command ingress is
16
+ # operator-grade (a tenant principal never reaches it).
17
+ class DeleteTenantData
18
+ include SessionPurge
19
+
20
+ def initialize(memory_store:, session_store:, tool_trace_store: nil,
21
+ context_trace_store: nil, model_visible_trace_store: nil,
22
+ outcome_store: nil, task_store: nil,
23
+ checkpoint_store: nil, outbox_store: nil, shadow_pairs: nil,
24
+ token_store: nil, funnel_store: nil, event_stream:,
25
+ followup_store: nil, contact_store: nil, proposal_store: nil,
26
+ harvest_store: nil)
27
+ @memory_store = memory_store
28
+ @token_store = token_store
29
+ @session_store = session_store
30
+ @tool_trace_store = tool_trace_store
31
+ @context_trace_store = context_trace_store
32
+ @model_visible_trace_store = model_visible_trace_store # ; nil = parity
33
+ @outcome_store = outcome_store
34
+ @task_store = task_store
35
+ @checkpoint_store = checkpoint_store
36
+ @outbox_store = outbox_store
37
+ @shadow_pairs = shadow_pairs
38
+ @funnel_store = funnel_store # ; nil = nothing to sweep
39
+ @followup_store = followup_store # ; nil = nothing to sweep
40
+ @contact_store = contact_store # ; nil = nothing to sweep
41
+ @proposal_store = proposal_store # ; nil = nothing to sweep
42
+ @harvest_store = harvest_store # ; nil = nothing to sweep
43
+ @event_stream = event_stream
44
+ end
45
+
46
+ # -> { tenant:, sessions:, memory_records:, outcomes:, tokens_revoked:,
47
+ # tasks:, checkpoints:, deliveries:, followups:, contacts: }.
48
+ def call(command)
49
+ tenant = Coercion.presence(command.payload[:tenant] || command.payload["tenant"])
50
+ raise ValidationError, "tenant is required" if tenant.nil?
51
+
52
+ # CREDENTIALS FIRST (WS1+WS8): erasing the data while the tenant's
53
+ # tokens still resolve leaves an offboarded tenant authenticating and
54
+ # opening a NEW session — the purge would report success over a live
55
+ # customer. Revoking before the sweep closes the door, so nothing the
56
+ # tenant does mid-purge survives it. nil store = single_tenant mode
57
+ # (no per-tenant credential exists).
58
+ tokens_revoked = @token_store ? @token_store.revoke_all(tenant_id: tenant) : 0
59
+
60
+ sessions = @session_store.each_id.select { |id| id.to_s.start_with?("#{tenant}:") }
61
+ purged = purge_sessions(sessions)
62
+
63
+ memory_records = @memory_store.purge_tenant(tenant)
64
+ outcomes = @outcome_store ? @outcome_store.purge(tenant: tenant) : 0
65
+ funnel = @funnel_store ? @funnel_store.purge(tenant: tenant) : 0
66
+ # the follow-up footprint dies with the tenant — records
67
+ # and contact cells under the same tenant prefix.
68
+ followups = @followup_store ? @followup_store.purge(tenant: tenant) : 0
69
+ contacts = @contact_store ? @contact_store.purge(tenant: tenant) : 0
70
+ # the distilled proposals die with the tenant.
71
+ proposals = @proposal_store ? @proposal_store.purge(tenant: tenant) : 0
72
+ # candidates reference sessions, and sessions carry the
73
+ # tenant prefix — the harvest rows die with the tenant (D11).
74
+ harvest = @harvest_store ? @harvest_store.purge(tenant: tenant) : 0
75
+
76
+ @event_stream.emit(Insika::Event.new(
77
+ type: :tenant_data_deleted,
78
+ data: { tenant: tenant, sessions: sessions,
79
+ memory_records: memory_records,
80
+ outcomes: outcomes,
81
+ funnel: funnel,
82
+ followups: followups,
83
+ contacts: contacts,
84
+ proposals: proposals,
85
+ harvest: harvest,
86
+ tokens_revoked: tokens_revoked }.merge(purged),
87
+ meta: { at: Time.now.utc.iso8601 }
88
+ ))
89
+ { tenant: tenant, sessions: sessions, memory_records: memory_records,
90
+ outcomes: outcomes, funnel: funnel, followups: followups, contacts: contacts,
91
+ proposals: proposals, harvest: harvest, tokens_revoked: tokens_revoked }.merge(purged)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command the LGPD access right): exports what the
8
+ # engine holds about ONE customer — their memory cell's facts + notes,
9
+ # expired facts excluded. The FULL content is the RETURN VALUE (the Studio
10
+ # turns it into a JSON download); the emitted event carries counts only, so
11
+ # the event stream, the audit store and every log stay content-free (D7).
12
+ # The return value never enters a store, an event or a log.
13
+ class ExportCustomerMemory
14
+ def initialize(memory_store:, event_stream:)
15
+ @memory_store = memory_store
16
+ @event_stream = event_stream
17
+ end
18
+
19
+ # payload: { customer: (REQUIRED), tenant: } — customer from the payload
20
+ # (an operator names the person); tenant from the payload || meta.
21
+ # -> { "customer" =>, "tenant" =>, "exported_at" =>, "facts" => [Fact#to_h],
22
+ # "notes" => [Note#to_h], "counts" => { "facts" =>, "notes" => } }
23
+ def call(command)
24
+ p = AgentPayload.symbolize(command.payload)
25
+ customer = AgentPayload.presence(p[:customer])
26
+ raise Insika::ValidationError, "customer is required" if customer.nil?
27
+
28
+ tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
29
+ facts = @memory_store.facts(tenant: tenant, customer: customer)
30
+ notes = @memory_store.notes(tenant: tenant, customer: customer)
31
+ exported_at = Time.now.utc.iso8601
32
+
33
+ @event_stream.emit(Insika::Event.new(
34
+ type: :customer_memory_exported,
35
+ data: { customer: customer, tenant: tenant,
36
+ counts: { facts: facts.size, notes: notes.size } },
37
+ meta: { at: exported_at }
38
+ ))
39
+ {
40
+ "customer" => customer, "tenant" => tenant, "exported_at" => exported_at,
41
+ "facts" => facts.map { |f| Coercion.deep_stringify(f.to_h) },
42
+ "notes" => notes.map { |n| Coercion.deep_stringify(n.to_h) },
43
+ "counts" => { "facts" => facts.size, "notes" => notes.size }
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (WS8, phase 2 — LGPD): purges what the engine holds about
8
+ # ONE customer — the customer's memory cell, their sessions (found through
9
+ # the `customer` var the Executor stamps on a tagged conversation) and
10
+ # everything those sessions left behind (traces, tasks, checkpoints, outbox
11
+ # deliveries — see SessionPurge). The scope string IS the isolation
12
+ # boundary, so zeroing it cannot touch another customer's or another
13
+ # tenant's data. Operator-only BY CONSTRUCTION: the generic command ingress
14
+ # is operator-grade (a tenant principal never reaches it).
15
+ #
16
+ # WHICH TENANT is the whole correctness of the purge: the memory cell is
17
+ # "memory:<tenant>:<customer>" and the sessions are the "<tenant>:" ones. It
18
+ # is read from the command meta (an internal caller acting AS a tenant) or
19
+ # from the payload (`{ customer:, tenant: }` — the operator naming it over
20
+ # HTTP, where the principal has no tenant of its own). Absent from both, the
21
+ # purge is deployment-wide: the untagged memory cell plus that customer's
22
+ # sessions in EVERY tenant — right for a single-tenant deployment, and never
23
+ # what a multi-tenant operator means, so they must name the tenant.
24
+ class ForgetCustomer
25
+ include SessionPurge
26
+
27
+ def initialize(memory_store:, session_store:, tool_trace_store: nil,
28
+ context_trace_store: nil, model_visible_trace_store: nil,
29
+ task_store: nil, checkpoint_store: nil,
30
+ outbox_store: nil, shadow_pairs: nil, audit_store: nil, event_stream:,
31
+ followup_store: nil, contact_store: nil, proposal_store: nil)
32
+ @memory_store = memory_store
33
+ @session_store = session_store
34
+ @tool_trace_store = tool_trace_store
35
+ @context_trace_store = context_trace_store
36
+ @model_visible_trace_store = model_visible_trace_store # ; nil = parity
37
+ @task_store = task_store
38
+ @checkpoint_store = checkpoint_store
39
+ @outbox_store = outbox_store
40
+ @shadow_pairs = shadow_pairs
41
+ @audit_store = audit_store
42
+ @event_stream = event_stream
43
+ @followup_store = followup_store # ; nil = nothing to sweep
44
+ @contact_store = contact_store # ; nil = nothing to sweep
45
+ @proposal_store = proposal_store # ; nil = nothing to sweep
46
+ end
47
+
48
+ # -> { customer:, tenant:, memory_records:, sessions: [], tasks:,
49
+ # checkpoints:, deliveries:, followups:, contacts: }.
50
+ def call(command)
51
+ customer = Coercion.presence(command.payload[:customer] || command.payload["customer"])
52
+ raise ValidationError, "customer is required" if customer.nil?
53
+
54
+ tenant = command.meta[:tenant] ||
55
+ Coercion.presence(command.payload[:tenant] || command.payload["tenant"])
56
+ memory_scope = [tenant, customer].compact.join(":")
57
+ memory_records = @memory_store.purge(tenant: memory_scope)
58
+
59
+ # the follow-up footprint dies with the person — the
60
+ # schedule records and the contact cell (LGPD).
61
+ followups = @followup_store&.purge_customer(tenant: tenant, customer: customer) || 0
62
+ contacts = @contact_store&.delete(tenant: tenant, customer: customer) ? 1 : 0
63
+
64
+ # the distilled PROPOSALS die with the person — a proposal
65
+ # is born inside a customer cell (D6), so forget_customer reaches it.
66
+ proposals = @proposal_store&.purge_customer(tenant: tenant, customer: customer) || 0
67
+
68
+ sessions = session_ids_for(customer, tenant)
69
+ purged = purge_sessions(sessions)
70
+
71
+ # the audit records the thing that happened, content-free —
72
+ # a digest-free line with the counts (the deleted VALUES never enter
73
+ # the audit store). Written AFTER the purge, so the line describes a
74
+ # deletion that actually happened. nil audit_store = no-op.
75
+ @audit_store&.record(
76
+ cell: @memory_store.cell_for(tenant, customer),
77
+ action: "purge", actor: operator(command), tenant: tenant, customer: customer,
78
+ note: "memory_records: #{memory_records}, sessions: #{sessions.size}"
79
+ )
80
+
81
+ @event_stream.emit(Insika::Event.new(
82
+ type: :customer_forgotten,
83
+ data: { customer: customer, tenant: tenant,
84
+ memory_records: memory_records,
85
+ sessions: sessions,
86
+ followups: followups,
87
+ contacts: contacts,
88
+ proposals: proposals }.merge(purged),
89
+ meta: { at: Time.now.utc.iso8601 }
90
+ ))
91
+ { customer: customer, tenant: tenant, memory_records: memory_records,
92
+ sessions: sessions, followups: followups, contacts: contacts,
93
+ proposals: proposals }.merge(purged)
94
+ end
95
+
96
+ private
97
+
98
+ def operator(command)
99
+ Coercion.presence(command.payload[:operator] || command.payload["operator"]) || "operator"
100
+ end
101
+
102
+ # The customer's sessions: the ones the Executor stamped with this
103
+ # customer var — plus, in multi_tenant, only ids inside the tenant's
104
+ # "<tenant>:" namespace (single_tenant sessions carry no prefix).
105
+ def session_ids_for(customer, tenant)
106
+ @session_store.each_id.each_with_object([]) do |id, acc|
107
+ session = @session_store.find(id)
108
+ next unless session
109
+ next unless Coercion.presence(session.vars["customer"]).to_s == customer.to_s
110
+ next if tenant && !id.to_s.start_with?("#{tenant}:")
111
+
112
+ acc << id.to_s
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ module Insika
7
+ module Commands
8
+ # the operator's FREEZE — turn the folded counts over a
9
+ # declared span into the baseline snapshot (A/B follow-up) and
10
+ # (promotion gate) read. One command, dispatched from the Studio
11
+ # and available to any operator-grade caller. Synchronous control command —
12
+ # no task, no actor, same discipline as RecordOutcome (WS7).
13
+ #
14
+ # The ≥ 4-week rule ("no 1.0 target before a remeasure") is
15
+ # enforced HERE, not by convention: a frozen span shorter than 28 days is a
16
+ # ValidationError. `frozen_at` stamps when; the baseline OVERWRITES (D5) —
17
+ # the freeze event is the audit surface.
18
+ class FreezeFunnelBaseline
19
+ MIN_SPAN_DAYS = 28
20
+
21
+ def initialize(funnel_store:, profiles:, event_stream:)
22
+ @funnel_store = funnel_store
23
+ @profiles = profiles
24
+ @event_stream = event_stream
25
+ end
26
+
27
+ # payload: { agent:, tenant:, from: "YYYY-MM-DD", to: "YYYY-MM-DD", operator: }
28
+ # agent REQUIRED (ValidationError); tenant from payload || meta[:tenant].
29
+ # from/to optional — default to the pair's folded span (first/last day
30
+ # cell); explicit from/to are inclusive bounds.
31
+ # -> the baseline record.
32
+ def call(command)
33
+ payload = command.payload
34
+ agent = Coercion.presence(payload[:agent] || payload["agent"])
35
+ raise ValidationError, "agent is required" if agent.nil?
36
+
37
+ declaration = declaration_for(agent)
38
+ tenant = payload[:tenant] || payload["tenant"] || command.meta[:tenant]
39
+
40
+ from, to = bounds(payload, tenant, agent)
41
+ validate_span!(from, to)
42
+
43
+ stages = @funnel_store.days(tenant: tenant, agent: agent, from: from, to: to)
44
+ .each_with_object(Hash.new(0)) do |(_, counts), acc|
45
+ counts.each { |stage, n| acc[stage] += n }
46
+ end
47
+ primary = declaration.primary
48
+ primary_count = stages[primary].to_i
49
+ first_count = stages[declaration.first_stage].to_i
50
+ conversion = first_count.zero? ? nil : primary_count.to_f / first_count
51
+
52
+ baseline = {
53
+ "from" => from, "to" => to, "stages" => stages,
54
+ "primary" => primary, "primary_count" => primary_count,
55
+ "conversion" => conversion, "window" => declaration.attribution_window,
56
+ "frozen_at" => Time.now.utc.iso8601
57
+ }
58
+ @funnel_store.set_baseline(tenant: tenant, agent: agent, record: baseline)
59
+
60
+ @event_stream.emit(Insika::Event.new(
61
+ type: :funnel_baseline_frozen,
62
+ data: { agent: agent, from: from, to: to,
63
+ primary: primary, primary_count: primary_count,
64
+ conversion: conversion },
65
+ meta: { at: Time.now.utc.iso8601 }
66
+ ))
67
+ baseline
68
+ end
69
+
70
+ private
71
+
72
+ # -> FunnelDeclaration | raise — the freeze needs the parsed declaration,
73
+ # so a malformed/absent one is a hard ValidationError (D8: the FOLD skips
74
+ # malformed declarations, but freezing without one is an operator error).
75
+ def declaration_for(agent)
76
+ profile = @profiles.fetch(agent)
77
+ decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
78
+ return decl if decl
79
+
80
+ raise ValidationError, "agent '#{agent}' has no valid funnel declaration"
81
+ end
82
+
83
+ # Explicit from/to (inclusive) or the pair's folded span (first/last day
84
+ # cell). -> [String, String].
85
+ def bounds(payload, tenant, agent)
86
+ from = Coercion.presence(payload[:from] || payload["from"])
87
+ to = Coercion.presence(payload[:to] || payload["to"])
88
+ # both given -> no scan at all (a pair with no cells still freezes,
89
+ # over a zero-count span, when the operator names the dates)
90
+ return [from, to] if from && to
91
+
92
+ days = @funnel_store.days(tenant: tenant, agent: agent)
93
+ days_keys = days.keys
94
+ raise ValidationError, "no folded days for agent '#{agent}'" if days_keys.empty?
95
+
96
+ [from || days_keys.first, to || days_keys.last]
97
+ end
98
+
99
+ def validate_span!(from, to)
100
+ raise ValidationError, "from must be before to" if from >= to
101
+
102
+ span = (Date.iso8601(to) - Date.iso8601(from)).to_i
103
+ if span < MIN_SPAN_DAYS
104
+ raise ValidationError,
105
+ "baseline span must cover at least #{MIN_SPAN_DAYS} days " \
106
+ "(the number needs a remeasured baseline)"
107
+ end
108
+ rescue Date::Error
109
+ raise ValidationError, "from/to must be ISO dates (YYYY-MM-DD)"
110
+ end
111
+ end
112
+ end
113
+ end