insika 0.2.0 → 0.7.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
data/docs/DEPLOY.md CHANGED
@@ -37,7 +37,9 @@ The image boots **N Falcon worker processes over one SQLite file**
37
37
  knob**: it decides which engine semantics hold cluster-wide and which are
38
38
  per-worker. The default is 1 because the per-worker semantics are the product
39
39
  — the queue modes (`collect`/`steer`) and FIFO ordering need one
40
- session actor; raise N only with sticky routing per session in front. The
40
+ session actor; raise N only with sticky routing per session in front. It is
41
+ also a [Soak](SOAK.md) precondition: with more than one worker the hourly RSS
42
+ series is a random process per sample, and a soak refuses to start. The
41
43
  contract:
42
44
 
43
45
  > Everything here describes N workers of **one** deployment — one graph, replicated.
@@ -55,7 +57,14 @@ contract:
55
57
  worker that holds the session's actor. The engine does **not** promise them
56
58
  across workers. A deploy that needs those semantics for a session must route
57
59
  that session's traffic to one worker (sticky routing) — or accept per-worker
58
- best-effort.
60
+ best-effort. **On Railway there is no sticky-routing option at any layer** —
61
+ confirmed against Railway's own docs, which distribute traffic randomly and
62
+ explicitly do not support sticky sessions — so N>1 there is not "per-worker
63
+ best-effort," it is a guaranteed cross-session leak the first time two
64
+ requests for the same session land on different workers. See the Railway
65
+ section below; `insika doctor` errors on `WEB_CONCURRENCY>1` there — unless
66
+ [`insika-router`](ROUTER.md), the session-sticky proxy, is in front,
67
+ which is what makes N>1 safe on Railway too (N local workers behind it).
59
68
  3. **Recovery is part of boot, in every wiring.** Every worker boots through
60
69
  `Server::Boot`, which runs recovery **before the listen**. The per-record
61
70
  sweeps (undelivered outbox records, undelivered delegation results) run in
@@ -112,10 +121,11 @@ this section is the single source of truth for what changing it means.
112
121
  | `DEEPSEEK_MODEL` | `deepseek-v4-flash` | model |
113
122
  | `CONSUMER_INTERNAL_URL` | — | base URL for data-tools calling back a consumer's internal API (see below) |
114
123
  | `INSIKA_EGRESS_HOSTS` | — | outbound host allowlist (SSRF guard) |
115
- | `INSIKA_EGRESS_ALLOW_HTTP` / `_ALLOW_PRIVATE` | off | for `http`/loopback callbacks only (**never in cloud**) |
124
+ | `INSIKA_EGRESS_ALLOW_HTTP` / `_ALLOW_PRIVATE` | off | for `http`/loopback callbacks only (**never in cloud**). Also opens inbound **media** fetches (audio/image URLs) to `http`/private targets — a local run serving media over `http://` needs it |
116
125
  | `INSIKA_RELAY_TOKEN` | — | **mounts the relay channel** at `POST /channels/relay/events`, and is the Bearer it requires. Empty = the route does not exist (`404`). See [Channels](CHANNELS.md) |
117
126
  | `INSIKA_RELAY_DELIVER_URL` | — | your callback; the engine POSTs each reply there. Goes through the egress guard |
118
127
  | `INSIKA_RELAY_DELIVER_TOKEN` | — | Bearer the engine sends **to** your callback (optional) |
128
+ | `INSIKA_RELAY_DELIVERY` | `at_end` | how the relay flushes the outbox: `at_end` (one POST) or `progressive` (one POST per balloon — [delivery policy](CHANNELS.md#delivery-policy)) |
119
129
  | `INSIKA_WIDGET_ORIGINS` | — | exact-match origins allowed to embed the [web widget](CHANNELS.md#the-web-widget), comma-separated. No wildcards. **Half the switch**: with `INSIKA_WIDGET_AGENTS` unset, nothing is mounted (`404`) |
120
130
  | `INSIKA_WIDGET_AGENTS` | — | agent ids a widget visitor may address, comma-separated. The other half of the switch. **A chat rate limit is also required** or the widget answers `503` |
121
131
  | `LITESTREAM_REPLICA_URL` | — | **enables Litestream** (backup/DR). Empty = disabled (default). See below |
@@ -218,11 +228,64 @@ healthcheck, and a restart policy.
218
228
  without a volume, SQLite is ephemeral and recovery resumes nothing after a
219
229
  redeploy.
220
230
  3. **Vars**: `DEEPSEEK_API_KEY`, `OPENCLAW_GATEWAY_TOKEN`, `CONSUMER_INTERNAL_URL`,
221
- `INSIKA_EGRESS_HOSTS` (and `WEB_CONCURRENCY` to match your plan/CPU).
231
+ `INSIKA_EGRESS_HOSTS`. **Leave `WEB_CONCURRENCY` at its default of 1** unless
232
+ you run [`insika-router`](ROUTER.md) in front. Railway's own docs say it
233
+ "does not support sticky sessions" and randomly distributes traffic across
234
+ replicas/workers — there is no way on this platform to satisfy the "sticky
235
+ routing per session in front" precondition item 2 of the process model
236
+ requires, at any layer (Railway replicas or Falcon's own `--count` workers
237
+ within one container). Raising `WEB_CONCURRENCY` without a router in front
238
+ is not a throughput knob, it is a guaranteed way to leak a reply across
239
+ sessions the first time two requests for the same session land on different
240
+ workers — `insika doctor` errors on this combination for exactly that
241
+ reason. `insika-router` is the fix: N local Falcon workers
242
+ behind one sticky proxy, still one Railway replica.
222
243
  4. The healthcheck hits `/up`.
223
244
  5. Point your consumer at the service's public URL, with a matching API token
224
245
  (see [RUNNING-LOCAL.md](RUNNING-LOCAL.md)).
225
246
 
247
+ ## Kubernetes
248
+
249
+ Same contract as Railway, for the same reason, verified against
250
+ ingress-nginx's own docs: **run one pod, `WEB_CONCURRENCY=1`.** Scale
251
+ vertically (bigger pod) for now.
252
+
253
+ Why a standard K8s setup does not get you out of this: the session id lives
254
+ in the **JSON body** of `POST /v1/responses` (the `user` field), not in a
255
+ header, cookie, or URL segment. That rules out every sticky-routing
256
+ mechanism K8s gives you for free:
257
+
258
+ - A vanilla `Service` (`ClusterIP`) load-balances across pod endpoints with
259
+ no notion of session at all — same failure mode as Railway's replicas,
260
+ one layer down.
261
+ - `Service.spec.sessionAffinity: ClientIP` does not help either: the caller
262
+ is the consumer's own backend (achei-b2b, a server-to-server call), not
263
+ the end customer's browser, so many different customers' sessions arrive
264
+ from the same source IP and would collide on the same pod instead of
265
+ spreading — the opposite of what you want, and it still does not restore
266
+ a real per-session guarantee.
267
+ - ingress-nginx's `nginx.ingress.kubernetes.io/upstream-hash-by` (the
268
+ standard sticky/consistent-hash annotation) hashes on nginx *variables* —
269
+ headers, cookies, `$request_uri`, client IP — not on a field parsed out of
270
+ a POST body. Reaching into the JSON body needs a custom Lua/OpenResty
271
+ snippet (or an Envoy filter) that parses the request and extracts `user`
272
+ before hashing. That is real, unbuilt engineering work, not a K8s
273
+ annotation to flip.
274
+
275
+ If real horizontal throughput is ever needed, two paths get there, neither
276
+ of them "just add replicas":
277
+ 1. **Build the body-aware sticky layer** above (Lua/Envoy consistent-hash on
278
+ the `user` field) in front of N pods, each still `WEB_CONCURRENCY=1`.
279
+ 2. **Move the session id into the URL**, the way the web widget transport
280
+ already does (`POST /api/widget/sessions/:token/messages` carries the
281
+ session token in the path) — that surface, unlike `/v1/responses`, *is*
282
+ sticky-routable today with a plain `upstream-hash-by: $request_uri`, no
283
+ custom scripting required.
284
+
285
+ Neither is urgent while a single pod's throughput is enough — this section
286
+ exists so scaling this deployment does not silently reintroduce the same
287
+ cross-session leak the Railway incident above already found once.
288
+
226
289
  ## Backup / DR — Litestream (opt-in, configurable)
227
290
 
228
291
  A single volume is the **one point of total loss** between a pilot and production
@@ -297,6 +360,12 @@ per pod + **sticky-by-agent** routing (shard by tenant), or **LiteFS**, or an
297
360
  optional **Postgres** adapter. **Litestream** (above) for backup/DR from day one —
298
361
  orthogonal to topology.
299
362
 
363
+ For the *session-routing* half specifically — as opposed to the storage
364
+ topology above — see [`insika-router`](ROUTER.md): N pods behind a
365
+ headless `Service`, one `insika-router` Deployment in front doing the
366
+ consistent-hash routing ingress-nginx's own `upstream-hash-by` cannot (it
367
+ hashes nginx variables, never a field parsed out of a POST body).
368
+
300
369
  ---
301
370
 
302
371
  ## Measuring performance / load
@@ -348,6 +417,8 @@ DEEPSEEK_API_KEY=sk-... ./scripts/loadtest-local.sh 4 24
348
417
 
349
418
  ## See also
350
419
 
420
+ - [ROUTER.md](ROUTER.md) — `insika-router`, the session-sticky proxy for
421
+ scaling past one worker.
351
422
  - [RUNNING-LOCAL.md](RUNNING-LOCAL.md) — run the engine locally, single-process.
352
423
  - [Security](SECURITY.md) — tokens, egress, strict config.
353
424
  - [BENCHMARK.md](BENCHMARK.md) — the neutral, key-free engine benchmark.
data/docs/EMBEDDING.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Embedding
3
- parent: Ship it
3
+ parent: Integrate
4
4
  nav_order: 4
5
5
  permalink: /embedding/
6
6
  ---
data/docs/EVALS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Evals
3
- parent: Operate & prove it
4
- nav_order: 4
3
+ parent: Improve
4
+ nav_order: 1
5
5
  permalink: /evals/
6
6
  ---
7
7
 
@@ -157,6 +157,82 @@ Three rules make the number worth quoting:
157
157
  Cost: **two provider calls per judge per case**, which is why it is opt-in and never
158
158
  part of the gate.
159
159
 
160
+ ## Simulated users — conversations the agent never had
161
+
162
+ A golden case fixes the user's turns in advance. Real customers branch — they answer
163
+ the agent's question, or ignore it, or send an order number three messages later. A
164
+ case with frozen turns can only ever test the path the author imagined.
165
+
166
+ A **simulated** case generates the conversation instead: a persona model (the cheap
167
+ platform `utility_model`) plays a customer with the persona as its **whole
168
+ instruction**, and the target agent answers through the same transport a replay uses.
169
+ They talk until the persona's `max_turns`, or until the persona emits a stop marker —
170
+ `<<goal_met>>` when its goal is served, `<<gave_up>>` when it abandons. Both are
171
+ recorded: "gave up at turn 3" is a finding.
172
+
173
+ ```yaml
174
+ id: loja-objetivo-difuso
175
+ agent: loja-chocolates
176
+ persona: # the SIMULATED customer (alternative to `turns:`)
177
+ goal: "descobrir o que comprar de presente; não sabe nomes de produto"
178
+ style: "mensagens curtas, responde o que perguntam, desiste se tiver que explicar duas vezes"
179
+ opens_with: "oi, queria um presente"
180
+ knows: # the ONLY facts the persona may assert
181
+ orcamento: "até R$ 100"
182
+ ocasião: "aniversário"
183
+ max_turns: 8
184
+ expect:
185
+ policy: investigate_first
186
+ rubric: |
187
+ Descobre o objetivo antes de recomendar (uma ou duas perguntas, não um formulário),
188
+ recomenda do catálogo real e fecha com um próximo passo claro. Reprova se despejar
189
+ catálogo antes de entender, ou se ficar perguntando sem nunca buscar.
190
+ min_score: 0.7
191
+ ```
192
+
193
+ **The anti-invention rule is the soul of the feature.** The persona's prompt contains
194
+ only the `knows` facts and the rule that it may assert exactly those and nothing else
195
+ — asked about anything it does not have, it answers with ignorance ("não sei", "não
196
+ tenho isso aqui"), like a real customer without that fact. A simulator that invents an
197
+ order number produces a conversation the agent could never have had, and a case that
198
+ tests nothing.
199
+
200
+ ```bash
201
+ insika evals:simulate --persona persona.yml --target loja-chocolates --staging
202
+ ```
203
+
204
+ - `--target <agent|url>` — an agent id over the deployment's `/v1/responses`, or a full
205
+ A2A URL (an agent that only speaks A2A rides the same Simulator through a thin A2A
206
+ transport; it polls once per second, bounded by `--timeout`, since a remote agent
207
+ takes seconds to finish a task).
208
+ - `--staging` or `--eval-profile` — **one is required**. A simulated conversation
209
+ marks its transcript `simulated: true`; it does NOT disarm the agent's tools. So the
210
+ Simulator only runs against (a) a target the operator declares is a staging
211
+ deployment (`--staging`), or (b) an eval profile where the agent's side-effect tools
212
+ are swapped for dry-runs (`--eval-profile`).
213
+ - `--eval-profile` is a **verified declaration, not a trust-me flag**. The CLI derives
214
+ the target's side-effect tools — the deployment's own registry over
215
+ `GET /v1/agents/:id` (`side_effect_tools`), or the local store when `INSIKA_DB`
216
+ points at it — and refuses unless `--eval-tools <a,b,c>` names every one of them.
217
+ The swap itself happens where the tools run (the deployment's eval profile; the
218
+ in-process overlay for a local graph, as in `examples/agent-tester/`); the
219
+ derivation is what keeps the client honest about what must be covered. An A2A target
220
+ has no reachable registry, so the explicit `--eval-tools` list is required there.
221
+ - `--persona-model` — the model playing the customer (default: the platform
222
+ `utility_model`). The judge that scores the transcript comes from the same
223
+ panel configuration as a replay.
224
+ - `--conv` — the conversation id. The default is unique per run
225
+ (`sim-<case id>-<random>`), because two runs on the same conv share the
226
+ deployment session: the second inherits the first's history — including
227
+ across the arms of an A/B comparison — and the grade stops measuring the
228
+ agent. Pass `--conv` only when continuing a session is the point.
229
+
230
+ Every simulated run is **`simulated: true`** — the transcript carries the flag end to
231
+ end, so a report never mixes a generated conversation with real traffic. A simulated
232
+ case (`persona:`) is one of the two shapes of a case; the replay Runner **skips** it
233
+ (the turns do not exist until a Simulator generates them) and only the simulate CLI
234
+ drives it.
235
+
160
236
  Cases live in two places, and it is the same YAML in both:
161
237
 
162
238
  - **`evals/golden/**`** in the repo — the curated corpus, reviewable in a pull request,
@@ -178,6 +254,55 @@ A case whose stored YAML no longer validates is **listed as broken** on the Eval
178
254
  rather than skipped in silence — a test suite that quietly shrinks is worse than a red
179
255
  one.
180
256
 
257
+ ### `run_persona_eval` — a QA agent that tests other agents
258
+
259
+ A tool (`lib/insika/tools/run_persona_eval.rb`), not a CLI: a QA agent picks one of its
260
+ authored simulated cases and runs it **in-process**, against the case's declared
261
+ target agent — the same Simulator + Judge machinery `evals:simulate` drives over HTTP,
262
+ minus the network hop.
263
+
264
+ ```ruby
265
+ tools %w[run_persona_eval save_artifact]
266
+ ```
267
+
268
+ The model only sees `case_id`, enumerated with the ids the tool can actually run
269
+ (never a free string it could invent) — every **simulated** case in the store, the same
270
+ `persona:` shape as above.
271
+
272
+ **Safety is derived here too, but there is no swap yet.** The tool computes the
273
+ target's reachable side-effect tools (`Evals::EvalProfile`, the same derivation the CLI
274
+ uses) and **refuses outright** — naming the tools — if that list is non-empty. Unlike
275
+ the CLI, nothing here actually swaps a side-effect tool for a dry-run: `Evals::
276
+ EvalProfile.registry` (the overlay) exists for exactly that, but nothing calls it yet.
277
+ So `run_persona_eval` only runs against **read-only** target agents today; wiring the
278
+ overlay into an in-process turn (so a target WITH a write tool can be tested safely) is
279
+ follow-up work, not something this tool claims to do.
280
+
281
+ **Budget**: the persona model + judge model calls are the cost of running the eval,
282
+ charged to the **calling** agent's own turn — never the target's (the target's own
283
+ turns bill normally, through the ordinary edge limiter, exactly as a real customer's
284
+ would). A hard cap on the calling agent skips the run — visibly, in the tool's own
285
+ result (`{skipped: true, reason: "budget", window: "daily"|"monthly"}`) — before a cent
286
+ is spent; a soft cap just runs (the ledger's own alert already warns).
287
+
288
+ Each run gets a **fresh session** (`eval-<case>-<random>`), never a reused one — a
289
+ reused session lets the target agent "remember" earlier runs, which is not what a
290
+ persona case is testing. See `examples/agent-tester/qa_scheduled.rb` for the full
291
+ loop: `schedule` fires the QA agent, it calls `run_persona_eval`, then
292
+ `save_artifact` publishes the quality report.
293
+
294
+ **Tenant isolation.** A golden case carries a `tenant` (`platform` — the
295
+ single-tenant default — unless the case declares one, the same convention
296
+ `save_artifact` uses for its own binding tenant). `run_persona_eval` only ever
297
+ lists or runs cases in the *calling* agent's own tenant: a case authored for
298
+ another tenant is invisible, not merely refused — the model cannot even learn
299
+ its id from the enum, and running it by a guessed id gets the same "unknown or
300
+ invalid" error a nonexistent id would (the two must not be distinguishable).
301
+ This is what makes "one QA agent per store" an actual boundary
302
+ in a deployment where several stores' persona cases live in the same
303
+ `GoldenStore` — without it, `qa-store-a` could enumerate and run
304
+ `qa-store-b`'s persona and read its `knows` in the transcript.
305
+
181
306
  ## Running
182
307
 
183
308
  ```bash
@@ -232,6 +357,15 @@ That is what you run before merging a prompt, tool or model change. A case with
232
357
  baseline entry never blocks: it shows as failing in the report, but a brand-new case is
233
358
  not a regression.
234
359
 
360
+ The same gate machinery guards the two automated loops — [Refinement](REFINEMENT.md)
361
+ (cloned-agent replay of a proposed edit) and [Harvest](HARVEST.md) (cloned-agent
362
+ replay of a mined skill). **Judges are mandatory for those gates** in exactly three
363
+ shapes: a gate without a recorded baseline refuses, an all-red
364
+ baseline refuses, and a baseline recorded WITH judge scores, replayed with no judge
365
+ configured, refuses — a rubric'd case with no verdict reads as a pass, so the
366
+ candidate would beat a measurement it never took. A store with no golden cases cannot
367
+ gate, and cannot promote.
368
+
235
369
  ## Honest limits
236
370
 
237
371
  - **A judge is a model.** It has taste and it has bad days; that is why the
@@ -271,3 +405,13 @@ needs to score a candidate agent with the same judge, and a second copy of the j
271
405
  would be the worst possible outcome. It stays a **client** even so: it reaches a running
272
406
  deployment over HTTP through `POST /v1/responses` and never reads a store directly.
273
407
  `evals/run.rb` is a thin CLI over it.
408
+
409
+ ## Parity — the shadow criterion
410
+
411
+ The shadow experiment's rule lives in a **deployment-side criterion file** —
412
+ the path `INSIKA_PARITY_CRITERION` points at, prose a human reads and a `yaml`
413
+ block the machine applies, in one file, so there is exactly one place to edit.
414
+ The file's whole bytes are hashed; every shadow pair records that hash,
415
+ and a window whose pairs disagree produces `:invalid`, never a verdict. Editing
416
+ the criterion mid-experiment is *caught*, not averaged away. The fold itself is
417
+ `lib/insika/parity/*` (see [Channels](CHANNELS.md#shadow-mode)).
data/docs/FACTS.md ADDED
@@ -0,0 +1,135 @@
1
+ ---
2
+ title: Facts
3
+ parent: Improve
4
+ nav_order: 5
5
+ permalink: /facts/
6
+ ---
7
+
8
+ # Facts — distilled customer memory, human-gated
9
+
10
+ Finished conversations teach a shop things — the customer's size, their budget,
11
+ how they like to pay — and today that knowledge dies with the session. The
12
+ engine can read it back out of the transcripts: **distillation** turns an idle,
13
+ finished customer conversation into a list of proposed facts, a human approves
14
+ or rejects them in the Studio, and an approved fact lands in the customer's
15
+ memory cell — the same cell the `<memory>` block injects on every later turn.
16
+
17
+ The loop has one hard rule: **nothing is ever applied automatically.** Zero
18
+ facts reach the store without a click. Distillation proposes; the operator
19
+ decides; the engine never applies its own proposal.
20
+
21
+ ## The loop
22
+
23
+ 1. A customer conversation (a session tagged with a `customer`, per
24
+ [Context](CONTEXT.md#memory)) goes idle — nobody has written for the
25
+ configured hours.
26
+ 2. The engine's distillation duty picks the session, sends its transcript to
27
+ the platform `utility_model` (off the turn path, on its own worker fiber),
28
+ and the model answers with a JSON list of durable facts: `name`, `value`,
29
+ an optional `confidence`, and the transcript message indexes that support
30
+ the fact (the **evidence**).
31
+ 3. The engine filters the answer against a safe subset (no invented scopes, no
32
+ oversized values, no out-of-range evidence), dedups it against the ledger,
33
+ and writes the survivors as **proposals**.
34
+ 4. The **Facts** page in the Studio shows the pending proposals with their
35
+ evidence excerpt. The operator **approves** (the fact is written to the
36
+ customer's memory, stamped with its origin), **rejects** (optionally with a
37
+ reason) or **dismisses** (it will never be proposed again).
38
+ 5. Approved facts join the customer's memory cell and are injected by the
39
+ Memory provider on the next turn of any session of that customer.
40
+
41
+ ## Enabling it — the `distill:` block
42
+
43
+ Distillation is pack data on the agent, exactly like `refinement:` or
44
+ `followup:` — absent = the feature is off for that agent, byte-identical engine:
45
+
46
+ ```ruby
47
+ agent = Insika.agent("store-support") do
48
+ instructions "…"
49
+ distill enabled: true,
50
+ idle_hours: 6, # how idle a session must be before it distills
51
+ min_messages: 3, # a shorter session distills noise, not facts
52
+ max_proposals: 10 # cap per session pass
53
+ # prompt: "<what counts as a fact for THIS store>" — the pack-authored half;
54
+ # absent = the engine's generic prompt. `model:` (absent = the platform
55
+ # utility_model) can name the distiller explicitly.
56
+ end
57
+ ```
58
+
59
+ The same keys work in a pack's `agent.config.json`. `idle_hours` /
60
+ `min_messages` / `max_proposals` are per-agent data; `prompt` is the store's
61
+ half — what counts as a fact for a fashion store ("size, preference, budget")
62
+ is not what counts for a logistics one ("address, delivery window, carrier").
63
+ The engine never writes store vocabulary.
64
+
65
+ ## The human gate, precisely
66
+
67
+ The **Facts** page (operate group, next to Follow-ups) shows:
68
+
69
+ - **Pending** — oldest first, because evidence ages. Each card is the fact,
70
+ its confidence, its scope (`tenant:customer`), and the evidence excerpt read
71
+ from the transcript at request time (evidence is a link, never a copy). Three
72
+ buttons: **Approve & save to memory**, **Reject** (with an optional reason,
73
+ shown on the card), **Dismiss** (ghost — labelled "will not be proposed
74
+ again").
75
+ - **Stale** — the CAS-lost re-present (below): the proposed value struck
76
+ through next to the operator's current value, resolved by dismissal.
77
+ - **Recent** — every resolved proposal, most recent first, with operator and
78
+ note.
79
+
80
+ **The latch** — a dismissed *or* rejected `(name, value)` tuple is never
81
+ proposed again. The proposal rows ARE the ledger: a human saw that tuple and
82
+ said no, and re-proposing it would train the operator to stop reading. An
83
+ unanswered proposal is never piled on, either. A *different* value for the same
84
+ name is a different tuple — "wears M" dismissed does not block "wears L".
85
+
86
+ **The CAS guarantee** — approval never silently overwrites an operator edit.
87
+ At distill time the engine records the target fact's existence and revision; at
88
+ approve time it writes through the store's optimistic compare-and-swap. A fact
89
+ the operator moved in between flips the proposal to `stale` with both values
90
+ visible — the operator's edit always wins, never a silent overwrite.
91
+
92
+ ## Provenance
93
+
94
+ An approved fact is written with `origin: "distilled:<session_ref>"` — the
95
+ The provenance discipline: `"engine"` (the `remember` tool), `"operator"`
96
+ (Studio edits), `"legacy"`, and `"distilled"` (+ the session that produced it).
97
+ The Closed loop reads the same cell every later turn injects, and the
98
+ distillation ledger suppresses re-proposing a fact that is already applied with
99
+ a `distilled:` origin.
100
+
101
+ ## LGPD
102
+
103
+ Distilled facts are personal data, and the engine treats them like it:
104
+
105
+ - **Forget a customer** — `forget_customer` purges the customer's proposals
106
+ (every status) along with their memory cell and sessions.
107
+ - **Delete a tenant** — `delete_tenant_data` purges the tenant's proposals.
108
+ - **Retention** — proposals age out under the same `retention_days` sweep as
109
+ the rest of the footprint (pending included — a proposal is evidence of a
110
+ transcript, and when the transcript dies the pending fact is stale). The
111
+ session marker dies with its proposals: **an unreviewed proposal that ages
112
+ out is expired, not lost-locked** — the session is re-distillable, and a
113
+ duplicate survivor is filtered by the ledger, never applied.
114
+
115
+ ## The honest limits
116
+
117
+ - **Best-effort extraction, re-scan recovery.** There is no distillation
118
+ queue. A crash mid-pass leaves the session unmarked; the next pass re-scans
119
+ it, and the ledger filters any duplicate proposal. Exactly-once is not
120
+ claimed — facts are re-derivable, and a duplicate is filtered, never applied.
121
+ - **Precision is a forge audit.** The engine guarantees the *gates* (schema,
122
+ dedup, CAS, human approval); it cannot guarantee the *model's judgment*.
123
+ "Is this fact true and durable?" is audited on real traffic, per store — the
124
+ pack prompt is where that judgment is tuned.
125
+ - **Scope comes from the session, never the model.** The proposal's landing
126
+ cell is assembled by the engine from the session's tenant and customer; the
127
+ schema rejects a model-authored scope outright (a cross-tenant escape), and
128
+ an untagged session is never distilled.
129
+ - **Sessions only.** Distillation targets customer-tagged sessions; a
130
+ session without a customer has no landing zone and is skipped.
131
+ - **One stamp, once.** A session receives `vars["agent"]` on the same write
132
+ that stamps its `customer` — the first tagged turn. The stamp is
133
+ idempotent: a session that already carries a `customer` (a pre-upgrade
134
+ conversation, or one that since moved to this agent) never gains `agent`
135
+ on its own, so it can never participate in distillation.
data/docs/HARVEST.md ADDED
@@ -0,0 +1,116 @@
1
+ ---
2
+ title: Harvest
3
+ parent: Improve
4
+ nav_order: 6
5
+ permalink: /harvest/
6
+ ---
7
+
8
+ # Harvest — skills from real traffic, promoted only if eval AND conversion hold
9
+
10
+ The harvest is the loop that makes a store smarter with its own traffic: it
11
+ reads the store's **finished** conversations, asks a model to propose SKILLS
12
+ for the agent's playbook, filters every proposal through two pre-registered
13
+ lists (the negative list, the evidence ledger), scores it with a double gate,
14
+ and — **only after a human approves** — lands it as a live skill. Nothing is
15
+ ever applied automatically, and a skill that fails the gate is terminal: the
16
+ same finding must re-surface with new evidence, there is no silent retry.
17
+
18
+ Per-store data (`harvest:` on the agent), absent = the loop is off for that
19
+ agent:
20
+
21
+ ```ruby
22
+ harvest enabled: true,
23
+ negative_list: [ { rule: "no-competitor-prices", pattern: "concorrente" } ],
24
+ miner: { model: "deepseek-v4-flash", # absent = the platform utility_model
25
+ window: { last_sessions: 200 },
26
+ max_proposals: 10,
27
+ budget: { tokens: 100_000 } },
28
+ idle_hours: 24, # how idle a session must be before it mines
29
+ min_messages: 3 # a shorter session mines noise
30
+ ```
31
+
32
+ ## The pass
33
+
34
+ A mine reads ONLY durable, finished data — never an in-flight turn, never a
35
+ live prompt prefix (the "fork" is structural: the mining writes nothing to the
36
+ sessions it read, so a customer turn's cache is untouched by construction). The
37
+ miner gets the transcript slices (masked), the session's evidence-ledger ids,
38
+ and the agent's current skill names so it does not re-propose them. Then the
39
+ filters, each drop counted and logged:
40
+
41
+ 1. **The negative list** — the versioned rules file (seed, imported
42
+ per store by `insika harvest:negative import --agent ID --file F`) and the
43
+ profile's hot-editable `harvest.negative_list`. A rule is a phrase or a
44
+ regex; phrases match case/accent-folded at word boundaries. Every rejected
45
+ candidate is logged with the rule id.
46
+ 2. **The grounding filter** — every product reference in a proposal must be
47
+ in the union of the origin sessions' evidence ids (the evidence ledger).
48
+ A store without `grounding.matcher.sku` does not mine at all: product
49
+ claims that cannot be verified are blocked by refusal, not by prompt.
50
+ 3. **Dedup** — an open `(agent, name)` tuple or a skill the store already has.
51
+
52
+ ## The double gate
53
+
54
+ A candidate that survives mining is scored by RUNNING it — the eval gate
55
+ clones the agent, writes the candidate skill into the clone's agent-scoped
56
+ store, enables it on the clone's allowlist, replays the golden set over the
57
+ ordinary public surface and compares to the accepted baseline. **Any
58
+ regression disqualifies** — the gate is a veto, never a score to argue with.
59
+ Judges are mandatory in exactly the shapes the refinement gate already
60
+ refuses: no recorded baseline, an all-red baseline, and a judged baseline
61
+ replayed with no judge (a rubric'd case with no verdict would count as a
62
+ pass, see [Evals](EVALS.md)).
63
+
64
+ The conversion gate is the second ruler: the store's funnel metric over the
65
+ criterion's window, compared to the **frozen baseline** (`freeze_funnel_baseline`). Outcome is evidence — this gate can only say "the
66
+ store is measurably worse than the accepted state" or "there is nothing to
67
+ compare against". It refuses on missing data, never passes: no frozen
68
+ baseline, no criterion, no funnel store, a fold that has not converged — each
69
+ named on the Harvest page.
70
+
71
+ Both passed → the candidate awaits **a human**. Promotion is snapshot-first
72
+ (the pre-promotion content + allowlist), then the two existing write commands,
73
+ then an append-only log row carrying `skill`, `origin`, `eval_ref`,
74
+ `conversion_ref`, `approver`, `snapshot_ref` and the criterion's sha. The
75
+ conversion ruler is re-read at the moment the skill lands — a store that
76
+ dipped below its frozen baseline since gating parks the promotion with the
77
+ current numbers, and a criterion file that changed since boot is a criterion
78
+ nobody froze.
79
+
80
+ ## Rollback
81
+
82
+ One click, deterministic: the snapshot is restored — `WriteSkill` with the
83
+ pre-promotion bytes (or `DeleteSkill` when the skill did not exist), the
84
+ allowlist restored to the snapshot's set, and the promotion row stamped
85
+ `rolled_back_at`. The log stays the single ledger: a skill promoted, rolled
86
+ back, re-promoted is three readable rows.
87
+
88
+ ## The honest limits
89
+
90
+ - **A store with no golden cases cannot gate, and cannot promote.** The gate's
91
+ strength is entirely the golden set.
92
+ - **Grounding first.** A store without a matcher does not mine — the product
93
+ loop is blocked until the evidence ledger is live (by refusal, not by
94
+ warning).
95
+ - **A promoted skill is live text**, like any skill: the SkillStore's version
96
+ history plus the harvest snapshot plus the log make the rollback path
97
+ deterministic.
98
+ - **The first-10 audit is human.** The negative list can carry a false
99
+ restriction (a phrase too broad) — that is exactly the Hermes failure, and
100
+ it is caught by the audit, not by the engine: the list grows and the loop
101
+ stops (`harvest.enabled: false`), both by data.
102
+ - **The harvest costs provider money**: one miner call per window plus one
103
+ full golden replay per gated candidate. The `budget` cap, the recorded run
104
+ cost and the manual trigger bound it; the automated loop mines one session
105
+ per claim window.
106
+
107
+ ## The operator surface
108
+
109
+ - `insika harvest --agent ID [--last-sessions N] [--since ISO] [--full]` — mine one window.
110
+ - `insika harvest:negative import --agent ID --file F` — seed the profile's list from a rules file.
111
+ - `insika harvest:criterion check --file F` — strict-load the frozen conversion criterion (the hook before any promotion).
112
+ - The **Harvest page** in the Studio: the human's inbox (each candidate with
113
+ its evidence excerpt, the eval report, the conversion card, promote/reject),
114
+ the gated-but-blocked rows with the named ruler hole, the pending list, the
115
+ append-only promoted log with the rollback mirror, the negative list with
116
+ per-rule rejection counts, and the criterion block read-only.