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 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    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub metric: Option<MetricSnapshot>,
181}