areev_loop/config.rs
1//! In-file loop config + state — file-truths persisted through the
2//! substrate's `load_state`/`store_state` as one JSON blob. Carries a schema
3//! version; unknown keys are ignored (serde default), so an older binary opens
4//! a newer file unchanged (proposal §7.3).
5
6use crate::model::Severity;
7use crate::recommendation::{MetricSnapshot, RecStatus};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use std::collections::BTreeMap;
11
12/// Current persisted-state schema version.
13pub const SCHEMA_VERSION: u32 = 1;
14
15/// The whole loop persisted blob.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LoopPersisted {
18 #[serde(default = "default_schema_version")]
19 pub schema_version: u32,
20 /// Per-analyzer config, keyed by full analyzer id.
21 #[serde(default)]
22 pub config: BTreeMap<String, AnalyzerConfig>,
23 #[serde(default)]
24 pub state: LoopState,
25 /// Rebuildable lifecycle cache: recommendation hash → status.
26 #[serde(default)]
27 pub status_index: BTreeMap<String, RecStatus>,
28 /// Per-recommendation latest audit hash, for hash-chaining.
29 #[serde(default)]
30 pub audit_heads: BTreeMap<String, String>,
31 /// The creating actor per recommendation (for the self-approval block).
32 #[serde(default)]
33 pub creators: BTreeMap<String, String>,
34 /// The principal that triggered the run which stored an LLM or
35 /// external-command recommendation — the self-approval block fires
36 /// against these too (the trigger must not approve their own model's
37 /// output). Omitted when empty: deterministic-only histories never
38 /// carry the key.
39 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40 pub co_creators: BTreeMap<String, String>,
41 /// Rejection cooldowns keyed by dedup_key → cooldown-until epoch-ms.
42 #[serde(default)]
43 pub cooldowns: BTreeMap<String, i64>,
44 /// How many times each dedup_key has been rejected — drives the exponential
45 /// backoff of `cooldowns` (7d, 14d, 28d, …) so a repeatedly-rejected finding
46 /// stops re-surfacing on a fixed cadence. Omitted when empty (no churn for
47 /// states without a rejection).
48 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49 pub cooldown_strikes: BTreeMap<String, u32>,
50 /// Applied-recommendation records (inverse plan, metric, timing).
51 #[serde(default)]
52 pub applied: BTreeMap<String, AppliedRecord>,
53 /// Per-recommendation set of horizons (ms after apply) already measured, so
54 /// each checkpoint is measured exactly once.
55 #[serde(default)]
56 pub measured: BTreeMap<String, Vec<i64>>,
57 /// Measured outcome time series (the Verify gate's output), keyed by
58 /// recommendation — one entry per horizon checkpoint.
59 #[serde(default)]
60 pub outcomes: BTreeMap<String, Vec<crate::recommendation::OutcomeResult>>,
61}
62
63fn default_schema_version() -> u32 {
64 SCHEMA_VERSION
65}
66
67impl Default for LoopPersisted {
68 fn default() -> Self {
69 LoopPersisted {
70 schema_version: SCHEMA_VERSION,
71 config: BTreeMap::new(),
72 state: LoopState::default(),
73 status_index: BTreeMap::new(),
74 audit_heads: BTreeMap::new(),
75 creators: BTreeMap::new(),
76 co_creators: BTreeMap::new(),
77 cooldowns: BTreeMap::new(),
78 cooldown_strikes: BTreeMap::new(),
79 applied: BTreeMap::new(),
80 measured: BTreeMap::new(),
81 outcomes: BTreeMap::new(),
82 }
83 }
84}
85
86impl LoopPersisted {
87 /// Decode from the substrate state blob; `Value::Null` (nothing stored) →
88 /// defaults.
89 pub fn from_value(v: Value) -> crate::error::Result<Self> {
90 if v.is_null() {
91 return Ok(Self::default());
92 }
93 serde_json::from_value(v)
94 .map_err(|e| crate::error::Error::Internal(format!("decode loop state: {e}")))
95 }
96
97 pub fn to_value(&self) -> crate::error::Result<Value> {
98 serde_json::to_value(self)
99 .map_err(|e| crate::error::Error::Internal(format!("encode loop state: {e}")))
100 }
101}
102
103/// Per-analyzer configuration. The file may enable/disable, raise severity
104/// floors, override params, and scope namespaces — never raise engine caps.
105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
106pub struct AnalyzerConfig {
107 /// `None` = follow the manifest default.
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub enabled: Option<bool>,
110 #[serde(default)]
111 pub params: Map<String, Value>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub severity_floor: Option<Severity>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub namespaces: Vec<String>,
116}
117
118/// A partial update to one analyzer's [`AnalyzerConfig`] — every field absent
119/// (`None`/`false`) leaves the stored value untouched, so the console can PATCH
120/// a single toggle. Deserialized straight from the `POST /api/loop/config`
121/// body.
122#[derive(Debug, Clone, Default, Deserialize)]
123pub struct AnalyzerConfigUpdate {
124 /// Enable/disable the analyzer. `None` leaves it as-is.
125 #[serde(default)]
126 pub enabled: Option<bool>,
127 /// Set the severity floor. `None` leaves it as-is; to CLEAR an existing
128 /// floor, send `clear_floor: true` instead.
129 #[serde(default)]
130 pub severity_floor: Option<Severity>,
131 #[serde(default)]
132 pub clear_floor: bool,
133 /// Replace the param overrides (validated against the manifest before store).
134 /// `None` leaves them as-is.
135 #[serde(default)]
136 pub params: Option<Map<String, Value>>,
137 /// Replace the namespace scoping. `None` leaves it as-is; `Some([])` clears.
138 #[serde(default)]
139 pub namespaces: Option<Vec<String>>,
140}
141
142/// One analyzer's effective settings for the Setup view: the manifest facts plus
143/// the resolved file-config (override or manifest default).
144#[derive(Debug, Clone, Serialize)]
145pub struct AnalyzerSetting {
146 pub id: String,
147 pub title: String,
148 /// The manifest's one-line "what it does", so the Setup view can say what
149 /// a toggle turns off without the reader having to know the analyzer.
150 pub description: String,
151 pub tier: String,
152 pub trust_class: String,
153 pub default_on: bool,
154 /// The effective on/off state (file override, else the manifest default).
155 pub enabled: bool,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub severity_floor: Option<String>,
158}
159
160/// Run state: the watermark that makes repeat runs cheap no-ops.
161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
162pub struct LoopState {
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub last_run_ms: Option<i64>,
165 /// Highest grain `created_at` processed so far.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub watermark_ms: Option<i64>,
168}
169
170/// Record of an applied recommendation: how to undo it and what to re-measure.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct AppliedRecord {
173 pub applied_at_ms: i64,
174 pub target_ref: String,
175 pub rollbackable: bool,
176 /// Grain hashes created by the apply, retracted on rollback (ADD inverse).
177 #[serde(default)]
178 pub created_hashes: Vec<String>,
179 /// CAL that undoes a change with no grain to retract.
180 ///
181 /// `created_hashes` is the inverse of an ADD: rollback retracts what the
182 /// apply created. A `DEFINE QUERY` / `DEFINE TEMPLATE` creates no grain —
183 /// it replaces a `qry:`/`tpl:` registry row — so retracting nothing would
184 /// let a rollback report success while the new definition stayed live.
185 /// This holds the statement that restores the previous definition (or
186 /// `DROP` when there was none), captured at apply time from the state
187 /// being replaced.
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub inverse_cal: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub metric: Option<MetricSnapshot>,
192}