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
@@ -2,8 +2,8 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
- # Evals — the quality harness (RFC-0008). It lives in `lib/` so the engine itself can
6
- # call it (the refinement gate of RFC-0013 needs to score a candidate agent, and a
5
+ # Evals — the quality harness. It lives in `lib/` so the engine itself can
6
+ # call it (the refinement gate of needs to score a candidate agent, and a
7
7
  # second copy of the judge would be the worst possible outcome), but it stays a
8
8
  # CLIENT: it reaches a running deployment over HTTP through `HttpTransport` and never
9
9
  # reads a store directly. `evals/run.rb` is a thin CLI over this module.
@@ -28,17 +28,17 @@ module Insika
28
28
  # Names of the negative assertions to run (e.g. "pii_leak", "tool_error").
29
29
  def must_not = Array(expect["must_not"]).map(&:to_s)
30
30
 
31
- # How much the agent should ask before acting (RFC-0014 §3.3). nil = the store
31
+ # How much the agent should ask before acting. nil = the store
32
32
  # has no opinion and only the rubric decides.
33
33
  def policy = GoldenLoader.presence(expect["policy"])
34
34
 
35
- # What the DEPLOYMENT must have for this case to mean anything (RFC-0014 §3.2).
35
+ # What the DEPLOYMENT must have for this case to mean anything.
36
36
  # Empty = runs everywhere.
37
37
  def required_tools = Array(requires["tools"]).map(&:to_s)
38
38
  def required_capabilities = Array(requires["capabilities"]).map(&:to_s)
39
39
  def requirements? = !(required_tools + required_capabilities).empty?
40
40
 
41
- # THE INCUMBENT'S CONVERSATION for the same opening (RFC-0014 §3.4) — the other
41
+ # THE INCUMBENT'S CONVERSATION for the same opening — the other
42
42
  # half of a pairwise comparison. Data in the case, not a store read: the eval is
43
43
  # a client, and a pair that lives in one reviewable file cannot go stale against
44
44
  # a database nobody looked at.
@@ -47,14 +47,14 @@ module Insika
47
47
  def reference? = !reference_messages.empty?
48
48
 
49
49
  # Did a PERSON type part of the reference half? After a handoff the operator's
50
- # words are stored as `role: assistant` (P23a), and comparing a model to a human
50
+ # words are stored as `role: assistant`, and comparing a model to a human
51
51
  # and calling it a win is a lie in both directions — so the pair is LABELLED and
52
52
  # the report never prints the outcome without it.
53
53
  def human_assisted?
54
54
  reference_messages.any? { |m| MessageOrigin.origin_of(m) == MessageOrigin::OPERATOR }
55
55
  end
56
56
 
57
- # LLM-judge rubric + threshold (consumed in Fase B — deferred here).
57
+ # LLM-judge rubric + threshold (consumed in — deferred here).
58
58
  def rubric = expect["rubric"]
59
59
  def min_score = expect["min_score"]
60
60
  end
@@ -126,7 +126,7 @@ module Insika
126
126
  raise InvalidGolden, "#{where} needs a 'role' of user or assistant" unless %w[user assistant].include?(role)
127
127
 
128
128
  text = presence(raw["text"]) || (raise InvalidGolden, "#{where} needs a non-empty 'text'")
129
- # The SAME closed vocabulary the engine stamps (P23a). A typo'd marker would
129
+ # The SAME closed vocabulary the engine stamps. A typo'd marker would
130
130
  # read as "absent" downstream, which is how a human turn gets scored as the
131
131
  # incumbent's model.
132
132
  origin = begin
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Insika
6
6
  module Evals
7
- # The LLM-judge (RFC-0008 §3.3, Fase B). Scores a golden's `rubric` against the
7
+ # The LLM-judge. Scores a golden's `rubric` against the
8
8
  # actual assistant reply — the subjective layer on top of the deterministic
9
9
  # asserts. Pure over an injected `ask` callable (prompt -> raw model text), so it's
10
10
  # unit-testable without an LLM; the real ask (RubyLLM on the utility_model, temp 0)
@@ -13,7 +13,7 @@ module Insika
13
13
  # Conservative by construction: an unparseable judge reply scores 0 (fails) rather
14
14
  # than silently passing.
15
15
  #
16
- # A PANEL, not a single voice (RFC-0013 §3.9). `quorum: N` samples ONE model N
16
+ # A PANEL, not a single voice. `quorum: N` samples ONE model N
17
17
  # times, which measures that model's variance and little else — at temperature 0 it
18
18
  # mostly returns the same answer, including the same blind spot. Two DIFFERENT
19
19
  # models disagreeing about a rubric is the signal worth having, so `asks:` takes one
@@ -95,7 +95,7 @@ module Insika
95
95
  end
96
96
  end
97
97
 
98
- # The `policy` is the one thing a rubric cannot carry alone (RFC-0014 §3.3): how
98
+ # The `policy` is the one thing a rubric cannot carry alone: how
99
99
  # much this store wants the agent to ask before acting is a per-store decision,
100
100
  # and a judge that is not TOLD it will guess — half the time wrongly. The
101
101
  # deterministic half is `Assertions.policy_checks`; this is the other half.
@@ -149,11 +149,11 @@ module Insika
149
149
  end
150
150
  end
151
151
 
152
- # Builds the configured judge panel from `settings["evals"]` (RFC-0013 §3.9).
152
+ # Builds the configured judge panel from `settings["evals"]`.
153
153
  #
154
154
  # It lives HERE and not in `evals/run.rb` because the CLI is no longer the only
155
155
  # caller: the refinement gate scores a candidate with the SAME judges the operator
156
- # configured, and §3.7 is explicit that a second copy of the judge would be the
156
+ # configured, and is explicit that a second copy of the judge would be the
157
157
  # worst possible outcome — the gate would then be grading against a rubric nobody
158
158
  # tuned. One builder, two callers.
159
159
  module JudgePanel
@@ -164,7 +164,7 @@ module Insika
164
164
  # -> [Judge, [model names]] | nil when nobody is configured to ask. NIL AND NOT
165
165
  # a no-op judge: a rubric'd case with no judge reads as `judge_pending`, which is
166
166
  # visible, where a judge that always passes would be silent.
167
- # `llm` (RFC-0017 A2): the graph's own RubyLLM context; nil = the global
167
+ # `llm`: the graph's own RubyLLM context; nil = the global
168
168
  # constant. Only the deployment root and the CLI build judges today, and both
169
169
  # are one graph per process — the seam keeps the default honest for the day
170
170
  # an embedded graph gates a candidate on its own credentials.
@@ -187,7 +187,7 @@ module Insika
187
187
  # Sugar for the callers that only want the judge (the gate).
188
188
  def judge(settings, **kw) = build(settings, **kw)&.first
189
189
 
190
- # The SAME configured models, asked a different question (RFC-0014 §3.4). One
190
+ # The SAME configured models, asked a different question. One
191
191
  # builder because "who judges here" is one operator decision: a pairwise panel
192
192
  # configured apart from the rubric panel would let a run be graded by judges
193
193
  # nobody chose. -> [Pairwise, [model names]] | nil when nobody is configured.
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Insika
6
6
  module Evals
7
- # PAIRWISE AGAINST THE INCUMBENT (RFC-0014 §3.4) — the number that answers "can we
7
+ # PAIRWISE AGAINST THE INCUMBENT — the number that answers "can we
8
8
  # replace it". An absolute 0.72 says a reply cleared a bar we invented; it says
9
9
  # nothing about whether the system already answering 403,231 chats would have done
10
10
  # better with the same customer.
@@ -29,7 +29,7 @@ module Insika
29
29
  # 3. **A split panel stays split.** Averaging "better" and "worse" into
30
30
  # "comparable" invents agreement that nobody expressed.
31
31
  #
32
- # Cost: 2 provider calls per judge per case (§5). This is why it never runs as
32
+ # Cost: 2 provider calls per judge per case. This is why it never runs as
33
33
  # part of the gate and is opt-in on the CLI.
34
34
  class Pairwise
35
35
  BETTER = "better"
@@ -39,7 +39,7 @@ module Insika
39
39
  UNKNOWN = "unknown"
40
40
 
41
41
  # `vs` names WHO the reference half actually is: `agent` (model against model)
42
- # or `human-assisted` (a person typed part of it, P23a's `origin: operator`).
42
+ # or `human-assisted` (a person typed part of it,'s `origin: operator`).
43
43
  # It rides on the verdict rather than beside it so a report cannot print the
44
44
  # outcome without the label.
45
45
  Verdict = Struct.new(:outcome, :reason, :vs, :judges, :order_dependent, keyword_init: true) do
@@ -5,7 +5,7 @@ require "json"
5
5
  module Insika
6
6
  module Evals
7
7
  # Renders a run's [CaseResult] as a machine-readable JSON blob (for the baseline
8
- # + gating in Fase C) and a human-readable markdown summary.
8
+ # gating in) and a human-readable markdown summary.
9
9
  # Pure over the results — takes a clock value in, never reads it (so callers stay
10
10
  # deterministic/testable).
11
11
  module Report
@@ -90,7 +90,7 @@ module Insika
90
90
  lines = ["# Eval report — #{at}", "",
91
91
  "**#{h['passed']}/#{h['total'] - h['skipped']} passed** · #{h['failed']} failed" \
92
92
  "#{" · #{h['skipped']} skipped" if h['skipped'].positive?}" \
93
- "#{" · #{h['judge_pending']} awaiting judge (Fase B)" if h['judge_pending'].positive?}", ""]
93
+ "#{" · #{h['judge_pending']} awaiting judge" if h['judge_pending'].positive?}", ""]
94
94
  results.each do |r|
95
95
  if r.skipped?
96
96
  # WITH the reason, always: "12 skipped" alone is indistinguishable from a
@@ -17,7 +17,7 @@ module Insika
17
17
  # `tokens` is what the whole case cost, summed over its turns, or nil when no
18
18
  # turn reported usage. `cached` is how much of that was served from the prompt
19
19
  # cache, carried separately because it is the number that explains a total.
20
- # Only the refinement gate reads them (RFC-0013 §3.9 records a run's cost); the
20
+ # Only the refinement gate reads them (records a run's cost); the
21
21
  # report and the exit code are untouched.
22
22
  RunCase = Struct.new(:result, :timings, :tokens, :cached, keyword_init: true)
23
23
 
@@ -26,13 +26,13 @@ module Insika
26
26
  #
27
27
  # capabilities: what the DEPLOYMENT has, per agent — anything answering
28
28
  # `#for(agent_id)` with { "tools" =>, "capabilities" => } or nil. Used to skip a
29
- # case the deployment cannot satisfy (RFC-0014 §3.2), BEFORE spending a turn on
29
+ # case the deployment cannot satisfy, BEFORE spending a turn on
30
30
  # it. nil (or an unknown agent) = no resolution, and then a case with `requires`
31
31
  # RUNS and says so in the report: "could not rule it out" is not a reason to
32
32
  # stop testing something, and a suite that shrinks in silence is the failure
33
33
  # this feature exists to avoid.
34
34
  #
35
- # pairwise: an Evals::Pairwise (optional, RFC-0014 §3.4). Only cases carrying a
35
+ # pairwise: an Evals::Pairwise (optional). Only cases carrying a
36
36
  # `reference:` are compared, and the verdict never touches pass/fail — it is the
37
37
  # answer to "can we replace it", reported beside the suite's own verdict.
38
38
  def initialize(transport:, judge: nil, conv_map: {}, capabilities: nil, pairwise: nil)
@@ -53,8 +53,8 @@ module Insika
53
53
  skip = skip_reason(golden)
54
54
  return RunCase.new(result: Assertions.skip(golden, skip), timings: []) if skip
55
55
 
56
- # A backend that resolves state from a pre-existing conversation (e.g. achei-b2b
57
- # needs a real Chat UUID as X-Chat-Id) supplies it via conv_map; otherwise the
56
+ # A backend that resolves state from a pre-existing conversation (e.g. a
57
+ # consumer needing a real Chat UUID as X-Chat-Id) supplies it via conv_map; otherwise the
58
58
  # synthetic "eval-<id>" keeps the adapter's own multi-turn continuation.
59
59
  conv = @conv_map[golden.id] || "eval-#{golden.id}"
60
60
  turns = []
@@ -76,7 +76,7 @@ module Insika
76
76
  # Subjective layer: only when a judge is configured, the case has a rubric, and
77
77
  # the turn ran cleanly (nothing to judge on an errored turn).
78
78
  result.judge = @judge.score(golden: golden, result: last) if @judge && result.rubric && result.error.nil?
79
- # Against the incumbent (RFC-0014 §3.4). Same rule as the judge: nothing to
79
+ # Against the incumbent. Same rule as the judge: nothing to
80
80
  # compare on a turn that errored — half a conversation would lose the
81
81
  # comparison for a reason that has nothing to do with the agent.
82
82
  result.pairwise = @pairwise.compare(golden: golden, turns: turns) if @pairwise && result.error.nil?
@@ -16,7 +16,7 @@ module Insika
16
16
  #
17
17
  # `usage` is the turn's token counts as the deployment reported them
18
18
  # (`response.completed`), or nil when the provider sent none. It is carried, never
19
- # asserted on: the consumer is RFC-0013's refinement budget, which has to bound the
19
+ # asserted on: the consumer is's refinement budget, which has to bound the
20
20
  # cost of a gate replay and cannot invent the number. nil is preserved as nil
21
21
  # rather than zeroed — "the provider did not say" and "it cost nothing" are
22
22
  # different facts, and a budget that confuses them stops being a budget.
@@ -72,7 +72,7 @@ module Insika
72
72
  end
73
73
  end
74
74
 
75
- # What the deployment HAS, per agent (RFC-0014 §3.2), read over the same gated
75
+ # What the deployment HAS, per agent, read over the same gated
76
76
  # `/v1` the replay uses. The eval stays a client: it asks the engine instead of
77
77
  # keeping its own idea of which tools exist.
78
78
  #
@@ -20,9 +20,11 @@ module Insika
20
20
  # local :error event — the turn never waits on transport.
21
21
  MAX_QUEUED = 1000
22
22
 
23
- def initialize(task_id: nil, session_id: nil, on_close: nil)
23
+ def initialize(task_id: nil, session_id: nil, tenant: nil, types: nil, on_close: nil)
24
24
  @task_id = task_id
25
25
  @session_id = session_id
26
+ @tenant = tenant
27
+ @types = types
26
28
  @on_close = on_close
27
29
  @queue = Async::Queue.new
28
30
  end
@@ -39,9 +41,21 @@ module Insika
39
41
 
40
42
  # Meta filter: nil = matches any value. Events with no task_id in
41
43
  # meta (e.g. :session_created) reach only subscribers with no task filter.
44
+ #
45
+ # A TENANT-scoped subscription is FAIL-CLOSED on the meta's tenant (WS1):
46
+ # an event that does not carry the tenant (control events, ignored turns)
47
+ # matches NO tenant subscription. The tenant only ever sees its own.
48
+ #
49
+ # `types:` (nil = any) keeps a subscriber's queue to the events it answers
50
+ # — an alert consumer must not sit behind a full-traffic stream's 1000-cap
51
+ # (WS6), and a filtered queue is the cheapest way to keep it there.
42
52
  def matches?(event)
43
53
  meta = event.meta || {}
44
- (@task_id.nil? || meta[:task_id] == @task_id) &&
54
+ owned = @tenant.nil? || meta[:tenant] == @tenant
55
+
56
+ owned &&
57
+ (@types.nil? || @types.include?(event.type)) &&
58
+ (@task_id.nil? || meta[:task_id] == @task_id) &&
45
59
  (@session_id.nil? || meta[:session_id] == @session_id)
46
60
  end
47
61
 
@@ -103,9 +117,13 @@ module Insika
103
117
  end
104
118
 
105
119
  # nil/nil = all events. Returns the Subscription (the caller iterates with
106
- # `#each` on its own fiber).
107
- def subscribe(task_id: nil, session_id: nil)
108
- sub = Subscription.new(task_id: task_id, session_id: session_id,
120
+ # `#each` on its own fiber). `tenant:` scopes the stream to one tenant's
121
+ # events (WS1) — fail-closed, see Subscription#matches?. `types:` (nil =
122
+ # any) filters by event type so a subscriber's queue only ever holds what
123
+ # its consumer answers (WS6).
124
+ def subscribe(task_id: nil, session_id: nil, tenant: nil, types: nil)
125
+ sub = Subscription.new(task_id: task_id, session_id: session_id, tenant: tenant,
126
+ types: types,
109
127
  on_close: ->(s) { @subscriptions.delete(s) })
110
128
  @subscriptions << sub
111
129
  sub