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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Contract suite for Insika::Store (doc 01 §7), exported for third-party
4
- # backends (RFC-0018 A4) — a gem like `insika-pg` writes its spec against THIS
3
+ # Contract suite for Insika::Store, exported for third-party
4
+ # backends — a gem like `insika-pg` writes its spec against THIS
5
5
  # file, not against a read of stores/sqlite.rb:
6
6
  #
7
7
  # require "insika/testing/store_contract"
@@ -11,7 +11,7 @@
11
11
  # it_behaves_like "an Insika store"
12
12
  # end
13
13
  #
14
- # Two groups, on purpose (RFC-0018 §4 item 4):
14
+ # Two groups, on purpose:
15
15
  #
16
16
  # - "an Insika store" — universal; every backend passes EXACTLY it (L2: the
17
17
  # suite is honest — a test that passes on Memory passes on SQLite). The
@@ -27,89 +27,89 @@
27
27
  # races) — those belong to the backend's own spec.
28
28
  RSpec.shared_examples "an Insika store" do
29
29
  describe "#get / #set (round-trip)" do
30
- it "C1 preserves Hash with string keys" do # C1
30
+ it " preserves Hash with string keys" do #
31
31
  store.set("s", "k", { "a" => 1, "b" => [1, 2] })
32
32
  expect(store.get("s", "k")).to eq({ "a" => 1, "b" => [1, 2] })
33
33
  end
34
34
 
35
- it "C2 preserves Array" do # C2
35
+ it " preserves Array" do #
36
36
  store.set("s", "k", [1, "x", true, nil])
37
37
  expect(store.get("s", "k")).to eq([1, "x", true, nil])
38
38
  end
39
39
 
40
- it "C3 preserves String" do # C3
40
+ it " preserves String" do #
41
41
  store.set("s", "k", "texto")
42
42
  expect(store.get("s", "k")).to eq("texto")
43
43
  end
44
44
 
45
- it "C4 preserves Integer" do # C4
45
+ it " preserves Integer" do #
46
46
  store.set("s", "k", 42)
47
47
  value = store.get("s", "k")
48
48
  expect(value).to eq(42)
49
49
  expect(value).to be_a(Integer)
50
50
  end
51
51
 
52
- it "C5 preserves Float" do # C5
52
+ it " preserves Float" do #
53
53
  store.set("s", "k", 3.14)
54
54
  value = store.get("s", "k")
55
55
  expect(value).to eq(3.14)
56
56
  expect(value).to be_a(Float)
57
57
  end
58
58
 
59
- it "C6 preserves booleans" do # C6
59
+ it " preserves booleans" do #
60
60
  store.set("s", "t", true)
61
61
  store.set("s", "f", false)
62
62
  expect(store.get("s", "t")).to be(true)
63
63
  expect(store.get("s", "f")).to be(false)
64
64
  end
65
65
 
66
- it "C7 preserves nil written without exception" do # C7
66
+ it " preserves nil written without exception" do #
67
67
  store.set("s", "k", nil)
68
68
  expect(store.get("s", "k")).to be_nil
69
69
  end
70
70
 
71
- it "C8 converts Symbols (keys and values) to Strings" do # C8
71
+ it " converts Symbols (keys and values) to Strings" do #
72
72
  store.set("s", "k", { chave: :valor })
73
73
  expect(store.get("s", "k")).to eq({ "chave" => "valor" })
74
74
  end
75
75
 
76
- it "C9 returns nil for an absent key, never an exception" do # C9
76
+ it " returns nil for an absent key, never an exception" do #
77
77
  expect(store.get("s", "nao-existe")).to be_nil
78
78
  end
79
79
 
80
- it "C10 overwrites silently (last-write-wins)" do # C10
80
+ it " overwrites silently (last-write-wins)" do #
81
81
  store.set("s", "k", "primeiro")
82
82
  store.set("s", "k", "segundo")
83
83
  expect(store.get("s", "k")).to eq("segundo")
84
84
  end
85
85
 
86
- it "C11 set returns the same object passed in (not the round-trip)" do # C11
86
+ it " set returns the same object passed in (not the round-trip)" do #
87
87
  obj = { "a" => 1 }
88
88
  expect(store.set("s", "k", obj)).to equal(obj)
89
89
  end
90
90
  end
91
91
 
92
92
  describe "#delete" do
93
- it "C12 removes existing and returns true" do # C12
93
+ it " removes existing and returns true" do #
94
94
  store.set("s", "k", 1)
95
95
  expect(store.delete("s", "k")).to be(true)
96
96
  expect(store.get("s", "k")).to be_nil
97
97
  end
98
98
 
99
- it "C13 returns false for a nonexistent key" do # C13
99
+ it " returns false for a nonexistent key" do #
100
100
  expect(store.delete("s", "k")).to be(false)
101
101
  end
102
102
  end
103
103
 
104
104
  describe "#list" do
105
- it "C14 returns scope keys sorted lexicographically" do # C14
105
+ it " returns scope keys sorted lexicographically" do #
106
106
  store.set("s", "b", 1)
107
107
  store.set("s", "a", 1)
108
108
  store.set("s", "c", 1)
109
109
  expect(store.list("s")).to eq(%w[a b c])
110
110
  end
111
111
 
112
- it "C15 filters by prefix with start_with? (not include?)" do # C15
112
+ it " filters by prefix with start_with? (not include?)" do #
113
113
  store.set("s", "task:1", 1)
114
114
  store.set("s", "task:2", 1)
115
115
  store.set("s", "checkpoint:1", 1)
@@ -117,7 +117,7 @@ RSpec.shared_examples "an Insika store" do
117
117
  expect(store.list("s", "task:")).to eq(%w[task:1 task:2])
118
118
  end
119
119
 
120
- it "C16 returns [] for an empty scope" do # C16
120
+ it " returns [] for an empty scope" do #
121
121
  expect(store.list("s")).to eq([])
122
122
  end
123
123
 
@@ -129,7 +129,7 @@ RSpec.shared_examples "an Insika store" do
129
129
  end
130
130
 
131
131
  describe "scope isolation" do
132
- it "C17 keeps scopes independent in get/list/delete" do # C17
132
+ it " keeps scopes independent in get/list/delete" do #
133
133
  store.set("s1", "k", 1)
134
134
  store.set("s2", "k", 2)
135
135
 
@@ -143,16 +143,16 @@ RSpec.shared_examples "an Insika store" do
143
143
  end
144
144
 
145
145
  describe "#transaction" do
146
- it "C18 returns the block's value" do # C18
146
+ it " returns the block's value" do #
147
147
  expect(store.transaction { 42 }).to eq(42)
148
148
  end
149
149
 
150
- it "C19 commits the block's writes" do # C19
150
+ it " commits the block's writes" do #
151
151
  store.transaction { store.set("s", "k", "commitado") }
152
152
  expect(store.get("s", "k")).to eq("commitado")
153
153
  end
154
154
 
155
- it "C20 does a real rollback of set AND delete when the block raises" do # C20
155
+ it " does a real rollback of set AND delete when the block raises" do #
156
156
  store.set("s", "manter", "antigo")
157
157
  store.set("s", "apagar", "existe")
158
158
 
@@ -169,7 +169,7 @@ RSpec.shared_examples "an Insika store" do
169
169
  expect(store.get("s", "apagar")).to eq("existe")
170
170
  end
171
171
 
172
- it "C21 reuses the outer transaction when nested" do # C21
172
+ it " reuses the outer transaction when nested" do #
173
173
  store.set("s", "k", "antigo")
174
174
 
175
175
  expect do
@@ -185,7 +185,7 @@ RSpec.shared_examples "an Insika store" do
185
185
  end
186
186
 
187
187
  describe "serialization errors" do
188
- it "C22 raises StoreError and does not write a non-serializable value" do # C22
188
+ it " raises StoreError and does not write a non-serializable value" do #
189
189
  expect do
190
190
  store.set("s", "k", Object.new)
191
191
  end.to raise_error(Insika::StoreError)
@@ -194,7 +194,7 @@ RSpec.shared_examples "an Insika store" do
194
194
  end
195
195
  end
196
196
 
197
- # Multi-worker safety (RFC-0018 A3). Every RFC-0016 A1 claim (outbox, delegation
197
+ # Multi-worker safety. Every claim (outbox, delegation
198
198
  # sweep, recovery) is a read-check-write inside `transaction` — on SQLite that is
199
199
  # atomic because the backend opens BEGIN IMMEDIATE; a backend whose transaction
200
200
  # only yields passes all 22 cases above and still double-claims under two
@@ -204,7 +204,7 @@ end
204
204
  # The `sleep` inside each transaction is deliberate: it holds the read state
205
205
  # open long enough that a backend without isolation ALWAYS lets a second
206
206
  # connection through, while a correct backend serializes the writers. Without
207
- # it a wrong backend could pass by scheduling luck — the failure E2 relies on.
207
+ # it a wrong backend could pass by scheduling luck — the failure relies on.
208
208
  RSpec.shared_examples "an Insika store safe for N workers" do
209
209
  # Starts N threads, each holding its OWN connection to the same backend,
210
210
  # releases them together, and returns each block's value. Connections are
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # The periodic tick: durability stops waiting for a reboot. One
7
+ # pass does two things, in this order:
8
+ #
9
+ # 1. DRAIN the outbox (`ChannelDelivery#sweep`) — replies a previous pass
10
+ # (or process) recorded and never claimed. Ungated: every record carries
11
+ # its own transactional claim, so N workers draining is safe.
12
+ # 2. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) — gated by a
13
+ # bucketed claim (`Recovery.claim_sweep` on "tick:<epoch/interval>"), so
14
+ # exactly one worker per window sweeps. The staleness threshold is the
15
+ # liveness gate: a live :running turn is bounded by turn_timeout, so
16
+ # anything untouched past it cannot be alive.
17
+ #
18
+ # It is NOT a job queue: no schedules, no priorities, no fan-out. The
19
+ # refinement hook once pictured here is dropped by merit —
20
+ # docs/REFINEMENT.md's "no scheduler in the engine" stands.
21
+ class Tick
22
+ # 60s: a customer waiting on WhatsApp is the deadline. 900s = 3x the
23
+ # default turn_timeout (300s) — the rule, not the number: the threshold
24
+ # must exceed the deployment's largest turn_timeout, or the sweep would
25
+ # judge live turns orphaned.
26
+ DEFAULT_INTERVAL = 60
27
+ DEFAULT_STALE_AFTER = 900
28
+
29
+ SCOPE = "tick"
30
+ KEY = "claim"
31
+
32
+ def initialize(store:, recovery:, channel_delivery:, logger: nil,
33
+ interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
34
+ sleeper: nil)
35
+ @store = store
36
+ @recovery = recovery
37
+ @channel_delivery = channel_delivery
38
+ @logger = logger
39
+ @interval = interval.to_i
40
+ @stale_after = stale_after.to_i
41
+ @sleeper = sleeper || method(:default_sleep)
42
+ end
43
+
44
+ def enabled? = @interval.positive?
45
+
46
+ # One pass, pure (no reactor needed): the serving loop calls it on a timer,
47
+ # specs call it directly. A StoreError propagates to the loop, which logs
48
+ # and keeps ticking.
49
+ def run_once
50
+ drained = @channel_delivery ? @channel_delivery.sweep : { dispatched: [] }
51
+ summary = { dispatched: drained[:dispatched], resumed: [], failed: [] }
52
+ return summary unless claim_window
53
+
54
+ result = @recovery.run(stale_after: @stale_after)
55
+ summary.merge(resumed: result[:resumed], failed: result[:failed])
56
+ end
57
+
58
+ # The loop, spawned as a child of the turn supervisor (the
59
+ # tick lives on the supervisor fiber — every serving arm gets it the moment
60
+ # `supervised = true` matters, with no arm edits). A failing pass logs and
61
+ # the loop continues: a sweeper that dies silently is the outage it exists
62
+ # to prevent. Restartable: when the supervisor is recreated its children
63
+ # died with it, so a stopped task is not a running one.
64
+ def start(parent:)
65
+ return false unless enabled?
66
+ return true if @task&.running?
67
+
68
+ @task = parent.async do |t|
69
+ t.annotate("insika-tick")
70
+ loop do
71
+ @sleeper.call(@interval)
72
+ run_once
73
+ rescue StandardError => e
74
+ log(:warn, "tick failed: #{e.class}: #{e.message}")
75
+ end
76
+ end
77
+ true
78
+ end
79
+
80
+ private
81
+
82
+ # One sweeper per window across N workers. Unlike the boot
83
+ # claim (one key per generation), the tick reuses a SINGLE key with a
84
+ # timestamp — a key per minute would be a slow leak in the store. The
85
+ # read-check-write rides Store#transaction like every claim:
86
+ # two workers racing the window serialize on the backend's lock and exactly
87
+ # one sweeps.
88
+ def claim_window
89
+ now = Time.now.utc
90
+ @store.transaction do
91
+ current = @store.get(SCOPE, KEY)
92
+ last = current && begin
93
+ Time.iso8601(current["claimed_at"].to_s)
94
+ rescue ArgumentError
95
+ nil # a corrupted claim is not a claim — take the window
96
+ end
97
+ if last.nil? || (now - last) >= @interval
98
+ @store.set(SCOPE, KEY, { "claimed_at" => now.iso8601 })
99
+ true
100
+ else
101
+ false
102
+ end
103
+ end
104
+ end
105
+
106
+ # Async when there is a reactor (production: the interval must not block
107
+ # the worker), plain sleep otherwise (specs driving the loop by hand use an
108
+ # injected sleeper anyway).
109
+ def default_sleep(seconds)
110
+ task = defined?(Async::Task) ? Async::Task.current? : nil
111
+ task ? task.sleep(seconds) : sleep(seconds)
112
+ end
113
+
114
+ # Same contract as Recovery's: logging is pure observability and must never
115
+ # alter the flow.
116
+ def log(level, message)
117
+ @logger&.public_send(level, "[tick] #{message}")
118
+ rescue StandardError
119
+ nil
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+ require "time"
6
+
7
+ module Insika
8
+ # Multi-tenant credentials (WS1): per-tenant tokens plus the operator token,
9
+ # stored ONLY as SHA-256 hashes — the plaintext is returned once at issue time
10
+ # and is never persisted, logged or evented. Every resolution is a hash lookup,
11
+ # so reading the store yields nothing usable. Behind any Insika::Store, like
12
+ # the other domain stores.
13
+ #
14
+ # A record describes ONE principal the edge can resolve a Bearer to:
15
+ # role: "operator" (tenant_id nil — the historical single credential)
16
+ # "tenant" (scoped to tenant_id)
17
+ # status: "active" | "revoked"
18
+ # A revoked record stops resolving immediately; a tenant's rotation is
19
+ # revoke-all + issue (the spec's rule — revoking one tenant's token never
20
+ # touches another tenant's: every cell is (id)- or (hash)-scoped cells).
21
+ class TokenStore
22
+ SCOPE = "tenant_tokens"
23
+
24
+ Record = Data.define(:id, :token_hash, :role, :tenant_id, :label,
25
+ :status, :created_at, :revoked_at) do
26
+ def active? = status.to_s == "active"
27
+ def tenant? = role.to_s == "tenant"
28
+ def to_h
29
+ { id: id, role: role, tenant_id: tenant_id, label: label,
30
+ status: status, created_at: created_at, revoked_at: revoked_at }
31
+ end
32
+ end
33
+
34
+ # The token a caller receives from a successful issue/rotate — the ONLY
35
+ # moment the plaintext exists in the process. `token` is never stored.
36
+ Issue = Data.define(:id, :token)
37
+
38
+ def initialize(store:)
39
+ @store = store
40
+ end
41
+
42
+ # Issues a token for tenant_id (nil = an OPERATOR token). -> Issue. The
43
+ # token is shown exactly once; there is no `get_token` — lost = rotate.
44
+ def issue(tenant_id: nil, label: "default")
45
+ validate_tenant_id!(tenant_id)
46
+ token = SecureRandom.hex(32)
47
+ hash = digest(token)
48
+ id = SecureRandom.uuid
49
+ @store.transaction do
50
+ @store.set(SCOPE, record_key(id), {
51
+ "id" => id, "token_hash" => hash,
52
+ "role" => tenant_id ? "tenant" : "operator",
53
+ "tenant_id" => tenant_id, "label" => label.to_s,
54
+ "status" => "active",
55
+ "created_at" => Time.now.utc.iso8601, "revoked_at" => nil
56
+ })
57
+ @store.set(SCOPE, hash_key(hash), id)
58
+ end
59
+ Issue.new(id: id, token: token)
60
+ end
61
+
62
+ # Active-token record for token_id; a revoked one reads WHO it was but not
63
+ # as resolvable. -> Record | nil.
64
+ def find(id)
65
+ return nil if id.to_s.empty?
66
+
67
+ record = @store.get(SCOPE, record_key(id.to_s))
68
+ record && to_record(record)
69
+ end
70
+
71
+ # The edge resolution: -> Record (active) | nil. A revoked token is
72
+ # indistinguishable from a missing one (fail-closed: the Bearer just 401s).
73
+ def resolve(token)
74
+ return nil if token.to_s.empty?
75
+
76
+ id = @store.get(SCOPE, hash_key(digest(token)))
77
+ return nil if id.nil?
78
+
79
+ record = to_record(@store.get(SCOPE, record_key(id)))
80
+ record&.active? ? record : nil
81
+ end
82
+
83
+ # -> bool: true only for an ACTIVE record (revoking an already-revoked/unknown
84
+ # id is false — a no-op, never an error). The read-modify-write rides
85
+ # `@store.transaction` (the repo's rule — WS1): `next false`, NOT `return
86
+ # false`, or the non-local return would skip the COMMIT and leak the open
87
+ # transaction (the same trap as the budget ledger's mark_alert).
88
+ def revoke(id)
89
+ @store.transaction do
90
+ record = find(id)
91
+ next false unless record&.active?
92
+
93
+ flipped = record.to_h.merge(status: "revoked", revoked_at: Time.now.utc.iso8601)
94
+ @store.set(SCOPE, record_key(id), stringify(flipped))
95
+ true
96
+ end
97
+ end
98
+
99
+ # Revokes every ACTIVE token of a tenant; the hash-index cells stay (they
100
+ # resolve to a revoked record -> nil). -> count of records revoked. Does NOT
101
+ # touch the operator token or any other tenant.
102
+ def revoke_all(tenant_id:)
103
+ ids = active_token_ids.select do |id|
104
+ record = to_record(@store.get(SCOPE, record_key(id)))
105
+ record&.tenant? && record.tenant_id.to_s == tenant_id.to_s
106
+ end
107
+ ids.count { |id| revoke(id) }
108
+ end
109
+
110
+ # Rotation: revoke the tenant's active tokens, issue a fresh one. Both in
111
+ # one transaction -> a crashed half-rotation never leaves the tenant with
112
+ # NOTHING valid. -> { revoked: n, issue: Issue }.
113
+ def rotate(tenant_id:, label: "default", now: Time.now)
114
+ @store.transaction do
115
+ revoked = revoke_all(tenant_id: tenant_id)
116
+ { revoked: revoked, issue: issue(tenant_id: tenant_id, label: label) }
117
+ end
118
+ end
119
+
120
+ # Every active token id (used by revoke_all). Reads the id-keyed cells.
121
+ def active_token_ids
122
+ @store.list(SCOPE, RECORD_PREFIX).filter_map do |key|
123
+ id = key.delete_prefix(RECORD_PREFIX)
124
+ record = @store.get(SCOPE, record_key(id))
125
+ record && record["status"] == "active" ? id : nil
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ # The tenant id IS the session/task namespace prefix ("<tenant>:<id>" — WS1).
132
+ # A ":" lets a second tenant forge an id that lands on the FIRST tenant's
133
+ # namespace: T1="loja" + session id "adma:x" => "loja:adma:x", which is the
134
+ # cell T2="loja:adma" would claim with id "x" (WS1). Guarded at the single
135
+ # chokepoint both issue and rotate flow through.
136
+ def validate_tenant_id!(tenant_id)
137
+ return if tenant_id.nil?
138
+
139
+ raise Insika::ValidationError, "tenant_id is required" if Insika::Coercion.blank?(tenant_id)
140
+ raise Insika::ValidationError, "tenant_id must not contain ':'" if tenant_id.to_s.include?(":")
141
+ end
142
+
143
+ def digest(token)
144
+ Digest::SHA256.hexdigest(token)
145
+ end
146
+
147
+ RECORD_PREFIX = "r:"
148
+ HASH_PREFIX = "h:"
149
+
150
+ def record_key(id) = "#{RECORD_PREFIX}#{id}"
151
+ def hash_key(hash) = "#{HASH_PREFIX}#{hash}"
152
+
153
+ def to_record(record)
154
+ return nil if record.nil?
155
+
156
+ Record.new(
157
+ id: record["id"], token_hash: record["token_hash"],
158
+ role: record["role"], tenant_id: record["tenant_id"],
159
+ label: record["label"], status: record["status"],
160
+ created_at: record["created_at"], revoked_at: record["revoked_at"]
161
+ )
162
+ end
163
+
164
+ def stringify(hash)
165
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
166
+ end
167
+ end
168
+ end
@@ -6,10 +6,10 @@ require "async/semaphore"
6
6
  module Insika
7
7
  # Turn-scoped assembly of the agent's tool instances (pipeline stage 3 tail):
8
8
  # capability resolution, instantiation (Entry#factory | ready instance),
9
- # turn-context (D2) injection, the capability<->direct dedup join, and the
9
+ # turn-context injection, the capability<->direct dedup join, and the
10
10
  # ToolEnvelope wrap (stage 7 seam: per-call timeout + side-effect recording).
11
11
  #
12
- # Extracted from the Executor (§11 B5) to keep the hot-path file smaller. It is
12
+ # Extracted from the Executor to keep the hot-path file smaller. It is
13
13
  # a pure collaborator — it holds only the injected registries/stores and no
14
14
  # per-turn state; everything turn-specific arrives via `state`/`turn_context`.
15
15
  # The Executor keeps thin delegators (resolve_capabilities/assemble_tool_instances/
@@ -84,7 +84,7 @@ module Insika
84
84
 
85
85
  private
86
86
 
87
- # D4 (item 30): the model decides the fan-out, so without a cap a batch of 15
87
+ # the model decides the fan-out, so without a cap a batch of 15
88
88
  # data-tools is 15 simultaneous requests to the same upstream — which is how
89
89
  # one turn earns a 429 for every other turn in the process. Note the contrast
90
90
  # with the primitives that already shipped: `spawn_subagents` caps at 8
@@ -103,7 +103,7 @@ module Insika
103
103
  end
104
104
 
105
105
  # Real Engine -> Entries (respond to factory); fakes -> ready instances.
106
- # `turn_context` (D2) is deposited into the instances that expose it
106
+ # `turn_context` is deposited into the instances that expose it
107
107
  # (data-tools); the rest ignore it (parity).
108
108
  def instantiate_tools(allowed, turn_context = nil)
109
109
  Array(allowed).map do |t|
@@ -113,7 +113,7 @@ module Insika
113
113
  end
114
114
  end
115
115
 
116
- # D2/G3 seam: deposits the turn context into the freshly created instance
116
+ # seam: deposits the turn context into the freshly created instance
117
117
  # (same idea as `remember`, which receives tenant/state) BEFORE the
118
118
  # ToolEnvelope. Duck-typed: only what exposes `turn_context=` (DataDefinedTool)
119
119
  # receives it. nil (a state with no turn_context, e.g. a test stub) -> no-op.
@@ -7,8 +7,8 @@ module Insika
7
7
  # Definition of a DATA-DEFINED TOOL (no Ruby code): name, description, parameters
8
8
  # and an HTTP call. Immutable value object, persisted by ToolStore and
9
9
  # materialized at runtime by Tools::DataDefinedTool (one class, N instances —
10
- # the same pattern as A2ARemote). Phase 5, Step A; parameters migrated to JSON
11
- # Schema in Phase 7, Step A.
10
+ # the same pattern as A2ARemote).,; parameters migrated to JSON
11
+ # Schema in,.
12
12
  #
13
13
  # Persisted form (JSON-serializable Hash; ConfigStore stringifies the keys):
14
14
  # { "name", "description",
@@ -17,9 +17,9 @@ module Insika
17
17
  # "response" => { "extract","path" },
18
18
  # "secret_headers" => [ "Authorization", ... ],
19
19
  # "side_effect" => bool, "timeout" => int|nil,
20
- # "group" => string|nil, "tags" => [ "b2b", ... ] } # Phase 7/D4/F5 (Step C)
20
+ # "group" => string|nil, "tags" => ["b2b",...] } #//
21
21
  #
22
- # `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP; Phase 7/D1):
22
+ # `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP):
23
23
  # a nestable object, fed straight into RubyLLM's `params_schema` (provider-
24
24
  # agnostic). The **flat array** (`[{name,type,required}]`) is SUGAR for the simple
25
25
  # case: it is lifted to JSON Schema at build time. The sugar covers scalars and
@@ -30,7 +30,7 @@ module Insika
30
30
  # Validation lives HERE (single source): `build`/`from_h` raise ValidationError
31
31
  # on malformed input. Name uniqueness and collision with a code tool are NOT
32
32
  # validated here (the value object does not know the registry) — that belongs to the
33
- # overlay (Step B). Secrets (credential headers) are the ToolStore's responsibility
33
+ # overlay. Secrets (credential headers) are the ToolStore's responsibility
34
34
  # (masks/reconciles); the definition itself is agnostic to masking.
35
35
  ToolDefinition = Data.define(
36
36
  :name, :description, :parameters, :request, :response,
@@ -49,7 +49,7 @@ module Insika
49
49
  EXTRACTS = %w[body_raw status json_path].freeze
50
50
  NAME_RE = /\A[a-z][a-z0-9_]*\z/ # identifier for the model
51
51
  # A `.` in the placeholder enables the turn-context namespace `{{ctx.*}}`
52
- # (Phase 6/D2), separate from the model's `{{param}}`. Params follow NAME_RE (no
52
+ # separate from the model's `{{param}}`. Params follow NAME_RE (no
53
53
  # dot) -> a placeholder with a dot can only be a ctx ref.
54
54
  PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
55
55
  # Turn-context namespace: values coming from the TURN (not the model),
@@ -108,7 +108,7 @@ module Insika
108
108
  )
109
109
  end
110
110
 
111
- # Group (Phase 7/D4/F5): enablement label by DATA (not name convention),
111
+ # Group: enablement label by DATA (not name convention),
112
112
  # target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
113
113
  def self.normalize_group(group)
114
114
  g = group.to_s.strip
@@ -145,7 +145,7 @@ module Insika
145
145
  def self.empty_schema = { "type" => "object", "properties" => {}, "required" => [] }
146
146
  private_class_method :empty_schema
147
147
 
148
- # Flat sugar -> JSON Schema. Preserves Phase 5 validation (NAME_RE, duplicates,
148
+ # Flat sugar -> JSON Schema. Preserves validation (NAME_RE, duplicates,
149
149
  # PARAM_TYPES) and lifts `array:<scalar>` into proper `items`.
150
150
  #
151
151
  # A bare `array` is REFUSED. It used to lift to `items: {type:"string"}` — the
@@ -69,7 +69,7 @@ module Insika
69
69
 
70
70
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
71
71
 
72
- # D4 (item 30): with parallel tool calls on, the turn's shared semaphore
72
+ # with parallel tool calls on, the turn's shared semaphore
73
73
  # (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
74
74
  # at once. Wraps the REAL call ONLY — the approval wait and the skip check are
75
75
  # outside it, so a call blocked on a human never holds a slot, and the per-call
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # Tool manifest (Phase 7, Step B): the BATCH, DATA-driven, industry-STANDARD
4
+ # Tool manifest: the BATCH, DATA-driven, industry-STANDARD
5
5
  # (JSON Schema) form of the data-tools. A `defaults` (common binding:
6
6
  # base_url/path_template/method/headers/secret_headers/response) + a list of
7
7
  # `tools`. Each tool is normalized — inheriting the defaults, applying the envelope
8
8
  # adapter (OpenAI/MCP/raw) and resolving `endpoint`→url + `{{secret.*}}`/
9
9
  # `{{env.*}}` — into a ToolDefinition Hash consumable by the ToolStore.
10
10
  #
11
- # GENERIC (NF1): nothing here mentions achei/openclaw — the manifest IS the contract. It
11
+ # GENERIC: nothing here mentions a consumer/gateway — the manifest IS the contract. It
12
12
  # only NORMALIZES/RESOLVES; the final validation (method/url/types, model placeholders
13
13
  # `{{param}}`/turn `{{ctx.*}}`, safe subset of JSON Schema) belongs to
14
14
  # ToolDefinition (single source). The batch upsert + hot reload belongs to the
@@ -18,12 +18,12 @@ module Insika
18
18
  # - `{{param}}`/`{{ctx.*}}` : resolved AT THE TURN (by DataDefinedTool) —
19
19
  # they stay INTACT here; ToolDefinition validates them.
20
20
  # - `{{secret.*}}`/`{{env.*}}` : resolved AT INGESTION (here) — from the deployment,
21
- # never from the manifest (D6/R3). `{{env.*}}` is non-secret config (any
21
+ # never from the manifest (R3). `{{env.*}}` is non-secret config (any
22
22
  # template); `{{secret.*}}` is a credential and is ONLY allowed in a header
23
23
  # declared in `secret_headers`, with value === `{{secret.X}}` (nothing literal,
24
24
  # nothing outside a header — otherwise the secret would leak without masking).
25
25
  #
26
- # Interface envelope (what the model sees), by adapter (D3):
26
+ # Interface envelope (what the model sees), by adapter:
27
27
  # - `parameters` (raw JSON Schema), OR
28
28
  # - `{ "function": { name, description, parameters } }` (OpenAI/Anthropic), OR
29
29
  # - `{ "inputSchema": {...} }` (MCP).
@@ -73,7 +73,7 @@ module Insika
73
73
  "response" => binding[:response],
74
74
  "secret_headers" => binding[:secret_headers],
75
75
  "side_effect" => t["side_effect"],
76
- "group" => t["group"] || defaults["group"], # Phase 7/D4/F5 (Step C):
76
+ "group" => t["group"] || defaults["group"], # per-group allowlist:
77
77
  "tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
78
78
  # Per-tool only: "this result ends the turn" is a property of THIS backend's
79
79
  # response, never something a manifest default should hand to its siblings.
@@ -83,7 +83,7 @@ module Insika
83
83
 
84
84
  private
85
85
 
86
- # Envelope adapter (D3): extracts the INTERFACE (name/description/parameters) from
86
+ # Envelope adapter: extracts the INTERFACE (name/description/parameters) from
87
87
  # any standard form. name/description fall back to the tool's top level when the
88
88
  # envelope does not carry them.
89
89
  def extract_interface(t)