insika 0.0.1 → 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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Contract suite for Insika::Store, exported for third-party
4
+ # backends — a gem like `insika-pg` writes its spec against THIS
5
+ # file, not against a read of stores/sqlite.rb:
6
+ #
7
+ # require "insika/testing/store_contract"
8
+ #
9
+ # RSpec.describe Insika::Stores::PG do
10
+ # subject(:store) { described_class.new(url:) }
11
+ # it_behaves_like "an Insika store"
12
+ # end
13
+ #
14
+ # Two groups, on purpose:
15
+ #
16
+ # - "an Insika store" — universal; every backend passes EXACTLY it (L2: the
17
+ # suite is honest — a test that passes on Memory passes on SQLite). The
18
+ # including group defines `store` (an empty, ready backend).
19
+ # - "an Insika store safe for N workers" — OPT-IN; the multi-worker half. The
20
+ # including group ALSO defines `store_factory`, a callable returning ANOTHER
21
+ # connection to the SAME underlying backend. A backend that cannot serialize
22
+ # transactions across connections must not include it — Memory is the
23
+ # canonical example, and a single-connection backend has nothing to prove.
24
+ # Anything that wants to sit under `WEB_CONCURRENCY > 1` has to.
25
+ #
26
+ # Do not include backend-specific cases here (file durability, WAL, boot
27
+ # races) — those belong to the backend's own spec.
28
+ RSpec.shared_examples "an Insika store" do
29
+ describe "#get / #set (round-trip)" do
30
+ it " preserves Hash with string keys" do #
31
+ store.set("s", "k", { "a" => 1, "b" => [1, 2] })
32
+ expect(store.get("s", "k")).to eq({ "a" => 1, "b" => [1, 2] })
33
+ end
34
+
35
+ it " preserves Array" do #
36
+ store.set("s", "k", [1, "x", true, nil])
37
+ expect(store.get("s", "k")).to eq([1, "x", true, nil])
38
+ end
39
+
40
+ it " preserves String" do #
41
+ store.set("s", "k", "texto")
42
+ expect(store.get("s", "k")).to eq("texto")
43
+ end
44
+
45
+ it " preserves Integer" do #
46
+ store.set("s", "k", 42)
47
+ value = store.get("s", "k")
48
+ expect(value).to eq(42)
49
+ expect(value).to be_a(Integer)
50
+ end
51
+
52
+ it " preserves Float" do #
53
+ store.set("s", "k", 3.14)
54
+ value = store.get("s", "k")
55
+ expect(value).to eq(3.14)
56
+ expect(value).to be_a(Float)
57
+ end
58
+
59
+ it " preserves booleans" do #
60
+ store.set("s", "t", true)
61
+ store.set("s", "f", false)
62
+ expect(store.get("s", "t")).to be(true)
63
+ expect(store.get("s", "f")).to be(false)
64
+ end
65
+
66
+ it " preserves nil written without exception" do #
67
+ store.set("s", "k", nil)
68
+ expect(store.get("s", "k")).to be_nil
69
+ end
70
+
71
+ it " converts Symbols (keys and values) to Strings" do #
72
+ store.set("s", "k", { chave: :valor })
73
+ expect(store.get("s", "k")).to eq({ "chave" => "valor" })
74
+ end
75
+
76
+ it " returns nil for an absent key, never an exception" do #
77
+ expect(store.get("s", "nao-existe")).to be_nil
78
+ end
79
+
80
+ it " overwrites silently (last-write-wins)" do #
81
+ store.set("s", "k", "primeiro")
82
+ store.set("s", "k", "segundo")
83
+ expect(store.get("s", "k")).to eq("segundo")
84
+ end
85
+
86
+ it " set returns the same object passed in (not the round-trip)" do #
87
+ obj = { "a" => 1 }
88
+ expect(store.set("s", "k", obj)).to equal(obj)
89
+ end
90
+ end
91
+
92
+ describe "#delete" do
93
+ it " removes existing and returns true" do #
94
+ store.set("s", "k", 1)
95
+ expect(store.delete("s", "k")).to be(true)
96
+ expect(store.get("s", "k")).to be_nil
97
+ end
98
+
99
+ it " returns false for a nonexistent key" do #
100
+ expect(store.delete("s", "k")).to be(false)
101
+ end
102
+ end
103
+
104
+ describe "#list" do
105
+ it " returns scope keys sorted lexicographically" do #
106
+ store.set("s", "b", 1)
107
+ store.set("s", "a", 1)
108
+ store.set("s", "c", 1)
109
+ expect(store.list("s")).to eq(%w[a b c])
110
+ end
111
+
112
+ it " filters by prefix with start_with? (not include?)" do #
113
+ store.set("s", "task:1", 1)
114
+ store.set("s", "task:2", 1)
115
+ store.set("s", "checkpoint:1", 1)
116
+ store.set("s", "my-task:1", 1) # trap: contains "task:" but does not start with it
117
+ expect(store.list("s", "task:")).to eq(%w[task:1 task:2])
118
+ end
119
+
120
+ it " returns [] for an empty scope" do #
121
+ expect(store.list("s")).to eq([])
122
+ end
123
+
124
+ it "sorts lexicographically, not numerically" do # edge case 2
125
+ store.set("s", "task:10", 1)
126
+ store.set("s", "task:2", 1)
127
+ expect(store.list("s", "task:")).to eq(%w[task:10 task:2])
128
+ end
129
+ end
130
+
131
+ describe "scope isolation" do
132
+ it " keeps scopes independent in get/list/delete" do #
133
+ store.set("s1", "k", 1)
134
+ store.set("s2", "k", 2)
135
+
136
+ expect(store.get("s1", "k")).to eq(1)
137
+ expect(store.get("s2", "k")).to eq(2)
138
+ expect(store.list("s1")).to eq(%w[k])
139
+
140
+ store.delete("s1", "k")
141
+ expect(store.get("s2", "k")).to eq(2)
142
+ end
143
+ end
144
+
145
+ describe "#transaction" do
146
+ it " returns the block's value" do #
147
+ expect(store.transaction { 42 }).to eq(42)
148
+ end
149
+
150
+ it " commits the block's writes" do #
151
+ store.transaction { store.set("s", "k", "commitado") }
152
+ expect(store.get("s", "k")).to eq("commitado")
153
+ end
154
+
155
+ it " does a real rollback of set AND delete when the block raises" do #
156
+ store.set("s", "manter", "antigo")
157
+ store.set("s", "apagar", "existe")
158
+
159
+ expect do
160
+ store.transaction do
161
+ store.set("s", "manter", "novo")
162
+ store.delete("s", "apagar")
163
+ raise "boom"
164
+ end
165
+ end.to raise_error("boom")
166
+
167
+ # all the block's effects undone
168
+ expect(store.get("s", "manter")).to eq("antigo")
169
+ expect(store.get("s", "apagar")).to eq("existe")
170
+ end
171
+
172
+ it " reuses the outer transaction when nested" do #
173
+ store.set("s", "k", "antigo")
174
+
175
+ expect do
176
+ store.transaction do
177
+ store.transaction { store.set("s", "k", "novo") }
178
+ raise "boom"
179
+ end
180
+ end.to raise_error("boom")
181
+
182
+ # outer rollback undoes the inner set (no nesting error)
183
+ expect(store.get("s", "k")).to eq("antigo")
184
+ end
185
+ end
186
+
187
+ describe "serialization errors" do
188
+ it " raises StoreError and does not write a non-serializable value" do #
189
+ expect do
190
+ store.set("s", "k", Object.new)
191
+ end.to raise_error(Insika::StoreError)
192
+ expect(store.get("s", "k")).to be_nil
193
+ end
194
+ end
195
+ end
196
+
197
+ # Multi-worker safety. Every claim (outbox, delegation
198
+ # sweep, recovery) is a read-check-write inside `transaction` — on SQLite that is
199
+ # atomic because the backend opens BEGIN IMMEDIATE; a backend whose transaction
200
+ # only yields passes all 22 cases above and still double-claims under two
201
+ # workers, silently. These cases close that hole, across REAL concurrent
202
+ # connections (threads, each on its own handle from `store_factory`).
203
+ #
204
+ # The `sleep` inside each transaction is deliberate: it holds the read state
205
+ # open long enough that a backend without isolation ALWAYS lets a second
206
+ # connection through, while a correct backend serializes the writers. Without
207
+ # it a wrong backend could pass by scheduling luck — the failure relies on.
208
+ RSpec.shared_examples "an Insika store safe for N workers" do
209
+ # Starts N threads, each holding its OWN connection to the same backend,
210
+ # releases them together, and returns each block's value. Connections are
211
+ # closed on the way out when the backend has a #close.
212
+ def with_concurrent_connections(n, &blk)
213
+ ready = Queue.new
214
+ go = Queue.new
215
+ threads = n.times.map do
216
+ Thread.new do
217
+ conn = store_factory.call
218
+ ready << true
219
+ go.pop # hold every connection at the line, then release them together
220
+ blk.call(conn)
221
+ ensure
222
+ conn.close if conn.respond_to?(:close)
223
+ end
224
+ end
225
+ n.times { ready.pop }
226
+ go.close # a closed queue pops nil immediately: the starting gun
227
+ threads.map(&:value)
228
+ end
229
+
230
+ # The outbox/delegation claim, verbatim in shape: read the status, and only
231
+ # the connection that still sees "pending" may flip it. A correct backend
232
+ # serializes the transactions, so exactly ONE of the 8 observes "pending".
233
+ it "claims a pending key exactly once across concurrent connections" do
234
+ store.set("jobs", "job:1", "pending")
235
+
236
+ claims = with_concurrent_connections(8) do |conn|
237
+ conn.transaction do
238
+ next unless conn.get("jobs", "job:1") == "pending"
239
+
240
+ sleep 0.01 # hold the read open: a wrong backend lets everyone through
241
+ conn.set("jobs", "job:1", "claimed")
242
+ true
243
+ end
244
+ end.count(true)
245
+
246
+ expect(claims).to eq(1)
247
+ expect(store.get("jobs", "job:1")).to eq("claimed")
248
+ end
249
+
250
+ # The lost-update shape the claim generalizes from: read-modify-write of a
251
+ # counter. A backend whose transaction yields without isolation drops
252
+ # increments whenever two connections overlap.
253
+ it "does not lose updates across concurrent connections" do
254
+ store.set("meters", "hits", 0)
255
+ connections = 4
256
+ increments = 10
257
+
258
+ with_concurrent_connections(connections) do |conn|
259
+ increments.times do
260
+ conn.transaction do
261
+ current = conn.get("meters", "hits")
262
+ sleep 0.001 # widen the window a wrong backend loses updates through
263
+ conn.set("meters", "hits", current + 1)
264
+ end
265
+ end
266
+ end
267
+
268
+ expect(store.get("meters", "hits")).to eq(connections * increments)
269
+ end
270
+ end
@@ -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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Cheap token estimate behind an interface.
5
+ # Default: text.length / 4 (off by ~±15%, absorbed by the budget margin).
6
+ # Swappable for a real tokenizer without touching the Builder: any object that
7
+ # responds to #estimate(text) -> Integer works as a replacement (injected at the
8
+ # composition root).
9
+ module TokenEstimator
10
+ module_function
11
+
12
+ def estimate(text)
13
+ text.to_s.length.ceildiv(4)
14
+ end
15
+ end
16
+ 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