insika 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
data/docs/CONTEXT.md CHANGED
@@ -20,19 +20,21 @@ Providers are chosen by a double gate (the provider opts in for the profile **an
20
20
  the agent's `context_providers` allowlist permits it), then assembled by priority
21
21
  into a deterministic prompt:
22
22
 
23
- | Provider | Block | Priority | Notes |
24
- |----------|-------|:--------:|-------|
25
- | **Identity** | system | **100 — pinned** | The agent's prompt files (global system files first). Never cut. |
26
- | **Skill trigger** | `<active_skill>` | 85 | Level-2 bodies: the agent's `skills_eager` set, plus the ones whose `triggers:` match the message — see [Skills](SKILLS.md). |
27
- | **Skills** | `<available_skills>` | 80 | Level-1 skill list, minus whatever is already eager — see [Skills](SKILLS.md). |
28
- | **Memory** | `<memory>` | 75 | Durable facts + recent notes, only if `memory` is on. Cuttable. |
29
- | **Tool search** | `<available_tools>` | 70 | Level-1 list of deferred tools see [Tools](TOOLS.md). |
30
- | **Session** | history | 60–79 | The running transcript; priority scales with recency. |
31
- | **Request** | `<request_context>` | 40 | Turn variables + tenant. Most cuttable; sits last. |
32
-
33
- The ordering is deliberate: the **stable identity sits first**, the **volatile
34
- request context sits last**. That keeps the cacheable prefix byte-stable (see the
35
- prefix cache below).
23
+ | Provider | Block | Priority | Layer | Notes |
24
+ |----------|-------|:--------:|-------|-------|
25
+ | **Identity** | system | **100 — pinned** | identity | The agent's prompt files (global system files first). Never cut. |
26
+ | **Skills** | `<available_skills>` | 80 | identity | Level-1 skill list, minus whatever is already eager — see [Skills](SKILLS.md). |
27
+ | **Tool search** | `<available_tools>` | 70 | identity | Level-1 list of deferred tools — see [Tools](TOOLS.md). |
28
+ | **Skill trigger** | `<active_skill>` | 85 | volatile | Level-2 bodies: the agent's `skills_eager` set, plus the ones whose `triggers:` match the message — see [Skills](SKILLS.md). |
29
+ | **Memory** | `<memory>` | 75 | volatile | Durable facts + recent notes, only if `memory` is on. Cuttable. |
30
+ | **Briefing** | `<briefing>` | 65 | volatile | The session's working state (known fields, still-missing list, next step) — only if the pack declared `briefing_fields`. Cuttable. |
31
+ | **Session** | history | 60–79 | volatile | The running transcript; priority scales with recency. |
32
+ | **Request** | `<request_context>` | 40 | volatile | Turn variables + tenant. Most cuttable; sits last. |
33
+
34
+ The ordering is deliberate: the render order is **identity layer first, volatile
35
+ layer after** — nothing volatile can sit above the cache boundary, whatever its
36
+ priority — and within each layer the priority sort above holds. That keeps the
37
+ cacheable prefix byte-stable (see the prefix cache below).
36
38
 
37
39
  ## Budget and eviction — the actual "compaction"
38
40
 
@@ -78,19 +80,82 @@ budget first.
78
80
  With `memory` enabled, an agent gains a built-in `remember` tool for durable
79
81
  facts, and those facts (plus recent notes) are injected back into the prompt on
80
82
  later turns — **including turns in a different session**. Memory is scoped per
81
- agent. This is distinct from *session history*, which is the transcript of one
83
+ agent, per `(tenant, customer)` when the message carries a `customer`, and per
84
+ session otherwise — a session's own memory lives in a marked `memory:chat:<session id>`
85
+ cell, never a bare one, so the Customers drill cannot read a conversation as a
86
+ customer. This is distinct from *session history*, which is the transcript of one
82
87
  conversation; memory is the small set of facts that should outlive any single
83
88
  conversation. Facts and notes are editable from the Studio agent page. See
84
89
  [`examples/memory/`](https://github.com/guizaols/insika/tree/main/examples/memory/) for a runnable cross-session example.
85
90
 
91
+ Facts carry **provenance metadata** (RFC-0031): every fact record stores `origin`
92
+ (who wrote it — `"engine"`, `"operator"`, `"legacy"` or `"distilled"`),
93
+ `created_at` / `updated_at` timestamps, and an optional `expires_at` (ISO8601) —
94
+ **an expired fact is never injected**, even before the daily sweep prunes it. The
95
+ Studio Customers drill reads and edits the same cell the next turn reads (injection
96
+ unchanged), and every operator mutation lands in the content-free audit trail
97
+ (digests, never values). The sweep honors the `memory_ttl_days` setting on its own
98
+ knob — see [Security](SECURITY.md#memory-and-the-right-to-be-forgotten-lgpd-rfc-0031).
99
+
100
+ An **approved distilled fact** (RFC-0034 — see [Facts](FACTS.md)) lands in the
101
+ same cell this provider injects, stamped `distilled:<session_ref>` — approved on
102
+ the Studio Facts page, never applied automatically.
103
+
104
+ ## Briefing — the session's working state
105
+
106
+ The **briefing** is the per-conversation working state the agent keeps and asks
107
+ for: which facts it already learned (size, budget, delivery day) and the agreed
108
+ next step. It is **engine-owned data** — one `"briefing"` key on the session
109
+ record, written only by the agent through tools — whose *fields* come from the
110
+ pack:
111
+
112
+ ```jsonc
113
+ // pack agent.config.json
114
+ { "id": "store-support", "briefing_fields": ["size", "budget", "delivery_day"] }
115
+ ```
116
+
117
+ ```ruby
118
+ # or the DSL — [] = the feature is off (no block, no tools)
119
+ briefing_fields "size", "budget", "delivery_day"
120
+ ```
121
+
122
+ With fields declared, the turn's `:system` context gains a `<briefing>` block
123
+ (priority 65 — below identity/skill/memory so it never breaks the cacheable
124
+ prefix, above the turn's own `<request_context>`):
125
+
126
+ ```
127
+ <briefing>
128
+ known:
129
+ size: M
130
+ still missing: budget, delivery_day
131
+ next step: send the payment link tomorrow at 10
132
+ </briefing>
133
+ ```
134
+
135
+ The `still missing` list is the point: the *model* sees which declared fields are
136
+ still unanswered, so it stops re-asking for something already given. Stored keys
137
+ that the pack no longer declares are never rendered. The Studio session screen
138
+ shows the persisted state (known fields + next step), read-only.
139
+
140
+ The agent writes the briefing through two built-in tools, wired only when the
141
+ pack declared fields:
142
+
143
+ - `update_briefing(field:, value:)` — records a field. An undeclared `field`
144
+ returns an envelope error (`unknown field '…'; declared: …`) and nothing is
145
+ persisted; a blank `value` clears the field.
146
+ - `set_next_step(text:)` — records the agreed next step; a blank `text` clears it.
147
+
148
+ Both are deterministic in-process writes (never enveloped) and survive across
149
+ turns and resumes — a resumed conversation re-opens with the briefing intact.
150
+
86
151
  ## The provider prefix cache
87
152
 
88
153
  Two distinct caching mechanisms — don't conflate them:
89
154
 
90
155
  - **Automatic server-side prefix cache.** Some providers prefix-cache a stable
91
156
  system prefix automatically, at no cost to configure. This works **only because**
92
- the identity is at the top of the system block and the volatile
93
- `<request_context>` is at the bottom, keeping the cacheable prefix byte-stable.
157
+ the engine renders the system in two layers (below) and the volatile half sits
158
+ **under** the identity boundary, keeping the cacheable prefix byte-stable.
94
159
  Anything that injects volatile content high in the system block breaks the cache.
95
160
  - **Manual cache breakpoints (opt-in).** With `prompt_caching` on **and** a
96
161
  provider that supports explicit cache control, the builder sets one cache
@@ -100,6 +165,52 @@ Two distinct caching mechanisms — don't conflate them:
100
165
  Cache accounting surfaces as `cached_tokens` (reads) and `cache_creation_tokens`
101
166
  (writes), visible in telemetry and the Studio tokens chip.
102
167
 
168
+ ### The two layers (RFC-0030)
169
+
170
+ The system block is partitioned into two cache layers:
171
+
172
+ - **Identity** — bytes that change only on deploy/config edit: the persona
173
+ prompt (`Prompt`), the level-1 skill list (`Skill`) and the deferred-tool
174
+ catalog (`ToolSearch`). This is the cacheable prefix.
175
+ - **Volatile** — bytes that may change per turn: memory, session history,
176
+ triggered skill bodies, the `<request_context>`. Everything else.
177
+
178
+ The layer is a **provider-class contract**, not profile data: `ContextProvider`
179
+ declares `def layer = :volatile` (conservative — nothing gets pinned by
180
+ accident) and the three identity builtins override to `:identity`. A pack does
181
+ not set it — a pack reorganizes *which content goes into the Prompt provider vs
182
+ the volatile providers*. The Builder stamps the layer on every fragment at
183
+ production, and the render order is **identity first, volatile after** — a
184
+ volatile block can never land above the cache boundary, whatever its priority.
185
+ Within each partition the existing priority sort is untouched.
186
+
187
+ The engine's own `doctor` check verifies the declaration: an engine-known
188
+ volatile provider (Memory, Session, Request, SkillTrigger) that overrides to
189
+ `:identity` is an **error** (guaranteed cache kill); any other custom
190
+ `:identity` provider is a **warning** (purity unverifiable from outside — the
191
+ output must be byte-stable across turns).
192
+
193
+ ### The observable cache: fingerprints and the invalidation reason
194
+
195
+ Each turn, the Executor hashes the rendered prefix into a PII-free fingerprint
196
+ chain — one SHA-256 per system category in render order, one for the tool
197
+ schemas, one cumulative `prefix` — and compares it against the previous turn's
198
+ entry. The **invalidation reason** is the first category whose bytes changed (or
199
+ vanished); a turn whose prefix held reports nothing. History is deliberately
200
+ excluded: a new user message is a divergence every turn, which would be noise,
201
+ not a reason.
202
+
203
+ The Studio surfaces it in two places: the **session Context card** shows the
204
+ turn's cache-hit percentage and the `broke: <category>` line (plus the
205
+ `identity` marker on the category rows), and the **agent detail** carries a
206
+ cache tab with the per-agent hit series over time. The per-agent series lives
207
+ in its own capped store, because a session does not stamp its author — the
208
+ per-session trace cannot answer "cache-hit over time for *this* agent".
209
+
210
+ With the prefix stable by construction, the existing `prompt_caching` breakpoint
211
+ sits on bytes that stay put — the first (write) turn of a deployment pays the
212
+ cache write once, every subsequent turn reads.
213
+
103
214
  ## The volume
104
215
 
105
216
  Agents, prompts, skills, and tools are **data in SQLite**, not files on a volume.
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.
@@ -112,10 +114,11 @@ this section is the single source of truth for what changing it means.
112
114
  | `DEEPSEEK_MODEL` | `deepseek-v4-flash` | model |
113
115
  | `CONSUMER_INTERNAL_URL` | — | base URL for data-tools calling back a consumer's internal API (see below) |
114
116
  | `INSIKA_EGRESS_HOSTS` | — | outbound host allowlist (SSRF guard) |
115
- | `INSIKA_EGRESS_ALLOW_HTTP` / `_ALLOW_PRIVATE` | off | for `http`/loopback callbacks only (**never in cloud**) |
117
+ | `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
118
  | `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
119
  | `INSIKA_RELAY_DELIVER_URL` | — | your callback; the engine POSTs each reply there. Goes through the egress guard |
118
120
  | `INSIKA_RELAY_DELIVER_TOKEN` | — | Bearer the engine sends **to** your callback (optional) |
121
+ | `INSIKA_RELAY_DELIVERY` | `at_end` | how the relay flushes the outbox: `at_end` (one POST) or `progressive` (one POST per balloon — [RFC-0027](CHANNELS.md#delivery-policy)) |
119
122
  | `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
123
  | `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
124
  | `LITESTREAM_REPLICA_URL` | — | **enables Litestream** (backup/DR). Empty = disabled (default). See below |
data/docs/EVALS.md CHANGED
@@ -232,6 +232,15 @@ That is what you run before merging a prompt, tool or model change. A case with
232
232
  baseline entry never blocks: it shows as failing in the report, but a brand-new case is
233
233
  not a regression.
234
234
 
235
+ The same gate machinery guards the two automated loops — [Refinement](REFINEMENT.md)
236
+ (cloned-agent replay of a proposed edit) and [Harvest](HARVEST.md) (cloned-agent
237
+ replay of a mined skill). **Judges are mandatory for those gates** in exactly three
238
+ shapes, the P18 lesson: a gate without a recorded baseline refuses, an all-red
239
+ baseline refuses, and a baseline recorded WITH judge scores, replayed with no judge
240
+ configured, refuses — a rubric'd case with no verdict reads as a pass, so the
241
+ candidate would beat a measurement it never took. A store with no golden cases cannot
242
+ gate, and cannot promote.
243
+
235
244
  ## Honest limits
236
245
 
237
246
  - **A judge is a model.** It has taste and it has bad days; that is why the
@@ -271,3 +280,13 @@ needs to score a candidate agent with the same judge, and a second copy of the j
271
280
  would be the worst possible outcome. It stays a **client** even so: it reaches a running
272
281
  deployment over HTTP through `POST /v1/responses` and never reads a store directly.
273
282
  `evals/run.rb` is a thin CLI over it.
283
+
284
+ ## Parity — the shadow criterion
285
+
286
+ The shadow experiment's rule lives in a **deployment-side criterion file** —
287
+ the path `INSIKA_PARITY_CRITERION` points at, prose a human reads and a `yaml`
288
+ block the machine applies, in one file, so there is exactly one place to edit.
289
+ The file's whole bytes are hashed; every shadow pair records that hash,
290
+ and a window whose pairs disagree produces `:invalid`, never a verdict. Editing
291
+ the criterion mid-experiment is *caught*, not averaged away. The fold itself is
292
+ `lib/insika/parity/*` (see [Channels](CHANNELS.md#shadow-mode)).
data/docs/FACTS.md ADDED
@@ -0,0 +1,135 @@
1
+ ---
2
+ title: Facts
3
+ parent: Operate & prove it
4
+ nav_order: 6
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
+ RFC-0031 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,117 @@
1
+ ---
2
+ title: Harvest
3
+ parent: Operate & prove it
4
+ nav_order: 7
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 (RFC-0029's 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 — the P18 lesson, 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** (the RFC-0032
66
+ `freeze_funnel_baseline`). Outcome is evidence — this gate can only say "the
67
+ store is measurably worse than the accepted state" or "there is nothing to
68
+ compare against". It refuses on missing data, never passes: no frozen
69
+ baseline, no criterion, no funnel store, a fold that has not converged — each
70
+ named on the Harvest page.
71
+
72
+ Both passed → the candidate awaits **a human**. Promotion is snapshot-first
73
+ (the pre-promotion content + allowlist), then the two existing write commands,
74
+ then an append-only log row carrying `skill`, `origin`, `eval_ref`,
75
+ `conversion_ref`, `approver`, `snapshot_ref` and the criterion's sha. The
76
+ conversion ruler is re-read at the moment the skill lands — a store that
77
+ dipped below its frozen baseline since gating parks the promotion with the
78
+ current numbers, and a criterion file that changed since boot is a criterion
79
+ nobody froze.
80
+
81
+ ## Rollback
82
+
83
+ One click, deterministic: the snapshot is restored — `WriteSkill` with the
84
+ pre-promotion bytes (or `DeleteSkill` when the skill did not exist), the
85
+ allowlist restored to the snapshot's set, and the promotion row stamped
86
+ `rolled_back_at`. The log stays the single ledger: a skill promoted, rolled
87
+ back, re-promoted is three readable rows.
88
+
89
+ ## The honest limits
90
+
91
+ - **A store with no golden cases cannot gate, and cannot promote.** The gate's
92
+ strength is entirely the golden set.
93
+ - **Grounding first.** A store without a matcher does not mine — the product
94
+ loop is blocked until the evidence ledger is live (by refusal, not by
95
+ warning).
96
+ - **A promoted skill is live text**, like any skill: the SkillStore's version
97
+ history plus the harvest snapshot plus the log make the rollback path
98
+ deterministic.
99
+ - **The first-10 audit is human.** The negative list can carry a false
100
+ restriction (a phrase too broad) — that is exactly the Hermes failure, and
101
+ it is caught by the audit, not by the engine: the list grows and the loop
102
+ stops (`harvest.enabled: false`), both by data.
103
+ - **The harvest costs provider money**: one miner call per window plus one
104
+ full golden replay per gated candidate. The `budget` cap, the recorded run
105
+ cost and the manual trigger bound it; the automated loop mines one session
106
+ per claim window.
107
+
108
+ ## The operator surface
109
+
110
+ - `insika harvest --agent ID [--last-sessions N] [--since ISO] [--full]` — mine one window.
111
+ - `insika harvest:negative import --agent ID --file F` — seed the profile's list from a rules file.
112
+ - `insika harvest:criterion check --file F` — strict-load the frozen conversion criterion (the hook before any promotion).
113
+ - The **Harvest page** in the Studio: the human's inbox (each candidate with
114
+ its evidence excerpt, the eval report, the conversion card, promote/reject),
115
+ the gated-but-blocked rows with the named ruler hole, the pending list, the
116
+ append-only promoted log with the rollback mirror, the negative list with
117
+ per-rule rejection counts, and the criterion block read-only.
data/docs/LOADTEST.md CHANGED
@@ -16,7 +16,7 @@ The whole point: the engine exposes `POST /v1/responses` as an **SSE drop-in** o
16
16
  the OpenClaw gateway. Same contract → the same load tools work against either side,
17
17
  so you can measure the engine you are about to ship against the gateway it replaces.
18
18
 
19
- There are three scripts, each answering a different question:
19
+ There are four scripts, each answering a different question:
20
20
 
21
21
  | Script | Question it answers | Needs a provider? |
22
22
  |--------|---------------------|-------------------|
@@ -24,8 +24,13 @@ There are three scripts, each answering a different question:
24
24
  | `scripts/loadtest.rb` | End-to-end: TTFB/total/tokens/cache/error against `/v1/responses` | Yes |
25
25
  | `scripts/loadtest-local.sh` | Single-proc baseline vs N-worker multi-proc on one box | Yes |
26
26
  | `scripts/loadtest_session.rb` | A full multi-message session (CEP, searches, FAQ) under C concurrent sessions — direct to the engine (`--surface engine`, stream vs steer) or through the consumer's real ingress (`--surface web`, the consumer's widget API) | Yes |
27
+ | `insika soak` | Does the deploy degrade over 72 h of steady load? | Yes |
27
28
 
28
- All three take `--help` / `-h`.
29
+ The first four take `--help` / `-h`; the soak is a shipped command (`insika soak --help`) rather
30
+ than a repo script. Bursts and uptime are different questions: a wave driver
31
+ measures a burst, and the soak's arrival process measures *degradation over
32
+ uptime* — the load-test table above deliberately stops where
33
+ [Soak](SOAK.md) begins.
29
34
 
30
35
  ---
31
36
 
@@ -64,7 +69,7 @@ frame that carries it.
64
69
  INSIKA_URL=http://localhost:9292 \
65
70
  OPENCLAW_GATEWAY_TOKEN=xxx \
66
71
  bundle exec ruby scripts/loadtest.rb \
67
- --agents bia,my-store --concurrency 16 --iterations 3 \
72
+ --agents demo,my-store --concurrency 16 --iterations 3 \
68
73
  --message "hi, how are you?"
69
74
  ```
70
75
 
@@ -75,7 +80,7 @@ Runs against a local server **or** a remote one (e.g. Railway) — just point
75
80
 
76
81
  | Flag | Default | Meaning |
77
82
  |------|---------|---------|
78
- | `--agents a,b,c` | `bia` | comma-separated agent ids (mapped to `model: openclaw:<agent>`) |
83
+ | `--agents a,b,c` | `demo` | comma-separated agent ids (mapped to `model: openclaw:<agent>`) |
79
84
  | `--concurrency N` | `8` | concurrent turns per wave |
80
85
  | `--iterations N` | `1` | number of waves per agent |
81
86
  | `--message TEXT` | greeting | user message sent every turn |
@@ -98,7 +103,7 @@ Use `--dry-run` to sanity-check your flags/URL/token before firing real traffic
98
103
 
99
104
  ```bash
100
105
  INSIKA_URL=http://localhost:9292 OPENCLAW_GATEWAY_TOKEN=xxx \
101
- bundle exec ruby scripts/loadtest.rb --agents bia --concurrency 16 --dry-run
106
+ bundle exec ruby scripts/loadtest.rb --agents demo --concurrency 16 --dry-run
102
107
  ```
103
108
 
104
109
  ### `--same-user` and the cache
@@ -130,7 +135,7 @@ DEEPSEEK_API_KEY=sk-... ./scripts/loadtest-local.sh [WORKERS] [CONCURRENCY]
130
135
  | `DEEPSEEK_API_KEY` | — (required) | real turns hit the provider; also auto-sourced from `.env.local` |
131
136
  | `OPENCLAW_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN`, then `local-demo` | Bearer for the sweep |
132
137
  | `PORT` | `9299` | bind port for the local Falcon |
133
- | `AGENT` | `bia` | agent id to load |
138
+ | `AGENT` | `demo` | agent id to load |
134
139
 
135
140
  The final block prints the lock counts; the expected reading is `0` for both:
136
141
 
@@ -163,7 +168,7 @@ drop-in for the gateway, you only change **where it points**:
163
168
  # In the OpenClaw checkout, run its gateway loadtest against the HARNESS:
164
169
  OPENCLAW_GATEWAY_URL=http://localhost:9292 \
165
170
  OPENCLAW_GATEWAY_TOKEN=<same bearer the engine accepts> \
166
- node scripts/loadtest-gateway.mjs --agents bia --concurrency 16 --iterations 3
171
+ node scripts/loadtest-gateway.mjs --agents demo --concurrency 16 --iterations 3
167
172
  ```
168
173
 
169
174
  Then run the exact same command with `OPENCLAW_GATEWAY_URL` pointing at the real
@@ -174,7 +179,7 @@ gateway, and diff the two reports. This is the shadow comparison the pilot needs
174
179
  - The OpenClaw checkout containing `scripts/loadtest-gateway.mjs` and Node installed.
175
180
  - A **bearer token accepted by both** sides. For the engine that is
176
181
  `OPENCLAW_GATEWAY_TOKEN` (see DEPLOY.md); point the gateway run at its own token.
177
- - **The same agent id provisioned on both** sides (e.g. `bia`) so `model:
182
+ - **The same agent id provisioned on both** sides (e.g. `demo`) so `model:
178
183
  openclaw:<agent>` resolves on each. On the engine, provision via
179
184
  `scripts/import_pack.rb`.
180
185
  - The **same provider** (or an equivalent-latency one) behind each, otherwise you
@@ -224,6 +229,7 @@ Work top-down and **measure before assuming** — avoid premature topology optim
224
229
  | 4 | Insika vs gateway, identical knobs | §4 (either method) | Is the engine at parity with the engine it replaces before cut-over? |
225
230
  | 5 | Cold vs hot conversation (cache) | `loadtest.rb` with/without `--same-user 1` | Expected steady-state cost/latency once conversations warm up. |
226
231
  | 6 | Remote (Railway) vs local | `loadtest.rb` with `INSIKA_URL` remote | Network/deploy overhead of the real environment. |
232
+ | 7 | Degradation over uptime (72 h) | `insika soak` (see [Soak](SOAK.md)) | The cut argument: does latency or memory grow with uptime? Run it last — it needs the topology settled first. |
227
233
 
228
234
  **Reaching for horizontal scale is only justified after 1–3 show the single box is
229
235
  the limit.** If it is, the paths are: sharding-by-tenant +
@@ -41,7 +41,18 @@ something that left no task of its own behind:
41
41
  | `:delivery_failed` | `channel`, `outbox_id`, `status`, `attempts`, `error` | a delivery exhausted its bounded retries — the alert face of the row above (WS6) |
42
42
  | `:budget_warning` | `agent`, `tenant`, `window`, `spent`, `cap` | a calendar budget crossed its threshold (`alert_at` or a soft cap) — once per window (WS2) |
43
43
  | `:breaker_open` | `agent`, `ref`, `tenant` | the reliability circuit breaker tripped for a `(tenant, provider/model)` — further turns fail fast until the cooldown (WS3/WS6) |
44
+ | `:provider_failure` | `agent`, `ref`, `error`, `kind` | one attempt against `ref` failed and spent a retry — emitted with or without a circuit breaker (WS3) |
45
+ | `:provider_fallback` | `agent`, `from`, `to`, `error`, `kind` | the turn ROTATED mid-flight to the next node of the fallback chain, and the error that caused it (WS3) |
44
46
  | `:ttft` | `task_id`, `session_id`, `ttft_ms` | the provider's time-to-first-token on the streaming envelope — only under `INSIKA_TURN_TIMING`, once per turn (WS6) |
47
+ | `:distillation_completed` | `session_ref`, `agent`, `proposals`, `dropped{}`, `deduped`, `cost{}` | a session's traffic was distilled into N proposals (RFC-0034) — counts and ids only, never a fact value |
48
+ | `:proposal_approved` | `proposal_id`, `status`, `operator` | a human approved a distilled fact on the Facts page — written to memory via CAS |
49
+ | `:proposal_rejected` | `proposal_id`, `status`, `operator` | a human rejected it (the reason is on the proposal record, not in the event) |
50
+ | `:proposal_dismissed` | `proposal_id`, `status`, `operator` | a human dismissed it — the tuple is latched, never proposed again |
51
+ | `:proposal_stale` | `proposal_id`, `status`, `operator` | the CAS lost: the fact moved after distillation — re-presented with both values on the wiki, never silently overwritten |
52
+ | `:harvest_mined` | `agent`, `run_id`, `candidates`, `rejected{}`, `cost{}` | a mining pass finished (RFC-0035) — counts and rule ids only, never a skill body |
53
+ | `:harvest_gated` | `run_id`, `candidate_id`, `agent`, `eval_passed`, `conversion_passed`, `reason` | the double gate's verdict on one candidate — ids and verdicts only |
54
+ | `:skill_promoted` | `agent`, `skill`, `candidate_id`, `snapshot_ref`, `promotion_ref`, `approver` | a human approved a mined skill — it is live for the store, with the snapshot for rollback |
55
+ | `:skill_rolled_back` | `snapshot_ref`, `skill`, `agent`, `operator` | the snapshot was restored — the promotion row carries the `rolled_back_at` stamp |
45
56
 
46
57
  `delivery_failed` and `breaker_open` are the two the operator config is pointed at
47
58
  (`alerts.webhook` on the profile): each only fires when something durable did
@@ -366,6 +377,49 @@ consumer that fell far behind would have its subscription closed (telemetry stop
366
377
  the turn does not). Span and instrument operations are cheap, so there's ample
367
378
  headroom.
368
379
 
380
+ ## Process vitals — `GET /v1/vitals`
381
+
382
+ OTel carries turn/tool telemetry; it says nothing about the **process**. For the
383
+ questions a soak (or any operator) asks — *which process is this, how long has it
384
+ been up, how much memory does it hold, and what is the Ruby heap doing?* — there
385
+ is one read-only route:
386
+
387
+ ```bash
388
+ curl -H "Authorization: Bearer $TOKEN" https://<target>/v1/vitals
389
+ ```
390
+
391
+ ```jsonc
392
+ {
393
+ "boot_id": "20260820T09-…", // one per container start; a change = a restart
394
+ "pid": 42,
395
+ "started_at": "2026-08-20T09:00:00Z",
396
+ "uptime_s": 259200,
397
+ "version": "0.2.0",
398
+ "ruby": "ruby 3.4.1 …",
399
+ "yjit": true,
400
+ "rss_bytes": 536870912, // nil when unreadable — never a guess
401
+ "gc": { "heap_live_slots": …, "major_gc_count": …, "malloc_increase_bytes": … },
402
+ "threads": 8,
403
+ "in_flight": 1, // the executor's in-flight turns
404
+ "db_bytes": { "db": …, "wal": …, "shm": … },
405
+ "at": "2026-08-20T09:00:00Z"
406
+ }
407
+ ```
408
+
409
+ The two fields that make it a **restart detector**: `boot_id` (one per container
410
+ start, exported by the entrypoint and shared by every worker) and `pid`. A
411
+ `boot_id` change is a container restart; a `pid` change under the same `boot_id`
412
+ is a worker respawn — the event a platform metrics API cannot see.
413
+
414
+ - **Operator-only.** The route is not in the public allowlist (no bearer →
415
+ unauthorized) and not on the tenant surface, so only an operator reads
416
+ process internals. `/up` stays the public health probe and carries no
417
+ process data.
418
+ - **Reads no store.** Pure OS/VM readings — safe to poll at any rate, and it
419
+ cannot contend with turns.
420
+ - **The soak's sampler.** [Soak](SOAK.md) polls it hourly; a `nil` RSS reads as
421
+ missing coverage, never as zero.
422
+
369
423
  ---
370
424
 
371
425
  *Packaging note.* Today the bridge lives in the Insika repo as an opt-in core