insika 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -0,0 +1,146 @@
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, retention: nil, funnel: nil, followup: 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
+ @retention = retention # WS8: the daily age-based sweep; nil = none
43
+ @funnel = funnel # the tick-driven outcome fold; nil = none
44
+ @followup = followup # the tick-driven follow-up firer; nil = none
45
+ end
46
+
47
+ # the fold is wired after the Tick is built (the graph passes
48
+ # it to `executor.tick.funnel =` — the outcome/funnel stores come from the
49
+ # spine). Setter + kwarg: same shape as `retention`.
50
+ attr_accessor :funnel
51
+
52
+ # the follow-up firer, wired after the Tick is built (same
53
+ # shape as `funnel` — the stores come from the spine).
54
+ attr_accessor :followup
55
+
56
+ def enabled? = @interval.positive?
57
+
58
+ # One pass, pure (no reactor needed): the serving loop calls it on a timer,
59
+ # specs call it directly. A StoreError propagates to the loop, which logs
60
+ # and keeps ticking.
61
+ def run_once
62
+ drained = @channel_delivery ? @channel_delivery.sweep : { dispatched: [] }
63
+ summary = { dispatched: drained[:dispatched], resumed: [], failed: [] }
64
+ # WS8 retention: cheap when not claimed (its own daily window) — the
65
+ # O(n) scans never ride the 60 s loop.
66
+ retention_summary = @retention&.run
67
+ summary[:retention] = retention_summary if retention_summary
68
+ # the outcome fold — one pass per claim window, cheap
69
+ # when another worker holds it. Sits next to retention, on the same tick.
70
+ funnel_summary = @funnel&.run
71
+ summary[:funnel] = funnel_summary if funnel_summary
72
+ # the follow-up firer — the tick's third duty, gated by its
73
+ # OWN claim window so the O(n) scans never ride the 60 s loop.
74
+ followup_summary = @followup&.run
75
+ summary[:followup] = followup_summary if followup_summary
76
+ return summary unless claim_window
77
+
78
+ result = @recovery.run(stale_after: @stale_after)
79
+ summary.merge(resumed: result[:resumed], failed: result[:failed])
80
+ end
81
+
82
+ # The loop, spawned as a child of the turn supervisor (the
83
+ # tick lives on the supervisor fiber — every serving arm gets it the moment
84
+ # `supervised = true` matters, with no arm edits). A failing pass logs and
85
+ # the loop continues: a sweeper that dies silently is the outage it exists
86
+ # to prevent. Restartable: when the supervisor is recreated its children
87
+ # died with it, so a stopped task is not a running one.
88
+ def start(parent:)
89
+ return false unless enabled?
90
+ return true if @task&.running?
91
+
92
+ @task = parent.async do |t|
93
+ t.annotate("insika-tick")
94
+ loop do
95
+ @sleeper.call(@interval)
96
+ run_once
97
+ rescue StandardError => e
98
+ log(:warn, "tick failed: #{e.class}: #{e.message}")
99
+ end
100
+ end
101
+ true
102
+ end
103
+
104
+ private
105
+
106
+ # One sweeper per window across N workers. Unlike the boot
107
+ # claim (one key per generation), the tick reuses a SINGLE key with a
108
+ # timestamp — a key per minute would be a slow leak in the store. The
109
+ # read-check-write rides Store#transaction like every claim:
110
+ # two workers racing the window serialize on the backend's lock and exactly
111
+ # one sweeps.
112
+ def claim_window
113
+ now = Time.now.utc
114
+ @store.transaction do
115
+ current = @store.get(SCOPE, KEY)
116
+ last = current && begin
117
+ Time.iso8601(current["claimed_at"].to_s)
118
+ rescue ArgumentError
119
+ nil # a corrupted claim is not a claim — take the window
120
+ end
121
+ if last.nil? || (now - last) >= @interval
122
+ @store.set(SCOPE, KEY, { "claimed_at" => now.iso8601 })
123
+ true
124
+ else
125
+ false
126
+ end
127
+ end
128
+ end
129
+
130
+ # Async when there is a reactor (production: the interval must not block
131
+ # the worker), plain sleep otherwise (specs driving the loop by hand use an
132
+ # injected sleeper anyway).
133
+ def default_sleep(seconds)
134
+ task = defined?(Async::Task) ? Async::Task.current? : nil
135
+ task ? task.sleep(seconds) : sleep(seconds)
136
+ end
137
+
138
+ # Same contract as Recovery's: logging is pure observability and must never
139
+ # alter the flow.
140
+ def log(level, message)
141
+ @logger&.public_send(level, "[tick] #{message}")
142
+ rescue StandardError
143
+ nil
144
+ end
145
+ end
146
+ 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,11 +30,12 @@ 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,
37
- :secret_headers, :side_effect, :timeout, :group, :tags, :halt_when
37
+ :secret_headers, :side_effect, :timeout, :group, :tags, :halt_when,
38
+ :evidence # Insika::Evidence::Spec | nil
38
39
  )
39
40
 
40
41
  class ToolDefinition
@@ -46,17 +47,17 @@ module Insika
46
47
  ARRAY_SUGAR = PARAM_TYPES.map { |t| "array:#{t}" }.freeze
47
48
  HTTP_METHODS = %w[GET HEAD POST PUT PATCH DELETE].freeze
48
49
  IDEMPOTENT = %w[GET HEAD].freeze # side_effect default = false
49
- EXTRACTS = %w[body_raw status json_path].freeze
50
+ EXTRACTS = %w[body_raw status json_path evidence_envelope].freeze
50
51
  NAME_RE = /\A[a-z][a-z0-9_]*\z/ # identifier for the model
51
52
  # 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
53
+ # separate from the model's `{{param}}`. Params follow NAME_RE (no
53
54
  # dot) -> a placeholder with a dot can only be a ctx ref.
54
55
  PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
55
56
  # Turn-context namespace: values coming from the TURN (not the model),
56
57
  # resolved by DataDefinedTool. Closed allowlist (a typo becomes a validation
57
58
  # error, not a silently empty header).
58
59
  CTX_PREFIX = "ctx."
59
- CTX_FIELDS = %w[chat_id store_id agent_id tenant].freeze
60
+ CTX_FIELDS = %w[chat_id store_id agent_id tenant image_url].freeze
60
61
 
61
62
  # ---- safe subset of JSON Schema (R1) --------------------------------------
62
63
  # Types supported by EVERY provider (OpenAI/Anthropic/Gemini/DeepSeek/Bedrock).
@@ -74,7 +75,7 @@ module Insika
74
75
  # `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
75
76
  def self.build(name:, description:, request:, parameters: nil, response: nil,
76
77
  secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
77
- halt_when: nil)
78
+ halt_when: nil, evidence: nil)
78
79
  name = name.to_s
79
80
  raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
80
81
 
@@ -84,6 +85,10 @@ module Insika
84
85
  schema = normalize_params(parameters)
85
86
  req = normalize_request(request, top_level_names(schema))
86
87
  resp = normalize_response(response)
88
+ if resp[:extract] == "evidence_envelope" && evidence.nil?
89
+ raise Insika::ValidationError,
90
+ "extract 'evidence_envelope' requires an 'evidence' declaration"
91
+ end
87
92
 
88
93
  method = req[:method]
89
94
  effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
@@ -93,7 +98,8 @@ module Insika
93
98
  secret_headers: Array(secret_headers).map(&:to_s), side_effect: effect,
94
99
  timeout: timeout.nil? ? nil : Integer(timeout),
95
100
  group: normalize_group(group), tags: normalize_tags(tags),
96
- halt_when: normalize_halt_when(halt_when)
101
+ halt_when: normalize_halt_when(halt_when),
102
+ evidence: Insika::Evidence::Spec.parse(evidence)
97
103
  )
98
104
  end
99
105
 
@@ -104,11 +110,11 @@ module Insika
104
110
  name: h[:name], description: h[:description], parameters: h[:parameters],
105
111
  request: h[:request] || {}, response: h[:response],
106
112
  secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
107
- group: h[:group], tags: h[:tags], halt_when: h[:halt_when]
113
+ group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence]
108
114
  )
109
115
  end
110
116
 
111
- # Group (Phase 7/D4/F5): enablement label by DATA (not name convention),
117
+ # Group: enablement label by DATA (not name convention),
112
118
  # target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
113
119
  def self.normalize_group(group)
114
120
  g = group.to_s.strip
@@ -145,7 +151,7 @@ module Insika
145
151
  def self.empty_schema = { "type" => "object", "properties" => {}, "required" => [] }
146
152
  private_class_method :empty_schema
147
153
 
148
- # Flat sugar -> JSON Schema. Preserves Phase 5 validation (NAME_RE, duplicates,
154
+ # Flat sugar -> JSON Schema. Preserves validation (NAME_RE, duplicates,
149
155
  # PARAM_TYPES) and lifts `array:<scalar>` into proper `items`.
150
156
  #
151
157
  # A bare `array` is REFUSED. It used to lift to `items: {type:"string"}` — the
@@ -405,7 +411,7 @@ module Insika
405
411
  # String-keyed Hash for persistence (ConfigStore stringifies again, but we
406
412
  # normalize here so the record is stable across backends).
407
413
  def to_h
408
- {
414
+ h = {
409
415
  "name" => name, "description" => description,
410
416
  "parameters" => parameters,
411
417
  "request" => request.transform_keys(&:to_s),
@@ -415,6 +421,10 @@ module Insika
415
421
  "group" => group, "tags" => tags,
416
422
  "halt_when" => halt_when&.transform_keys(&:to_s)
417
423
  }
424
+ # present only when declared — a tool without evidence is byte-identical
425
+ # to today (no declaration, no envelope processing).
426
+ h["evidence"] = evidence.to_h if evidence
427
+ h
418
428
  end
419
429
 
420
430
  # -> true when this response ENDS the turn (no further model call). `body` is the
@@ -56,6 +56,11 @@ module Insika
56
56
 
57
57
  started = monotonic
58
58
  result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { __getobj__.call(args) } }
59
+ # the ONE seam every tool result passes on its way to the model.
60
+ # For a declared-evidence tool: reshape to the lean envelope, record the ids
61
+ # on the ledger, hoard the attachments. No evidence = the result passes
62
+ # through untouched (one nil-check — parity).
63
+ result = process_evidence(result)
59
64
  record_side_effect!(call_id) if side_effect?
60
65
  trace(call_id, args, result, started)
61
66
  result
@@ -69,7 +74,7 @@ module Insika
69
74
 
70
75
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
71
76
 
72
- # D4 (item 30): with parallel tool calls on, the turn's shared semaphore
77
+ # with parallel tool calls on, the turn's shared semaphore
73
78
  # (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
74
79
  # at once. Wraps the REAL call ONLY — the approval wait and the skip check are
75
80
  # outside it, so a call blocked on a human never holds a slot, and the per-call
@@ -136,5 +141,69 @@ module Insika
136
141
  @checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
137
142
  tool_call_id: call_id)
138
143
  end
144
+
145
+ # ---- evidence ---------------------------------------------
146
+
147
+ # The evidence spec for the wrapped tool (D4). Resolution order:
148
+ # 1. the wrapped tool responds to `evidence` -> its spec (the data-tool
149
+ # path — DataDefinedTool exposes its definition's evidence);
150
+ # 2. otherwise the tool_registry entry's metadata carries an `evidence`
151
+ # spec (the code-tool path — a registry tool opts in at registration).
152
+ # No spec = pass the result through untouched (parity, byte-identical).
153
+ def evidence_spec
154
+ tool = __getobj__
155
+ if tool.respond_to?(:evidence)
156
+ raw = tool.evidence
157
+ return raw && Insika::Evidence::Spec.parse(raw)
158
+ end
159
+
160
+ entry = @tool_registry.respond_to?(:entries) ? registry_entry(real_name) : nil
161
+ metadata = entry&.respond_to?(:metadata) ? entry.metadata : nil
162
+ raw = metadata && (metadata[:evidence] || metadata["evidence"])
163
+ raw && Insika::Evidence::Spec.parse(raw)
164
+ end
165
+
166
+ def registry_entry(name)
167
+ @tool_registry.entries.find { |e| e.name == name.to_s }
168
+ end
169
+
170
+ # -> result (possibly reshaped). NEVER raises out: a broken evidence result
171
+ # becomes the envelope error the model can act on, exactly like a malformed
172
+ # CALL is today. A tool ERROR result is never reshaped (an error must reach
173
+ # the model verbatim — the DataDefinedTool rule).
174
+ def process_evidence(result)
175
+ spec = evidence_spec
176
+ return result unless spec
177
+ return result if result.is_a?(Hash) && (result[:error] || result["error"])
178
+
179
+ raw = Insika::Evidence::Processor.raw(spec, result)
180
+ bad = Insika::SchemaGuard.violation_output(spec, raw)
181
+ return { error: bad } if bad
182
+
183
+ lean, attachments = Insika::Evidence::Processor.build(spec, raw)
184
+ record_evidence!(spec, lean)
185
+ hoard_attachments!(attachments)
186
+ lean
187
+ rescue StandardError => e
188
+ { error: "evidence processing failed: #{e.message}" }
189
+ end
190
+
191
+ # Ledger write + attachment hoarding, both via the state (duck-typed — the
192
+ # envelope's existing specs construct state stubs without these readers).
193
+ def record_evidence!(_spec, lean)
194
+ ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
195
+ return unless ledger
196
+
197
+ ids = Array(lean["items"]).map { |i| i["id"] }
198
+ ledger.record(ids) unless ids.empty?
199
+ end
200
+
201
+ def hoard_attachments!(attachments)
202
+ return if attachments.empty?
203
+ return unless @state.respond_to?(:evidence_attachments)
204
+
205
+ @state.evidence_attachments ||= []
206
+ @state.evidence_attachments.concat(attachments)
207
+ end
139
208
  end
140
209
  end
@@ -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,17 +73,21 @@ 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.
80
- "halt_when" => t["halt_when"]
80
+ "halt_when" => t["halt_when"],
81
+ # the evidence declaration (lean envelope + ledger feed are the
82
+ # SAME data contract — D1). Normalized here; a malformed declaration is a
83
+ # ValidationError at ingestion, isolable per tool (R4).
84
+ "evidence" => t["evidence"] && Insika::Evidence::Spec.parse(t["evidence"]).to_h
81
85
  }.compact
82
86
  end
83
87
 
84
88
  private
85
89
 
86
- # Envelope adapter (D3): extracts the INTERFACE (name/description/parameters) from
90
+ # Envelope adapter: extracts the INTERFACE (name/description/parameters) from
87
91
  # any standard form. name/description fall back to the tool's top level when the
88
92
  # envelope does not carry them.
89
93
  def extract_interface(t)