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
@@ -3,8 +3,9 @@
3
3
  require "json"
4
4
  require "rack"
5
5
  require "async"
6
+ require "securerandom"
6
7
  require_relative "sse_body"
7
- require_relative "admin_auth" # Bearer checker shared by the gateway edge (fail-closed)
8
+ require_relative "tenant_auth" # WS1: Bearer -> { role:, tenant_id: } (single/multi-tenant)
8
9
  require_relative "a2a/app" # A2A edge adapter (pulls protocol/errors/message/projection/card)
9
10
  require_relative "responses" # OpenAI Responses adapter (/v1/responses) — drop-in for the OpenClaw gateway
10
11
 
@@ -32,7 +33,7 @@ module Insika
32
33
  TERMINAL_EVENTS = %i[task_completed task_failed task_cancelled].freeze
33
34
  private_constant :TERMINAL_EVENTS
34
35
 
35
- # RFC-0016 A5: the `/v1` contract, versioned by date. A caller PINS behaviour
36
+ # the `/v1` contract, versioned by date. A caller PINS behaviour
36
37
  # with `Insika-Version: YYYY-MM-DD` so a future breaking change does not move
37
38
  # silently underneath it; absent header = today's (only) version. Only one
38
39
  # entry exists so far — the day a second one is added, the routes that
@@ -41,13 +42,21 @@ module Insika
41
42
  KNOWN_VERSIONS = ["2026-08-08"].freeze
42
43
  private_constant :KNOWN_VERSIONS
43
44
 
44
- # The operator control UI now lives in the Studio (§12 G5); server/ is a
45
+ # the 500 envelope. A 500 is by definition unexpected — the client
46
+ # cannot fix the request, so the contract is "you may retry, wait this
47
+ # long, and quote this ref when you report it". The ref only means
48
+ # anything because the same line goes to the server log (see #internal_error_response).
49
+ RETRY_AFTER_SECONDS = 1
50
+ private_constant :RETRY_AFTER_SECONDS
51
+
52
+ # The operator control UI now lives in the Studio; server/ is a
45
53
  # pure transport surface (/v1, /a2a). The constitutional rule holds: server/
46
54
  # only READS stores and never imports the Executor, store writes, or RubyLLM.
47
55
  def initialize(command_bus:, event_stream:, session_store:, task_store:,
48
56
  config:, pending_action_store: nil, a2a: nil, provisioner: nil,
49
57
  workflow_registry: nil, onboarding: nil, profiles: nil,
50
- channels: nil)
58
+ channels: nil, logger: nil, token_store: nil, outcome_store: nil,
59
+ executor: nil, db_path: nil)
51
60
  @command_bus = command_bus
52
61
  @event_stream = event_stream
53
62
  @session_store = session_store
@@ -56,26 +65,45 @@ module Insika
56
65
  @pending_action_store = pending_action_store # read for GET /v1/tasks/:id
57
66
  @a2a = a2a # A2A edge. nil = server does not expose A2A (parity).
58
67
  @provisioner = provisioner # PackImporter. nil = provisioning not exposed.
59
- # Item 22 / §4.4: READ-ONLY registry, injected only where workflows are
68
+ # WS1 multi-tenant credentials. nil = single_tenant mode (the classic
69
+ # single operator credential, gateway_token). Present = tokens resolve
70
+ # from the store (per-tenant + operator), gateway_token still resolves
71
+ # as operator (an existing deployment switching modes keeps its token).
72
+ @token_store = token_store
73
+ # "single_tenant" (default, parity) | "multi_tenant".
74
+ @tenancy = config.fetch(:tenancy, "single_tenant")
75
+ # READ-ONLY registry, injected only where workflows are
60
76
  # exposed (the minimal wiring). nil = no /v1/workflows routes (parity — the
61
77
  # deployment does not expose workflows). Reading a catalog is a READ, like a
62
78
  # store read: the constitutional rule (no Executor/store-writes/RubyLLM) holds.
63
79
  @workflow_registry = workflow_registry
64
- # Item 20 / §5.6: LLM-first onboarding surface (start.md + models.json +
80
+ # LLM-first onboarding surface (start.md + models.json +
65
81
  # docs). PUBLIC (no auth — the whole point of the "read <base>/start.md" trick
66
82
  # is that the developer's coding agent can fetch it), and READ-ONLY, so the
67
83
  # constitutional rule holds. nil = routes not exposed (parity — the production
68
84
  # deployment opts in). Reading files/masked stores is a READ, like a store read.
69
85
  @onboarding = onboarding
70
- # RFC-0014 §3.2: READ-ONLY ProfileSource, so `GET /v1/agents/:id` can answer
86
+ # READ-ONLY ProfileSource, so `GET /v1/agents/:id` can answer
71
87
  # what an agent has — the eval is a client and cannot read a store. Same
72
88
  # constitutional footing as the workflow registry: reading a catalog is a
73
89
  # READ. nil = the route 404s (parity).
74
90
  @profiles = profiles
75
- # RFC-0011 §4.4: ONE generic route family for every channel, opt-in by
91
+ # ONE generic route family for every channel, opt-in by
76
92
  # injecting the registry (nil ⇒ the routes do not exist, parity with @a2a).
77
93
  # The channel does the translating; this class keeps doing only transport.
78
94
  @channels = channels
95
+ # WS7: business outcomes per conversation (POST /v1/outcomes — a Control
96
+ # command on the bus — and the tenant-scoped GET /v1/outcomes read).
97
+ # nil = the routes 404 (parity).
98
+ @outcome_store = outcome_store
99
+ # where a 500's error_ref goes to be FOUND. nil = silent (parity for
100
+ # embedders); the serving wirings pass $stdout. Class+message+backtrace
101
+ # only — the ref never travels with request payloads (secrets stay out).
102
+ @logger = logger
103
+ # GET /v1/vitals: process readings, operator-only, no store.
104
+ # Both optional — nil means the body simply omits those readings.
105
+ @executor = executor
106
+ @db_path = db_path
79
107
  @heartbeat = config.fetch(:heartbeat, 15)
80
108
  @sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
81
109
  end
@@ -96,7 +124,7 @@ module Insika
96
124
  rescue Async::TimeoutError => e
97
125
  error_response(504, e) # synchronous control request exceeded the ceiling
98
126
  rescue StandardError => e
99
- error_response(500, e)
127
+ internal_error_response(e)
100
128
  end
101
129
 
102
130
  private
@@ -114,8 +142,15 @@ module Insika
114
142
  return version_error if version_error
115
143
  end
116
144
 
117
- gate = public_route?(req.request_method, segments) ? nil : gateway_gate(req)
145
+ gate = public_route?(req.request_method, segments) ? nil : gateway_gate(req)
118
146
  return gate if gate
147
+ # WS1: a TENANT principal is confined to its own runtime surfaces (chat
148
+ # + its own reads). Every authoring/provisioning surface stays
149
+ # operator-only — a tenant can never mint tokens, author tools or
150
+ # change platform config. single_tenant (no principal) is untouched.
151
+ if tenant_principal?(req) && !tenant_surface?(req.request_method, segments)
152
+ return auth_error(403, "operator surface")
153
+ end
119
154
 
120
155
  case [req.request_method, segments]
121
156
  in ["GET", ["up"]]
@@ -140,6 +175,10 @@ module Insika
140
175
  handle_trigger_workflow(req, name)
141
176
  in ["POST", ["v1", "responses"]]
142
177
  handle_responses(req)
178
+ in ["POST", ["v1", "outcomes"]] if @outcome_store
179
+ handle_record_outcome(req)
180
+ in ["GET", ["v1", "outcomes"]] if @outcome_store
181
+ handle_list_outcomes(req)
143
182
  in ["POST", ["v1", "tools", "manifest"]]
144
183
  handle_import_tools(req)
145
184
  in ["POST", ["v1", "mcp", name, "import"]]
@@ -151,13 +190,17 @@ module Insika
151
190
  in ["GET", ["v1", "agents", id]] if @profiles
152
191
  handle_read_agent(id)
153
192
  in ["GET", ["v1", "sessions", id]]
154
- handle_read_session(id)
193
+ handle_read_session(req, id)
155
194
  in ["GET", ["v1", "tasks", id]]
156
- handle_read_task(id)
195
+ handle_read_task(req, id)
157
196
  in ["GET", ["v1", "events"]]
158
197
  handle_events(req)
198
+ in ["GET", ["v1", "vitals"]]
199
+ handle_vitals
159
200
  in ["POST", ["channels", id, "events"]] if @channels
160
201
  handle_channel_event(req, id)
202
+ in ["POST", ["channels", id, "shadow-reply"]] if @channels
203
+ handle_channel_shadow_reply(req, id)
161
204
  in ["POST", ["channels", id, "sessions"]] if @channels
162
205
  handle_channel_session(req, id)
163
206
  in ["POST", ["channels", id, "messages"]] if @channels
@@ -215,7 +258,7 @@ module Insika
215
258
  # tomorrow is gated by default and publishing it is a deliberate edit here.
216
259
  def channel_route?(method, segments)
217
260
  case [method, segments]
218
- in ["POST", ["channels", _, "events" | "sessions" | "messages"]] then true
261
+ in ["POST", ["channels", _, "events" | "sessions" | "messages" | "shadow-reply"]] then true
219
262
  in ["GET", ["channels", _, "asset", _]] then true
220
263
  in ["OPTIONS", ["channels", _, *]] then true # CORS preflight carries no credential, by spec
221
264
  else false
@@ -233,16 +276,62 @@ module Insika
233
276
  # POST /v1/commands/:type — generic: every new Command is born with a
234
277
  # transport. The control vs turn distinction is BY THE SHAPE of the result (the
235
278
  # transport knows no semantics).
279
+ #
280
+ # The principal's tenant is stamped like on every other surface. It is
281
+ # nil for an operator (this route is operator-only — see TENANT_SURFACES),
282
+ # which is exactly why a tenant-scoped command such as `forget_customer`
283
+ # ALSO reads a `tenant` from its payload: over HTTP the operator names the
284
+ # tenant, because the credential does not carry one.
236
285
  def handle_command(req, type)
237
- command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http)
286
+ command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http,
287
+ tenant: req_tenant(req))
238
288
  command_response(dispatch_with_timeout(command))
239
289
  end
240
290
 
291
+ # POST /v1/outcomes — the operator or the integration records a business
292
+ # outcome for a conversation (WS7). A Control command on the bus, tenant
293
+ # stamped from the principal (WS1) — additive, outside the response
294
+ # contract: the engine transports the outcome, never interprets it.
295
+ # 201 { outcome: { agent, outcome, value, session_id, at } }.
296
+ def handle_record_outcome(req)
297
+ command = Insika::Command.build(:record_outcome, parse_body(req), transport: :http,
298
+ tenant: req_tenant(req))
299
+ record = dispatch_with_timeout(command)
300
+ json_response(201, { outcome: { agent: record.agent, outcome: record.outcome,
301
+ value: record.value, session_id: record.session_id,
302
+ at: record.at } })
303
+ end
304
+
305
+ # GET /v1/outcomes[?agent=] — the Studio scorecard's data: the LAST
306
+ # outcome per agent (state cards) + the per-day series. Tenant-scoped
307
+ # (WS1): a tenant principal reads only its own outcomes.
308
+ def handle_list_outcomes(req)
309
+ tenant = req_tenant(req)
310
+ agent = req.GET["agent"]
311
+ latest = @outcome_store.latest_per_agent(tenant: tenant)
312
+ latest = { agent => latest[agent] }.compact if agent && !agent.empty?
313
+ series = @outcome_store.series(tenant: tenant, agent: agent)
314
+ json_response(200, { latest: latest, series: series })
315
+ end
316
+
241
317
  # POST /v1/sessions — sugar for create_session; 201 {session}.
242
318
  def handle_create_session(req)
243
319
  body = parse_body(req)
244
- command = Insika::Command.build(:create_session, { vars: body[:vars] || {} },
245
- transport: :http)
320
+ tenant = req_tenant(req)
321
+ # WS1: a tenant's session must be born under its OWN "<tenant>:" namespace
322
+ # — the read path (GET /v1/sessions/:id) refuses anything else. Scope the
323
+ # caller's id the same way message_flow scopes a session_id; a tenant that
324
+ # passed none gets a namespaced uuid instead of an unprefixed one it could
325
+ # never read back.
326
+ if tenant
327
+ id = body[:id] || body["id"]
328
+ id = Insika::Coercion.blank?(id) ? scoped_session_id(tenant, SecureRandom.uuid)
329
+ : scoped_session_id(tenant, id)
330
+ body = body.merge(id: id)
331
+ end
332
+ command = Insika::Command.build(:create_session,
333
+ { vars: body[:vars] || {}, id: body[:id] }.compact,
334
+ transport: :http, tenant: tenant)
246
335
  session = dispatch_with_timeout(command)
247
336
  json_response(201, { session: session.to_h })
248
337
  end
@@ -251,13 +340,14 @@ module Insika
251
340
  # "false" -> 200 JSON aggregated at the terminal event.
252
341
  def handle_send_message(req)
253
342
  stream = req.GET["stream"] != "false"
254
- # RFC-0015 §5.5: only the aggregated-JSON form has room for the `merged`/`steered`
343
+ # only the aggregated-JSON form has room for the `merged`/`steered`
255
344
  # verdict, so only it may join a message to another turn. Once the stream is open
256
345
  # there is no way to tell the caller it does not own the reply.
257
- message_flow(parse_body(req), stream: stream, transport: stream ? :http : :"http:json")
346
+ message_flow(parse_body(req), stream: stream, transport: stream ? :http : :"http:json",
347
+ tenant: req_tenant(req))
258
348
  end
259
349
 
260
- # GET /docs/:name.md — one public doc as raw markdown (item 20 / §5.6). The
350
+ # GET /docs/:name.md — one public doc as raw markdown. The
261
351
  # slug is a KEY of the onboarding allowlist, so no filesystem traversal is
262
352
  # possible; an unknown slug -> 404. `file` still carries the ".md" suffix.
263
353
  def handle_doc(file)
@@ -274,7 +364,7 @@ module Insika
274
364
  Insika::Coercion.presence(@config[:public_url]) || req.base_url
275
365
  end
276
366
 
277
- # GET /v1/workflows — discovery (item 22 / §4.4). Direct read of the
367
+ # GET /v1/workflows — discovery. Direct read of the
278
368
  # registry catalog (name + description + the I/O schema contract). Not a
279
369
  # Command; opt-in via the injected registry.
280
370
  def handle_list_workflows
@@ -293,7 +383,8 @@ module Insika
293
383
  body = parse_body(req)
294
384
  payload = { workflow: name, agent: body[:agent],
295
385
  input: body[:input], session_id: body[:session_id] }.compact
296
- workflow_flow(payload, stream: req.GET["stream"] == "true")
386
+ workflow_flow(payload, stream: req.GET["stream"] == "true",
387
+ tenant: req_tenant(req))
297
388
  end
298
389
 
299
390
  # POST /v1/responses — OpenAI Responses adapter (drop-in for the OpenClaw
@@ -305,14 +396,20 @@ module Insika
305
396
  return gate if gate
306
397
 
307
398
  parsed = Responses.parse_request(parse_body(req), req) # ValidationError -> 422
308
- ensure_session(parsed[:user])
399
+ tenant = req_tenant(req)
400
+ ensure_session(parsed[:user], tenant: tenant)
309
401
  payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
310
402
  payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
311
- message_flow(payload, stream: true, serialize: Responses.method(:frame_for))
403
+ payload[:customer] = parsed[:customer] if parsed[:customer] # WS8: the memory scope handle
404
+ payload[:parts] = parsed[:parts] if parsed[:parts] # WS9: multimodal content parts
405
+ payload[:source] = parsed[:source] if parsed[:source] # WS9: voice-marked text
406
+ payload[:channel] = parsed[:channel] if parsed[:channel] # WS9 (saída): the channel's output capabilities
407
+ message_flow(payload, stream: true, serialize: Responses.method(:frame_for),
408
+ tenant: tenant)
312
409
  end
313
410
 
314
411
  # POST /v1/agents — provisions (upserts) an agent from a standardized
315
- # PACK (Phase 6/D4/F7). Same Bearer as /v1/responses (gateway_token,
412
+ # PACK. Same Bearer as /v1/responses (gateway_token,
316
413
  # fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
317
414
  # JSON; the PackImporter emits the authoring Commands. -> 200 { summary }.
318
415
  # Raw body (string keys): the pack's file/skill names are data keys,
@@ -326,7 +423,7 @@ module Insika
326
423
  end
327
424
 
328
425
  # POST /v1/tools/manifest — BATCH ingestion of data-tools via manifest
329
- # (Phase 7, Step B). Same Bearer as provisioning (gateway_token, fail-
426
+ # Same Bearer as provisioning (gateway_token, fail-
330
427
  # closed): it's an authoring/provisioning surface and resolves the
331
428
  # deployment's secrets. RAW body (string keys): the JSON Schema property names and
332
429
  # the headers are DATA, not symbols. Dispatches :import_tools -> 200 { per-tool
@@ -341,7 +438,7 @@ module Insika
341
438
  json_response(200, dispatch_with_timeout(command))
342
439
  end
343
440
 
344
- # POST /v1/mcp/:name/import — LIVE MCP ingestion (Phase 7, Step E). Same
441
+ # POST /v1/mcp/:name/import — LIVE MCP ingestion. Same
345
442
  # Bearer as provisioning (gateway_token, fail-closed): it's an authoring
346
443
  # surface. Discovers the tools of the MCP instance `:name` (via a client injectable
347
444
  # at the composition root) and ingests them as data-tools (reuses :import_tools:
@@ -367,7 +464,7 @@ module Insika
367
464
 
368
465
  # GET /v1/agents/:id — what this deployment HAS for that agent, so an eval
369
466
  # (a client: it never reads a store) can tell "this case cannot run here" from
370
- # "this case failed" (RFC-0014 §3.2). Deliberately NOT the profile: the prompt,
467
+ # "this case failed". Deliberately NOT the profile: the prompt,
371
468
  # the model and the guardrail config are none of the caller's business. Just the
372
469
  # two facts a case declares `requires` against.
373
470
  #
@@ -400,26 +497,85 @@ module Insika
400
497
  end
401
498
 
402
499
  # Gateway Bearer (fail-closed). -> error response (503/401) OR nil when
403
- # ok (the handler proceeds).
500
+ # ok (the handler proceeds). In multi_tenant mode the resolved principal
501
+ # is stashed on the request env: `tenant_principal?`/`req_tenant` read it
502
+ # back for the surface gate and the command stamping.
404
503
  def gateway_gate(req)
405
- case Insika::Server::AdminAuth.check(@config[:gateway_token], req.get_header("HTTP_AUTHORIZATION"))
504
+ result = Insika::Server::TenantAuth.check(@config[:gateway_token], @token_store,
505
+ req.get_header("HTTP_AUTHORIZATION"))
506
+ case result
406
507
  when :disabled then auth_error(503, "gateway disabled")
407
508
  when :unauthorized then auth_error(401, "unauthorized", "www-authenticate" => "Bearer")
509
+ else
510
+ req.set_header("insika.principal", result)
511
+ nil
408
512
  end
409
513
  end
410
514
 
411
- # POST /channels/:id/events — the Shape B inbound webhook (RFC-0011 §4.4).
515
+ # -> bool: is the requester a TENANT principal (multi_tenant mode only)?
516
+ # The surface gate and the session/task read gates consume it; an operator
517
+ # principal is NOT a tenant (it has the run of the deployment, exactly as
518
+ # in single_tenant mode).
519
+ def tenant_principal?(req)
520
+ p = req.get_header("insika.principal")
521
+ p && p[:role] == "tenant"
522
+ end
523
+
524
+ # The tenant the request operates AS: nil for an operator/classic request.
525
+ def req_tenant(req)
526
+ p = req.get_header("insika.principal")
527
+ p && p[:role] == "tenant" ? p[:tenant_id] : nil
528
+ end
529
+
530
+ # A tenant may reach ONLY its own runtime surfaces. Everything else
531
+ # (commands, provisioning, authoring, config) is the operator's. An
532
+ # unknown route is NOT here -> a tenant is refused (the surface exists —
533
+ # just not for them), not told it is missing.
534
+ TENANT_SURFACES = [
535
+ ["POST", ["v1", "sessions"]],
536
+ ["POST", ["v1", "messages"]],
537
+ ["POST", ["v1", "responses"]],
538
+ ["POST", ["v1", "outcomes"]],
539
+ ["GET", ["v1", "outcomes"]],
540
+ ["POST", ["v1", "workflows", nil]],
541
+ ["GET", ["v1", "workflows"]],
542
+ ["GET", ["v1", "sessions", nil]],
543
+ ["GET", ["v1", "tasks", nil]],
544
+ ["GET", ["v1", "events"]]
545
+ ].freeze
546
+ private_constant :TENANT_SURFACES
547
+
548
+ def tenant_surface?(method, segments)
549
+ TENANT_SURFACES.any? do |m, s|
550
+ m == method && s.zip(segments).all? { |pattern, got| pattern.nil? || pattern == got }
551
+ end
552
+ end
553
+
554
+ # Session id namespacing (WS1): a tenant's session lives under
555
+ # "<tenant>:<id>", so two tenants using the SAME chat id never share a
556
+ # session — the key itself is the isolation, not a convention. ":"
557
+ # (never "/") so the id stays one URL path segment; the same delimiter
558
+ # convention as the channels' "<channel>:<external_id>". Idempotent (a
559
+ # caller passing its own namespaced id back is not double-prefixed).
560
+ def scoped_session_id(tenant, id)
561
+ return id if tenant.nil? || id.nil? || id.to_s.empty?
562
+ return id if id.to_s.start_with?("#{tenant}:")
563
+
564
+ "#{tenant}:#{id}"
565
+ end
566
+
567
+ # POST /channels/:id/events — the Shape B inbound webhook.
412
568
  # ACK FAST and never the reply: the platform (or the relay consumer) is holding
413
569
  # a connection open with a retry timer on it, so this dispatches the turn and
414
570
  # answers with its id. The answer itself leaves later, out of band, through the
415
- # channel's own `deliver` (§6.5).
571
+ # channel's own `deliver`.
416
572
  #
417
573
  # The channel does ALL the translating — auth, envelope, session correlation —
418
574
  # and this handler stays what `server/` is allowed to be: a route that turns a
419
575
  # request into a Command. Four answers, and each one is a different fact:
420
576
  # 202 {task_id} a turn is running; its reply will be delivered
421
- # 200 {task_id, duplicate} we already ran this event id (§6.4)
422
- # 200 {task_id, merged} it joined a turn at the door (RFC-0015 §5.5)
577
+ # 200 {task_id, duplicate} we already ran this event id
578
+ # 200 {task_id, merged} it joined a turn at the door
423
579
  # 200 {task_id, steered} it was appended to a turn already running
424
580
  # A consumer that treats the last three as 202 delivers the same answer twice.
425
581
  def handle_channel_event(req, id)
@@ -435,11 +591,56 @@ module Insika
435
591
 
436
592
  payload = { agent: parsed[:agent], session_id: session_id,
437
593
  message: parsed[:message], event_id: parsed[:event_id] }.compact
438
- channel_ack(payload, transport: :"channel:#{id}")
594
+ ack = channel_ack(payload, transport: :"channel:#{id}")
595
+ # in shadow the mirror's own reply may ride the SAME call
596
+ # (Shape 1) — recorded before we answer, through the one command.
597
+ if channel.respond_to?(:shadow?) && channel.shadow?
598
+ dispatch_shadow_reply(id, parsed) if parsed[:incumbent_reply]
599
+ return shadow_ack(ack)
600
+ end
601
+ ack
602
+ end
603
+
604
+ # POST /channels/:id/shadow-reply — the mirror contract's fallback (Shape 2),
605
+ # for a consumer that mirrors the exchange BEFORE it answers the customer.
606
+ # Same channel_gate (auth is the channel's, not the route's); 404 unless the
607
+ # channel is in shadow — the same parity every other optional surface has.
608
+ def handle_channel_shadow_reply(req, id)
609
+ channel = @channels.find(id)
610
+ return not_found if channel.nil? || !(channel.respond_to?(:shadow?) && channel.shadow?)
611
+
612
+ gate = channel_gate(channel, req)
613
+ return gate if gate
614
+
615
+ parsed = channel.parse_shadow_reply(req, body: parse_raw_body(req)) # ValidationError -> 422
616
+ result = dispatch_shadow_reply(id, parsed)
617
+ json_response(202, { pair_id: result[:pair_id], status: result[:status] })
618
+ end
619
+
620
+ # The incumbent half rides ONE command whatever shape it arrived in, so
621
+ # server/ never writes to a store and both doors share one behaviour.
622
+ def dispatch_shadow_reply(id, parsed)
623
+ @command_bus.dispatch(
624
+ Insika::Command.build(:record_shadow_reply,
625
+ { channel: id.to_s, external_id: parsed[:external_id],
626
+ event_id: parsed[:event_id], reply: parsed[:incumbent_reply] || parsed[:reply],
627
+ at: parsed[:at] }.compact,
628
+ transport: :"channel:#{id}")
629
+ )
630
+ end
631
+
632
+ # 202 -> 200 {task_id, shadow: true}: a consumer wired to "202 means a
633
+ # reply is coming" must not be silently misled. The duplicate/merged/
634
+ # steered verdicts pass through untouched (they already say 200).
635
+ def shadow_ack(ack)
636
+ status, _headers, body = ack
637
+ return ack unless status == 202
638
+
639
+ json_response(200, JSON.parse(body.join).merge("shadow" => true))
439
640
  end
440
641
 
441
642
  # POST /channels/:id/sessions — mint a conversation for a PUBLIC Shape A
442
- # channel (RFC-0011 §4.3). The engine issues the id and the client never
643
+ # channel. The engine issues the id and the client never
443
644
  # proposes one: an endpoint that created a session from a caller-supplied id
444
645
  # would let anyone read someone else's conversation by guessing.
445
646
  #
@@ -523,7 +724,7 @@ module Insika
523
724
  end
524
725
 
525
726
  # Does this session exist AND belong to this channel? `vars["channel"]` is
526
- # written when the session is minted (§4.3).
727
+ # written when the session is minted.
527
728
  def channel_session?(id, session_id)
528
729
  session = session_id && @session_store.find(session_id)
529
730
  return false if session.nil?
@@ -569,7 +770,7 @@ module Insika
569
770
  json_response(202, { task_id: result[:task_id] })
570
771
  end
571
772
 
572
- # RFC-0011 §4.3: `channel` + `external_id` on the session are how a later turn
773
+ # `channel` + `external_id` on the session are how a later turn
573
774
  # (and the outbox) know where a reply goes. The consumer's own `vars` ride along
574
775
  # on first contact, but never over those two — a caller must not be able to
575
776
  # rewrite its own conversation's address.
@@ -582,19 +783,29 @@ module Insika
582
783
  # namespaced `<channel>:<external_id>` for a channel): creates if new, continues
583
784
  # if it exists (multi-turn). Via Command (server/ does not write to a store).
584
785
  # Benign race (two near-simultaneous turns creating) -> ArgumentError from the
585
- # store, treated as "already exists".
586
- def ensure_session(id, vars: { channel: "responses" })
786
+ # store, treated as "already exists". A TENANT's id is namespaced (WS1), so
787
+ # two tenants with the same chat id get two isolated sessions.
788
+ def ensure_session(id, vars: { channel: "responses" }, tenant: nil)
789
+ id = scoped_session_id(tenant, id)
587
790
  return if @session_store.find(id)
588
791
 
589
792
  @command_bus.dispatch(
590
- Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http)
793
+ Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http,
794
+ tenant: tenant)
591
795
  )
592
796
  rescue ArgumentError
593
797
  nil
594
798
  end
595
799
 
596
- # GET /v1/sessions/:id — direct read (not a Command).
597
- def handle_read_session(id)
800
+ # GET /v1/sessions/:id — direct read (not a Command). A tenant may only
801
+ # read its OWN sessions: the ownership is the id namespace itself (its
802
+ # sessions live under "<tenant>:…"), anything else reads as a 404.
803
+ def handle_read_session(req, id)
804
+ tenant = req_tenant(req)
805
+ if tenant && !id.to_s.start_with?("#{tenant}:")
806
+ raise Insika::NotFoundError, "session not found: #{id}"
807
+ end
808
+
598
809
  session = @session_store.find(id)
599
810
  raise Insika::NotFoundError, "session not found: #{id}" if session.nil?
600
811
 
@@ -603,11 +814,18 @@ module Insika
603
814
 
604
815
  # GET /v1/tasks/:id — direct read. This is where the consumer observes
605
816
  # PolicyDenied/post-202 failures: the terminal state lives in the Task
606
- # Store; nothing is lost if the client disconnected.
607
- def handle_read_task(id)
817
+ # Store; nothing is lost if the client disconnected. A tenant may only
818
+ # read a task its own command stamped (the task record carries the
819
+ # command with its meta.tenant) — someone else's reads as a 404.
820
+ def handle_read_task(req, id)
608
821
  task = @task_store.find(id)
609
822
  raise Insika::NotFoundError, "task not found: #{id}" if task.nil?
610
823
 
824
+ tenant = req_tenant(req)
825
+ if tenant && task_tenant(task) != tenant
826
+ raise Insika::NotFoundError, "task not found: #{id}"
827
+ end
828
+
611
829
  body = { task: task_to_h(task) }
612
830
  # pending approvals: this is where the consumer/operator sees
613
831
  # what needs approval after an :approval_requested.
@@ -617,12 +835,24 @@ module Insika
617
835
  json_response(200, body)
618
836
  end
619
837
 
838
+ # The tenant stamped on the task's persisted command (WS1): string or
839
+ # symbol keys, whichever the store round-trip produced.
840
+ def task_tenant(task)
841
+ command = task.respond_to?(:command) ? task.command : nil
842
+ return nil unless command.is_a?(Hash)
843
+
844
+ meta = command["meta"] || command[:meta] || {}
845
+ meta["tenant"] || meta[:tenant]
846
+ end
847
+
620
848
  # GET /v1/events?task_id=&session_id= — here the filters ARE known.
621
849
  # CONTINUOUS stream (post-crash reconnection route): does not close on a
622
- # terminal event — ends on client disconnect or cap.
850
+ # terminal event — ends on client disconnect or cap. A tenant's stream is
851
+ # scoped to its own events (fail-closed on the event's meta.tenant).
623
852
  def handle_events(req)
624
853
  subscription = @event_stream.subscribe(task_id: req.GET["task_id"],
625
- session_id: req.GET["session_id"])
854
+ session_id: req.GET["session_id"],
855
+ tenant: req_tenant(req))
626
856
  sse_response(subscription)
627
857
  end
628
858
 
@@ -650,8 +880,45 @@ module Insika
650
880
  # transport (TaskFilter). A SYNCHRONOUS handler error (Validation/NotFound)
651
881
  # happens here, BEFORE the SSE opens -> closes the subscription and propagates to the
652
882
  # #call rescue (becomes an HTTP status).
653
- def message_flow(payload, stream:, serialize: nil, transport: :http)
654
- command = Insika::Command.build(:send_message, payload, transport: transport)
883
+ def message_flow(payload, stream:, serialize: nil, transport: :http, tenant: nil)
884
+ # WS9: content parts are CONTRACT at the edge — the closed shape is
885
+ # validated here (422) before the command is built; the engine stays
886
+ # lenient for transports that bypass this surface.
887
+ parts = payload[:parts] || payload["parts"]
888
+ if parts && !Insika::Media.well_formed?(parts)
889
+ raise Insika::ValidationError,
890
+ "malformed content part — each part must be {type: text|image|audio} with text/url"
891
+ end
892
+ # WS9: the only declared message source is "voice" (pre-transcribed
893
+ # audio) — a consumer that writes prose here is told so, not silently.
894
+ source = payload[:source] || payload["source"]
895
+ if source && source.to_s != "voice"
896
+ raise Insika::ValidationError, 'source must be "voice"'
897
+ end
898
+ # WS9 (saída): the channel's declared OUTPUT media capabilities. The
899
+ # closed list IS the "abstraction admits only what leaks" rule: an
900
+ # unknown value is refused here (422), never silently ignored; a
901
+ # declared value is what the executor may wire (generate_image/tts).
902
+ channel = payload[:channel] || payload["channel"]
903
+ if channel.is_a?(Hash)
904
+ caps = Insika::Media.channel_capabilities(channel)
905
+ unknown = caps - Insika::Media::OUTPUT_CAPABILITIES
906
+ unless unknown.empty?
907
+ raise Insika::ValidationError,
908
+ "unknown channel capability: #{unknown.join(', ')}"
909
+ end
910
+
911
+ payload = payload.dup
912
+ payload[:channel] = { capabilities: caps }
913
+ end
914
+ # WS1: a tenant's session_id is NAMESPACED before the command is built,
915
+ # so the turn lands on the tenant's OWN session even when another tenant
916
+ # uses the same chat id. The payload the caller sent is untouched.
917
+ if tenant
918
+ payload = payload.dup
919
+ payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
920
+ end
921
+ command = Insika::Command.build(:send_message, payload, transport: transport, tenant: tenant)
655
922
  subscription = @event_stream.subscribe
656
923
  result =
657
924
  begin
@@ -661,7 +928,7 @@ module Insika
661
928
  raise
662
929
  end
663
930
 
664
- # RFC-0015 §5.5: the message joined another turn — one still waiting at the
931
+ # the message joined another turn — one still waiting at the
665
932
  # door (`merged`) or one already running (`steered`). Either way this call
666
933
  # owns no reply; the one holding `task_id` does. Say exactly that and open no
667
934
  # stream: a caller that delivered this response's (empty) output would
@@ -682,14 +949,20 @@ module Insika
682
949
  stream ? sse_response(filtered, serialize: serialize) : aggregate_response(filtered, task_id)
683
950
  end
684
951
 
685
- # Workflow trigger flow (item 22). Async by default (the honest workflow
952
+ # Workflow trigger flow. Async by default (the honest workflow
686
953
  # contract: fire the run, return the runId); ?stream=true streams the run's
687
954
  # events like a turn. Same subscribe-before-dispatch discipline as
688
955
  # message_flow so no eager event is lost when streaming. A synchronous handler
689
956
  # error (bad input / unknown workflow) closes the subscription and propagates
690
957
  # to #call (HTTP status).
691
- def workflow_flow(payload, stream:)
692
- command = Insika::Command.build(:trigger_workflow, payload, transport: :http)
958
+ def workflow_flow(payload, stream:, tenant: nil)
959
+ # WS1: same session-id namespacing as message_flow — the workflow's run
960
+ # session is the tenant's own.
961
+ if tenant
962
+ payload = payload.dup
963
+ payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
964
+ end
965
+ command = Insika::Command.build(:trigger_workflow, payload, transport: :http, tenant: tenant)
693
966
 
694
967
  unless stream
695
968
  result = dispatch_with_timeout(command)
@@ -724,7 +997,11 @@ module Insika
724
997
  events << event.to_h
725
998
  case event.type
726
999
  when :content then content << event.data[:delta].to_s
727
- when :task_failed then error = { class: event.data[:error], message: event.data[:message] }
1000
+ when :task_failed
1001
+ error = { class: event.data[:error], message: event.data[:message] }
1002
+ # A8: the classification rides through when the executor wrapped
1003
+ # the failure (ProviderError) — additive for every other terminal.
1004
+ error = error.merge(event.data.slice(:kind, :retryable, :retry_after))
728
1005
  when :task_cancelled then error = { class: "Insika::CancelledError", message: "task cancelled" }
729
1006
  when :error then error ||= { class: nil, message: event.data[:message] }
730
1007
  end
@@ -812,15 +1089,50 @@ module Insika
812
1089
  json_response(status, { error: { class: error.class.name, message: error.message } })
813
1090
  end
814
1091
 
1092
+ # the 500 is the ONE status whose body carries the retry envelope —
1093
+ # retryable/retry_after tell the client what to do, error_ref is what it
1094
+ # quotes when the retry keeps failing. The SAME ref is logged here, or the
1095
+ # field is decoration. 4xx stay bare: a client error is fixed by editing
1096
+ # the request, not by waiting.
1097
+ def internal_error_response(error)
1098
+ ref = "err_#{SecureRandom.hex(8)}"
1099
+ # Observability only (shutdown.rb's rule): a logger failure must never
1100
+ # mask the 500 the client is owed.
1101
+ begin
1102
+ @logger&.puts("[server] #{ref} #{error.class}: #{error.message}\n" \
1103
+ "#{Array(error.backtrace).first(5).join("\n")}")
1104
+ rescue StandardError
1105
+ nil
1106
+ end
1107
+ # B9/A8: a classified ProviderError quotes its own retry guidance;
1108
+ # everything else keeps the blanket retry (the caller may have died mid
1109
+ # request — a bounded wait is the safe default).
1110
+ retryable = error.respond_to?(:retryable) && !error.retryable.nil? ? error.retryable : true
1111
+ retry_after = error.respond_to?(:retry_after) && error.retry_after ? error.retry_after : RETRY_AFTER_SECONDS
1112
+ body = { error: { class: error.class.name, message: error.message,
1113
+ retryable: retryable, retry_after: retry_after, error_ref: ref } }
1114
+ body[:error][:kind] = error.kind if error.respond_to?(:kind) && error.kind
1115
+ json_response(500, body)
1116
+ end
1117
+
815
1118
  def not_found
816
1119
  [404, { "content-type" => "text/plain" }, ["not found"]]
817
1120
  end
818
1121
 
819
1122
  # Liveness/readiness. Fixed 200: if the process accepts the connection and recovery
820
- # has already run (Boot only returns the app afterward — doc 07 §4), it's ready. Does NOT
1123
+ # has already run (Boot only returns the app afterward), it's ready. Does NOT
821
1124
  # touch a store (health cannot fail on IO nor require auth).
822
1125
  def health = json_response(200, { status: "ok" })
823
1126
 
1127
+ # process vitals. Operator-only by construction: not in
1128
+ # PUBLIC_ROUTES (no bearer -> unauthorized) and not in TENANT_SURFACES
1129
+ # (a tenant principal hits the operator-surface gate), so only an
1130
+ # operator reads process internals. Reads no store — pure OS/VM
1131
+ # readings, safe at any rate.
1132
+ def handle_vitals
1133
+ json_response(200, Insika::Vitals.snapshot(executor: @executor, db_path: @db_path))
1134
+ end
1135
+
824
1136
  # Thin Subscription decorator: discards
825
1137
  # events from OTHER tasks and CLOSES the subscription after forwarding the task's
826
1138
  # terminal event. Solves the subscribe-before-task_id gap without touching