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 checkpoints already measured, so each is
54 /// measured exactly once. A time checkpoint serializes as its bare ms
55 /// value, which is what this field held before checkpoints had units —
56 /// so a state blob from then reads back as the same schedule.
57 #[serde(default)]
58 pub measured: BTreeMap<String, Vec<crate::recommendation::Checkpoint>>,
59 /// Measured outcome time series (the Verify gate's output), keyed by
60 /// recommendation — one entry per horizon checkpoint.
61 #[serde(default)]
62 pub outcomes: BTreeMap<String, Vec<crate::recommendation::OutcomeResult>>,
63}
64
65fn default_schema_version() -> u32 {
66 SCHEMA_VERSION
67}
68
69impl Default for LoopPersisted {
70 fn default() -> Self {
71 LoopPersisted {
72 schema_version: SCHEMA_VERSION,
73 config: BTreeMap::new(),
74 state: LoopState::default(),
75 status_index: BTreeMap::new(),
76 audit_heads: BTreeMap::new(),
77 creators: BTreeMap::new(),
78 co_creators: BTreeMap::new(),
79 cooldowns: BTreeMap::new(),
80 cooldown_strikes: BTreeMap::new(),
81 applied: BTreeMap::new(),
82 measured: BTreeMap::new(),
83 outcomes: BTreeMap::new(),
84 }
85 }
86}
87
88impl LoopPersisted {
89 /// Decode from the substrate state blob; `Value::Null` (nothing stored) →
90 /// defaults.
91 pub fn from_value(v: Value) -> crate::error::Result<Self> {
92 if v.is_null() {
93 return Ok(Self::default());
94 }
95 serde_json::from_value(v)
96 .map_err(|e| crate::error::Error::Internal(format!("decode loop state: {e}")))
97 }
98
99 pub fn to_value(&self) -> crate::error::Result<Value> {
100 serde_json::to_value(self)
101 .map_err(|e| crate::error::Error::Internal(format!("encode loop state: {e}")))
102 }
103}
104
105/// Per-analyzer configuration. The file may enable/disable, raise severity
106/// floors, override params, and scope namespaces — never raise engine caps.
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct AnalyzerConfig {
109 /// `None` = follow the manifest default.
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub enabled: Option<bool>,
112 #[serde(default)]
113 pub params: Map<String, Value>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub severity_floor: Option<Severity>,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub namespaces: Vec<String>,
118}
119
120/// A partial update to one analyzer's [`AnalyzerConfig`] — every field absent
121/// (`None`/`false`) leaves the stored value untouched, so the console can PATCH
122/// a single toggle. Deserialized straight from the `POST /api/loop/config`
123/// body.
124#[derive(Debug, Clone, Default, Deserialize)]
125pub struct AnalyzerConfigUpdate {
126 /// Enable/disable the analyzer. `None` leaves it as-is.
127 #[serde(default)]
128 pub enabled: Option<bool>,
129 /// Set the severity floor. `None` leaves it as-is; to CLEAR an existing
130 /// floor, send `clear_floor: true` instead.
131 #[serde(default)]
132 pub severity_floor: Option<Severity>,
133 #[serde(default)]
134 pub clear_floor: bool,
135 /// Replace the param overrides (validated against the manifest before store).
136 /// `None` leaves them as-is.
137 #[serde(default)]
138 pub params: Option<Map<String, Value>>,
139 /// Replace the namespace scoping. `None` leaves it as-is; `Some([])` clears.
140 #[serde(default)]
141 pub namespaces: Option<Vec<String>>,
142}
143
144/// One analyzer's effective settings for the Setup view: the manifest facts plus
145/// the resolved file-config (override or manifest default).
146#[derive(Debug, Clone, Serialize)]
147pub struct AnalyzerSetting {
148 pub id: String,
149 pub title: String,
150 /// The manifest's one-line "what it does", so the Setup view can say what
151 /// a toggle turns off without the reader having to know the analyzer.
152 pub description: String,
153 pub tier: String,
154 pub trust_class: String,
155 pub default_on: bool,
156 /// The effective on/off state (file override, else the manifest default).
157 pub enabled: bool,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub severity_floor: Option<String>,
160}
161
162/// Run state: the watermark that makes repeat runs cheap no-ops.
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
164pub struct LoopState {
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub last_run_ms: Option<i64>,
167 /// Highest grain `created_at` processed so far.
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub watermark_ms: Option<i64>,
170}
171
172/// Record of an applied recommendation: how to undo it and what to re-measure.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct AppliedRecord {
175 pub applied_at_ms: i64,
176 pub target_ref: String,
177 pub rollbackable: bool,
178 /// Grain hashes created by the apply, retracted on rollback (ADD inverse).
179 #[serde(default)]
180 pub created_hashes: Vec<String>,
181 /// CAL that undoes a change with no grain to retract.
182 ///
183 /// `created_hashes` is the inverse of an ADD: rollback retracts what the
184 /// apply created. A `DEFINE QUERY` / `DEFINE TEMPLATE` creates no grain —
185 /// it replaces a `qry:`/`tpl:` registry row — so retracting nothing would
186 /// let a rollback report success while the new definition stayed live.
187 /// This holds the statement that restores the previous definition (or
188 /// `DROP` when there was none), captured at apply time from the state
189 /// being replaced.
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub inverse_cal: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub metric: Option<MetricSnapshot>,
194}