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        // origin=llm drafts carry free text (clearly marked llm) rather than a
86        // deterministic template — the model's proposed summary rides in {text}.
87        "llm.discover" => "{text}",
88        // External command analyzers (trust class Command): free text from the
89        // subprocess, rendered as-is (the trust badge, not the prose, marks it).
90        "command.finding" => "{text}",
91        _ => return None,
92    })
93}
94
95fn interpolate(template: &str, args: &Map<String, Value>, template_id: &str) -> String {
96    let mut out = String::with_capacity(template.len());
97    let mut chars = template.chars().peekable();
98    while let Some(c) = chars.next() {
99        if c == '{' {
100            let mut key = String::new();
101            for k in chars.by_ref() {
102                if k == '}' {
103                    break;
104                }
105                key.push(k);
106            }
107            if key == "template_id" {
108                out.push_str(template_id);
109            } else {
110                match args.get(&key) {
111                    Some(Value::String(s)) => out.push_str(s),
112                    Some(v) => out.push_str(&v.to_string()),
113                    None => {
114                        // Missing arg — leave a visible marker rather than lie.
115                        out.push('{');
116                        out.push_str(&key);
117                        out.push('}');
118                    }
119                }
120            }
121        } else {
122            out.push(c);
123        }
124    }
125    out
126}
127
128/// A reproducible metric snapshot; powers outcome review.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct MetricSnapshot {
131    /// Metric kind — the engine knows how to re-measure a fixed set
132    /// (e.g. `tool_error_recurrence`); unknown kinds are skipped, not faked.
133    pub metric: String,
134    pub baseline: f64,
135    pub unit: String,
136    pub n: u64,
137    pub window: String,
138    /// The subject the metric is about (e.g. the tool name), used by the
139    /// engine's typed re-measurement.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub subject: Option<String>,
142    /// Namespace scope for the re-measurement, normalized (case-fold/trim).
143    /// `None` = all namespaces. Additive: older snapshots deserialize to
144    /// `None` and existing metric kinds ignore it.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub namespace: Option<String>,
147    /// Relation scope for fact-shaped metrics (e.g. which functional relation
148    /// a contradiction was resolved under), normalized. Additive like
149    /// `namespace`.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub relation: Option<String>,
152    /// CAL that recomputes the metric at verify time (reproducibility /
153    /// documentation; the engine re-measures with typed reads).
154    pub query: String,
155    /// How long after apply to re-measure, in epoch-ms delta (the first / only
156    /// checkpoint when `horizons_ms` is empty).
157    pub review_after_ms: i64,
158    /// A schedule of checkpoints (ms after apply) to re-measure at. An outcome
159    /// that `held` at an early checkpoint can `regress` at a later one, so a
160    /// verdict is never final until the last horizon. Empty → `[review_after_ms]`.
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub horizons_ms: Vec<i64>,
163}
164
165impl MetricSnapshot {
166    /// The measurement schedule, sorted — `horizons_ms` if set, else the single
167    /// `review_after_ms`.
168    pub fn horizons(&self) -> Vec<i64> {
169        let mut h = if self.horizons_ms.is_empty() {
170            vec![self.review_after_ms]
171        } else {
172            self.horizons_ms.clone()
173        };
174        h.sort_unstable();
175        h
176    }
177}
178
179/// A measured outcome for an applied recommendation at one checkpoint — the
180/// Verify gate's output. `held` = the metric did not regress at this horizon;
181/// `regressed` = it got worse (a revert is proposed). A recommendation
182/// accumulates one of these per horizon, forming a time series.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct OutcomeResult {
185    pub rec_hash: String,
186    pub metric: String,
187    pub baseline: f64,
188    pub current: f64,
189    pub verdict: String,
190    /// Which checkpoint this measurement is for (ms after apply).
191    #[serde(default)]
192    pub horizon_ms: i64,
193    pub measured_at_ms: i64,
194}
195
196/// The proposed change. Exactly one variant per recommendation.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(tag = "proposal", rename_all = "snake_case")]
199pub enum Proposal {
200    /// A batch of CAL Tier-1 evolve writes (ADD/SUPERSEDE), MAY contain FORGET.
201    Cal { cal: String },
202    /// A doc-target edit: `{format, base_digest, diff}` (base_digest enables a
203    /// staleness check at apply).
204    Edit {
205        format: String,
206        base_digest: String,
207        diff: String,
208    },
209    /// An opaque map for host targets (applied by the host, §12.3).
210    Data { data: Map<String, Value> },
211}
212
213/// What an analyzer emits. `dedup_key`, `origin`, and the params snapshot are
214/// **not** here — the engine stamps them. Non-exhaustive so the engine can add
215/// fields without breaking analyzers.
216#[derive(Debug, Clone, PartialEq)]
217#[non_exhaustive]
218pub struct RecDraft {
219    pub target_ref: String,
220    pub action_kind: ActionKind,
221    pub summary: Summary,
222    pub severity: Severity,
223    pub proposal: Proposal,
224    /// Bounded to ≤64 representative evidence hashes by the engine.
225    pub evidence: Vec<String>,
226    pub evidence_query: Option<String>,
227    pub metric: Option<MetricSnapshot>,
228    pub confidence: f64,
229    pub importance: f64,
230    /// Rule E1 pin for `code_revision` drafts (see [`validate_code_rules`]).
231    pub evalset_hash: Option<String>,
232}
233
234impl RecDraft {
235    pub fn new(
236        target_ref: impl Into<String>,
237        action_kind: ActionKind,
238        summary: Summary,
239        proposal: Proposal,
240    ) -> Self {
241        RecDraft {
242            target_ref: target_ref.into(),
243            action_kind,
244            summary,
245            severity: Severity::Low,
246            proposal,
247            evidence: Vec::new(),
248            evidence_query: None,
249            metric: None,
250            confidence: 0.8,
251            importance: 0.5,
252            evalset_hash: None,
253        }
254    }
255
256    pub fn severity(mut self, s: Severity) -> Self {
257        self.severity = s;
258        self
259    }
260    pub fn evidence(mut self, hashes: Vec<String>) -> Self {
261        self.evidence = hashes;
262        self
263    }
264    pub fn evidence_query(mut self, q: impl Into<String>) -> Self {
265        self.evidence_query = Some(q.into());
266        self
267    }
268    pub fn metric(mut self, m: MetricSnapshot) -> Self {
269        self.metric = Some(m);
270        self
271    }
272    pub fn confidence(mut self, c: f64) -> Self {
273        self.confidence = c;
274        self
275    }
276    pub fn importance(mut self, i: f64) -> Self {
277        self.importance = i;
278        self
279    }
280    pub fn evalset_hash(mut self, h: impl Into<String>) -> Self {
281        self.evalset_hash = Some(h.into());
282        self
283    }
284}
285
286/// Maximum representative evidence hashes carried inline (proposal §7.1).
287pub const MAX_EVIDENCE: usize = 64;
288
289/// Compute the dedup key: `family ⟂ target_ref ⟂ action_kind`, case-folded.
290/// Excludes proposal content and evidence by construction.
291pub fn dedup_key(family: &str, target_ref: &str, action: ActionKind) -> String {
292    // U+001F (unit separator) cannot appear in any of the inputs.
293    format!(
294        "{}\u{1f}{}\u{1f}{}",
295        normalize_ident(family),
296        normalize_ident(target_ref),
297        action.as_str()
298    )
299}
300
301/// Lifecycle status — a rebuildable index-layer cache (the recommendation's
302/// content hash is stable for its whole life).
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum RecStatus {
306    #[default]
307    Pending,
308    Approved,
309    Rejected,
310    Applied,
311    RolledBack,
312    Expired,
313}
314
315impl RecStatus {
316    pub fn as_str(&self) -> &'static str {
317        match self {
318            RecStatus::Pending => "pending",
319            RecStatus::Approved => "approved",
320            RecStatus::Rejected => "rejected",
321            RecStatus::Applied => "applied",
322            RecStatus::RolledBack => "rolled_back",
323            RecStatus::Expired => "expired",
324        }
325    }
326
327    /// Is a transition to `to` allowed from this state? `by_policy` marks the
328    /// auto-apply actor, the only one permitted the reasonless
329    /// `pending → applied` jump.
330    pub fn can_transition_to(&self, to: RecStatus, by_policy: bool) -> bool {
331        use RecStatus::*;
332        match (self, to) {
333            (Pending, Approved) | (Pending, Rejected) => true,
334            (Pending, Applied) => by_policy, // auto-apply only
335            (Approved, Applied) => true,
336            (Applied, RolledBack) => true,
337            // `expired` is computed from valid_to, applied to still-open recs.
338            (Pending, Expired) | (Approved, Expired) => true,
339            _ => false,
340        }
341    }
342}
343
344/// Who/what performed a transition.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "lowercase")]
347pub enum ObserverType {
348    Human,
349    Agent,
350    Policy,
351    System,
352}
353
354/// One immutable audit Observation per transition, hash-chained per
355/// recommendation.
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357pub struct AuditRecord {
358    pub rec_hash: String,
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub from: Option<RecStatus>,
361    pub to: RecStatus,
362    /// Host-asserted actor label, e.g. `user:alice`, `agent:worker-3`,
363    /// `policy:auto`.
364    pub actor: String,
365    pub observer_type: ObserverType,
366    /// Mandatory written reason (≤500 chars), the review statement's BECAUSE.
367    pub because: String,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub previous_audit_hash: Option<String>,
370    /// §7.4: present exactly when this transition applied a code revision —
371    /// the evalset-run edge, recorded where the forensics look.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub gating: Option<GatingEvidence>,
374    pub at_ms: i64,
375}
376
377/// Maximum length of a BECAUSE reason.
378pub const MAX_BECAUSE: usize = 500;
379
380impl AuditRecord {
381    /// Build the Observation grain that records this transition. `derived_from`
382    /// chains `[rec_hash, previous_audit_hash]` per the lifecycle spec.
383    pub fn to_grain_spec(&self, namespace: &str) -> GrainSpec {
384        let mut derived_from = vec![Value::from(self.rec_hash.clone())];
385        if let Some(prev) = &self.previous_audit_hash {
386            derived_from.push(Value::from(prev.clone()));
387        }
388        let mut spec = GrainSpec::new(crate::model::grain_type::OBSERVATION, namespace)
389            .with_field("observation_kind", "loop_audit")
390            .with_field("rec_hash", self.rec_hash.clone())
391            .with_field("to_status", self.to.as_str())
392            .with_field("actor", self.actor.clone())
393            .with_field(
394                "observer_type",
395                serde_json::to_value(self.observer_type).unwrap(),
396            )
397            .with_field("because", self.because.clone())
398            .with_field("at_ms", self.at_ms)
399            .with_field("derived_from", Value::Array(derived_from));
400        if let Some(from) = self.from {
401            spec.fields
402                .insert("from_status".into(), Value::from(from.as_str()));
403        }
404        if let Some(g) = &self.gating {
405            spec.fields
406                .insert("gating_evalset".into(), Value::from(g.evalset_hash.clone()));
407            spec.fields
408                .insert("gating_run_id".into(), Value::from(g.run_id.clone()));
409            spec.fields.insert("gating_passed".into(), Value::from(g.passed));
410            spec.fields.insert("gating_failed".into(), Value::from(g.failed));
411        }
412        spec
413    }
414}
415
416/// A stored recommendation. `hash` (the content address) and `status` (the
417/// index-layer cache) are set by the engine, not serialized into the grain
418/// body — the body is immutable content, the lifecycle lives in the state
419/// index and the audit chain.
420#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
421pub struct Recommendation {
422    #[serde(skip)]
423    pub hash: String,
424    pub analyzer: String,
425    pub params_snapshot: Map<String, Value>,
426    pub origin: Origin,
427    pub target_ref: String,
428    pub action_kind: ActionKind,
429    pub dedup_key: String,
430    pub summary: Summary,
431    pub severity: Severity,
432    #[serde(flatten)]
433    pub proposal: Proposal,
434    pub destructive: bool,
435    pub rollbackable: bool,
436    #[serde(default)]
437    pub evidence: Vec<String>,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub evidence_query: Option<String>,
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub metric: Option<MetricSnapshot>,
442    pub confidence: f64,
443    pub importance: f64,
444    pub created_at_ms: i64,
445    /// Optional LLM guidance: an ENRICH note on a deterministic recommendation,
446    /// or an `origin = llm` draft's own rationale (§9). Whitelisted, capped
447    /// text — it never replaces the engine-templated `summary`.
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub guidance: Option<String>,
450    /// Rule E1's pin (§7.4): the evalset hash a `code_revision` was gated
451    /// against — REQUIRED for code targets, forbidden elsewhere (a
452    /// recommendation targets code XOR an evalset, never both, and only
453    /// code carries the pin). Shown at review; checked live at apply
454    /// (superseded evalset ⇒ the pin is stale ⇒ re-gate).
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub evalset_hash: Option<String>,
457    #[serde(skip)]
458    pub status: RecStatus,
459}
460
461/// The recorded evalset-run edge (§7.4): what an apply of a code revision
462/// must present, and what its audit Observation carries. "Trust me, it
463/// passed" is not an edge; (evalset, run, stats) is.
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
465pub struct GatingEvidence {
466    /// The evalset the gate ran — must equal the recommendation's pin.
467    pub evalset_hash: String,
468    /// The `areev run`/`areev eval` run id that executed the gate.
469    pub run_id: String,
470    pub passed: u64,
471    pub failed: u64,
472}
473
474/// Rule E1's structural checks (§7.4), applied when a draft is stamped and
475/// re-checked when a stored grain is loaded (a hand-authored grain must not
476/// bypass the rule):
477/// - `code_revision` ⇔ a `tool:` target, and it MUST pin an evalset hash.
478/// - An `evalset:` target is its own always-human-approved class: never a
479///   `code_revision`, and never itself pinned (the gate cannot gate itself).
480/// - No other target class carries a pin.
481pub fn validate_code_rules(
482    action: ActionKind,
483    target_class: &str,
484    evalset_hash: Option<&str>,
485) -> Result<()> {
486    let e1 = |why: &str| Err(Error::InvalidRecommendation(format!("Rule E1: {why}")));
487    match (action, target_class) {
488        (ActionKind::CodeRevision, "code") => {
489            if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
490                return e1(
491                    "a code_revision must pin the evalset hash it was gated \
492                     against (evalset_hash)",
493                );
494            }
495        }
496        (ActionKind::CodeRevision, other) => {
497            return e1(&format!(
498                "code_revision requires a tool: target, got class '{other}'"
499            ));
500        }
501        // A REVERT of an applied code revision targets the same tool: —
502        // it is the rollback inverse, not new code, so it needs no pin
503        // (blocking it here would leave the highest-stakes target class
504        // unable to propose its own measured rollback).
505        (ActionKind::Revert, "code") => {
506            if evalset_hash.is_some() {
507                return e1("a code revert carries no evalset pin");
508            }
509        }
510        (_, "code") => {
511            return e1("a tool: target requires action_kind code_revision");
512        }
513        (_, "evalset") => {
514            if evalset_hash.is_some() {
515                return e1(
516                    "an evalset-target recommendation cannot pin an evalset — \
517                     the gate cannot gate itself",
518                );
519            }
520        }
521        _ => {
522            if evalset_hash.is_some() {
523                return e1("evalset_hash is only valid on code_revision recommendations");
524            }
525        }
526    }
527    Ok(())
528}
529
530impl Recommendation {
531    /// Serialize the immutable body into grain fields (excludes hash/status).
532    pub fn to_grain_spec(&self, namespace: &str) -> Result<GrainSpec> {
533        let value = serde_json::to_value(self)
534            .map_err(|e| Error::Internal(format!("serialize recommendation: {e}")))?;
535        let obj = value
536            .as_object()
537            .ok_or_else(|| Error::Internal("recommendation did not serialize to object".into()))?
538            .clone();
539        Ok(GrainSpec {
540            grain_type: crate::model::grain_type::RECOMMENDATION.to_string(),
541            namespace: namespace.to_string(),
542            fields: obj,
543        })
544    }
545
546    /// Reconstruct from a stored grain body. `hash` comes from the record's
547    /// address; `status` is supplied from the state index by the caller.
548    /// Rule E1 is RE-CHECKED here — a hand-authored recommendation grain
549    /// must not smuggle an unpinned code revision past the stamped path.
550    pub fn from_fields(hash: &str, fields: &Map<String, Value>) -> Result<Self> {
551        let mut rec: Recommendation = serde_json::from_value(Value::Object(fields.clone()))
552            .map_err(|e| Error::InvalidRecommendation(format!("decode {hash}: {e}")))?;
553        rec.hash = hash.to_string();
554        let class = crate::model::TargetRef::parse(&rec.target_ref)
555            .map(|t| t.target_class())
556            .unwrap_or("host");
557        validate_code_rules(rec.action_kind, class, rec.evalset_hash.as_deref())?;
558        Ok(rec)
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use serde_json::json;
566
567    #[test]
568    fn summary_renders_deterministically() {
569        let mut args = Map::new();
570        args.insert("count".into(), json!(3));
571        args.insert("subject".into(), json!("acme"));
572        let s = Summary::new("duplicate.exact", args);
573        assert_eq!(
574            s.render(),
575            "Consolidate 3 exact-duplicate grains for \"acme\""
576        );
577    }
578
579    #[test]
580    fn dedup_key_ignores_content_and_case() {
581        let a = dedup_key(
582            "loop.duplicate_sweep",
583            "entity:NS/John",
584            ActionKind::Consolidate,
585        );
586        let b = dedup_key(
587            "loop.duplicate_sweep",
588            "entity:ns/john",
589            ActionKind::Consolidate,
590        );
591        assert_eq!(a, b, "case-folded to one identity");
592    }
593
594    #[test]
595    fn dedup_key_distinguishes_action() {
596        let a = dedup_key("f", "entity:ns/x", ActionKind::Consolidate);
597        let b = dedup_key("f", "entity:ns/x", ActionKind::FlagContradiction);
598        assert_ne!(a, b);
599    }
600
601    #[test]
602    fn lifecycle_gates_pending_to_applied() {
603        assert!(!RecStatus::Pending.can_transition_to(RecStatus::Applied, false));
604        assert!(RecStatus::Pending.can_transition_to(RecStatus::Applied, true)); // policy
605        assert!(RecStatus::Pending.can_transition_to(RecStatus::Approved, false));
606        assert!(RecStatus::Approved.can_transition_to(RecStatus::Applied, false));
607        assert!(RecStatus::Applied.can_transition_to(RecStatus::RolledBack, false));
608        assert!(!RecStatus::Rejected.can_transition_to(RecStatus::Applied, true));
609    }
610
611    #[test]
612    fn rule_e1_pins_code_and_only_code() {
613        use crate::model::ActionKind as A;
614        // code_revision on a tool target with a pin: OK.
615        assert!(validate_code_rules(A::CodeRevision, "code", Some("abc")).is_ok());
616        // Unpinned code revision: refused.
617        assert!(validate_code_rules(A::CodeRevision, "code", None).is_err());
618        assert!(validate_code_rules(A::CodeRevision, "code", Some("  ")).is_err());
619        // code_revision off a tool target: refused.
620        assert!(validate_code_rules(A::CodeRevision, "memory", Some("abc")).is_err());
621        // A tool target with a non-code action: refused.
622        assert!(validate_code_rules(A::Flag, "code", None).is_err());
623        // The gate cannot gate itself.
624        assert!(validate_code_rules(A::Flag, "evalset", Some("abc")).is_err());
625        assert!(validate_code_rules(A::Flag, "evalset", None).is_ok());
626        // Pins don't leak onto ordinary targets.
627        assert!(validate_code_rules(A::Consolidate, "memory", Some("abc")).is_err());
628        assert!(validate_code_rules(A::Consolidate, "memory", None).is_ok());
629    }
630
631    #[test]
632    fn from_fields_rechecks_rule_e1() {
633        // A hand-authored grain body carrying an unpinned code revision must
634        // not decode into a usable recommendation.
635        let mut rec = Recommendation {
636            hash: String::new(),
637            analyzer: "loop.codegen/1".into(),
638            params_snapshot: Map::new(),
639            origin: Origin::Builtin,
640            target_ref: "tool:abc123".into(),
641            action_kind: ActionKind::CodeRevision,
642            dedup_key: "k".into(),
643            summary: Summary::new("command.finding", Map::new()),
644            severity: Severity::Low,
645            proposal: Proposal::Data { data: Map::new() },
646            destructive: false,
647            rollbackable: false,
648            evidence: vec![],
649            evidence_query: None,
650            metric: None,
651            confidence: 0.5,
652            importance: 0.5,
653            created_at_ms: 0,
654            guidance: None,
655            evalset_hash: None, // the smuggle attempt
656            status: RecStatus::Pending,
657        };
658        let spec = rec.to_grain_spec("ns").unwrap();
659        assert!(Recommendation::from_fields("h", &spec.fields).is_err());
660        rec.evalset_hash = Some("es-hash".into());
661        let spec = rec.to_grain_spec("ns").unwrap();
662        let back = Recommendation::from_fields("h", &spec.fields).unwrap();
663        assert_eq!(back.evalset_hash.as_deref(), Some("es-hash"));
664    }
665
666    #[test]
667    fn recommendation_round_trips_through_fields() {
668        let rec = Recommendation {
669            hash: "ignored".into(),
670            analyzer: "loop.staleness/1".into(),
671            params_snapshot: Map::new(),
672            origin: Origin::Builtin,
673            target_ref: "grain:sha256:abc".into(),
674            action_kind: ActionKind::Expire,
675            dedup_key: "k".into(),
676            summary: Summary::new("staleness.expired", Map::new()),
677            severity: Severity::Low,
678            proposal: Proposal::Cal {
679                cal: "FORGET sha256:abc".into(),
680            },
681            destructive: true,
682            rollbackable: false,
683            evidence: vec!["sha256:abc".into()],
684            evidence_query: None,
685            metric: None,
686            confidence: 0.9,
687            importance: 0.4,
688            created_at_ms: 1000,
689            guidance: None,
690            evalset_hash: None,
691            status: RecStatus::Pending,
692        };
693        let spec = rec.to_grain_spec("ns").unwrap();
694        // hash and status are excluded from the immutable body.
695        assert!(!spec.fields.contains_key("hash"));
696        assert!(!spec.fields.contains_key("status"));
697        let back = Recommendation::from_fields("realhash", &spec.fields).unwrap();
698        assert_eq!(back.hash, "realhash");
699        assert_eq!(back.analyzer, "loop.staleness/1");
700        assert!(back.destructive);
701        assert!(matches!(back.proposal, Proposal::Cal { .. }));
702    }
703}