insika 0.1.0 → 0.2.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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -3
  3. data/README.md +1 -1
  4. data/bin/insika +22 -7
  5. data/docs/AGENTS.md +129 -5
  6. data/docs/CHANNELS.md +1 -1
  7. data/docs/CONTEXT.md +22 -5
  8. data/docs/DEPLOY.md +30 -10
  9. data/docs/EMBEDDING.md +11 -7
  10. data/docs/EVALS.md +1 -1
  11. data/docs/LOADTEST.md +3 -2
  12. data/docs/OBSERVABILITY.md +11 -2
  13. data/docs/REFINEMENT.md +6 -6
  14. data/docs/RELEASING.md +7 -7
  15. data/docs/RUNNING-LOCAL.md +1 -1
  16. data/docs/SECURITY.md +24 -11
  17. data/docs/SKILLS.md +189 -3
  18. data/docs/WHY.md +1 -1
  19. data/docs/WORKFLOWS.md +2 -2
  20. data/docs/index.md +1 -1
  21. data/docs/onboarding/start.md +1 -1
  22. data/lib/insika/agent_profile.rb +89 -22
  23. data/lib/insika/alert_dispatcher.rb +139 -0
  24. data/lib/insika/baseline_store.rb +2 -2
  25. data/lib/insika/budget_ledger.rb +135 -0
  26. data/lib/insika/channel_delivery.rb +14 -11
  27. data/lib/insika/channel_registry.rb +1 -1
  28. data/lib/insika/channels/relay.rb +3 -3
  29. data/lib/insika/channels/web/widget.js +2 -2
  30. data/lib/insika/channels/web.rb +7 -7
  31. data/lib/insika/channels/webhook.rb +58 -0
  32. data/lib/insika/chat_builder.rb +62 -13
  33. data/lib/insika/circuit_state.rb +114 -0
  34. data/lib/insika/coercion.rb +8 -0
  35. data/lib/insika/commands/agent_payload.rb +5 -3
  36. data/lib/insika/commands/create_agent.rb +2 -2
  37. data/lib/insika/commands/create_session.rb +1 -1
  38. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  39. data/lib/insika/commands/delete_skill.rb +43 -0
  40. data/lib/insika/commands/gate_refinement.rb +12 -12
  41. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  42. data/lib/insika/commands/import_tools.rb +4 -4
  43. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  44. data/lib/insika/commands/resolve_refinement.rb +1 -1
  45. data/lib/insika/commands/revoke_token.rb +39 -0
  46. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  47. data/lib/insika/commands/run_refinement.rb +5 -5
  48. data/lib/insika/commands/send_message.rb +9 -9
  49. data/lib/insika/commands/set_agent_tools.rb +1 -1
  50. data/lib/insika/commands/set_skill_agents.rb +60 -19
  51. data/lib/insika/commands/trigger_workflow.rb +1 -1
  52. data/lib/insika/commands/update_agent.rb +1 -1
  53. data/lib/insika/commands/write_data_tool.rb +1 -1
  54. data/lib/insika/commands/write_golden.rb +1 -1
  55. data/lib/insika/commands/write_skill.rb +19 -9
  56. data/lib/insika/config_store.rb +8 -4
  57. data/lib/insika/context/builder.rb +2 -2
  58. data/lib/insika/context/fragment.rb +27 -3
  59. data/lib/insika/context/priority.rb +3 -2
  60. data/lib/insika/context/providers/memory.rb +1 -1
  61. data/lib/insika/context/providers/request.rb +1 -1
  62. data/lib/insika/context/providers/session.rb +17 -2
  63. data/lib/insika/context/providers/skill.rb +5 -1
  64. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  65. data/lib/insika/context_trace_store.rb +92 -0
  66. data/lib/insika/delegation_store.rb +2 -2
  67. data/lib/insika/doctor.rb +250 -5
  68. data/lib/insika/dsl/runtime.rb +12 -9
  69. data/lib/insika/dsl/server_boot.rb +4 -3
  70. data/lib/insika/dsl/system.rb +1 -1
  71. data/lib/insika/dsl.rb +72 -15
  72. data/lib/insika/edge_limiter.rb +144 -6
  73. data/lib/insika/egress_guard.rb +3 -3
  74. data/lib/insika/env_schema.rb +13 -10
  75. data/lib/insika/errors.rb +61 -5
  76. data/lib/insika/evals/assertions.rb +12 -12
  77. data/lib/insika/evals/baseline.rb +3 -3
  78. data/lib/insika/evals/golden.rb +8 -8
  79. data/lib/insika/evals/judge.rb +7 -7
  80. data/lib/insika/evals/pairwise.rb +3 -3
  81. data/lib/insika/evals/report.rb +2 -2
  82. data/lib/insika/evals/runner.rb +6 -6
  83. data/lib/insika/evals/transport.rb +2 -2
  84. data/lib/insika/event_stream.rb +23 -5
  85. data/lib/insika/executor.rb +423 -108
  86. data/lib/insika/frontmatter.rb +1 -1
  87. data/lib/insika/golden_store.rb +2 -2
  88. data/lib/insika/http_client.rb +3 -3
  89. data/lib/insika/inbound_log.rb +1 -1
  90. data/lib/insika/llm_configurator.rb +3 -3
  91. data/lib/insika/loop_detector.rb +143 -0
  92. data/lib/insika/mcp_http_client.rb +4 -4
  93. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  94. data/lib/insika/message_origin.rb +2 -2
  95. data/lib/insika/model_resolver.rb +1 -1
  96. data/lib/insika/model_selection.rb +5 -4
  97. data/lib/insika/onboarding.rb +2 -2
  98. data/lib/insika/outbox_store.rb +2 -2
  99. data/lib/insika/overlay_tool_registry.rb +3 -4
  100. data/lib/insika/pack.rb +3 -3
  101. data/lib/insika/pack_importer.rb +17 -15
  102. data/lib/insika/pending_action_store.rb +1 -1
  103. data/lib/insika/plugin/loader.rb +2 -2
  104. data/lib/insika/policy/policy.rb +1 -1
  105. data/lib/insika/profile_source.rb +12 -6
  106. data/lib/insika/provider_error_classifier.rb +160 -0
  107. data/lib/insika/queue_policy.rb +2 -2
  108. data/lib/insika/recovery.rb +47 -6
  109. data/lib/insika/refinement/candidate.rb +4 -4
  110. data/lib/insika/refinement/evidence_collector.rb +6 -6
  111. data/lib/insika/refinement/gate.rb +7 -7
  112. data/lib/insika/refinement/panel.rb +7 -7
  113. data/lib/insika/refinement/proposer.rb +9 -9
  114. data/lib/insika/refinement_store.rb +12 -12
  115. data/lib/insika/reliability.rb +185 -0
  116. data/lib/insika/safety/config.rb +2 -2
  117. data/lib/insika/safety/detectors.rb +5 -5
  118. data/lib/insika/safety/factory.rb +3 -3
  119. data/lib/insika/safety/input_guardrail.rb +19 -4
  120. data/lib/insika/safety/moderator.rb +19 -11
  121. data/lib/insika/safety/output_filter.rb +2 -2
  122. data/lib/insika/safety/output_validator.rb +2 -2
  123. data/lib/insika/safety/safe_responses.rb +1 -1
  124. data/lib/insika/sandbox/boundary.rb +2 -2
  125. data/lib/insika/sandbox.rb +1 -1
  126. data/lib/insika/server/app.rb +223 -51
  127. data/lib/insika/server/boot.rb +4 -4
  128. data/lib/insika/server/rack_app.rb +15 -7
  129. data/lib/insika/server/responses.rb +18 -8
  130. data/lib/insika/server/tenant_auth.rb +61 -0
  131. data/lib/insika/session_actor.rb +3 -3
  132. data/lib/insika/session_store.rb +1 -1
  133. data/lib/insika/settings_store.rb +5 -5
  134. data/lib/insika/shutdown.rb +4 -4
  135. data/lib/insika/skill_catalog.rb +127 -20
  136. data/lib/insika/skill_store.rb +70 -22
  137. data/lib/insika/steer_injector.rb +1 -1
  138. data/lib/insika/store.rb +1 -1
  139. data/lib/insika/studio/app.rb +183 -61
  140. data/lib/insika/studio/assets/dist/application.js +25 -24
  141. data/lib/insika/studio/forms.rb +13 -18
  142. data/lib/insika/studio/nav_icons.rb +1 -1
  143. data/lib/insika/studio/views/_message.erb +2 -2
  144. data/lib/insika/studio/views/agent_detail.erb +2 -2
  145. data/lib/insika/studio/views/agents.erb +1 -1
  146. data/lib/insika/studio/views/refinement.erb +4 -4
  147. data/lib/insika/studio/views/session.erb +78 -3
  148. data/lib/insika/studio/views/settings.erb +7 -12
  149. data/lib/insika/studio/views/skills.erb +67 -12
  150. data/lib/insika/subagent_graph.rb +3 -3
  151. data/lib/insika/task_actor.rb +3 -3
  152. data/lib/insika/task_store.rb +1 -1
  153. data/lib/insika/telemetry/pricing.rb +3 -3
  154. data/lib/insika/telemetry/recorder.rb +1 -1
  155. data/lib/insika/telemetry.rb +2 -2
  156. data/lib/insika/testing/store_contract.rb +27 -27
  157. data/lib/insika/tick.rb +122 -0
  158. data/lib/insika/token_store.rb +168 -0
  159. data/lib/insika/tool_assembly.rb +5 -5
  160. data/lib/insika/tool_definition.rb +8 -8
  161. data/lib/insika/tool_envelope.rb +1 -1
  162. data/lib/insika/tool_manifest.rb +6 -6
  163. data/lib/insika/tool_output_compressor.rb +100 -0
  164. data/lib/insika/tool_store.rb +1 -1
  165. data/lib/insika/tool_trace_store.rb +1 -1
  166. data/lib/insika/tools/concurrency.rb +2 -2
  167. data/lib/insika/tools/data_defined_tool.rb +4 -5
  168. data/lib/insika/tools/load_skill.rb +61 -3
  169. data/lib/insika/tools/stuck_signal.rb +44 -0
  170. data/lib/insika/tools/subagent.rb +4 -4
  171. data/lib/insika/tools/subagents.rb +1 -1
  172. data/lib/insika/turn_output.rb +2 -2
  173. data/lib/insika/turn_state.rb +17 -13
  174. data/lib/insika/turn_timing.rb +2 -2
  175. data/lib/insika/usage_ledger.rb +1 -1
  176. data/lib/insika/version.rb +1 -1
  177. data/lib/insika/wiring/graph.rb +77 -26
  178. data/lib/insika/workflow.rb +1 -1
  179. data/lib/insika/workflow_registry.rb +1 -1
  180. data/lib/insika.rb +32 -15
  181. metadata +19 -2
  182. 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,20 @@ 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)
51
59
  @command_bus = command_bus
52
60
  @event_stream = event_stream
53
61
  @session_store = session_store
@@ -56,26 +64,37 @@ module Insika
56
64
  @pending_action_store = pending_action_store # read for GET /v1/tasks/:id
57
65
  @a2a = a2a # A2A edge. nil = server does not expose A2A (parity).
58
66
  @provisioner = provisioner # PackImporter. nil = provisioning not exposed.
59
- # Item 22 / §4.4: READ-ONLY registry, injected only where workflows are
67
+ # WS1 multi-tenant credentials. nil = single_tenant mode (the classic
68
+ # single operator credential, gateway_token). Present = tokens resolve
69
+ # from the store (per-tenant + operator), gateway_token still resolves
70
+ # as operator (an existing deployment switching modes keeps its token).
71
+ @token_store = token_store
72
+ # "single_tenant" (default, parity) | "multi_tenant".
73
+ @tenancy = config.fetch(:tenancy, "single_tenant")
74
+ # READ-ONLY registry, injected only where workflows are
60
75
  # exposed (the minimal wiring). nil = no /v1/workflows routes (parity — the
61
76
  # deployment does not expose workflows). Reading a catalog is a READ, like a
62
77
  # store read: the constitutional rule (no Executor/store-writes/RubyLLM) holds.
63
78
  @workflow_registry = workflow_registry
64
- # Item 20 / §5.6: LLM-first onboarding surface (start.md + models.json +
79
+ # LLM-first onboarding surface (start.md + models.json +
65
80
  # docs). PUBLIC (no auth — the whole point of the "read <base>/start.md" trick
66
81
  # is that the developer's coding agent can fetch it), and READ-ONLY, so the
67
82
  # constitutional rule holds. nil = routes not exposed (parity — the production
68
83
  # deployment opts in). Reading files/masked stores is a READ, like a store read.
69
84
  @onboarding = onboarding
70
- # RFC-0014 §3.2: READ-ONLY ProfileSource, so `GET /v1/agents/:id` can answer
85
+ # READ-ONLY ProfileSource, so `GET /v1/agents/:id` can answer
71
86
  # what an agent has — the eval is a client and cannot read a store. Same
72
87
  # constitutional footing as the workflow registry: reading a catalog is a
73
88
  # READ. nil = the route 404s (parity).
74
89
  @profiles = profiles
75
- # RFC-0011 §4.4: ONE generic route family for every channel, opt-in by
90
+ # ONE generic route family for every channel, opt-in by
76
91
  # injecting the registry (nil ⇒ the routes do not exist, parity with @a2a).
77
92
  # The channel does the translating; this class keeps doing only transport.
78
93
  @channels = channels
94
+ # where a 500's error_ref goes to be FOUND. nil = silent (parity for
95
+ # embedders); the serving wirings pass $stdout. Class+message+backtrace
96
+ # only — the ref never travels with request payloads (secrets stay out).
97
+ @logger = logger
79
98
  @heartbeat = config.fetch(:heartbeat, 15)
80
99
  @sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
81
100
  end
@@ -96,7 +115,7 @@ module Insika
96
115
  rescue Async::TimeoutError => e
97
116
  error_response(504, e) # synchronous control request exceeded the ceiling
98
117
  rescue StandardError => e
99
- error_response(500, e)
118
+ internal_error_response(e)
100
119
  end
101
120
 
102
121
  private
@@ -114,8 +133,15 @@ module Insika
114
133
  return version_error if version_error
115
134
  end
116
135
 
117
- gate = public_route?(req.request_method, segments) ? nil : gateway_gate(req)
136
+ gate = public_route?(req.request_method, segments) ? nil : gateway_gate(req)
118
137
  return gate if gate
138
+ # WS1: a TENANT principal is confined to its own runtime surfaces (chat
139
+ # + its own reads). Every authoring/provisioning surface stays
140
+ # operator-only — a tenant can never mint tokens, author tools or
141
+ # change platform config. single_tenant (no principal) is untouched.
142
+ if tenant_principal?(req) && !tenant_surface?(req.request_method, segments)
143
+ return auth_error(403, "operator surface")
144
+ end
119
145
 
120
146
  case [req.request_method, segments]
121
147
  in ["GET", ["up"]]
@@ -151,9 +177,9 @@ module Insika
151
177
  in ["GET", ["v1", "agents", id]] if @profiles
152
178
  handle_read_agent(id)
153
179
  in ["GET", ["v1", "sessions", id]]
154
- handle_read_session(id)
180
+ handle_read_session(req, id)
155
181
  in ["GET", ["v1", "tasks", id]]
156
- handle_read_task(id)
182
+ handle_read_task(req, id)
157
183
  in ["GET", ["v1", "events"]]
158
184
  handle_events(req)
159
185
  in ["POST", ["channels", id, "events"]] if @channels
@@ -241,8 +267,21 @@ module Insika
241
267
  # POST /v1/sessions — sugar for create_session; 201 {session}.
242
268
  def handle_create_session(req)
243
269
  body = parse_body(req)
244
- command = Insika::Command.build(:create_session, { vars: body[:vars] || {} },
245
- transport: :http)
270
+ tenant = req_tenant(req)
271
+ # WS1: a tenant's session must be born under its OWN "<tenant>:" namespace
272
+ # — the read path (GET /v1/sessions/:id) refuses anything else. Scope the
273
+ # caller's id the same way message_flow scopes a session_id; a tenant that
274
+ # passed none gets a namespaced uuid instead of an unprefixed one it could
275
+ # never read back.
276
+ if tenant
277
+ id = body[:id] || body["id"]
278
+ id = Insika::Coercion.blank?(id) ? scoped_session_id(tenant, SecureRandom.uuid)
279
+ : scoped_session_id(tenant, id)
280
+ body = body.merge(id: id)
281
+ end
282
+ command = Insika::Command.build(:create_session,
283
+ { vars: body[:vars] || {}, id: body[:id] }.compact,
284
+ transport: :http, tenant: tenant)
246
285
  session = dispatch_with_timeout(command)
247
286
  json_response(201, { session: session.to_h })
248
287
  end
@@ -251,13 +290,14 @@ module Insika
251
290
  # "false" -> 200 JSON aggregated at the terminal event.
252
291
  def handle_send_message(req)
253
292
  stream = req.GET["stream"] != "false"
254
- # RFC-0015 §5.5: only the aggregated-JSON form has room for the `merged`/`steered`
293
+ # only the aggregated-JSON form has room for the `merged`/`steered`
255
294
  # verdict, so only it may join a message to another turn. Once the stream is open
256
295
  # 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")
296
+ message_flow(parse_body(req), stream: stream, transport: stream ? :http : :"http:json",
297
+ tenant: req_tenant(req))
258
298
  end
259
299
 
260
- # GET /docs/:name.md — one public doc as raw markdown (item 20 / §5.6). The
300
+ # GET /docs/:name.md — one public doc as raw markdown. The
261
301
  # slug is a KEY of the onboarding allowlist, so no filesystem traversal is
262
302
  # possible; an unknown slug -> 404. `file` still carries the ".md" suffix.
263
303
  def handle_doc(file)
@@ -274,7 +314,7 @@ module Insika
274
314
  Insika::Coercion.presence(@config[:public_url]) || req.base_url
275
315
  end
276
316
 
277
- # GET /v1/workflows — discovery (item 22 / §4.4). Direct read of the
317
+ # GET /v1/workflows — discovery. Direct read of the
278
318
  # registry catalog (name + description + the I/O schema contract). Not a
279
319
  # Command; opt-in via the injected registry.
280
320
  def handle_list_workflows
@@ -293,7 +333,8 @@ module Insika
293
333
  body = parse_body(req)
294
334
  payload = { workflow: name, agent: body[:agent],
295
335
  input: body[:input], session_id: body[:session_id] }.compact
296
- workflow_flow(payload, stream: req.GET["stream"] == "true")
336
+ workflow_flow(payload, stream: req.GET["stream"] == "true",
337
+ tenant: req_tenant(req))
297
338
  end
298
339
 
299
340
  # POST /v1/responses — OpenAI Responses adapter (drop-in for the OpenClaw
@@ -305,14 +346,16 @@ module Insika
305
346
  return gate if gate
306
347
 
307
348
  parsed = Responses.parse_request(parse_body(req), req) # ValidationError -> 422
308
- ensure_session(parsed[:user])
349
+ tenant = req_tenant(req)
350
+ ensure_session(parsed[:user], tenant: tenant)
309
351
  payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
310
352
  payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
311
- message_flow(payload, stream: true, serialize: Responses.method(:frame_for))
353
+ message_flow(payload, stream: true, serialize: Responses.method(:frame_for),
354
+ tenant: tenant)
312
355
  end
313
356
 
314
357
  # POST /v1/agents — provisions (upserts) an agent from a standardized
315
- # PACK (Phase 6/D4/F7). Same Bearer as /v1/responses (gateway_token,
358
+ # PACK. Same Bearer as /v1/responses (gateway_token,
316
359
  # fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
317
360
  # JSON; the PackImporter emits the authoring Commands. -> 200 { summary }.
318
361
  # Raw body (string keys): the pack's file/skill names are data keys,
@@ -326,7 +369,7 @@ module Insika
326
369
  end
327
370
 
328
371
  # POST /v1/tools/manifest — BATCH ingestion of data-tools via manifest
329
- # (Phase 7, Step B). Same Bearer as provisioning (gateway_token, fail-
372
+ # Same Bearer as provisioning (gateway_token, fail-
330
373
  # closed): it's an authoring/provisioning surface and resolves the
331
374
  # deployment's secrets. RAW body (string keys): the JSON Schema property names and
332
375
  # the headers are DATA, not symbols. Dispatches :import_tools -> 200 { per-tool
@@ -341,7 +384,7 @@ module Insika
341
384
  json_response(200, dispatch_with_timeout(command))
342
385
  end
343
386
 
344
- # POST /v1/mcp/:name/import — LIVE MCP ingestion (Phase 7, Step E). Same
387
+ # POST /v1/mcp/:name/import — LIVE MCP ingestion. Same
345
388
  # Bearer as provisioning (gateway_token, fail-closed): it's an authoring
346
389
  # surface. Discovers the tools of the MCP instance `:name` (via a client injectable
347
390
  # at the composition root) and ingests them as data-tools (reuses :import_tools:
@@ -367,7 +410,7 @@ module Insika
367
410
 
368
411
  # GET /v1/agents/:id — what this deployment HAS for that agent, so an eval
369
412
  # (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,
413
+ # "this case failed". Deliberately NOT the profile: the prompt,
371
414
  # the model and the guardrail config are none of the caller's business. Just the
372
415
  # two facts a case declares `requires` against.
373
416
  #
@@ -400,26 +443,83 @@ module Insika
400
443
  end
401
444
 
402
445
  # Gateway Bearer (fail-closed). -> error response (503/401) OR nil when
403
- # ok (the handler proceeds).
446
+ # ok (the handler proceeds). In multi_tenant mode the resolved principal
447
+ # is stashed on the request env: `tenant_principal?`/`req_tenant` read it
448
+ # back for the surface gate and the command stamping.
404
449
  def gateway_gate(req)
405
- case Insika::Server::AdminAuth.check(@config[:gateway_token], req.get_header("HTTP_AUTHORIZATION"))
450
+ result = Insika::Server::TenantAuth.check(@config[:gateway_token], @token_store,
451
+ req.get_header("HTTP_AUTHORIZATION"))
452
+ case result
406
453
  when :disabled then auth_error(503, "gateway disabled")
407
454
  when :unauthorized then auth_error(401, "unauthorized", "www-authenticate" => "Bearer")
455
+ else
456
+ req.set_header("insika.principal", result)
457
+ nil
458
+ end
459
+ end
460
+
461
+ # -> bool: is the requester a TENANT principal (multi_tenant mode only)?
462
+ # The surface gate and the session/task read gates consume it; an operator
463
+ # principal is NOT a tenant (it has the run of the deployment, exactly as
464
+ # in single_tenant mode).
465
+ def tenant_principal?(req)
466
+ p = req.get_header("insika.principal")
467
+ p && p[:role] == "tenant"
468
+ end
469
+
470
+ # The tenant the request operates AS: nil for an operator/classic request.
471
+ def req_tenant(req)
472
+ p = req.get_header("insika.principal")
473
+ p && p[:role] == "tenant" ? p[:tenant_id] : nil
474
+ end
475
+
476
+ # A tenant may reach ONLY its own runtime surfaces. Everything else
477
+ # (commands, provisioning, authoring, config) is the operator's. An
478
+ # unknown route is NOT here -> a tenant is refused (the surface exists —
479
+ # just not for them), not told it is missing.
480
+ TENANT_SURFACES = [
481
+ ["POST", ["v1", "sessions"]],
482
+ ["POST", ["v1", "messages"]],
483
+ ["POST", ["v1", "responses"]],
484
+ ["POST", ["v1", "workflows", nil]],
485
+ ["GET", ["v1", "workflows"]],
486
+ ["GET", ["v1", "sessions", nil]],
487
+ ["GET", ["v1", "tasks", nil]],
488
+ ["GET", ["v1", "events"]]
489
+ ].freeze
490
+ private_constant :TENANT_SURFACES
491
+
492
+ def tenant_surface?(method, segments)
493
+ TENANT_SURFACES.any? do |m, s|
494
+ m == method && s.zip(segments).all? { |pattern, got| pattern.nil? || pattern == got }
408
495
  end
409
496
  end
410
497
 
411
- # POST /channels/:id/events — the Shape B inbound webhook (RFC-0011 §4.4).
498
+ # Session id namespacing (WS1): a tenant's session lives under
499
+ # "<tenant>:<id>", so two tenants using the SAME chat id never share a
500
+ # session — the key itself is the isolation, not a convention. ":"
501
+ # (never "/") so the id stays one URL path segment; the same delimiter
502
+ # convention as the channels' "<channel>:<external_id>". Idempotent (a
503
+ # caller passing its own namespaced id back is not double-prefixed).
504
+ def scoped_session_id(tenant, id)
505
+ return id if tenant.nil? || id.nil? || id.to_s.empty?
506
+ return id if id.to_s.start_with?("#{tenant}:")
507
+
508
+ "#{tenant}:#{id}"
509
+ end
510
+
511
+ # POST /channels/:id/events — the Shape B inbound webhook.
412
512
  # ACK FAST and never the reply: the platform (or the relay consumer) is holding
413
513
  # a connection open with a retry timer on it, so this dispatches the turn and
414
514
  # answers with its id. The answer itself leaves later, out of band, through the
415
- # channel's own `deliver` (§6.5).
515
+ # channel's own `deliver`.
416
516
  #
417
517
  # The channel does ALL the translating — auth, envelope, session correlation —
418
518
  # and this handler stays what `server/` is allowed to be: a route that turns a
419
519
  # request into a Command. Four answers, and each one is a different fact:
420
520
  # 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)
521
+ # 200 {task_id, duplicate} we already ran this event id
522
+ # 200 {task_id, merged} it joined a turn at the door
423
523
  # 200 {task_id, steered} it was appended to a turn already running
424
524
  # A consumer that treats the last three as 202 delivers the same answer twice.
425
525
  def handle_channel_event(req, id)
@@ -439,7 +539,7 @@ module Insika
439
539
  end
440
540
 
441
541
  # 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
542
+ # channel. The engine issues the id and the client never
443
543
  # proposes one: an endpoint that created a session from a caller-supplied id
444
544
  # would let anyone read someone else's conversation by guessing.
445
545
  #
@@ -523,7 +623,7 @@ module Insika
523
623
  end
524
624
 
525
625
  # Does this session exist AND belong to this channel? `vars["channel"]` is
526
- # written when the session is minted (§4.3).
626
+ # written when the session is minted.
527
627
  def channel_session?(id, session_id)
528
628
  session = session_id && @session_store.find(session_id)
529
629
  return false if session.nil?
@@ -569,7 +669,7 @@ module Insika
569
669
  json_response(202, { task_id: result[:task_id] })
570
670
  end
571
671
 
572
- # RFC-0011 §4.3: `channel` + `external_id` on the session are how a later turn
672
+ # `channel` + `external_id` on the session are how a later turn
573
673
  # (and the outbox) know where a reply goes. The consumer's own `vars` ride along
574
674
  # on first contact, but never over those two — a caller must not be able to
575
675
  # rewrite its own conversation's address.
@@ -582,19 +682,29 @@ module Insika
582
682
  # namespaced `<channel>:<external_id>` for a channel): creates if new, continues
583
683
  # if it exists (multi-turn). Via Command (server/ does not write to a store).
584
684
  # Benign race (two near-simultaneous turns creating) -> ArgumentError from the
585
- # store, treated as "already exists".
586
- def ensure_session(id, vars: { channel: "responses" })
685
+ # store, treated as "already exists". A TENANT's id is namespaced (WS1), so
686
+ # two tenants with the same chat id get two isolated sessions.
687
+ def ensure_session(id, vars: { channel: "responses" }, tenant: nil)
688
+ id = scoped_session_id(tenant, id)
587
689
  return if @session_store.find(id)
588
690
 
589
691
  @command_bus.dispatch(
590
- Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http)
692
+ Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http,
693
+ tenant: tenant)
591
694
  )
592
695
  rescue ArgumentError
593
696
  nil
594
697
  end
595
698
 
596
- # GET /v1/sessions/:id — direct read (not a Command).
597
- def handle_read_session(id)
699
+ # GET /v1/sessions/:id — direct read (not a Command). A tenant may only
700
+ # read its OWN sessions: the ownership is the id namespace itself (its
701
+ # sessions live under "<tenant>:…"), anything else reads as a 404.
702
+ def handle_read_session(req, id)
703
+ tenant = req_tenant(req)
704
+ if tenant && !id.to_s.start_with?("#{tenant}:")
705
+ raise Insika::NotFoundError, "session not found: #{id}"
706
+ end
707
+
598
708
  session = @session_store.find(id)
599
709
  raise Insika::NotFoundError, "session not found: #{id}" if session.nil?
600
710
 
@@ -603,11 +713,18 @@ module Insika
603
713
 
604
714
  # GET /v1/tasks/:id — direct read. This is where the consumer observes
605
715
  # 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)
716
+ # Store; nothing is lost if the client disconnected. A tenant may only
717
+ # read a task its own command stamped (the task record carries the
718
+ # command with its meta.tenant) — someone else's reads as a 404.
719
+ def handle_read_task(req, id)
608
720
  task = @task_store.find(id)
609
721
  raise Insika::NotFoundError, "task not found: #{id}" if task.nil?
610
722
 
723
+ tenant = req_tenant(req)
724
+ if tenant && task_tenant(task) != tenant
725
+ raise Insika::NotFoundError, "task not found: #{id}"
726
+ end
727
+
611
728
  body = { task: task_to_h(task) }
612
729
  # pending approvals: this is where the consumer/operator sees
613
730
  # what needs approval after an :approval_requested.
@@ -617,12 +734,24 @@ module Insika
617
734
  json_response(200, body)
618
735
  end
619
736
 
737
+ # The tenant stamped on the task's persisted command (WS1): string or
738
+ # symbol keys, whichever the store round-trip produced.
739
+ def task_tenant(task)
740
+ command = task.respond_to?(:command) ? task.command : nil
741
+ return nil unless command.is_a?(Hash)
742
+
743
+ meta = command["meta"] || command[:meta] || {}
744
+ meta["tenant"] || meta[:tenant]
745
+ end
746
+
620
747
  # GET /v1/events?task_id=&session_id= — here the filters ARE known.
621
748
  # CONTINUOUS stream (post-crash reconnection route): does not close on a
622
- # terminal event — ends on client disconnect or cap.
749
+ # terminal event — ends on client disconnect or cap. A tenant's stream is
750
+ # scoped to its own events (fail-closed on the event's meta.tenant).
623
751
  def handle_events(req)
624
752
  subscription = @event_stream.subscribe(task_id: req.GET["task_id"],
625
- session_id: req.GET["session_id"])
753
+ session_id: req.GET["session_id"],
754
+ tenant: req_tenant(req))
626
755
  sse_response(subscription)
627
756
  end
628
757
 
@@ -650,8 +779,15 @@ module Insika
650
779
  # transport (TaskFilter). A SYNCHRONOUS handler error (Validation/NotFound)
651
780
  # happens here, BEFORE the SSE opens -> closes the subscription and propagates to the
652
781
  # #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)
782
+ def message_flow(payload, stream:, serialize: nil, transport: :http, tenant: nil)
783
+ # WS1: a tenant's session_id is NAMESPACED before the command is built,
784
+ # so the turn lands on the tenant's OWN session even when another tenant
785
+ # uses the same chat id. The payload the caller sent is untouched.
786
+ if tenant
787
+ payload = payload.dup
788
+ payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
789
+ end
790
+ command = Insika::Command.build(:send_message, payload, transport: transport, tenant: tenant)
655
791
  subscription = @event_stream.subscribe
656
792
  result =
657
793
  begin
@@ -661,7 +797,7 @@ module Insika
661
797
  raise
662
798
  end
663
799
 
664
- # RFC-0015 §5.5: the message joined another turn — one still waiting at the
800
+ # the message joined another turn — one still waiting at the
665
801
  # door (`merged`) or one already running (`steered`). Either way this call
666
802
  # owns no reply; the one holding `task_id` does. Say exactly that and open no
667
803
  # stream: a caller that delivered this response's (empty) output would
@@ -682,14 +818,20 @@ module Insika
682
818
  stream ? sse_response(filtered, serialize: serialize) : aggregate_response(filtered, task_id)
683
819
  end
684
820
 
685
- # Workflow trigger flow (item 22). Async by default (the honest workflow
821
+ # Workflow trigger flow. Async by default (the honest workflow
686
822
  # contract: fire the run, return the runId); ?stream=true streams the run's
687
823
  # events like a turn. Same subscribe-before-dispatch discipline as
688
824
  # message_flow so no eager event is lost when streaming. A synchronous handler
689
825
  # error (bad input / unknown workflow) closes the subscription and propagates
690
826
  # to #call (HTTP status).
691
- def workflow_flow(payload, stream:)
692
- command = Insika::Command.build(:trigger_workflow, payload, transport: :http)
827
+ def workflow_flow(payload, stream:, tenant: nil)
828
+ # WS1: same session-id namespacing as message_flow — the workflow's run
829
+ # session is the tenant's own.
830
+ if tenant
831
+ payload = payload.dup
832
+ payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
833
+ end
834
+ command = Insika::Command.build(:trigger_workflow, payload, transport: :http, tenant: tenant)
693
835
 
694
836
  unless stream
695
837
  result = dispatch_with_timeout(command)
@@ -724,7 +866,11 @@ module Insika
724
866
  events << event.to_h
725
867
  case event.type
726
868
  when :content then content << event.data[:delta].to_s
727
- when :task_failed then error = { class: event.data[:error], message: event.data[:message] }
869
+ when :task_failed
870
+ error = { class: event.data[:error], message: event.data[:message] }
871
+ # A8: the classification rides through when the executor wrapped
872
+ # the failure (ProviderError) — additive for every other terminal.
873
+ error = error.merge(event.data.slice(:kind, :retryable, :retry_after))
728
874
  when :task_cancelled then error = { class: "Insika::CancelledError", message: "task cancelled" }
729
875
  when :error then error ||= { class: nil, message: event.data[:message] }
730
876
  end
@@ -812,12 +958,38 @@ module Insika
812
958
  json_response(status, { error: { class: error.class.name, message: error.message } })
813
959
  end
814
960
 
961
+ # the 500 is the ONE status whose body carries the retry envelope —
962
+ # retryable/retry_after tell the client what to do, error_ref is what it
963
+ # quotes when the retry keeps failing. The SAME ref is logged here, or the
964
+ # field is decoration. 4xx stay bare: a client error is fixed by editing
965
+ # the request, not by waiting.
966
+ def internal_error_response(error)
967
+ ref = "err_#{SecureRandom.hex(8)}"
968
+ # Observability only (shutdown.rb's rule): a logger failure must never
969
+ # mask the 500 the client is owed.
970
+ begin
971
+ @logger&.puts("[server] #{ref} #{error.class}: #{error.message}\n" \
972
+ "#{Array(error.backtrace).first(5).join("\n")}")
973
+ rescue StandardError
974
+ nil
975
+ end
976
+ # B9/A8: a classified ProviderError quotes its own retry guidance;
977
+ # everything else keeps the blanket retry (the caller may have died mid
978
+ # request — a bounded wait is the safe default).
979
+ retryable = error.respond_to?(:retryable) && !error.retryable.nil? ? error.retryable : true
980
+ retry_after = error.respond_to?(:retry_after) && error.retry_after ? error.retry_after : RETRY_AFTER_SECONDS
981
+ body = { error: { class: error.class.name, message: error.message,
982
+ retryable: retryable, retry_after: retry_after, error_ref: ref } }
983
+ body[:error][:kind] = error.kind if error.respond_to?(:kind) && error.kind
984
+ json_response(500, body)
985
+ end
986
+
815
987
  def not_found
816
988
  [404, { "content-type" => "text/plain" }, ["not found"]]
817
989
  end
818
990
 
819
991
  # 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
992
+ # has already run (Boot only returns the app afterward), it's ready. Does NOT
821
993
  # touch a store (health cannot fail on IO nor require auth).
822
994
  def health = json_response(200, { status: "ok" })
823
995
 
@@ -52,12 +52,12 @@ module Insika
52
52
  Sync { do_recovery }
53
53
  end
54
54
 
55
- # Task recovery THEN delegation recovery (RFC-0010 Fase 2): the delegation
55
+ # Task recovery THEN delegation recovery: the delegation
56
56
  # sweep re-delivers completed-but-undelivered async delegations, and depends
57
57
  # on the task sweep having re-dispatched any in-flight children first. Both
58
58
  # create task fibers, so both must run inside the reactor scope of run_recovery.
59
59
  #
60
- # The TASK sweep is additionally gated per boot generation (RFC-0016 E2):
60
+ # The TASK sweep is additionally gated per boot generation:
61
61
  # its "orphaned :running" test cannot see a sibling worker's live fiber, so
62
62
  # only the worker that claims the generation sweeps — the others would steal
63
63
  # in-flight turns. The delegation and channel sweeps stay ungated: each of
@@ -89,7 +89,7 @@ module Insika
89
89
  log("boot: delegations re-delivered — #{Array(result && result[:delivered]).size}")
90
90
  end
91
91
 
92
- # RFC-0011 §6.5: replies a previous process committed but never handed to the
92
+ # replies a previous process committed but never handed to the
93
93
  # channel. Runs AFTER the task recovery for the same reason the delegation
94
94
  # sweep does — a resumed turn writes its own outbox record at its terminal, and
95
95
  # sweeping first would miss it.
@@ -108,7 +108,7 @@ module Insika
108
108
  return if @wiring.durable?
109
109
 
110
110
  log("boot: WARNING — EPHEMERAL backend (no INSIKA_DB): recovery will " \
111
- "not resume anything after a restart (doc 02 §6).")
111
+ "not resume anything after a restart.")
112
112
  end
113
113
 
114
114
  def log(message)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # RFC-0017 A3 — the /v1 transport as a VALUE the host app mounts, instead of a
3
+ # the /v1 transport as a VALUE the host app mounts, instead of a
4
4
  # server the engine starts.
5
5
  #
6
6
  # mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
@@ -11,7 +11,7 @@
11
11
  # server boot now calls this instead of inlining it, which is what keeps the two
12
12
  # from drifting.
13
13
  #
14
- # The Studio is deliberately NOT part of this (the embed contract, item 4): it is
14
+ # The Studio is deliberately NOT part of this (the embed contract): it is
15
15
  # a class-level singleton, so it is one per process, and a host that wants the
16
16
  # operator UI mounts `Studio::App` itself and accepts that limitation.
17
17
 
@@ -44,27 +44,35 @@ module Insika
44
44
  attr_reader :token
45
45
 
46
46
  def app
47
+ tenancy = @config[:tenancy] || ENV["INSIKA_TENANCY"] || "single_tenant"
48
+ # WS1: the token store is handed over ONLY in multi_tenant mode — in
49
+ # single_tenant the classic gateway token is the only credential
50
+ # (passing the store would silently widen the surface).
51
+ store = tenancy == "multi_tenant" ? @graph.token_store : nil
47
52
  @app ||= Insika::Server::App.new(
48
53
  command_bus: @graph.bus, event_stream: @graph.event_stream,
49
54
  session_store: @graph.session_store, task_store: @graph.task_store,
50
55
  pending_action_store: @graph.pending_action_store,
51
56
  provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
52
57
  # GET /v1/agents/:id — the read-only capability view a case's `requires`
53
- # resolves against (RFC-0014 §3.2).
58
+ # resolves against.
54
59
  profiles: @graph.profiles,
55
- # Item 20 / §5.6: the OSS onboarding surface (start.md + models.json + docs).
60
+ # the OSS onboarding surface (start.md + models.json + docs).
56
61
  # This is the primary "build my first agent" target — models.json reports the
57
62
  # DSL's stores + the agents this process serves (each id IS the `model`).
58
63
  onboarding: build_onboarding,
59
- # Item 22: GET /v1/workflows + POST /v1/workflows/:name, opt-in by
64
+ # GET /v1/workflows + POST /v1/workflows/:name, opt-in by
60
65
  # injection like every other edge — nil when the system declares none,
61
66
  # so the routes simply do not exist (404, parity).
62
67
  workflow_registry: (@graph.workflow_registry if workflows?),
63
- # RFC-0011: the bundled relay, when the env turns it on. Same rule as the
68
+ # the bundled relay, when the env turns it on. Same rule as the
64
69
  # OTEL bridge — a feature only `config.ru` can reach is a feature the
65
70
  # docs are half-true about.
66
71
  channels: (@graph.channel_registry if channels?),
67
- config: { gateway_token: @token }.merge(@config)
72
+ config: { gateway_token: @token, tenancy: tenancy }.merge(@config),
73
+ token_store: store,
74
+ # a 500's error_ref must be findable in the process log.
75
+ logger: $stdout
68
76
  )
69
77
  end
70
78