Skip to main content

areev_loop/
recommendation.rs

1//! The recommendation object (OMS 0x0C), the deterministic summary renderer,
2//! the dedup key, and the lifecycle state machine + audit records.
3//!
4//! Invariants enforced here:
5//! - Analyzers emit a `(template_id, args)` summary, never free prose.
6//! - `dedup_key`, `origin`, and the params snapshot are engine-stamped.
7//! - `dedup_key` excludes proposal content and the `/major` version, so a
8//!   growing cluster or an analyzer upgrade does not re-propose as novel.
9//! - Lifecycle transitions are gated; `pending → applied` is policy-only.
10
11use crate::error::{Error, Result};
12use crate::model::{normalize_ident, ActionKind, Origin, Severity};
13use crate::substrate::GrainSpec;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16
17/// A deterministic, template-rendered summary. The analyzer chooses a
18/// `template_id` and supplies `args`; the text is produced here, so an
19/// analyzer can never emit arbitrary prose into the queue.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct Summary {
22    pub template_id: String,
23    #[serde(default)]
24    pub args: Map<String, Value>,
25}
26
27impl Summary {
28    pub fn new(template_id: impl Into<String>, args: Map<String, Value>) -> Self {
29        Summary {
30            template_id: template_id.into(),
31            args,
32        }
33    }
34
35    /// Render the summary. Unknown template ids fall back to a stable, honest
36    /// string (never a panic) so a manifest/template mismatch is visible, not
37    /// fatal.
38    pub fn render(&self) -> String {
39        let t = builtin_template(&self.template_id).unwrap_or("{template_id}: {summary}");
40        interpolate(t, &self.args, &self.template_id)
41    }
42}
43
44/// The built-in template table. Deterministic; the only place summary prose
45/// lives. Keep placeholders in `{name}` form matching `args` keys.
46fn builtin_template(id: &str) -> Option<&'static str> {
47    Some(match id {
48        "duplicate.exact" => "Consolidate {count} exact-duplicate grains for \"{subject}\"",
49        "duplicate.near" => {
50            "Consolidate {count} near-duplicate observations (similarity ≥ {threshold})"
51        }
52        "contradiction.functional" => {
53            "\"{subject}\" holds {count} live values for functional relation \"{relation}\""
54        }
55        "tool_failure.cluster" => {
56            "Tool \"{tool}\" failed {count} times ({rate}% of calls): {signature}"
57        }
58        "staleness.expired" => "Expire \"{subject}\": past its declared valid_to ({age_days}d ago)",
59        "fork.multi_head" => "Entity \"{entity}\" has {count} competing heads",
60        "skill.stall" => {
61            "Skill \"{skill}\" isn't improving: practiced {practice_count}× but proficiency is still {proficiency}"
62        }
63        "goal.stagnation" => "Goal \"{goal}\" is stalled: active {age_days}d with {progress} progress",
64        "cold.grain" => {
65            "Cold memory: \"{subject}\" ({age_days}d old) has never been recalled — retire candidate"
66        }
67        "coverage.gap" => {
68            "Recurring question with no matching memory: \"{query}\" asked {count}× ({empty_rate}% empty)"
69        }
70        "budget.pressure" => {
71            "Assembly budget overflowed on {overflow_rate}% of {samples} recalls — raise the budget or curate memory"
72        }
73        "retention.overdue" => {
74            "Retention: {count} grain(s) in \"{namespace}\" exceed the {max_age_days}d policy (oldest {oldest_days}d); {remaining} more await a later pass"
75        }
76        "outcome.regression" => {
77            "Applied recommendation regressed: {metric} moved {baseline} → {current}"
78        }
79        "run.failures" => {
80            "Workflow {workflow} failed {failed}/{runs} recent runs ({rate}%): {last_error}"
81        }
82        "run.cost" => {
83            "Workflow {workflow} spent ${usd} across {runs} runs (avg ${avg_usd}/run)"
84        }
85        "adapter.candidate" => {
86            "Promote adapter for \"{model}\" (base {base_model}) — gated on its pinned evalset"
87        }
88        // origin=llm drafts carry free text (clearly marked llm) rather than a
89        // deterministic template — the model's proposed summary rides in {text}.
90        "llm.discover" => "{text}",
91        // External command analyzers (trust class Command): free text from the
92        // subprocess, rendered as-is (the trust badge, not the prose, marks it).
93        "command.finding" => "{text}",
94        _ => return None,
95    })
96}
97
98fn interpolate(template: &str, args: &Map<String, Value>, template_id: &str) -> String {
99    let mut out = String::with_capacity(template.len());
100    let mut chars = template.chars().peekable();
101    while let Some(c) = chars.next() {
102        if c == '{' {
103            let mut key = String::new();
104            for k in chars.by_ref() {
105                if k == '}' {
106                    break;
107                }
108                key.push(k);
109            }
110            if key == "template_id" {
111                out.push_str(template_id);
112            } else {
113                match args.get(&key) {
114                    Some(Value::String(s)) => out.push_str(s),
115                    Some(v) => out.push_str(&v.to_string()),
116                    None => {
117                        // Missing arg — leave a visible marker rather than lie.
118                        out.push('{');
119                        out.push_str(&key);
120                        out.push('}');
121                    }
122                }
123            }
124        } else {
125            out.push(c);
126        }
127    }
128    out
129}
130
131/// A reproducible metric snapshot; powers outcome review.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct MetricSnapshot {
134    /// Metric kind — the engine knows how to re-measure a fixed set
135    /// (e.g. `tool_error_recurrence`); unknown kinds are skipped, not faked.
136    pub metric: String,
137    pub baseline: f64,
138    pub unit: String,
139    pub n: u64,
140    pub window: String,
141    /// The subject the metric is about (e.g. the tool name), used by the
142    /// engine's typed re-measurement.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub subject: Option<String>,
145    /// Namespace scope for the re-measurement, normalized (case-fold/trim).
146    /// `None` = all namespaces. Additive: older snapshots deserialize to
147    /// `None` and existing metric kinds ignore it.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub namespace: Option<String>,
150    /// Relation scope for fact-shaped metrics (e.g. which functional relation
151    /// a contradiction was resolved under), normalized. Additive like
152    /// `namespace`.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub relation: Option<String>,
155    /// CAL that recomputes the metric at verify time (reproducibility /
156    /// documentation; the engine re-measures with typed reads).
157    pub query: String,
158    /// How long after apply to re-measure, in epoch-ms delta (the first / only
159    /// checkpoint when `horizons_ms` is empty).
160    pub review_after_ms: i64,
161    /// A schedule of checkpoints (ms after apply) to re-measure at. An outcome
162    /// that `held` at an early checkpoint can `regress` at a later one, so a
163    /// verdict is never final until the last horizon. Empty → `[review_after_ms]`.
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub horizons_ms: Vec<i64>,
166    /// Which direction is an improvement. The built-in metrics are all
167    /// recurrence counts, where lower is better — so this defaults to `false`
168    /// and every existing snapshot deserializes unchanged. An evalset accuracy
169    /// is the opposite, and getting it wrong does not merely misreport: the
170    /// Verify gate would read a rule that IMPROVED accuracy as a regression and
171    /// propose reverting it.
172    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
173    pub higher_is_better: bool,
174}
175
176/// The one regression rule.
177///
178/// The verdict is computed in two places — once by the engine for the recorded
179/// `OutcomeResult`, once by `outcome_review` when it drafts the revert — and
180/// the two silently disagreeing would either revert a change that held or sit
181/// on one that regressed. So both call this.
182pub fn is_regression(baseline: f64, current: f64, higher_is_better: bool) -> bool {
183    /// Minimum worsening to call a regression (avoids noise at n=1).
184    const EPSILON: f64 = 1e-9;
185    if higher_is_better {
186        current < baseline - EPSILON
187    } else {
188        current > baseline + EPSILON
189    }
190}
191
192impl MetricSnapshot {
193    /// The measurement schedule, sorted — `horizons_ms` if set, else the single
194    /// `review_after_ms`.
195    pub fn horizons(&self) -> Vec<i64> {
196        let mut h = if self.horizons_ms.is_empty() {
197            vec![self.review_after_ms]
198        } else {
199            self.horizons_ms.clone()
200        };
201        h.sort_unstable();
202        h
203    }
204}
205
206/// A measured outcome for an applied recommendation at one checkpoint — the
207/// Verify gate's output. `held` = the metric did not regress at this horizon;
208/// `regressed` = it got worse (a revert is proposed). A recommendation
209/// accumulates one of these per horizon, forming a time series.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct OutcomeResult {
212    pub rec_hash: String,
213    pub metric: String,
214    pub baseline: f64,
215    pub current: f64,
216    pub verdict: String,
217    /// Which checkpoint this measurement is for (ms after apply).
218    #[serde(default)]
219    pub horizon_ms: i64,
220    pub measured_at_ms: i64,
221}
222
223/// The proposed change. Exactly one variant per recommendation.
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225#[serde(tag = "proposal", rename_all = "snake_case")]
226pub enum Proposal {
227    /// A batch of CAL Tier-1 evolve writes (ADD/SUPERSEDE), MAY contain FORGET.
228    Cal { cal: String },
229    /// A doc-target edit: `{format, base_digest, diff}` (base_digest enables a
230    /// staleness check at apply).
231    Edit {
232        format: String,
233        base_digest: String,
234        diff: String,
235    },
236    /// An opaque map for host targets (applied by the host, §12.3).
237    Data { data: Map<String, Value> },
238}
239
240/// What an analyzer emits. `dedup_key`, `origin`, and the params snapshot are
241/// **not** here — the engine stamps them. Non-exhaustive so the engine can add
242/// fields without breaking analyzers.
243#[derive(Debug, Clone, PartialEq)]
244#[non_exhaustive]
245pub struct RecDraft {
246    pub target_ref: String,
247    pub action_kind: ActionKind,
248    pub summary: Summary,
249    pub severity: Severity,
250    pub proposal: Proposal,
251    /// Bounded to ≤64 representative evidence hashes by the engine.
252    pub evidence: Vec<String>,
253    pub evidence_query: Option<String>,
254    pub metric: Option<MetricSnapshot>,
255    pub confidence: f64,
256    pub importance: f64,
257    /// Rule E1 pin for `code_revision` drafts (see [`validate_code_rules`]).
258    pub evalset_hash: Option<String>,
259}
260
261impl RecDraft {
262    pub fn new(
263        target_ref: impl Into<String>,
264        action_kind: ActionKind,
265        summary: Summary,
266        proposal: Proposal,
267    ) -> Self {
268        RecDraft {
269            target_ref: target_ref.into(),
270            action_kind,
271            summary,
272            severity: Severity::Low,
273            proposal,
274            evidence: Vec::new(),
275            evidence_query: None,
276            metric: None,
277            confidence: 0.8,
278            importance: 0.5,
279            evalset_hash: None,
280        }
281    }
282
283    pub fn severity(mut self, s: Severity) -> Self {
284        self.severity = s;
285        self
286    }
287    pub fn evidence(mut self, hashes: Vec<String>) -> Self {
288        self.evidence = hashes;
289        self
290    }
291    pub fn evidence_query(mut self, q: impl Into<String>) -> Self {
292        self.evidence_query = Some(q.into());
293        self
294    }
295    pub fn metric(mut self, m: MetricSnapshot) -> Self {
296        self.metric = Some(m);
297        self
298    }
299    pub fn confidence(mut self, c: f64) -> Self {
300        self.confidence = c;
301        self
302    }
303    pub fn importance(mut self, i: f64) -> Self {
304        self.importance = i;
305        self
306    }
307    pub fn evalset_hash(mut self, h: impl Into<String>) -> Self {
308        self.evalset_hash = Some(h.into());
309        self
310    }
311}
312
313/// Maximum representative evidence hashes carried inline (proposal §7.1).
314pub const MAX_EVIDENCE: usize = 64;
315
316/// Compute the dedup key: `family ⟂ target_ref ⟂ action_kind`, case-folded.
317/// Excludes proposal content and evidence by construction.
318pub fn dedup_key(family: &str, target_ref: &str, action: ActionKind) -> String {
319    // U+001F (unit separator) cannot appear in any of the inputs.
320    format!(
321        "{}\u{1f}{}\u{1f}{}",
322        normalize_ident(family),
323        normalize_ident(target_ref),
324        action.as_str()
325    )
326}
327
328/// Lifecycle status — a rebuildable index-layer cache (the recommendation's
329/// content hash is stable for its whole life).
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332pub enum RecStatus {
333    #[default]
334    Pending,
335    Approved,
336    Rejected,
337    Applied,
338    RolledBack,
339    Expired,
340}
341
342impl RecStatus {
343    pub fn as_str(&self) -> &'static str {
344        match self {
345            RecStatus::Pending => "pending",
346            RecStatus::Approved => "approved",
347            RecStatus::Rejected => "rejected",
348            RecStatus::Applied => "applied",
349            RecStatus::RolledBack => "rolled_back",
350            RecStatus::Expired => "expired",
351        }
352    }
353
354    /// Is a transition to `to` allowed from this state? `by_policy` marks the
355    /// auto-apply actor, the only one permitted the reasonless
356    /// `pending → applied` jump.
357    pub fn can_transition_to(&self, to: RecStatus, by_policy: bool) -> bool {
358        use RecStatus::*;
359        match (self, to) {
360            (Pending, Approved) | (Pending, Rejected) => true,
361            (Pending, Applied) => by_policy, // auto-apply only
362            (Approved, Applied) => true,
363            (Applied, RolledBack) => true,
364            // `expired` is computed from valid_to, applied to still-open recs.
365            (Pending, Expired) | (Approved, Expired) => true,
366            _ => false,
367        }
368    }
369}
370
371/// Who/what performed a transition.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "lowercase")]
374pub enum ObserverType {
375    Human,
376    Agent,
377    Policy,
378    System,
379}
380
381/// One immutable audit Observation per transition, hash-chained per
382/// recommendation.
383#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
384pub struct AuditRecord {
385    pub rec_hash: String,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub from: Option<RecStatus>,
388    pub to: RecStatus,
389    /// Host-asserted actor label, e.g. `user:alice`, `agent:worker-3`,
390    /// `policy:auto`.
391    pub actor: String,
392    pub observer_type: ObserverType,
393    /// Mandatory written reason (≤500 chars), the review statement's BECAUSE.
394    pub because: String,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub previous_audit_hash: Option<String>,
397    /// §7.4: present exactly when this transition applied a code revision —
398    /// the evalset-run edge, recorded where the forensics look.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub gating: Option<GatingEvidence>,
401    pub at_ms: i64,
402}
403
404/// Maximum length of a BECAUSE reason.
405pub const MAX_BECAUSE: usize = 500;
406
407impl AuditRecord {
408    /// Build the Observation grain that records this transition. `derived_from`
409    /// chains `[rec_hash, previous_audit_hash]` per the lifecycle spec.
410    pub fn to_grain_spec(&self, namespace: &str) -> GrainSpec {
411        let mut derived_from = vec![Value::from(self.rec_hash.clone())];
412        if let Some(prev) = &self.previous_audit_hash {
413            derived_from.push(Value::from(prev.clone()));
414        }
415        let mut spec = GrainSpec::new(crate::model::grain_type::OBSERVATION, namespace)
416            .with_field("observation_kind", "loop_audit")
417            .with_field("rec_hash", self.rec_hash.clone())
418            .with_field("to_status", self.to.as_str())
419            .with_field("actor", self.actor.clone())
420            .with_field(
421                "observer_type",
422                serde_json::to_value(self.observer_type).unwrap(),
423            )
424            .with_field("because", self.because.clone())
425            .with_field("at_ms", self.at_ms)
426            .with_field("derived_from", Value::Array(derived_from));
427        if let Some(from) = self.from {
428            spec.fields
429                .insert("from_status".into(), Value::from(from.as_str()));
430        }
431        if let Some(g) = &self.gating {
432            spec.fields
433                .insert("gating_evalset".into(), Value::from(g.evalset_hash.clone()));
434            spec.fields
435                .insert("gating_run_id".into(), Value::from(g.run_id.clone()));
436            spec.fields.insert("gating_passed".into(), Value::from(g.passed));
437            spec.fields.insert("gating_failed".into(), Value::from(g.failed));
438        }
439        spec
440    }
441}
442
443/// A stored recommendation. `hash` (the content address) and `status` (the
444/// index-layer cache) are set by the engine, not serialized into the grain
445/// body — the body is immutable content, the lifecycle lives in the state
446/// index and the audit chain.
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448pub struct Recommendation {
449    #[serde(skip)]
450    pub hash: String,
451    pub analyzer: String,
452    pub params_snapshot: Map<String, Value>,
453    pub origin: Origin,
454    pub target_ref: String,
455    pub action_kind: ActionKind,
456    pub dedup_key: String,
457    pub summary: Summary,
458    pub severity: Severity,
459    #[serde(flatten)]
460    pub proposal: Proposal,
461    pub destructive: bool,
462    pub rollbackable: bool,
463    #[serde(default)]
464    pub evidence: Vec<String>,
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub evidence_query: Option<String>,
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub metric: Option<MetricSnapshot>,
469    pub confidence: f64,
470    pub importance: f64,
471    pub created_at_ms: i64,
472    /// Optional LLM guidance: an ENRICH note on a deterministic recommendation,
473    /// or an `origin = llm` draft's own rationale (§9). Whitelisted, capped
474    /// text — it never replaces the engine-templated `summary`.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub guidance: Option<String>,
477    /// Rule E1's pin (§7.4): the evalset hash a `code_revision` was gated
478    /// against — REQUIRED for code targets, forbidden elsewhere (a
479    /// recommendation targets code XOR an evalset, never both, and only
480    /// code carries the pin). Shown at review; checked live at apply
481    /// (superseded evalset ⇒ the pin is stale ⇒ re-gate).
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub evalset_hash: Option<String>,
484    #[serde(skip)]
485    pub status: RecStatus,
486}
487
488/// The recorded evalset-run edge (§7.4): what an apply of a code revision
489/// must present, and what its audit Observation carries. "Trust me, it
490/// passed" is not an edge; (evalset, run, stats) is.
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
492pub struct GatingEvidence {
493    /// The evalset the gate ran — must equal the recommendation's pin.
494    pub evalset_hash: String,
495    /// The `areev run`/`areev eval` run id that executed the gate.
496    pub run_id: String,
497    pub passed: u64,
498    pub failed: u64,
499}
500
501/// Rule E1's structural checks (§7.4), applied when a draft is stamped and
502/// re-checked when a stored grain is loaded (a hand-authored grain must not
503/// bypass the rule):
504/// - `code_revision` ⇔ a `tool:` target, and it MUST pin an evalset hash.
505/// - `adapter_revision` ⇔ a `model:` target, same mandatory pin (the tuning
506///   seam inherits §7.4's gate wholesale).
507/// - An `evalset:` target is its own always-human-approved class: never a
508///   gated revision, and never itself pinned (the gate cannot gate itself).
509/// - No other target class carries a pin.
510pub fn validate_code_rules(
511    action: ActionKind,
512    target_class: &str,
513    evalset_hash: Option<&str>,
514) -> Result<()> {
515    let e1 = |why: &str| Err(Error::InvalidRecommendation(format!("Rule E1: {why}")));
516    match (action, target_class) {
517        (ActionKind::CodeRevision, "code") => {
518            if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
519                return e1(
520                    "a code_revision must pin the evalset hash it was gated \
521                     against (evalset_hash)",
522                );
523            }
524        }
525        (ActionKind::CodeRevision, other) => {
526            return e1(&format!(
527                "code_revision requires a tool: target, got class '{other}'"
528            ));
529        }
530        (ActionKind::AdapterRevision, "model") => {
531            if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
532                return e1(
533                    "an adapter_revision must pin the evalset hash it was \
534                     gated against (evalset_hash)",
535                );
536            }
537        }
538        (ActionKind::AdapterRevision, other) => {
539            return e1(&format!(
540                "adapter_revision requires a model: target, got class '{other}'"
541            ));
542        }
543        // A REVERT of an applied code revision targets the same tool: —
544        // it is the rollback inverse, not new code, so it needs no pin
545        // (blocking it here would leave the highest-stakes target class
546        // unable to propose its own measured rollback).
547        (ActionKind::Revert, "code") => {
548            if evalset_hash.is_some() {
549                return e1("a code revert carries no evalset pin");
550            }
551        }
552        (_, "code") => {
553            return e1("a tool: target requires action_kind code_revision");
554        }
555        // Same rollback-inverse escape hatch for the adapter class.
556        (ActionKind::Revert, "model") => {
557            if evalset_hash.is_some() {
558                return e1("an adapter revert carries no evalset pin");
559            }
560        }
561        (_, "model") => {
562            return e1("a model: target requires action_kind adapter_revision");
563        }
564        (_, "evalset") => {
565            if evalset_hash.is_some() {
566                return e1(
567                    "an evalset-target recommendation cannot pin an evalset — \
568                     the gate cannot gate itself",
569                );
570            }
571        }
572        _ => {
573            if evalset_hash.is_some() {
574                return e1(
575                    "evalset_hash is only valid on code_revision or \
576                     adapter_revision recommendations",
577                );
578            }
579        }
580    }
581    Ok(())
582}
583
584impl Recommendation {
585    /// Serialize the immutable body into grain fields (excludes hash/status).
586    pub fn to_grain_spec(&self, namespace: &str) -> Result<GrainSpec> {
587        let value = serde_json::to_value(self)
588            .map_err(|e| Error::Internal(format!("serialize recommendation: {e}")))?;
589        let obj = value
590            .as_object()
591            .ok_or_else(|| Error::Internal("recommendation did not serialize to object".into()))?
592            .clone();
593        Ok(GrainSpec {
594            grain_type: crate::model::grain_type::RECOMMENDATION.to_string(),
595            namespace: namespace.to_string(),
596            fields: obj,
597        })
598    }
599
600    /// Reconstruct from a stored grain body. `hash` comes from the record's
601    /// address; `status` is supplied from the state index by the caller.
602    /// Rule E1 is RE-CHECKED here — a hand-authored recommendation grain
603    /// must not smuggle an unpinned code revision past the stamped path.
604    pub fn from_fields(hash: &str, fields: &Map<String, Value>) -> Result<Self> {
605        let mut rec: Recommendation = serde_json::from_value(Value::Object(fields.clone()))
606            .map_err(|e| Error::InvalidRecommendation(format!("decode {hash}: {e}")))?;
607        rec.hash = hash.to_string();
608        let class = crate::model::TargetRef::parse(&rec.target_ref)
609            .map(|t| t.target_class())
610            .unwrap_or("host");
611        validate_code_rules(rec.action_kind, class, rec.evalset_hash.as_deref())?;
612        Ok(rec)
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use serde_json::json;
620
621    #[test]
622    fn summary_renders_deterministically() {
623        let mut args = Map::new();
624        args.insert("count".into(), json!(3));
625        args.insert("subject".into(), json!("acme"));
626        let s = Summary::new("duplicate.exact", args);
627        assert_eq!(
628            s.render(),
629            "Consolidate 3 exact-duplicate grains for \"acme\""
630        );
631    }
632
633    #[test]
634    fn dedup_key_ignores_content_and_case() {
635        let a = dedup_key(
636            "loop.duplicate_sweep",
637            "entity:NS/John",
638            ActionKind::Consolidate,
639        );
640        let b = dedup_key(
641            "loop.duplicate_sweep",
642            "entity:ns/john",
643            ActionKind::Consolidate,
644        );
645        assert_eq!(a, b, "case-folded to one identity");
646    }
647
648    #[test]
649    fn dedup_key_distinguishes_action() {
650        let a = dedup_key("f", "entity:ns/x", ActionKind::Consolidate);
651        let b = dedup_key("f", "entity:ns/x", ActionKind::FlagContradiction);
652        assert_ne!(a, b);
653    }
654
655    #[test]
656    fn lifecycle_gates_pending_to_applied() {
657        assert!(!RecStatus::Pending.can_transition_to(RecStatus::Applied, false));
658        assert!(RecStatus::Pending.can_transition_to(RecStatus::Applied, true)); // policy
659        assert!(RecStatus::Pending.can_transition_to(RecStatus::Approved, false));
660        assert!(RecStatus::Approved.can_transition_to(RecStatus::Applied, false));
661        assert!(RecStatus::Applied.can_transition_to(RecStatus::RolledBack, false));
662        assert!(!RecStatus::Rejected.can_transition_to(RecStatus::Applied, true));
663    }
664
665    #[test]
666    fn rule_e1_pins_code_and_only_code() {
667        use crate::model::ActionKind as A;
668        // code_revision on a tool target with a pin: OK.
669        assert!(validate_code_rules(A::CodeRevision, "code", Some("abc")).is_ok());
670        // Unpinned code revision: refused.
671        assert!(validate_code_rules(A::CodeRevision, "code", None).is_err());
672        assert!(validate_code_rules(A::CodeRevision, "code", Some("  ")).is_err());
673        // code_revision off a tool target: refused.
674        assert!(validate_code_rules(A::CodeRevision, "memory", Some("abc")).is_err());
675        // A tool target with a non-code action: refused.
676        assert!(validate_code_rules(A::Flag, "code", None).is_err());
677        // The gate cannot gate itself.
678        assert!(validate_code_rules(A::Flag, "evalset", Some("abc")).is_err());
679        assert!(validate_code_rules(A::Flag, "evalset", None).is_ok());
680        // Pins don't leak onto ordinary targets.
681        assert!(validate_code_rules(A::Consolidate, "memory", Some("abc")).is_err());
682        assert!(validate_code_rules(A::Consolidate, "memory", None).is_ok());
683    }
684
685    #[test]
686    fn rule_e1_pins_adapter_revisions_like_code() {
687        use crate::model::ActionKind as A;
688        // adapter_revision on a model target with a pin: OK.
689        assert!(validate_code_rules(A::AdapterRevision, "model", Some("abc")).is_ok());
690        // Unpinned adapter revision: refused.
691        assert!(validate_code_rules(A::AdapterRevision, "model", None).is_err());
692        assert!(validate_code_rules(A::AdapterRevision, "model", Some("  ")).is_err());
693        // adapter_revision off a model target: refused.
694        assert!(validate_code_rules(A::AdapterRevision, "memory", Some("abc")).is_err());
695        assert!(validate_code_rules(A::AdapterRevision, "code", Some("abc")).is_err());
696        // A model target with a non-adapter action: refused.
697        assert!(validate_code_rules(A::Flag, "model", None).is_err());
698        // The rollback inverse needs no pin — and must carry none.
699        assert!(validate_code_rules(A::Revert, "model", None).is_ok());
700        assert!(validate_code_rules(A::Revert, "model", Some("abc")).is_err());
701    }
702
703    #[test]
704    fn from_fields_rechecks_rule_e1() {
705        // A hand-authored grain body carrying an unpinned code revision must
706        // not decode into a usable recommendation.
707        let mut rec = Recommendation {
708            hash: String::new(),
709            analyzer: "loop.codegen/1".into(),
710            params_snapshot: Map::new(),
711            origin: Origin::Builtin,
712            target_ref: "tool:abc123".into(),
713            action_kind: ActionKind::CodeRevision,
714            dedup_key: "k".into(),
715            summary: Summary::new("command.finding", Map::new()),
716            severity: Severity::Low,
717            proposal: Proposal::Data { data: Map::new() },
718            destructive: false,
719            rollbackable: false,
720            evidence: vec![],
721            evidence_query: None,
722            metric: None,
723            confidence: 0.5,
724            importance: 0.5,
725            created_at_ms: 0,
726            guidance: None,
727            evalset_hash: None, // the smuggle attempt
728            status: RecStatus::Pending,
729        };
730        let spec = rec.to_grain_spec("ns").unwrap();
731        assert!(Recommendation::from_fields("h", &spec.fields).is_err());
732        rec.evalset_hash = Some("es-hash".into());
733        let spec = rec.to_grain_spec("ns").unwrap();
734        let back = Recommendation::from_fields("h", &spec.fields).unwrap();
735        assert_eq!(back.evalset_hash.as_deref(), Some("es-hash"));
736    }
737
738    #[test]
739    fn recommendation_round_trips_through_fields() {
740        let rec = Recommendation {
741            hash: "ignored".into(),
742            analyzer: "loop.staleness/1".into(),
743            params_snapshot: Map::new(),
744            origin: Origin::Builtin,
745            target_ref: "grain:sha256:abc".into(),
746            action_kind: ActionKind::Expire,
747            dedup_key: "k".into(),
748            summary: Summary::new("staleness.expired", Map::new()),
749            severity: Severity::Low,
750            proposal: Proposal::Cal {
751                cal: "FORGET sha256:abc".into(),
752            },
753            destructive: true,
754            rollbackable: false,
755            evidence: vec!["sha256:abc".into()],
756            evidence_query: None,
757            metric: None,
758            confidence: 0.9,
759            importance: 0.4,
760            created_at_ms: 1000,
761            guidance: None,
762            evalset_hash: None,
763            status: RecStatus::Pending,
764        };
765        let spec = rec.to_grain_spec("ns").unwrap();
766        // hash and status are excluded from the immutable body.
767        assert!(!spec.fields.contains_key("hash"));
768        assert!(!spec.fields.contains_key("status"));
769        let back = Recommendation::from_fields("realhash", &spec.fields).unwrap();
770        assert_eq!(back.hash, "realhash");
771        assert_eq!(back.analyzer, "loop.staleness/1");
772        assert!(back.destructive);
773        assert!(matches!(back.proposal, Proposal::Cal { .. }));
774    }
775}