Skip to main content

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}