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
@@ -0,0 +1,548 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # the durable half of the harvest. One record per mining RUN
8
+ # (window, candidates, cost), the per-candidate lifecycle (the unit a human
9
+ # acts on — D8), the APPEND-ONLY promotion log, the pre-promotion snapshots,
10
+ # and the per-session mined markers (D10's re-scan discipline). A dumb
11
+ # domain store: no policy (which candidate is worth gating is the gates'),
12
+ # no model, no skills — the SkillStore stays the skill's home.
13
+ #
14
+ # Key shapes (string keys, Store-contract JSON):
15
+ # "harvest" "run:<agent>:<started_at>:<id>" -> the mining run
16
+ # "harvest" "cand:<id>" -> one candidate lifecycle
17
+ # "harvest" "promo:<agent>:<at>:<id>" -> APPEND ONLY (D8)
18
+ # "harvest" "snap:<id>" -> the pre-promotion state
19
+ # "harvest" "session:<session_ref>" -> the D10 marker
20
+ #
21
+ # The agent id must not contain ":" (the run-key split — the RefinementStore
22
+ # rule). The `body` is the only content the store holds: behavior
23
+ # instructions, the same trust level as SkillStore content (D11).
24
+ class HarvestStore
25
+ SCOPE = "harvest"
26
+
27
+ # Run statuses: mining -> completed | no_candidates | failed. The run does
28
+ # NOT carry awaiting_approval: several candidates of one run can be at
29
+ # different gates (D8) — the human answers CANDIDATES, the run only frames.
30
+ RUN_STATUSES = %w[mining completed no_candidates failed].freeze
31
+ # Candidate statuses (the lifecycle a human acts on):
32
+ # pending -> gated -> awaiting_approval -> promoted | rejected
33
+ # (gated FAIL -> rejected with the report; the latch)
34
+ CANDIDATE_STATUSES = %w[pending gated awaiting_approval promoted rejected].freeze
35
+ # A candidate is OPEN (dedup-suppressing) until terminal.
36
+ OPEN_STATUSES = %w[pending gated awaiting_approval].freeze
37
+
38
+ Candidate = Data.define(:id, :run_id, :agent, :name, :description, :body,
39
+ :triggers, :rationale, :origin, :evidence_turns,
40
+ :proposer, :status, :rejected, :eval_gate,
41
+ :conversion_gate, :criterion_sha, :decision,
42
+ :promotion_ref, :created_at, :updated_at)
43
+ Promotion = Data.define(:id, :agent, :skill, :origin, :eval_ref,
44
+ :conversion_ref, :approver, :snapshot_ref,
45
+ :criterion_sha, :rolled_back_at, :at)
46
+ Snapshot = Data.define(:id, :agent, :skill, :content, :existed, :enabled_for, :at)
47
+ Run = Data.define(:id, :agent_id, :status, :window, :candidates, :rejected,
48
+ :budget, :cost, :started_at, :finished_at, :error)
49
+
50
+ # session_store: optional — the session source for `unmined_sessions`
51
+ # (D10). nil = the scan is inert (the engine may enumerate sessions
52
+ # itself; the store never invents a session space).
53
+ def initialize(store:, session_store: nil)
54
+ @store = store
55
+ @sessions = session_store
56
+ end
57
+
58
+ # ---- runs -----------------------------------------------------------------
59
+
60
+ # Opens a run (:mining). `window` is the miner's window as data
61
+ # ({ "last_sessions" => N } | { "since" => iso8601 } | { "session_ids" => [...] }).
62
+ # `budget` is the pack's harvest.miner.budget (the cap the gates read —
63
+ # the refinement budget discipline). -> Run.
64
+ def create_run(agent_id:, window: {}, budget: nil, id: SecureRandom.uuid)
65
+ agent = agent_id.to_s
66
+ raise Insika::ValidationError, "agent_id is required" if agent.empty?
67
+ raise Insika::ValidationError, "agent_id must not contain ':'" if agent.include?(":")
68
+
69
+ started = timestamp
70
+ record = {
71
+ "id" => id.to_s, "agent_id" => agent, "status" => "mining",
72
+ "window" => Coercion.deep_stringify(window || {}),
73
+ "candidates" => 0, "rejected" => {}, "budget" => Coercion.deep_stringify(budget),
74
+ "cost" => nil,
75
+ "started_at" => started, "finished_at" => nil, "error" => nil
76
+ }
77
+ @store.set(SCOPE, "run:#{agent}:#{started}:#{id}", record)
78
+ to_run(record)
79
+ end
80
+
81
+ # Closes a run with its candidate count. Zero candidates -> :no_candidates
82
+ # (a distinct outcome from :completed — "we looked and it was clean").
83
+ # `rejected` is the { reason/rule => count } map the filters counted (D4:
84
+ # "every rejected-by-list candidate is logged with the matching rule").
85
+ # -> Run. ArgumentError when the run is already terminal.
86
+ def complete_run(id, candidates:, cost: nil, rejected: nil)
87
+ update_run(id) do |record|
88
+ guard_run_state!(record, "mining")
89
+ record["candidates"] = Integer(candidates)
90
+ record["rejected"] = rejected ? Coercion.deep_stringify(rejected) : {}
91
+ record["cost"] = Coercion.deep_stringify(cost) if cost
92
+ record["status"] = Integer(candidates).positive? ? "completed" : "no_candidates"
93
+ record["finished_at"] = timestamp
94
+ end
95
+ end
96
+
97
+ # Closes a run as :failed, recording the error. -> Run.
98
+ def fail_run(id, error:)
99
+ update_run(id) do |record|
100
+ guard_run_state!(record, "mining")
101
+ record["status"] = "failed"
102
+ record["error"] = error.to_s
103
+ record["finished_at"] = timestamp
104
+ end
105
+ end
106
+
107
+ def find_run(id)
108
+ key = run_key(id)
109
+ key && to_run(@store.get(SCOPE, key))
110
+ end
111
+
112
+ # -> [Run] for one agent, MOST RECENT FIRST, capped.
113
+ def runs_for(agent_id, limit: 20)
114
+ keys = @store.list(SCOPE, "run:#{agent_id}:").reverse
115
+ keys = keys.first(limit) if limit
116
+ keys.filter_map { |k| to_run(@store.get(SCOPE, k)) }
117
+ end
118
+
119
+ # ---- candidates -----------------------------------------------------------
120
+
121
+ # The engine stamps agent/origin/proposer — never the model (D3).
122
+ def create_candidate(run_id:, agent:, name:, description:, body:,
123
+ triggers: [], rationale:, origin:, evidence_turns: [],
124
+ proposer:, id: SecureRandom.uuid)
125
+ now = timestamp
126
+ record = {
127
+ "id" => id.to_s, "run_id" => run_id.to_s, "agent" => agent.to_s,
128
+ "name" => name.to_s, "description" => description.to_s, "body" => body.to_s,
129
+ "triggers" => Array(triggers).map(&:to_s), "rationale" => rationale.to_s,
130
+ "origin" => Array(origin).map(&:to_s),
131
+ "evidence_turns" => Array(evidence_turns).map(&:to_i),
132
+ "proposer" => proposer.to_s, "status" => "pending",
133
+ "rejected" => [], "eval_gate" => nil, "conversion_gate" => nil,
134
+ "criterion_sha" => nil, "decision" => nil, "promotion_ref" => nil,
135
+ "created_at" => now, "updated_at" => now
136
+ }
137
+ @store.set(SCOPE, "cand:#{id}", record)
138
+ to_candidate(record)
139
+ end
140
+
141
+ def find_candidate(id)
142
+ to_candidate(@store.get(SCOPE, "cand:#{id}"))
143
+ end
144
+
145
+ # -> [Candidate] filtered by agent/status, most recent first.
146
+ def candidates(agent_id: nil, status: nil)
147
+ scan_candidates.select do |c|
148
+ (agent_id.nil? || c.agent == agent_id.to_s) &&
149
+ (status.nil? || c.status == status.to_s)
150
+ end
151
+ end
152
+
153
+ # -> [Candidate] EVERY candidate parked on a human, most recent first.
154
+ def awaiting_approval(limit: 50)
155
+ list = candidates(status: "awaiting_approval").sort_by { |c| c.updated_at.to_s }.reverse
156
+ list.first(limit)
157
+ end
158
+
159
+ # Dedup: an OPEN (non-terminal) candidate with the same (agent, name)
160
+ # suppresses a re-proposal. -> bool.
161
+ def open_pending?(agent:, name:)
162
+ @store.list(SCOPE, "cand:").any? do |k|
163
+ record = @store.get(SCOPE, k)
164
+ next false unless record
165
+ next false unless record["agent"] == agent.to_s && record["name"] == name.to_s
166
+
167
+ OPEN_STATUSES.include?(record["status"].to_s)
168
+ end
169
+ end
170
+
171
+ # Transitions (read-check-write on #transaction; ArgumentError on a wrong
172
+ # source state — the task_store idiom).
173
+
174
+ # Appends a { rule, reason } rejection entry (the negative-list / gate
175
+ # evidence lines). Accumulates; only on a non-terminal candidate.
176
+ def attach_rejected(candidate_id, rules:, reason:)
177
+ update_candidate(candidate_id) do |record|
178
+ guard_candidate_non_terminal!(record)
179
+ list = record["rejected"] || []
180
+ record["rejected"] = list + Array(rules).map { |r| { "rule" => r.to_s, "reason" => reason.to_s } }
181
+ end
182
+ end
183
+
184
+ # Records both gate reports + the boot-loaded criterion sha; pending ->
185
+ # gated.
186
+ def attach_gate(candidate_id, eval_gate:, conversion_gate:, criterion_sha:)
187
+ update_candidate(candidate_id) do |record|
188
+ guard_candidate_state!(record, "pending")
189
+ record["eval_gate"] = Coercion.deep_stringify(eval_gate)
190
+ record["conversion_gate"] = Coercion.deep_stringify(conversion_gate)
191
+ record["criterion_sha"] = criterion_sha.to_s
192
+ record["status"] = "gated"
193
+ record["updated_at"] = timestamp
194
+ end
195
+ end
196
+
197
+ # gated -> awaiting_approval. The refusal parks: a conversion REFUSAL
198
+ # keeps the candidate AT gated (the page shows the ruler's hole).
199
+ def mark_awaiting(candidate_id)
200
+ update_candidate(candidate_id) do |record|
201
+ guard_candidate_state!(record, "gated")
202
+ record["status"] = "awaiting_approval"
203
+ record["updated_at"] = timestamp
204
+ end
205
+ end
206
+
207
+ # -> rejected (terminal) from pending | gated | awaiting_approval — a
208
+ # human may always outvote the miner. The decision records by/at/note.
209
+ def mark_rejected(candidate_id, operator:, note: nil)
210
+ update_candidate(candidate_id) do |record|
211
+ guard_candidate_non_terminal!(record)
212
+ record["status"] = "rejected"
213
+ record["decision"] = { "by" => (Coercion.presence(operator) || "operator").to_s,
214
+ "at" => timestamp, "note" => Coercion.presence(note) }.compact
215
+ record["updated_at"] = timestamp
216
+ end
217
+ end
218
+
219
+ # awaiting_approval -> promoted. Recorded ONLY after the writes land (the
220
+ # record-after rule).
221
+ def mark_promoted(candidate_id, promotion_ref:)
222
+ update_candidate(candidate_id) do |record|
223
+ guard_candidate_state!(record, "awaiting_approval")
224
+ record["status"] = "promoted"
225
+ record["promotion_ref"] = promotion_ref.to_s
226
+ record["updated_at"] = timestamp
227
+ end
228
+ end
229
+
230
+ # D8-bis: the conversion ruler is re-read AT APPROVE TIME and may have
231
+ # moved below the threshold since gating. The promote path re-checks; a
232
+ # dip parks the candidate BACK at gated with the FRESH report — the
233
+ # operator re-decides, and a skill does not land into a ruler that has
234
+ # moved the wrong way. awaiting_approval -> gated.
235
+ def recheck_conversion(candidate_id, conversion_gate:)
236
+ update_candidate(candidate_id) do |record|
237
+ guard_candidate_state!(record, "awaiting_approval")
238
+ record["conversion_gate"] = Coercion.deep_stringify(conversion_gate)
239
+ record["status"] = "gated"
240
+ record["updated_at"] = timestamp
241
+ end
242
+ end
243
+
244
+ # ---- the append-only promotion log + snapshots ----------------------------
245
+
246
+ # Appends the promotion row — NO update, NO delete, no re-key. A second row with
247
+ # the same id is refused loudly.
248
+ def append_promotion(id:, agent:, skill:, origin: [], eval_ref: nil,
249
+ conversion_ref: nil, approver:, snapshot_ref: nil,
250
+ criterion_sha: nil, at: nil)
251
+ id = id.to_s
252
+ agent = agent.to_s
253
+ raise Insika::ValidationError, "promotion id is required" if id.empty?
254
+ raise Insika::ValidationError, "promotion already recorded: #{id}" if find_promotion_key(id)
255
+
256
+ now = at || timestamp
257
+ record = {
258
+ "id" => id, "agent" => agent, "skill" => skill.to_s,
259
+ "origin" => Array(origin).map(&:to_s), "eval_ref" => eval_ref.to_s,
260
+ "conversion_ref" => conversion_ref.to_s, "approver" => (Coercion.presence(approver) || "operator").to_s,
261
+ "snapshot_ref" => snapshot_ref.to_s, "criterion_sha" => criterion_sha.to_s,
262
+ "rolled_back_at" => nil, "at" => now
263
+ }
264
+ @store.set(SCOPE, "promo:#{agent}:#{now}:#{id}", record)
265
+ to_promotion(record)
266
+ end
267
+
268
+ # Stamps rolled_back_at on the row — the log stays the single ledger (D9).
269
+ # -> Promotion.
270
+ def append_rollback(promotion_id:, operator: nil, reason: nil)
271
+ key = find_promotion_key(promotion_id.to_s)
272
+ raise Insika::NotFoundError, "promotion not found: #{promotion_id}" if key.nil?
273
+
274
+ record = @store.get(SCOPE, key)
275
+ record["rolled_back_at"] = timestamp
276
+ @store.set(SCOPE, key, record)
277
+ to_promotion(record)
278
+ end
279
+
280
+ # -> [Promotion] new-first, optionally per agent, capped.
281
+ def promotions(agent_id: nil, limit: 100)
282
+ keys = @store.list(SCOPE, agent_id ? "promo:#{agent_id}:" : "promo:")
283
+ rows = keys.filter_map { |k| to_promotion(@store.get(SCOPE, k)) }
284
+ rows.sort_by { |p| p.at.to_s }.reverse.first(limit)
285
+ end
286
+
287
+ # The pre-promotion state (D8 — snapshot FIRST, then the writes).
288
+ def create_snapshot(agent:, skill:, content:, existed:, enabled_for:)
289
+ id = SecureRandom.uuid
290
+ record = { "id" => id, "agent" => agent.to_s, "skill" => skill.to_s,
291
+ "content" => content, "existed" => !!existed,
292
+ "enabled_for" => Array(enabled_for).map(&:to_s),
293
+ "at" => timestamp }
294
+ @store.set(SCOPE, "snap:#{id}", record)
295
+ to_snapshot(record)
296
+ end
297
+
298
+ def find_snapshot(id)
299
+ to_snapshot(@store.get(SCOPE, "snap:#{id}"))
300
+ end
301
+
302
+ # ---- the per-session marker (D10 - the re-scan discipline) ----------------
303
+
304
+ def mark_mined(session_ref, candidates: 0)
305
+ ref = session_ref.to_s
306
+ return {} if ref.empty?
307
+
308
+ record = { "session_ref" => ref, "mined_at" => timestamp,
309
+ "candidates" => Integer(candidates) }
310
+ @store.set(SCOPE, "session:#{ref}", record)
311
+ record
312
+ end
313
+
314
+ def mined?(session_ref)
315
+ !@store.get(SCOPE, "session:#{session_ref.to_s}").nil?
316
+ end
317
+
318
+ # The engine's scan space: every session the source knows MINUS the marked
319
+ # set, optionally bounded by `since` (sessions updated at/after it — the
320
+ # incremental boundary). Nil source = inert (parity — the caller that owns
321
+ # a SessionStore enumerates itself).
322
+ # -> [String]
323
+ def unmined_sessions(since: nil)
324
+ return [] unless @sessions
325
+
326
+ @sessions.each_id.filter_map do |id|
327
+ next if mined?(id)
328
+
329
+ if since
330
+ session = @sessions.respond_to?(:find) ? @sessions.find(id) : nil
331
+ next unless session
332
+ next unless Time.parse(session.updated_at.to_s).utc >= Time.parse(since.to_s).utc
333
+ end
334
+ id.to_s
335
+ rescue ArgumentError
336
+ next
337
+ end.to_a
338
+ end
339
+
340
+ # ---- LGPD / retention (C13) -----------------------------------------------
341
+
342
+ # The tenant is the store's (candidates reference sessions; sessions carry
343
+ # the tenant prefix) — a prefix scan over the scope keys. Removes the
344
+ # tenant's candidates, promotion rows, their snapshots and the session
345
+ # markers. -> count removed.
346
+ def purge(tenant:)
347
+ prefix = "#{tenant_id(tenant)}:"
348
+ removed = 0
349
+
350
+ promo_refs = []
351
+ @store.list(SCOPE, "promo:").each do |k|
352
+ record = @store.get(SCOPE, k)
353
+ next unless record && Array(record["origin"]).any? { |o| o.to_s.start_with?(prefix) }
354
+
355
+ @store.delete(SCOPE, k)
356
+ promo_refs << record["snapshot_ref"].to_s
357
+ removed += 1
358
+ end
359
+
360
+ @store.list(SCOPE, "cand:").each do |k|
361
+ record = @store.get(SCOPE, k)
362
+ next unless record && Array(record["origin"]).any? { |o| o.to_s.start_with?(prefix) }
363
+
364
+ @store.delete(SCOPE, k)
365
+ removed += 1
366
+ end
367
+
368
+ promo_refs.each do |ref|
369
+ next if ref.empty?
370
+
371
+ snap_key = "snap:#{ref}"
372
+ existing = @store.get(SCOPE, snap_key)
373
+ next unless existing
374
+
375
+ @store.delete(SCOPE, snap_key)
376
+ removed += 1
377
+ end
378
+
379
+ @store.list(SCOPE, "session:#{prefix}").each do |k|
380
+ @store.delete(SCOPE, k)
381
+ removed += 1
382
+ end
383
+
384
+ removed
385
+ end
386
+
387
+ # Candidates (pending AND terminal), log rows, snapshots and runs past the
388
+ # cutoff. They are re-derivable (D2) — pruning is never data loss. The
389
+ # session MARKERS are never pruned (the marker is the claim).
390
+ # -> count removed.
391
+ def delete_older_than(time)
392
+ cutoff = Time.parse(time.to_s).utc
393
+ removed = 0
394
+
395
+ [["cand:", "created_at"], ["snap:", "at"], ["run:", "started_at"]].each do |prefix, field|
396
+ @store.list(SCOPE, prefix).each do |k|
397
+ record = @store.get(SCOPE, k)
398
+ next unless record && record[field]
399
+
400
+ begin
401
+ next unless Time.parse(record[field].to_s).utc < cutoff
402
+ rescue ArgumentError
403
+ next
404
+ end
405
+
406
+ @store.delete(SCOPE, k)
407
+ removed += 1
408
+ end
409
+ end
410
+
411
+ @store.list(SCOPE, "promo:").each do |k|
412
+ record = @store.get(SCOPE, k)
413
+ next unless record && record["at"]
414
+
415
+ begin
416
+ next unless Time.parse(record["at"].to_s).utc < cutoff
417
+ rescue ArgumentError
418
+ next
419
+ end
420
+
421
+ @store.delete(SCOPE, k)
422
+ removed += 1
423
+ end
424
+
425
+ removed
426
+ end
427
+
428
+ private
429
+
430
+ def tenant_id(tenant)
431
+ t = tenant.to_s
432
+ t.empty? ? "platform" : t
433
+ end
434
+
435
+ # Fractional seconds so the run/promotion keys ("...:<started_at>:<id>")
436
+ # order CHRONOLOGICALLY under the store's lexicographic contract even for two
437
+ # writes in the same second (the "runs_for is newest-first" guarantee).
438
+ def timestamp = Time.now.utc.iso8601(6)
439
+
440
+ def run_key(id)
441
+ suffix = ":#{id}"
442
+ @store.list(SCOPE, "run:").find { |k| k.end_with?(suffix) }
443
+ end
444
+
445
+ def find_promotion_key(id)
446
+ suffix = ":#{id}"
447
+ @store.list(SCOPE, "promo:").find { |k| k.end_with?(suffix) }
448
+ end
449
+
450
+ def update_run(id)
451
+ key = run_key(id.to_s)
452
+ raise Insika::NotFoundError, "harvest run not found: #{id}" if key.nil?
453
+
454
+ record = @store.get(SCOPE, key)
455
+ if record.nil?
456
+ raise Insika::NotFoundError, "harvest run not found: #{id}"
457
+ end
458
+
459
+ yield record
460
+ @store.set(SCOPE, key, record)
461
+ to_run(record)
462
+ end
463
+
464
+ def guard_run_state!(record, expected)
465
+ return if record["status"] == expected
466
+
467
+ raise ArgumentError, "run #{record['id']} is #{record['status']}, expected #{expected}"
468
+ end
469
+
470
+ def update_candidate(candidate_id)
471
+ key = "cand:#{candidate_id}"
472
+ record = @store.get(SCOPE, key)
473
+ raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}" if record.nil?
474
+
475
+ yield record
476
+ record["updated_at"] = timestamp
477
+ @store.set(SCOPE, key, record)
478
+ to_candidate(record)
479
+ end
480
+
481
+ def guard_candidate_state!(record, expected)
482
+ return if record["status"] == expected
483
+
484
+ raise ArgumentError, "candidate #{record['id']} is #{record['status']}, expected #{expected}"
485
+ end
486
+
487
+ def guard_candidate_non_terminal!(record)
488
+ return if OPEN_STATUSES.include?(record["status"].to_s)
489
+
490
+ raise ArgumentError, "candidate #{record['id']} is already #{record['status']}"
491
+ end
492
+
493
+ def scan_candidates
494
+ @store.list(SCOPE, "cand:").filter_map { |k| to_candidate(@store.get(SCOPE, k)) }
495
+ end
496
+
497
+ def to_run(record)
498
+ return nil if record.nil?
499
+
500
+ Run.new(
501
+ id: record["id"], agent_id: record["agent_id"],
502
+ status: record["status"].to_s, window: record["window"] || {},
503
+ candidates: record["candidates"] || 0, rejected: record["rejected"] || {},
504
+ budget: record["budget"], cost: record["cost"],
505
+ started_at: record["started_at"], finished_at: record["finished_at"],
506
+ error: record["error"]
507
+ )
508
+ end
509
+
510
+ def to_candidate(record)
511
+ return nil if record.nil?
512
+
513
+ Candidate.new(
514
+ id: record["id"], run_id: record["run_id"], agent: record["agent"],
515
+ name: record["name"], description: record["description"], body: record["body"],
516
+ triggers: Array(record["triggers"]), rationale: record["rationale"],
517
+ origin: Array(record["origin"]), evidence_turns: Array(record["evidence_turns"]),
518
+ proposer: record["proposer"], status: record["status"].to_s,
519
+ rejected: record["rejected"] || [], eval_gate: record["eval_gate"],
520
+ conversion_gate: record["conversion_gate"], criterion_sha: record["criterion_sha"],
521
+ decision: record["decision"], promotion_ref: record["promotion_ref"],
522
+ created_at: record["created_at"], updated_at: record["updated_at"]
523
+ )
524
+ end
525
+
526
+ def to_promotion(record)
527
+ return nil if record.nil?
528
+
529
+ Promotion.new(
530
+ id: record["id"], agent: record["agent"], skill: record["skill"],
531
+ origin: Array(record["origin"]), eval_ref: record["eval_ref"],
532
+ conversion_ref: record["conversion_ref"], approver: record["approver"],
533
+ snapshot_ref: record["snapshot_ref"], criterion_sha: record["criterion_sha"],
534
+ rolled_back_at: record["rolled_back_at"], at: record["at"]
535
+ )
536
+ end
537
+
538
+ def to_snapshot(record)
539
+ return nil if record.nil?
540
+
541
+ Snapshot.new(
542
+ id: record["id"], agent: record["agent"], skill: record["skill"],
543
+ content: record["content"], existed: record["existed"] == true,
544
+ enabled_for: Array(record["enabled_for"]), at: record["at"]
545
+ )
546
+ end
547
+ end
548
+ end