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/ROUTER.md ADDED
@@ -0,0 +1,213 @@
1
+ ---
2
+ title: Router
3
+ parent: Ship it
4
+ nav_order: 4
5
+ permalink: /router/
6
+ ---
7
+
8
+ # Session-sticky router
9
+
10
+ `insika-router` is a standalone proxy that lets you run **N engine backends**
11
+ (`WEB_CONCURRENCY=1` each) and get the same per-session guarantees a single
12
+ worker gives you today — FIFO ordering, `collect`/`steer`, the SSE watch (see
13
+ [DEPLOY.md "The process model"](DEPLOY.md#the-process-model)) — at N>1
14
+ capacity. It is **entirely opt-in**: if one worker is enough for you, ignore
15
+ this file, run the engine exactly as DEPLOY.md already describes, and nothing
16
+ changes. Reach for the router only when you outgrow one worker and want to
17
+ scale up.
18
+
19
+ It changes nothing about the engine itself — no code in `SessionActor` or
20
+ `Executor` is aware the router exists. It solves routing, and routing only: a
21
+ given session's requests always land on the same backend, so that backend's
22
+ in-memory session state is always the one being read and written.
23
+
24
+ ## Why this exists
25
+
26
+ `WEB_CONCURRENCY>1` without sticky routing in front is not "reduced
27
+ guarantees" — it is a correctness bug (a reply from one session can leak into
28
+ another's transcript; `insika doctor`'s `web-concurrency` check exists because
29
+ this happened in staging). Sticky routing is the documented escape hatch, but
30
+ neither deploy target the engine ships for has it built in:
31
+
32
+ - **Railway** does not support sticky sessions at all — traffic is randomly
33
+ distributed across replicas, with no configuration that changes that.
34
+ - **Kubernetes** `Service` load-balances with no session notion, and
35
+ ingress-nginx's `upstream-hash-by` (the usual sticky mechanism) hashes on
36
+ nginx *variables* — headers, cookies, the URL — never a field parsed out of
37
+ a POST body. The session id here is exactly that: the `user` field inside
38
+ `POST /v1/responses`'s JSON body.
39
+
40
+ So this is a small piece of new infrastructure, not a config flag.
41
+
42
+ ## How it decides where a request goes
43
+
44
+ Per request, in order:
45
+
46
+ 1. `GET /up` → answered directly by the router (its own liveness), never
47
+ proxied.
48
+ 2. `POST /v1/responses` or `POST /v1/messages` → the session key is the
49
+ `user` field of the JSON body.
50
+ 3. `POST /channels/:id/messages` or `POST /channels/:id/events` (the web
51
+ widget and the relay channel) → the session key is the `session_id` field
52
+ of the JSON body.
53
+ 4. Everything else (health checks, `/studio/*`, onboarding, minting a new
54
+ channel session) → no session key, plain round-robin. None of these depend
55
+ on a worker's in-memory `SessionActor` — a Studio read hits the durable
56
+ store, and minting a session has no existing state to be sticky about.
57
+
58
+ A request with a session key is routed by a ketama-style **consistent hash
59
+ ring** over the backend list: the same key always reaches the same backend,
60
+ and adding or removing one backend remaps only ~1/N of the key space, not the
61
+ whole ring — a rolling deploy does not bounce every live session to a new
62
+ owner at once. A request whose key isn't found (a corner-case route) or whose
63
+ key extraction is skipped (see body size cap below) round-robins across all
64
+ backends.
65
+
66
+ The whole request body is always read and forwarded byte-for-byte —
67
+ `INSIKA_ROUTER_BODY_MAX_BYTES` (default 256 KiB) only bounds how much of it
68
+ the router will attempt to parse as JSON while looking for a session key; a
69
+ body over that cap round-robins instead of erroring, and the router logs it.
70
+
71
+ **A request whose chosen backend is unreachable is never retried against a
72
+ different backend** — that backend may already hold a durable, at-most-once
73
+ claim on the task the request names, and retrying elsewhere could
74
+ double-process it. It answers the same retry envelope a single overloaded
75
+ backend would:
76
+
77
+ ```json
78
+ {"error": {"class": "Insika::Router::BackendUnavailable", "message": "no backend reachable",
79
+ "retryable": true, "retry_after": 1}}
80
+ ```
81
+
82
+ SSE responses stream through the router with no added buffering — a client
83
+ watching a long turn sees the same chunks, in the same order, as if it had
84
+ hit the backend directly.
85
+
86
+ ## Deploy shape 1 — Railway (N local workers, one replica)
87
+
88
+ Railway's replica load balancer has no sticky option, full stop — this shape
89
+ does not attempt to fix that. What it fixes is the *unsafe* alternative
90
+ (`WEB_CONCURRENCY=N` Falcon workers behind Railway's own port, which is
91
+ exactly the leak `insika doctor` errors on). Instead, run N engine processes
92
+ on different local ports and put the router in front of them, all inside the
93
+ one container Railway load-balances to:
94
+
95
+ ```bash
96
+ # three engine workers, WEB_CONCURRENCY=1 each (the entrypoint's own
97
+ # `falcon serve --count 1`), on different local ports — never `--count 3` on
98
+ # one port, which is exactly the unsafe fan-out this replaces
99
+ bundle exec falcon serve --bind http://127.0.0.1:9292 --count 1 config.ru &
100
+ bundle exec falcon serve --bind http://127.0.0.1:9293 --count 1 config.ru &
101
+ bundle exec falcon serve --bind http://127.0.0.1:9294 --count 1 config.ru &
102
+
103
+ # the router, bound to the port Railway actually forwards
104
+ INSIKA_ROUTER_PORT=$PORT \
105
+ INSIKA_ROUTER_BACKENDS=http://127.0.0.1:9292,http://127.0.0.1:9293,http://127.0.0.1:9294 \
106
+ bundle exec insika-router
107
+ ```
108
+
109
+ `insika doctor` treats `WEB_CONCURRENCY>1` as `ok` (not `error`/`warn`) once
110
+ it sees `INSIKA_ROUTER_BACKENDS` or `INSIKA_ROUTER_BACKENDS_DNS` set — it
111
+ cannot verify a router process is actually running at those addresses, only
112
+ that one was configured, same as every other env-based capability check in
113
+ `insika doctor`.
114
+
115
+ ## Deploy shape 2 — Kubernetes (a headless Service)
116
+
117
+ Run N engine pods (`WEB_CONCURRENCY=1` each) behind a **headless** Service
118
+ (`clusterIP: None` — this is what makes DNS resolve to one A/AAAA record per
119
+ ready pod instead of a single virtual IP), and the router as its own
120
+ Deployment in front:
121
+
122
+ ```yaml
123
+ apiVersion: v1
124
+ kind: Service
125
+ metadata:
126
+ name: insika-headless
127
+ spec:
128
+ clusterIP: None
129
+ selector: { app: insika }
130
+ ports: [{ port: 9292 }]
131
+ ---
132
+ # insika-router Deployment env:
133
+ env:
134
+ - name: INSIKA_ROUTER_BACKENDS_DNS
135
+ value: insika-headless.default.svc.cluster.local
136
+ - name: INSIKA_ROUTER_BACKEND_PORT
137
+ value: "9292"
138
+ - name: INSIKA_ROUTER_DNS_INTERVAL
139
+ value: "15"
140
+ ```
141
+
142
+ The router holds no session state itself, so it needs no sticky routing in
143
+ front of *itself* — it scales trivially (1-2 replicas behind an ordinary
144
+ `Service`). It re-resolves the headless Service on `INSIKA_ROUTER_DNS_INTERVAL`
145
+ (default 15s) and rebuilds its hash ring only when the resolved pod set
146
+ actually changed. A pod that just became ready is invisible to the router
147
+ until the next resolve — capacity added a few seconds late, never wrong.
148
+
149
+ ## Environment variables
150
+
151
+ | Variable | Default | Meaning |
152
+ |---|---|---|
153
+ | `INSIKA_ROUTER_BACKENDS` | — | Comma-separated backend URLs (static mode). Exactly one of this or the DNS var below. |
154
+ | `INSIKA_ROUTER_BACKENDS_DNS` | — | A headless-Service hostname to re-resolve (DNS mode). Requires `INSIKA_ROUTER_BACKEND_PORT`. |
155
+ | `INSIKA_ROUTER_BACKEND_PORT` | — | The engine port on every DNS-resolved pod. |
156
+ | `INSIKA_ROUTER_DNS_INTERVAL` | `15` | Seconds between DNS re-resolves. |
157
+ | `INSIKA_ROUTER_BODY_MAX_BYTES` | `262144` | Size cap on the session-key JSON peek (never on what is forwarded). |
158
+ | `INSIKA_ROUTER_BACKEND_TIMEOUT` | `10` | Connect/read timeout to a backend, in seconds. |
159
+ | `INSIKA_ROUTER_HOST` | `0.0.0.0` | Bind address for the router itself. |
160
+ | `INSIKA_ROUTER_PORT` | `9090` | Listen port for the router itself. |
161
+
162
+ ## A runnable smoke test
163
+
164
+ The shape below is what the router's acceptance criteria were verified
165
+ against: two fake backends and the router in front, run entirely in-process.
166
+
167
+ ```ruby
168
+ require "async"; require "async/http/server"; require "async/http/client"
169
+ require "async/http/endpoint"; require "protocol/rack"; require "insika/router"
170
+
171
+ Async do |task|
172
+ echo = ->(name) { ->(env) { [200, {}, ["#{name} #{Rack::Request.new(env).path_info}"]] } }
173
+ %w[9292 9293].each_with_index do |port, i|
174
+ endpoint = Async::HTTP::Endpoint.parse("http://127.0.0.1:#{port}")
175
+ task.async { Async::HTTP::Server.new(Protocol::Rack::Adapter.new(echo["backend-#{i}"]), endpoint).run }
176
+ end
177
+ task.sleep(0.2)
178
+
179
+ pool = Insika::Router::BackendPool.new(static: %w[http://127.0.0.1:9292 http://127.0.0.1:9293])
180
+ app = Insika::Router::App.new(pool: pool)
181
+ endpoint = Async::HTTP::Endpoint.parse("http://127.0.0.1:9090")
182
+ task.async { Async::HTTP::Server.new(Protocol::Rack::Adapter.new(app), endpoint).run }
183
+ task.sleep(0.2)
184
+
185
+ client = Async::HTTP::Client.new(Async::HTTP::Endpoint.parse("http://127.0.0.1:9090"))
186
+ 3.times { |i| puts client.post("/v1/responses", {}, [%({"user":"sess-1","i":#{i}})]).read }
187
+ # -> the same "backend-N" answers all three times, even though two backends are up.
188
+ ensure
189
+ task.stop
190
+ end
191
+ ```
192
+
193
+ ## What this deliberately does not attempt
194
+
195
+ - **Railway cross-*replica* routing.** Railway's replica load balancer itself
196
+ has no sticky option and this router cannot sit in front of Railway's own
197
+ edge. Railway stays at one replica; this only raises the ceiling of that one
198
+ replica (N local workers instead of N=1).
199
+ - **A distributed `SessionActor`.** The alternative design — making any
200
+ worker able to safely pick up any session, removing the need for sticky
201
+ routing entirely — is a much larger rewrite (debounce windows, steer
202
+ mailboxes, and SSE fan-out would all have to move into the shared store with
203
+ lease semantics) for the same outcome this router reaches with an unchanged
204
+ engine. Worth revisiting only if this approach turns out not to scale far
205
+ enough.
206
+ - Native WhatsApp/Slack channel routing — those channels are shelved; the
207
+ relay channel rides the same `/v1/responses`-shaped call this router already
208
+ covers.
209
+
210
+ ## See also
211
+
212
+ - [DEPLOY.md "The process model"](DEPLOY.md#the-process-model) — the contract
213
+ this router satisfies (FIFO/`collect`/`steer` guarantees, recovery, drain).
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  title: Running locally
3
- parent: Build an agent
4
- nav_order: 8
3
+ parent: Start here
4
+ nav_order: 2
5
5
  permalink: /running-local/
6
6
  ---
7
7
 
8
8
  # Running the Insika locally
9
9
 
10
10
  Boots the engine single-process, serving `/studio` and `/v1/*` against a demo
11
- agent (the `bia` persona on DeepSeek). Every message runs the **same**
11
+ agent (the `demo` agent on DeepSeek). Every message runs the **same**
12
12
  `send_message` the API runs — real tools, skills, and memory.
13
13
 
14
14
  ## Boot
@@ -26,7 +26,7 @@ Open `http://localhost:9292`:
26
26
  | URL | What |
27
27
  |-----|------|
28
28
  | `/studio` | management UI (log in with the token; default `local-demo`) |
29
- | `/studio/chats` | chat with the demo agent (`agent: bia`, `session_id: web`, multi-turn ready) |
29
+ | `/studio/chats` | chat with the demo agent (`agent: demo`, `session_id: web`, multi-turn ready) |
30
30
  | `/studio/tasks` | tasks / approvals console |
31
31
  | `/v1/responses` | OpenAI-Responses ingress (Bearer) — the drop-in API contract |
32
32
  | `/v1/agents` | provisioning by definition/pack (Bearer) — `POST` imports, `DELETE /:id` removes |
@@ -82,7 +82,7 @@ the `model`:
82
82
  curl -N http://localhost:9292/v1/responses \
83
83
  -H "Authorization: Bearer local-demo" \
84
84
  -H "Content-Type: application/json" \
85
- -d '{ "model": "bia", "user": "web", "stream": true, "input": "hello" }'
85
+ -d '{ "model": "demo", "user": "web", "stream": true, "input": "hello" }'
86
86
  ```
87
87
 
88
88
  `user` is the session id (any stable id for a multi-turn conversation).
@@ -122,7 +122,7 @@ prompt files, skills, and one data-tool per file:
122
122
  > **Data-tool URLs must be literal on the pack path.** The pack import does not
123
123
  > resolve `{{env.*}}` — bake the backend base URL into each `tools/*.json` at
124
124
  > generation time. (Only the *manifest* path resolves `{{env.*}}`.) See
125
- > [Tools](TOOLS.md#the-one-gotcha-env-templating-is-manifest-only).
125
+ > [Tools](TOOLS.md#the-one-gotcha-envsecret-templating-is-manifest-only).
126
126
 
127
127
  Provision it (runs as a client against the live server; the internal token comes
128
128
  from the environment, never disk):
@@ -0,0 +1,121 @@
1
+ ---
2
+ title: Schedules
3
+ parent: Operate
4
+ nav_order: 2
5
+ permalink: /schedules/
6
+ ---
7
+
8
+ # Schedules — recurring turns the engine fires
9
+
10
+ A schedule is a turn nobody has to remember to send: a daily report at 22:00,
11
+ an eval sweep every night, a heartbeat every hour. The engine fires it on its
12
+ own periodic tick — no cron on some other box pointing at an authenticated
13
+ route. (That route still works, if you want it; the built-in trigger just
14
+ removes the homework.)
15
+
16
+ A schedule is **declared on the agent** — pack data, like `followup:` or the
17
+ budget — in one of the same three places every profile field is edited: the
18
+ DSL at import, `POST /v1/agents` in the pack, or the Studio's config form
19
+ (the **Schedules** group on the agent page). Edits are hot: the next pass
20
+ sees them.
21
+
22
+ ## The declaration
23
+
24
+ ```ruby
25
+ agent = Insika.agent("reporter") do
26
+ schedule "daily_report", cron: "0 22 * * *", tz: "America/Sao_Paulo",
27
+ message: "Run the daily report now.",
28
+ overrides: { turn_timeout: 900, max_tool_calls: 200 }
29
+ schedule "heartbeat", every: 3600, message: "Say you are alive."
30
+ end
31
+ ```
32
+
33
+ | Key | Meaning |
34
+ |---|---|
35
+ | `id` | the schedule's name (the argument). Lowercase, `[a-z][a-z0-9_-]*` |
36
+ | `cron` | a five-field expression (`minute hour day-of-month month day-of-week`), or |
37
+ | `every` | a plain interval in seconds — the two are **exclusive** |
38
+ | `tz` | IANA zone for **cron** materialization (default `Etc/UTC`). `every` never needs it — every comparison runs in UTC |
39
+ | `message` | the synthetic inbound that kicks each run — what the agent "hears" |
40
+ | `session_mode` | `"new"` (default) — a fresh session per run, the report shape; `"fixed"` — one standing session, the "standing assistant" shape |
41
+ | `session_id` | for `fixed` sessions: the standing session (created on first run when missing) |
42
+ | `overrides` | per-run ceilings: `turn_timeout`, `max_tool_calls`, `model` — a report needs a bigger ceiling than a chat turn; the base profile is untouched |
43
+ | `enabled` | `false` pauses the schedule (the Studio toggle / the JSON field) |
44
+
45
+ > **The cadence floor.** Firing rides one claim window per pass — a schedule
46
+ > fires **at most once per window**. That is the true cadence ceiling: an
47
+ > `every: 60` does not fire sixty times a minute, it fires once per window.
48
+ > `doctor` warns when a declared `every` is shorter than the claim window.
49
+
50
+ The Studio renders the schedules section as a JSON array of the same
51
+ declarations plus a read-only card: each schedule's next fire, last run and —
52
+ when a window was skipped — the skip reason. `doctor` parses every
53
+ declaration with the engine's own parser: an invalid cron, an unknown
54
+ runtime zone, a schedule with neither trigger, an unknown override key —
55
+ each is named, per agent, as an error finding.
56
+
57
+ ## The engine's triggers: cron subset
58
+
59
+ Five fields, whitespace-separated. Per field: `*` (or `?`), a single value, a
60
+ range (`N-M`), a step (`*/N`, `N-M/N`, `N/N`), or a comma list of those.
61
+ Day-of-week is `0-7` with `7` = Sunday; when **both** day fields are
62
+ restricted the date matches on **either** (standard cron OR semantics).
63
+ `L`, `W`, `#` and month/day names are refused loudly at creation — the engine
64
+ will not silently ignore a cron that only some dates understand.
65
+
66
+ ```text
67
+ minute hour day-of-month month day-of-week
68
+ 0 22 * * *
69
+ ```
70
+
71
+ ## Firing: one turn per window, no catch-up
72
+
73
+ Firing rides the tick, gated by its own claim window (one scheduler per
74
+ window across `N` workers — the same claim the outbox and recovery sweeps
75
+ use). Each due schedule is claimed transactionally: the task and the
76
+ schedule's state (last run, last task id, next fire) commit together, so two
77
+ workers racing serialize on the backend's lock and **exactly one fires per
78
+ window**.
79
+
80
+ Three skip rules are part of the contract, all recorded on the schedule and
81
+ shown in the Studio — never silent, never queued:
82
+
83
+ - **late** — the no-catch-up policy. A window more than one claim window in
84
+ the past is **missed, not replayed**: a deploy that was down over 22:00 does
85
+ not fire a 22:00 report at 06:00 the next morning. The schedule's lattice
86
+ advances to the next window and the skip is recorded.
87
+ - **overlap** — the previous run's task is still live (`queued`/`running`/
88
+ `waiting`/`paused`). The window is skipped, recorded, and the next one
89
+ fires. There is no queue buildup: one scheduled run at a time per schedule.
90
+ - **budget** — a **hard** calendar budget (`budget daily: …` on the profile)
91
+ already at/over its cap. The edge would fail the turn anyway; the engine
92
+ refuses to even queue it. A `soft` budget crosses and runs — the ledger
93
+ warns as usual.
94
+
95
+ A bounded run also costs what it costs: the run's usage lands on the same
96
+ `BudgetLedger` the edge enforces, and `billed = total + cached +
97
+ cache_creation` — a long report is cache-heavy, measure the caps against it.
98
+
99
+ ## What a schedule run is
100
+
101
+ A first-class turn, stamped `origin: "scheduled"` so a refinement read can
102
+ never mistake the engine's kick for a customer speaking. It enters through the
103
+ pipeline directly (never through the message edge, so no rate-limit/token
104
+ ceiling gate applies — same as a resume), is charged to the ledger like any
105
+ turn, and its result is delivered wherever the agent's outputs go: a channel
106
+ answer, or – for the report shape – nothing at all, when the run publishes an
107
+ artifact instead. The Studio's schedule card links the last run's task.
108
+
109
+ No customer ever receives anything from a schedule unless your agent sends a
110
+ message in reply — the engine contacts no one.
111
+
112
+ ## The boundaries
113
+
114
+ - **Not a job queue.** No priorities, no fan-out, no retries of a failed run
115
+ beyond what Recovery already does for any task.
116
+ - **Not the follow-up feature.** `schedule_followup` is a one-shot,
117
+ customer-facing, consent-gated contact from inside a conversation — and it
118
+ keeps being that. These schedules are operator-declared recurring internal
119
+ triggers: no contact policy, no consent, no customer.
120
+ - **Not a replacement for your cron.** The external route stays; this is the
121
+ built-in one.
data/docs/SECURITY.md CHANGED
@@ -171,7 +171,7 @@ LLM calls**. A *resumed* turn is never re-counted.
171
171
  > `0` to explicitly disable. A malformed value in the Studio raises a validation
172
172
  > error rather than silently disabling a production limit.
173
173
 
174
- See [Agents §Layer 4](AGENTS.md#layer-4-edge-limits-flood-and-spend-control).
174
+ See [Agents §Layer 4](POLICY.md#layer-4-edge-limits-flood-and-spend-control).
175
175
 
176
176
  ## Guardrails
177
177
 
@@ -203,7 +203,7 @@ Strictness selects the detector categories (`low` = injection only; `medium`
203
203
  (default) and `high` add sexual and abuse). Safe-reply lookup falls back per
204
204
  category: the agent's category reply → the agent's default → the builtin
205
205
  category → the builtin default. All of it is editable in the Studio Configuration
206
- form. See [Agents §Layer 3](AGENTS.md#layer-3-guardrails-content-safety).
206
+ form. See [Agents §Layer 3](POLICY.md#layer-3-guardrails-content-safety).
207
207
 
208
208
  ## Human approval
209
209
 
@@ -367,6 +367,83 @@ rotated key or a typo never takes the whole service down. The `insika doctor`
367
367
  command runs the same checks on demand against a live database. See
368
368
  [Deploy](DEPLOY.md#strict-config-and-insika-doctor).
369
369
 
370
+ ## Memory and the right to be forgotten (LGPD)
371
+
372
+ Memory is scoped per **`(tenant, customer)`** — the cell `"memory:<tenant>:<customer>"`
373
+ is the isolation boundary. A query against one tenant never touches another's
374
+ cells. A turn with no customer falls back to the session's own **marked** cell
375
+ (`"memory:chat:<session id>"`) — the marker keeps the Studio drill and the doctor
376
+ from ever reading a conversation as a customer (a bare `memory:<id>` cell is
377
+ indistinguishable from a single-tenant customer ref). Cells written before the
378
+ marker (bare session ids) may still appear as customers in the drill until
379
+ forgotten or aged out. Three operations enforce the right to be forgotten:
380
+
381
+ - **Retention** — the `memory_ttl_days` setting (platform default per cell, or an
382
+ ops-authored per-tenant map) sweeps facts older than the window. A per-fact
383
+ `expires_at` override wins over the cell TTL. Both run on the daily retention
384
+ tick, **not** gated by `retention_days` (the conversation-footprint window).
385
+ Note the interaction: an explicit `expires_at` also exempts the fact from the
386
+ `retention_days` age-based sweep — an explicit date owns that fact's life, so a
387
+ far-future date makes the fact immune to *both* age passes. Export and forget
388
+ still see it; the only way to remove it early is the Customers drill.
389
+ - **Export** — `export_customer_memory` returns the full fact + note content to the
390
+ **operator** (the Studio turns it into a JSON download). The emitted event carries
391
+ counts only — the event stream, the audit store and every log stay content-free.
392
+ - **Forget** — `forget_customer` purges the customer's memory cell AND their
393
+ sessions (and everything those sessions left behind: traces, tasks, checkpoints,
394
+ outbox deliveries). The operator-mutation audit store records a digest-free line
395
+ ("a purge happened, with N records") — content-free by construction.
396
+
397
+ ### Distilled facts are personal data
398
+
399
+ The distillation loop ([Facts](FACTS.md)) writes **proposals** — a distilled
400
+ fact's name and value are personal data, and they are treated like the rest of
401
+ the memory footprint: `forget_customer` deletes the person's proposals (every
402
+ status), `delete_tenant_data` deletes a tenant's, and the `retention_days`
403
+ sweep ages them out with the transcripts they were distilled from. Provenance
404
+ holds: an approved fact is written with `origin: "distilled:<session_ref>"`
405
+ (the closed set gains one spelling, never an open string). Events and
406
+ audit carry ids and counts only — a fact value never enters the stream, the
407
+ ledger or a log; the evidence excerpt is a link read from the transcript at
408
+ request time, never a copy.
409
+
410
+ The doctor's `memory-scopes` check flags bare cells only in a `multi_tenant`
411
+ deployment (in `single_tenant` the bare cell is the designed customer shape), and
412
+ never the session-marked cells. See [Context](CONTEXT.md#memory) and
413
+ [Deploy](DEPLOY.md#strict-config-and-insika-doctor).
414
+
415
+ ### Harvest candidates are derived data
416
+
417
+ The harvest loop ([Harvest](HARVEST.md)) writes **candidates** — a mined skill
418
+ proposal is behavior instructions, the same trust level as any skill content,
419
+ and it is store-scoped, never customer-scoped: `forget_customer` does NOT reach
420
+ it (a candidate's skill body is not customer content). Candidates reference
421
+ sessions by id and their evidence excerpt is the transcript rendered at request
422
+ time through the output filter — never a copy. `delete_tenant_data` purges a
423
+ tenant's candidates, promotion rows and markers; the `retention_days` sweep
424
+ ages them out with the transcripts they were derived from (they are
425
+ re-derivable — pruning is never data loss). **No auto-application**: nothing
426
+ reaches the catalog without a human click, and the grounding filter refuses any
427
+ product claim the origin sessions' evidence ledger did not see. Events and the
428
+ promotion log carry ids, refs and verdicts only — a skill body never enters
429
+ the stream.
430
+
431
+ ### Learned knowledge is redacted before it is stored
432
+
433
+ The [Knowledge](KNOWLEDGE.md) loop writes **concepts** extracted from finished
434
+ conversations — store-scoped, not customer-scoped, but written from real
435
+ transcript text, so the write path is deliberately conservative: a concept's
436
+ body goes through the same PII/secret redactor the output guardrail uses
437
+ before it is ever persisted, and `sources` holds session ids only — never
438
+ message content, never a customer identifier. This is the one write path in
439
+ the engine where a model-authored field is rejected outright rather than
440
+ merely validated: `provenance`, `confidence`, `sources` and the timestamps are
441
+ stamped by the engine, so a model cannot self-assign trust it did not earn or
442
+ smuggle a scope into a store-wide record. A repeat sighting that contradicts
443
+ what's on record is never silently merged — the conservative default when
444
+ the engine cannot tell is to flag it for a human, not to guess. Events carry
445
+ names and counts only — a concept's content never enters the stream.
446
+
370
447
  ## See also
371
448
 
372
449
  - [Agents](AGENTS.md) — the five access layers per agent.
data/docs/SKILLS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Skills
3
- parent: Build an agent
4
- nav_order: 3
3
+ parent: Core concepts
4
+ nav_order: 4
5
5
  permalink: /skills/
6
6
  ---
7
7
 
@@ -16,6 +16,14 @@ while paying for the text of only the ones it opens.
16
16
 
17
17
  See [`examples/skills/`](https://github.com/guizaols/insika/tree/main/examples/skills/) for a runnable one.
18
18
 
19
+ **Skills vs. Knowledge.** They share a format — YAML frontmatter over a
20
+ Markdown body, progressive loading — which makes them easy to confuse. A
21
+ skill is **curated**: a human writes it, and it is canonical until a human
22
+ changes it. A [Knowledge](KNOWLEDGE.md) concept is **learned**: the engine
23
+ extracts it from finished conversations, it is `provenance: observed`, and it
24
+ sits below skills in the context priority ladder — earned trust, not
25
+ authored trust.
26
+
19
27
  ## Format
20
28
 
21
29
  ```markdown
@@ -219,8 +227,8 @@ Write one with `agent:`, and remove it the same way (which un-specializes, leavi
219
227
  the shared skill in place):
220
228
 
221
229
  ```ruby
222
- dispatch(:write_skill, { name: "escalation-to-human", agent: "store-cacau", content: md })
223
- dispatch(:delete_skill, { name: "escalation-to-human", agent: "store-cacau" })
230
+ dispatch(:write_skill, { name: "escalation-to-human", agent: "store-demo", content: md })
231
+ dispatch(:delete_skill, { name: "escalation-to-human", agent: "store-demo" })
224
232
  ```
225
233
 
226
234
  In the Studio: **Skills → specialize for this agent**, which seeds the override from
@@ -281,4 +289,5 @@ reading the doctor:
281
289
  - [Agents](AGENTS.md) — the skills allowlist.
282
290
  - [Tools](TOOLS.md) — `load_skill` and deferred-tool progressive disclosure.
283
291
  - [Plugins](PLUGINS.md) — shipping skills inside a plugin, and the two extension tiers.
292
+ - [Knowledge](KNOWLEDGE.md) — the learned counterpart: engine-extracted concepts, same format, earned trust.
284
293
  - [`examples/skills/`](https://github.com/guizaols/insika/tree/main/examples/skills/) — progressive loading, runnable.
data/docs/SOAK.md ADDED
@@ -0,0 +1,127 @@
1
+ ---
2
+ title: Soak
3
+ parent: Operate
4
+ nav_order: 5
5
+ permalink: /soak/
6
+ ---
7
+
8
+ # SOAK — does it degrade over uptime?
9
+
10
+ The soak is the third question the measurement toolbox answers, and it is the
11
+ one the other two cannot:
12
+
13
+ | Tool | Question | Drives |
14
+ |------|----------|--------|
15
+ | [`scripts/bench.rb`](BENCHMARK.md) | How much overhead does the engine itself add? | publishable claims |
16
+ | [Load test](LOADTEST.md) | What does a burst look like, end to end? | topology + provider choice |
17
+ | **`insika soak`** | **Does the deploy degrade over 72 hours of steady load?** | the cut argument: "it does not tax uptime" |
18
+
19
+ A load test fires a wave, joins it, and reports. A soak **sustains a declared
20
+ arrival rate for days** and asks whether the *process itself* — resident memory,
21
+ per-turn local work, restarts — degrades with uptime. A leak that only shows at
22
+ hour 40 is invisible to a 10-minute bench.
23
+
24
+ ## The protocol in one paragraph
25
+
26
+ 1. **Freeze the envelope first.** A deployment-side envelope file declares the
27
+ load shape, the gated ceilings and the isolation contract — *before* the
28
+ first turn is fired. Its SHA-256 is stamped into every hourly snapshot;
29
+ editing it mid-run turns the run `invalid` instead of producing a verdict.
30
+ 2. **Preflight.** `insika soak --preflight` refuses to start unless the target
31
+ answers `/v1/vitals` with a readable RSS, turns carry the `INSIKA_TURN_TIMING`
32
+ breakdown *and* a `usage` block, the pid is stable, and the target host
33
+ matches the envelope. Each check is a named refusal, not a runtime warning.
34
+ 3. **Run.** `insika soak --run` fires the arrival process (Poisson, seeded,
35
+ concurrency-capped), polls vitals hourly, and appends every observation to
36
+ `<out>/<run>.jsonl` as it happens — a runner that dies at hour 60 must
37
+ leave 60 usable hours behind. No retry, no self-healing: a failed turn is
38
+ evidence.
39
+ 4. **Read the verdict.** `insika soak --verify <file>.jsonl.gz` recomputes the
40
+ whole verdict offline from the raw records — no network, no target, no
41
+ clock. Two people running it get the same answer; so does CI.
42
+
43
+ ## What is gated, and what is only reported
44
+
45
+ The gate is on the part **Insika owns**:
46
+
47
+ | Gated (binding) | Reported (never gated) |
48
+ |-----------------|------------------------|
49
+ | `rss_growth_ratio` — upper 95% bound of the RSS fit, hour 6 → hour 72 | `rss_slope_mb_per_day`, `rss_peak_mb` |
50
+ | `prep_p95_drift_ratio` — p95 of the local per-turn work (`prep_ms`: context build, policy, guardrails, chat assembly), last 6 h vs first measured 6 h | `ttft_p95_ms`, `total_p95` hourly series |
51
+ | `restarts_max` — any `boot_id` **or** `pid` change is a fail; there is no "declared deploy" exception | `heap_growth_ratio`, `db_growth_mb` |
52
+ | `error_rate_ceiling`, `no_usage_rate_ceiling` | `cost_usd`, `tokens_per_turn` |
53
+ | calibrated absolute ceilings (written from the E1 dry run, ×2) | — |
54
+
55
+ Time-to-first-token is **reported, never gated**: it was measured at ~720 ms and
56
+ shown to be ~100% provider. Gating on a number the engine cannot move would mean
57
+ failing a clean soak because the provider had a bad afternoon — and then
58
+ loosening the envelope, which the protocol forbids.
59
+
60
+ The **`no_usage_rate`** ceiling is the trap detector. The edge limiter answers a
61
+ breach with a canned reply, zero LLM calls and HTTP 200 — so a soak that ran
62
+ entirely into the limiter looks like thousands of successful turns with a
63
+ gorgeous 2 ms p95 and flat memory. A turn with no `usage` block called no model;
64
+ the ceiling keeps that from reading as a pass.
65
+
66
+ ## The isolation contract
67
+
68
+ - **Staging or a dedicated tenant, never a production tenant.** The run writes
69
+ ~4,000 sessions and tasks into the store; against production that is
70
+ synthetic data mixed into real customer history. The envelope names the
71
+ tenant (`soak`), so `delete_tenant_data` cleans the entire run in one
72
+ command.
73
+ - **Its own agent, with the edge limits off.** At a real ~48k tokens per turn,
74
+ the production token ceiling admits about 10 turns per hour — a 60 turns/h
75
+ soak against a production-configured agent measures its own limiter within
76
+ minutes. `chat_rate_limit: 0` and `agent_token_ceiling: 0` on the soak agent
77
+ are part of the frozen envelope, not an afternoon decision.
78
+ - **Deploys are frozen for the window.** Any restart is a fail, including a
79
+ deployment — a marker the operator writes after the fact would turn every
80
+ crash into a deploy.
81
+
82
+ ## Running it
83
+
84
+ ```bash
85
+ # the plan + one sample request, no traffic
86
+ insika soak --dry-run --envelope soak-envelope.md
87
+
88
+ # every precondition, and nothing else
89
+ insika soak --preflight --envelope soak-envelope.md
90
+
91
+ # the run itself (INSIKA_URL + OPENCLAW_GATEWAY_TOKEN, like loadtest.rb)
92
+ INSIKA_URL=https://<target> insika soak --run --envelope soak-envelope.md --out soak-out/
93
+
94
+ # resume after a short outage (the gap is recorded and counts against the window)
95
+ insika soak --run --resume soak-out/staging-2026-08-20T09-00-00Z.jsonl
96
+
97
+ # the verdict, recomputed offline — the only step that needs no target
98
+ insika soak --verify soak-out/staging-2026-08-20T09-00-00Z.jsonl.gz
99
+ ```
100
+
101
+ Duration, rate and ceilings all come from the envelope — there is no
102
+ `--duration` override, so a short run cannot be passed off as a soak.
103
+
104
+ ## Reading the verdict
105
+
106
+ `insika soak --verify` prints one of four, in this order of precedence:
107
+
108
+ - **`invalid`** — the snapshots carry more than one envelope hash, more than
109
+ one process id appeared under one boot generation, or the file is materially
110
+ truncated. **No metric is reported at all**; the file cannot be argued with.
111
+ - **`insufficient`** — coverage is short: a gap over 15 minutes, an hour under
112
+ the turn floor, or a runner that did not reach hour 72. Short coverage
113
+ restarts the window; it is never rounded up into a pass.
114
+ - **`fail`** — a gated ceiling was breached. The report names each one and
115
+ prints the leak-hunt starting point: whether the Ruby heap grew with RSS
116
+ (Ruby-side retention) or stayed flat while RSS climbed (allocator or a
117
+ native buffer).
118
+ - **`pass`** — none of the above.
119
+
120
+ A fail means *find the leak* — never cut, and never loosen the envelope.
121
+
122
+ ## See also
123
+
124
+ - [Observability](OBSERVABILITY.md) — `GET /v1/vitals`, the process readings
125
+ the soak samples hourly.
126
+ - [Deploy](DEPLOY.md) — the process model; `WEB_CONCURRENCY=1` is a soak
127
+ precondition, not only a queue-semantics one.