Skip to main content

meerkat_mobkit/memory/
hygienist.rs

1//! Hygienist — context curation at boundaries (§8.6).
2//!
3//! The user-visible half of "dreaming": an off-turn LLM pass that keeps a
4//! long-lived embodiment's context semantically pristine by pruning dead
5//! tool results and collapsing repeated scaffolding, while preserving
6//! decisions and their rationale. Everything applies through meerkat's
7//! audited same-session transcript revisions (`SessionServiceTranscriptEditExt::
8//! rewrite_session_transcript` — session identity unchanged, originals
9//! restorable, every commit audited), reached host-side through the
10//! [`TranscriptRevisionSeam`]. Mid-turn sessions are refused by meerkat's
11//! own `TranscriptEditRunningBehavior::Reject` default — fail-closed.
12//!
13//! The judgment (what is dead, what is scaffolding, what is a decision) is
14//! the LLM's; everything structural is deterministic validator law here:
15//! range bounds and role legality, the §8.6 quarantine hard-block (spans
16//! referenced by `Quarantined` records are untouchable until steward review
17//! completes — an attacker must not steer the Hygienist into pruning the
18//! tool output documenting the attack), active-record span flags as audit
19//! events, and the §8.4 ordering invariant (distillation for the affected
20//! window must have run first — post-compaction runs are sequenced behind
21//! the distiller's harvest through its follow-up hook; on-demand runs
22//! consult the distiller's window cursor).
23//!
24//! Curation vocabulary is deliberately narrower than a free-form rewrite:
25//! `prune_tool_results` stubs the payload of tool-result messages in place
26//! (the tool-call/result pairing the provider APIs require survives), and
27//! `collapse` replaces a contiguous run of non-tool messages with one typed
28//! system notice. Deleting arbitrary messages is not expressible — the
29//! validator, not the prompt, is what makes tool-pairing breakage
30//! impossible.
31
32use std::path::{Path, PathBuf};
33use std::sync::{Arc, Mutex};
34use std::time::Duration;
35
36use async_trait::async_trait;
37use futures::StreamExt;
38use serde::Deserialize;
39
40use meerkat_client::{LlmClient, LlmDoneOutcome, LlmError, LlmEvent, LlmRequest};
41use meerkat_core::event::AgentEvent;
42use meerkat_core::{Message, Provider, SystemNoticeKind, SystemNoticeMessage, UserMessage};
43
44use crate::identity_first::agent_memory::{compact_whitespace, truncate_utf8_boundary};
45use crate::memory::capabilities::StewardStore;
46use crate::memory::distiller::{CompactionFollowUp, DistillOutcome, DistillerEngine};
47use crate::memory::events::{MemoryEventSink, MemoryTimelineEvent};
48use crate::memory::guards::{BackgroundBudget, BackgroundBudgetConfig};
49use crate::memory::records::{ManifestTier, MemoryScope, RecordStatus};
50use crate::memory::selector::FactorySelectorHandle;
51use crate::memory::taint::MemberAgentEventSink;
52
53/// Embedded prompt bundle (crate-local copy of
54/// `memory-evals/prompts/hygienist-v0.md`; a unit test enforces byte
55/// equality so the calibration artifact and the shipped default cannot
56/// drift — same pattern as the other stages).
57pub const EMBEDDED_PROMPT_V0: &str = include_str!("hygienist_prompt_v0.md");
58
59const TRANSCRIPT_PLACEHOLDER: &str = "{{transcript}}";
60const PROTECTED_RANGES_PLACEHOLDER: &str = "{{protected_ranges}}";
61
62/// Default hygiene runs per realm per day (§8.6 boundary cadence; the
63/// concrete number is a §16-class open question, this is the conservative
64/// starting point).
65pub const DEFAULT_RUNS_PER_DAY: u32 = 2;
66/// Per-message and total byte bounds on the rendered transcript.
67const MAX_TRANSCRIPT_MESSAGE_BYTES: usize = 2 * 1024;
68const MAX_TRANSCRIPT_TOTAL_BYTES: usize = 48 * 1024;
69/// Output budget for the structured op list.
70const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 2048;
71/// Quarantine rows consulted per pass for the §8.6 hard-block.
72const SPAN_QUARANTINE_LIMIT: usize = 256;
73/// Cap on the collapse replacement note.
74const MAX_COLLAPSE_REPLACEMENT_BYTES: usize = 512;
75
76// ---------------------------------------------------------------------------
77// Errors
78// ---------------------------------------------------------------------------
79
80#[derive(Debug)]
81pub enum HygienistError {
82    Profile(String),
83    Auth(String),
84    Client(String),
85    Parse(String),
86    Seam(String),
87    Spans(String),
88}
89
90impl std::fmt::Display for HygienistError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            Self::Profile(msg) => write!(f, "hygienist profile error: {msg}"),
94            Self::Auth(msg) => write!(f, "hygienist auth error: {msg}"),
95            Self::Client(msg) => write!(f, "hygienist client error: {msg}"),
96            Self::Parse(msg) => write!(f, "hygienist parse error: {msg}"),
97            Self::Seam(msg) => write!(f, "hygienist revision seam error: {msg}"),
98            Self::Spans(msg) => write!(f, "hygienist span source error: {msg}"),
99        }
100    }
101}
102
103impl std::error::Error for HygienistError {}
104
105// ---------------------------------------------------------------------------
106// Calibration profile (§11)
107// ---------------------------------------------------------------------------
108
109#[derive(Debug, Clone, Deserialize)]
110pub struct HygienistParams {
111    #[serde(default = "default_temperature")]
112    pub temperature: f32,
113    #[serde(default = "default_max_output_tokens")]
114    pub max_output_tokens: u32,
115}
116
117fn default_temperature() -> f32 {
118    0.0
119}
120fn default_max_output_tokens() -> u32 {
121    DEFAULT_MAX_OUTPUT_TOKENS
122}
123
124impl Default for HygienistParams {
125    fn default() -> Self {
126        Self {
127            temperature: default_temperature(),
128            max_output_tokens: default_max_output_tokens(),
129        }
130    }
131}
132
133/// A loaded hygienist calibration profile (§11), prompt template resolved.
134#[derive(Debug, Clone)]
135pub struct HygienistProfile {
136    pub stage: String,
137    pub version: String,
138    pub model: String,
139    pub provider: Provider,
140    pub prompt_bundle: String,
141    pub prompt_template: String,
142    pub params: HygienistParams,
143}
144
145#[derive(Debug, Deserialize)]
146struct RawProfile {
147    stage: String,
148    version: String,
149    model: String,
150    #[serde(default)]
151    provider: Option<String>,
152    prompt_bundle: String,
153    #[serde(default)]
154    params: Option<HygienistParams>,
155}
156
157impl HygienistProfile {
158    /// The embedded default: `memory-evals/profiles/hygienist-v0.toml` with
159    /// the prompt compiled in. The model tier is a calibration decision
160    /// (§11); `hygienist.model` overrides per-deployment.
161    pub fn embedded_default() -> Self {
162        Self {
163            stage: "hygienist".to_string(),
164            version: "0".to_string(),
165            model: "claude-sonnet-4-6".to_string(),
166            provider: Provider::Anthropic,
167            prompt_bundle: "prompts/hygienist-v0.md".to_string(),
168            prompt_template: EMBEDDED_PROMPT_V0.to_string(),
169            params: HygienistParams::default(),
170        }
171    }
172
173    /// Replace the profile's model (the config-block override). Fail-loud:
174    /// the model must resolve in the catalog.
175    pub fn with_model_override(mut self, model: &str) -> Result<Self, HygienistError> {
176        let model = model.trim();
177        if model.is_empty() {
178            return Err(HygienistError::Profile(
179                "hygienist model override must not be empty".to_string(),
180            ));
181        }
182        self.provider = meerkat_models::infer_provider(model).ok_or_else(|| {
183            HygienistError::Profile(format!(
184                "hygienist model override '{model}' is not in the model catalog"
185            ))
186        })?;
187        self.model = model.to_string();
188        Ok(self)
189    }
190
191    /// Load an external calibration profile (fail-loud), same layout rules
192    /// as the other stages' loaders.
193    pub fn load(path: &Path) -> Result<Self, HygienistError> {
194        let text = std::fs::read_to_string(path).map_err(|err| {
195            HygienistError::Profile(format!("cannot read profile '{}': {err}", path.display()))
196        })?;
197        let raw: RawProfile = toml::from_str(&text).map_err(|err| {
198            HygienistError::Profile(format!("invalid profile '{}': {err}", path.display()))
199        })?;
200        if raw.stage != "hygienist" {
201            return Err(HygienistError::Profile(format!(
202                "profile '{}' is for stage '{}', not 'hygienist'",
203                path.display(),
204                raw.stage
205            )));
206        }
207        if raw.model.trim().is_empty() || raw.model == "PLACEHOLDER" {
208            return Err(HygienistError::Profile(format!(
209                "profile '{}' does not name a model",
210                path.display()
211            )));
212        }
213        let provider = match raw.provider.as_deref() {
214            Some(name) => Provider::parse_strict(name).ok_or_else(|| {
215                HygienistError::Profile(format!(
216                    "profile '{}': unknown provider '{name}'",
217                    path.display()
218                ))
219            })?,
220            None => meerkat_models::infer_provider(&raw.model).ok_or_else(|| {
221                HygienistError::Profile(format!(
222                    "profile '{}': model '{}' is not in the catalog; set `provider` explicitly",
223                    path.display(),
224                    raw.model
225                ))
226            })?,
227        };
228        let base = path.parent().unwrap_or_else(|| Path::new("."));
229        let candidates = [
230            base.join(&raw.prompt_bundle),
231            base.parent()
232                .unwrap_or_else(|| Path::new("."))
233                .join(&raw.prompt_bundle),
234        ];
235        let bundle_path = candidates.iter().find(|p| p.is_file()).ok_or_else(|| {
236            HygienistError::Profile(format!(
237                "profile '{}': prompt_bundle '{}' does not resolve",
238                path.display(),
239                raw.prompt_bundle
240            ))
241        })?;
242        let prompt_template = std::fs::read_to_string(bundle_path).map_err(|err| {
243            HygienistError::Profile(format!(
244                "cannot read prompt bundle '{}': {err}",
245                bundle_path.display()
246            ))
247        })?;
248        let profile = Self {
249            stage: raw.stage,
250            version: raw.version,
251            model: raw.model,
252            provider,
253            prompt_bundle: raw.prompt_bundle,
254            prompt_template,
255            params: raw.params.unwrap_or_default(),
256        };
257        profile.validate()?;
258        Ok(profile)
259    }
260
261    fn validate(&self) -> Result<(), HygienistError> {
262        for placeholder in [TRANSCRIPT_PLACEHOLDER, PROTECTED_RANGES_PLACEHOLDER] {
263            if !self.prompt_template.contains(placeholder) {
264                return Err(HygienistError::Profile(format!(
265                    "prompt bundle '{}' is missing placeholder `{placeholder}`",
266                    self.prompt_bundle
267                )));
268            }
269        }
270        Ok(())
271    }
272}
273
274// ---------------------------------------------------------------------------
275// Config (`agent_memory.hygienist { ... }`)
276// ---------------------------------------------------------------------------
277
278/// Hygienist config block. `enabled` defaults **off**: §15 ships this stage
279/// last because it is the highest-risk stage, and flipping the default is a
280/// calibration-scorecard decision (§11).
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct HygienistConfig {
283    pub enabled: bool,
284    /// Hard per-realm window cap (window = 24 h, concurrency = 1).
285    pub runs_per_day: u32,
286    /// Model override for the embedded profile.
287    pub model: Option<String>,
288}
289
290impl Default for HygienistConfig {
291    fn default() -> Self {
292        Self {
293            enabled: false,
294            runs_per_day: DEFAULT_RUNS_PER_DAY,
295            model: None,
296        }
297    }
298}
299
300// ---------------------------------------------------------------------------
301// Client handle
302// ---------------------------------------------------------------------------
303
304/// One bounded model-client acquisition per pass (§8.1 invocation seam).
305#[async_trait]
306pub trait HygienistClientHandle: Send + Sync {
307    async fn client(&self) -> Result<Arc<dyn LlmClient>, HygienistError>;
308    fn invalidate(&self) {}
309}
310
311/// The production handle: meerkat's factory seam with auth-lease refresh,
312/// shared implementation with the Selector/Distiller.
313pub struct FactoryHygienistHandle {
314    inner: FactorySelectorHandle,
315}
316
317impl FactoryHygienistHandle {
318    pub fn new(
319        store_path: PathBuf,
320        config: meerkat::Config,
321        realm: impl Into<String>,
322        profile: &HygienistProfile,
323    ) -> Self {
324        Self {
325            inner: FactorySelectorHandle::for_model(
326                store_path,
327                config,
328                realm,
329                &profile.model,
330                profile.provider,
331            ),
332        }
333    }
334}
335
336#[async_trait]
337impl HygienistClientHandle for FactoryHygienistHandle {
338    async fn client(&self) -> Result<Arc<dyn LlmClient>, HygienistError> {
339        use crate::memory::selector::{SelectorError, SelectorHandle};
340        self.inner.client().await.map_err(|err| match err {
341            SelectorError::Auth(msg) => HygienistError::Auth(msg),
342            other => HygienistError::Client(other.to_string()),
343        })
344    }
345
346    fn invalidate(&self) {
347        use crate::memory::selector::SelectorHandle;
348        self.inner.invalidate();
349    }
350}
351
352// ---------------------------------------------------------------------------
353// The transcript-revision seam (meerkat 0.7.9 apply surface)
354// ---------------------------------------------------------------------------
355
356/// Receipt of one committed transcript revision.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct AppliedRevision {
359    pub parent_revision: String,
360    pub revision: String,
361    pub message_count: usize,
362}
363
364/// Host-side reach into meerkat's audited same-session transcript revisions
365/// (§8.6 mechanism). The production implementation wraps the CONCRETE
366/// `PersistentSessionService` (which implements meerkat-core's
367/// `SessionServiceTranscriptEditExt`) — the erased `Arc<dyn
368/// MobSessionService>` the mob layer consumes does not carry the edit
369/// extension, so the gateway threads a typed handle to here at bootstrap.
370#[async_trait]
371pub trait TranscriptRevisionSeam: Send + Sync {
372    /// The session's current transcript together with its head revision id at
373    /// read time (ask 4 refinement: `list_transcript_revisions` now exposes
374    /// the head, so the caller can pin what it read and compare-and-swap on
375    /// the rewrite). The head is `None` when the source cannot report one.
376    /// `Ok(None)` when the session does not exist.
377    async fn read_messages(
378        &self,
379        session_key: &str,
380    ) -> Result<Option<(Vec<Message>, Option<String>)>, String>;
381
382    /// Commit one audited rewrite replacing `[start, end)` with
383    /// `replacement`. Implementations must refuse mid-turn sessions
384    /// (meerkat's `TranscriptEditRunningBehavior::Reject` default).
385    /// `expected_parent_revision` (the head observed by the matching
386    /// `read_messages`) is a compare-and-swap guard: the rewrite is rejected
387    /// if the head advanced since, so hygiene never commits against a
388    /// transcript that changed under it.
389    async fn rewrite(
390        &self,
391        session_key: &str,
392        start: usize,
393        end: usize,
394        replacement: Vec<Message>,
395        note: &str,
396        expected_parent_revision: Option<String>,
397    ) -> Result<AppliedRevision, String>;
398}
399
400/// The concrete session-service surface the seam needs: history reads plus
401/// typed transcript edits. Blanket-implemented, so any service implementing
402/// both extension traits (meerkat-session's `PersistentSessionService`
403/// does) coerces to `Arc<dyn TranscriptEditSessionService>`.
404pub trait TranscriptEditSessionService:
405    meerkat_core::service::SessionServiceHistoryExt
406    + meerkat_core::service::SessionServiceTranscriptEditExt
407{
408}
409
410impl<T> TranscriptEditSessionService for T where
411    T: meerkat_core::service::SessionServiceHistoryExt
412        + meerkat_core::service::SessionServiceTranscriptEditExt
413        + ?Sized
414{
415}
416
417/// Production seam over the concrete session service (meerkat-session's
418/// `PersistentSessionService` implements both extension traits; reads go
419/// through `read_history`).
420pub struct SessionServiceRevisionSeam {
421    service: Arc<dyn TranscriptEditSessionService>,
422}
423
424impl SessionServiceRevisionSeam {
425    pub fn new(service: Arc<dyn TranscriptEditSessionService>) -> Self {
426        Self { service }
427    }
428}
429
430#[async_trait]
431impl TranscriptRevisionSeam for SessionServiceRevisionSeam {
432    async fn read_messages(
433        &self,
434        session_key: &str,
435    ) -> Result<Option<(Vec<Message>, Option<String>)>, String> {
436        let session_id = meerkat_core::types::SessionId::parse(session_key)
437            .map_err(|err| format!("invalid session key '{session_key}': {err}"))?;
438        let messages = match self
439            .service
440            .read_history(
441                &session_id,
442                meerkat_core::service::SessionHistoryQuery {
443                    offset: 0,
444                    limit: None,
445                },
446            )
447            .await
448        {
449            Ok(page) => page.messages,
450            Err(meerkat_core::SessionError::NotFound { .. }) => return Ok(None),
451            Err(err) => return Err(err.to_string()),
452        };
453        // Ask 4 refinement: capture the head revision for the rewrite CAS.
454        // `limit: Some(0)` fetches the head without the commit log. A store
455        // that does not support revision listing degrades to `None` (no CAS,
456        // same as before) rather than failing the hygiene read.
457        let head_revision = match self
458            .service
459            .list_transcript_revisions(
460                &session_id,
461                meerkat_core::service::SessionTranscriptRevisionListQuery {
462                    limit: Some(0),
463                    offset: None,
464                },
465            )
466            .await
467        {
468            Ok(list) => Some(list.head_revision),
469            Err(meerkat_core::SessionError::Unsupported(_)) => None,
470            Err(err) => return Err(err.to_string()),
471        };
472        Ok(Some((messages, head_revision)))
473    }
474
475    async fn rewrite(
476        &self,
477        session_key: &str,
478        start: usize,
479        end: usize,
480        replacement: Vec<Message>,
481        note: &str,
482        expected_parent_revision: Option<String>,
483    ) -> Result<AppliedRevision, String> {
484        let session_id = meerkat_core::types::SessionId::parse(session_key)
485            .map_err(|err| format!("invalid session key '{session_key}': {err}"))?;
486        let mut reason = meerkat_core::TranscriptRewriteReason::new("hygiene");
487        reason.note = Some(note.to_string());
488        let result = self
489            .service
490            .rewrite_session_transcript(
491                &session_id,
492                meerkat_core::service::SessionTranscriptRewriteRequest {
493                    selection: meerkat_core::TranscriptRewriteSelection::MessageRange {
494                        start,
495                        end,
496                    },
497                    replacement,
498                    reason,
499                    actor: Some("mobkit-hygienist".to_string()),
500                    // Ask 4 refinement: compare-and-swap against the head the
501                    // matching read observed (via list_transcript_revisions).
502                    // If the head advanced since, meerkat rejects the rewrite,
503                    // so hygiene never commits against a changed transcript.
504                    // Belt-and-braces with the mutation guard + Reject default.
505                    expected_parent_revision,
506                    running_behavior: meerkat_core::TranscriptEditRunningBehavior::default(),
507                },
508            )
509            .await
510            .map_err(|err| err.to_string())?;
511        Ok(AppliedRevision {
512            parent_revision: result.parent_revision,
513            revision: result.revision,
514            message_count: result.message_count,
515        })
516    }
517}
518
519// ---------------------------------------------------------------------------
520// Span references (§8.6 validator inputs)
521// ---------------------------------------------------------------------------
522
523/// One memory record whose provenance cites the session under curation.
524#[derive(Debug, Clone, PartialEq, Eq)]
525pub struct SpanReference {
526    pub record_id: String,
527    /// Quarantined records hard-block (§8.6: until steward review
528    /// completes); active records only flag.
529    pub quarantined: bool,
530    /// Cited message range within the session. `None` means the record
531    /// cites the session without a range — conservatively treated as
532    /// spanning the whole transcript.
533    pub range: Option<(u64, u64)>,
534}
535
536/// Where the validator learns which spans are referenced by records.
537#[async_trait]
538pub trait SpanReferenceSource: Send + Sync {
539    async fn span_references(
540        &self,
541        identity: &str,
542        session_key: &str,
543    ) -> Result<Vec<SpanReference>, String>;
544}
545
546/// Production source over any steward-capable store's existing readers: the
547/// realm's quarantine queue (hard-block set) plus the identity/realm-scope
548/// active manifests resolved to full records (audit-flag set). Mob-scope
549/// records do not carry session evidence (promotion copies drop evidence
550/// refs), so identity + realm scopes are the complete evidence-citing
551/// population.
552pub struct StoreSpanReferenceSource {
553    store: Arc<dyn StewardStore>,
554    realm: String,
555}
556
557impl StoreSpanReferenceSource {
558    pub fn new(store: Arc<dyn StewardStore>, realm: impl Into<String>) -> Self {
559        Self {
560            store,
561            realm: realm.into(),
562        }
563    }
564}
565
566#[async_trait]
567impl SpanReferenceSource for StoreSpanReferenceSource {
568    async fn span_references(
569        &self,
570        identity: &str,
571        session_key: &str,
572    ) -> Result<Vec<SpanReference>, String> {
573        let mut references = Vec::new();
574        let quarantined = self
575            .store
576            .quarantined_records(&self.realm, SPAN_QUARANTINE_LIMIT)
577            .await
578            .map_err(|err| err.to_string())?;
579        for record in &quarantined {
580            for evidence in &record.provenance.evidence {
581                if evidence.session_id == session_key {
582                    references.push(SpanReference {
583                        record_id: record.id.clone(),
584                        quarantined: true,
585                        range: evidence.range,
586                    });
587                }
588            }
589        }
590        let scopes = vec![
591            MemoryScope::Identity {
592                realm: self.realm.clone(),
593                identity: identity.to_string(),
594            },
595            MemoryScope::Realm {
596                realm: self.realm.clone(),
597            },
598        ];
599        let manifest = self
600            .store
601            .manifest(&scopes, ManifestTier::Full)
602            .await
603            .map_err(|err| err.to_string())?;
604        let ids: Vec<String> = manifest.into_iter().map(|meta| meta.id).collect();
605        let records = self
606            .store
607            .records_by_ids(&self.realm, &ids)
608            .await
609            .map_err(|err| err.to_string())?;
610        for record in &records {
611            if record.status != RecordStatus::Active {
612                continue;
613            }
614            for evidence in &record.provenance.evidence {
615                if evidence.session_id == session_key {
616                    references.push(SpanReference {
617                        record_id: record.id.clone(),
618                        quarantined: false,
619                        range: evidence.range,
620                    });
621                }
622            }
623        }
624        Ok(references)
625    }
626}
627
628// ---------------------------------------------------------------------------
629// Ordering gate (§8.4 invariant)
630// ---------------------------------------------------------------------------
631
632/// Where the §8.4 ordering check learns how far distillation has run.
633pub trait DistillationGate: Send + Sync {
634    /// Transcript index up to which distillation has covered
635    /// `(identity, session_key)`.
636    fn distilled_through(&self, identity: &str, session_key: &str) -> u64;
637}
638
639impl DistillationGate for DistillerEngine {
640    fn distilled_through(&self, identity: &str, session_key: &str) -> u64 {
641        self.distilled_cursor(identity, session_key)
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Proposal model + parse
647// ---------------------------------------------------------------------------
648
649/// What one hygiene op does to its range.
650#[derive(Debug, Clone, PartialEq, Eq)]
651pub enum RevisionAction {
652    /// Stub the payload of the tool-result messages in the range; the
653    /// messages (and their tool_use pairing) survive.
654    PruneToolResults,
655    /// Replace the messages in the range with one typed system notice
656    /// carrying this note.
657    Collapse { replacement: String },
658}
659
660#[derive(Debug, Clone, PartialEq, Eq)]
661pub struct RevisionOp {
662    pub action: RevisionAction,
663    /// `[start, end)` message indices in the transcript being revised.
664    pub start: usize,
665    pub end: usize,
666    pub rationale: String,
667}
668
669/// A parsed (not yet validated) revision proposal.
670#[derive(Debug, Clone, PartialEq, Eq, Default)]
671pub struct RevisionProposal {
672    pub ops: Vec<RevisionOp>,
673}
674
675#[derive(Deserialize)]
676struct RawReply {
677    #[serde(default)]
678    ops: Vec<RawOp>,
679}
680
681#[derive(Deserialize)]
682struct RawOp {
683    op: String,
684    range: (usize, usize),
685    #[serde(default)]
686    replacement: Option<String>,
687    #[serde(default)]
688    rationale: String,
689}
690
691/// Parse the model's reply into a proposal. Tolerates surrounding prose by
692/// slicing the outermost JSON object (same tolerance as the other stages);
693/// unknown op names are a parse error, not a silent drop — the reply is one
694/// semantic unit.
695pub fn parse_revision_reply(reply: &str) -> Result<RevisionProposal, String> {
696    let start = reply
697        .find('{')
698        .ok_or_else(|| "reply contains no JSON object".to_string())?;
699    let end = reply
700        .rfind('}')
701        .ok_or_else(|| "reply contains no closing brace".to_string())?;
702    if end < start {
703        return Err("reply braces are unbalanced".to_string());
704    }
705    let raw: RawReply = serde_json::from_str(&reply[start..=end])
706        .map_err(|err| format!("reply did not parse: {err}"))?;
707    let mut ops = Vec::new();
708    for raw_op in raw.ops {
709        let (start, end) = raw_op.range;
710        let action = match raw_op.op.as_str() {
711            "prune_tool_results" => RevisionAction::PruneToolResults,
712            "collapse" => {
713                let replacement = raw_op
714                    .replacement
715                    .as_deref()
716                    .map(compact_whitespace)
717                    .unwrap_or_default();
718                if replacement.is_empty() {
719                    return Err("collapse op without a replacement note".to_string());
720                }
721                RevisionAction::Collapse {
722                    replacement: truncate_utf8_boundary(
723                        &replacement,
724                        MAX_COLLAPSE_REPLACEMENT_BYTES,
725                    ),
726                }
727            }
728            other => return Err(format!("unknown op '{other}'")),
729        };
730        ops.push(RevisionOp {
731            action,
732            start,
733            end,
734            rationale: compact_whitespace(&raw_op.rationale),
735        });
736    }
737    Ok(RevisionProposal { ops })
738}
739
740// ---------------------------------------------------------------------------
741// Deterministic validator (§8.6)
742// ---------------------------------------------------------------------------
743
744/// Role projection used for validator law and prompt rendering. Total over
745/// the raw message list so op indices always address the real transcript.
746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
747pub enum HygieneRole {
748    System,
749    SystemNotice,
750    User,
751    /// Assistant message without tool calls.
752    Assistant,
753    /// Assistant message that issues tool calls — collapsing it would
754    /// orphan the paired tool results, so it is untouchable.
755    AssistantToolUse,
756    ToolResults,
757}
758
759impl HygieneRole {
760    pub fn of(message: &Message) -> Self {
761        match message {
762            Message::System(_) => Self::System,
763            Message::SystemNotice(_) => Self::SystemNotice,
764            Message::User(_) => Self::User,
765            Message::BlockAssistant(assistant) => {
766                if assistant.has_tool_calls() {
767                    Self::AssistantToolUse
768                } else {
769                    Self::Assistant
770                }
771            }
772            Message::ToolResults { .. } => Self::ToolResults,
773        }
774    }
775
776    pub fn as_str(&self) -> &'static str {
777        match self {
778            Self::System => "system",
779            Self::SystemNotice => "system notice",
780            Self::User => "user",
781            Self::Assistant => "assistant",
782            Self::AssistantToolUse => "assistant (tool call)",
783            Self::ToolResults => "tool results",
784        }
785    }
786}
787
788/// Why the validator refused a proposal wholesale. A refused proposal
789/// applies nothing — the revision is one semantic unit.
790#[derive(Debug, Clone, PartialEq, Eq)]
791pub enum RevisionReject {
792    /// Malformed ranges: out of bounds, empty, overlapping.
793    InvalidRange { detail: String },
794    /// Role law: prune targets non-tool messages, collapse touches tool
795    /// activity or the system prompt.
796    IllegalRole { detail: String },
797    /// §8.6 hard-block: the revision touches a span referenced by a
798    /// quarantined record whose steward review has not completed.
799    QuarantineReferenced { record_id: String },
800    /// §8.4 ordering invariant: distillation has not covered the affected
801    /// window.
802    OrderingUnmet { cursor: u64, needed: u64 },
803}
804
805impl std::fmt::Display for RevisionReject {
806    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
807        match self {
808            Self::InvalidRange { detail } => write!(f, "invalid range: {detail}"),
809            Self::IllegalRole { detail } => write!(f, "illegal role: {detail}"),
810            Self::QuarantineReferenced { record_id } => write!(
811                f,
812                "range referenced by quarantined record '{record_id}' (review incomplete)"
813            ),
814            Self::OrderingUnmet { cursor, needed } => write!(
815                f,
816                "distillation cursor {cursor} has not covered the affected window (needs {needed})"
817            ),
818        }
819    }
820}
821
822/// A validated proposal plus its §8.6 audit flags.
823#[derive(Debug, Clone, PartialEq, Eq)]
824pub struct ValidatedRevision {
825    pub ops: Vec<RevisionOp>,
826    /// Active records whose evidence spans the revision touches — allowed,
827    /// audited (§8.6).
828    pub flagged_active_records: Vec<String>,
829}
830
831/// How the §8.4 ordering invariant is discharged for this pass.
832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833pub enum OrderingContext {
834    /// The pass was sequenced behind the distiller's compaction harvest
835    /// (the follow-up hook) — the invariant holds by construction.
836    SequencedAfterHarvest,
837    /// On-demand: check the distiller's window cursor. `None` means no
838    /// distiller is deployed — nothing to order against (revisions stay
839    /// restorable regardless; §8.4's "where enabled" clause).
840    Cursor(Option<u64>),
841}
842
843/// The §8.6 deterministic validator. Judgment picked the ranges; this is
844/// the law they pass through.
845pub fn validate_revision(
846    proposal: &RevisionProposal,
847    roles: &[HygieneRole],
848    spans: &[SpanReference],
849    ordering: OrderingContext,
850) -> Result<ValidatedRevision, RevisionReject> {
851    let len = roles.len();
852    let mut sorted: Vec<&RevisionOp> = proposal.ops.iter().collect();
853    sorted.sort_by_key(|op| op.start);
854    let mut previous_end = 0usize;
855    for op in &sorted {
856        if op.start >= op.end {
857            return Err(RevisionReject::InvalidRange {
858                detail: format!("empty range [{}, {})", op.start, op.end),
859            });
860        }
861        if op.end > len {
862            return Err(RevisionReject::InvalidRange {
863                detail: format!(
864                    "range [{}, {}) exceeds transcript length {len}",
865                    op.start, op.end
866                ),
867            });
868        }
869        if op.start < previous_end {
870            return Err(RevisionReject::InvalidRange {
871                detail: format!("range [{}, {}) overlaps an earlier op", op.start, op.end),
872            });
873        }
874        previous_end = op.end;
875        for (index, &role) in roles.iter().enumerate().take(op.end).skip(op.start) {
876            match op.action {
877                RevisionAction::PruneToolResults => {
878                    if role != HygieneRole::ToolResults {
879                        return Err(RevisionReject::IllegalRole {
880                            detail: format!(
881                                "prune_tool_results range [{}, {}) covers a {} message at [{index}]",
882                                op.start,
883                                op.end,
884                                role.as_str()
885                            ),
886                        });
887                    }
888                }
889                RevisionAction::Collapse { .. } => {
890                    if !matches!(
891                        role,
892                        HygieneRole::User | HygieneRole::SystemNotice | HygieneRole::Assistant
893                    ) {
894                        return Err(RevisionReject::IllegalRole {
895                            detail: format!(
896                                "collapse range [{}, {}) covers a {} message at [{index}]",
897                                op.start,
898                                op.end,
899                                role.as_str()
900                            ),
901                        });
902                    }
903                }
904            }
905        }
906    }
907    // §8.6 quarantine hard-block, then active-record audit flags.
908    let mut flagged: Vec<String> = Vec::new();
909    for span in spans {
910        let (span_start, span_end) = match span.range {
911            Some((start, end)) => (start as usize, (end as usize).saturating_add(1)),
912            // A record citing the session without a range conservatively
913            // spans everything.
914            None => (0, len.max(1)),
915        };
916        let touched = sorted
917            .iter()
918            .any(|op| op.start < span_end && span_start < op.end);
919        if !touched {
920            continue;
921        }
922        if span.quarantined {
923            return Err(RevisionReject::QuarantineReferenced {
924                record_id: span.record_id.clone(),
925            });
926        }
927        if !flagged.contains(&span.record_id) {
928            flagged.push(span.record_id.clone());
929        }
930    }
931    // §8.4 ordering invariant.
932    if let OrderingContext::Cursor(Some(cursor)) = ordering {
933        let needed = sorted.iter().map(|op| op.end as u64).max().unwrap_or(0);
934        if needed > cursor {
935            return Err(RevisionReject::OrderingUnmet { cursor, needed });
936        }
937    }
938    Ok(ValidatedRevision {
939        ops: sorted.into_iter().cloned().collect(),
940        flagged_active_records: flagged,
941    })
942}
943
944// ---------------------------------------------------------------------------
945// Prompt rendering
946// ---------------------------------------------------------------------------
947
948fn message_text(message: &Message) -> String {
949    match message {
950        Message::System(_) => "(system prompt — untouchable)".to_string(),
951        Message::SystemNotice(notice) => notice.body.clone().unwrap_or_default(),
952        Message::User(user) => user.text_content(),
953        Message::BlockAssistant(assistant) => {
954            assistant.text_blocks().collect::<Vec<_>>().join("\n")
955        }
956        Message::ToolResults { results, .. } => results
957            .iter()
958            .map(|result| meerkat_core::types::text_content(&result.content))
959            .collect::<Vec<_>>()
960            .join("\n"),
961    }
962}
963
964/// Render the transcript with `[N]` raw indices and roles, bounded by the
965/// total byte budget (oldest messages drop first).
966pub fn render_transcript(messages: &[Message]) -> String {
967    let mut lines: Vec<String> = Vec::new();
968    let mut total = 0usize;
969    for (index, message) in messages.iter().enumerate().rev() {
970        let role = HygieneRole::of(message);
971        let text = truncate_utf8_boundary(
972            &compact_whitespace(&message_text(message)),
973            MAX_TRANSCRIPT_MESSAGE_BYTES,
974        );
975        let line = format!("[{index}] {}: {text}", role.as_str());
976        if total + line.len() + 1 > MAX_TRANSCRIPT_TOTAL_BYTES && !lines.is_empty() {
977            lines.push("(earlier messages omitted for budget)".to_string());
978            break;
979        }
980        total += line.len() + 1;
981        lines.push(line);
982    }
983    lines.reverse();
984    lines.join("\n")
985}
986
987fn render_protected_ranges(spans: &[SpanReference]) -> String {
988    if spans.is_empty() {
989        return "(none)".to_string();
990    }
991    spans
992        .iter()
993        .map(|span| {
994            let range = match span.range {
995                Some((start, end)) => format!("[{start}-{end}]"),
996                None => "[whole session]".to_string(),
997            };
998            format!(
999                "- {} {} referenced by record '{}'",
1000                if span.quarantined {
1001                    "QUARANTINED"
1002                } else {
1003                    "active"
1004                },
1005                range,
1006                span.record_id
1007            )
1008        })
1009        .collect::<Vec<_>>()
1010        .join("\n")
1011}
1012
1013pub fn render_prompt(
1014    profile: &HygienistProfile,
1015    messages: &[Message],
1016    spans: &[SpanReference],
1017) -> String {
1018    profile
1019        .prompt_template
1020        .replace(TRANSCRIPT_PLACEHOLDER, &render_transcript(messages))
1021        .replace(
1022            PROTECTED_RANGES_PLACEHOLDER,
1023            &render_protected_ranges(spans),
1024        )
1025}
1026
1027// ---------------------------------------------------------------------------
1028// Replacement construction
1029// ---------------------------------------------------------------------------
1030
1031/// Build the replacement for the hull `[hull_start, hull_end)` of the
1032/// validated ops: untouched messages pass through, pruned tool results keep
1033/// their `tool_use_id` pairing with stubbed payloads, collapsed runs become
1034/// one typed system notice.
1035pub fn build_replacement(
1036    messages: &[Message],
1037    ops: &[RevisionOp],
1038) -> Option<(usize, usize, Vec<Message>)> {
1039    let hull_start = ops.iter().map(|op| op.start).min()?;
1040    let hull_end = ops.iter().map(|op| op.end).max()?;
1041    let mut replacement = Vec::new();
1042    let mut index = hull_start;
1043    while index < hull_end {
1044        if let Some(op) = ops.iter().find(|op| op.start == index) {
1045            match &op.action {
1046                RevisionAction::PruneToolResults => {
1047                    for pruned in &messages[op.start..op.end] {
1048                        if let Message::ToolResults {
1049                            results,
1050                            created_at,
1051                        } = pruned
1052                        {
1053                            let stubbed = results
1054                                .iter()
1055                                .map(|result| {
1056                                    meerkat_core::types::ToolResult::new(
1057                                        result.tool_use_id.clone(),
1058                                        format!("[pruned by hygienist: {}]", op.rationale),
1059                                        result.is_error,
1060                                    )
1061                                })
1062                                .collect();
1063                            replacement.push(Message::ToolResults {
1064                                results: stubbed,
1065                                created_at: *created_at,
1066                            });
1067                        }
1068                    }
1069                }
1070                RevisionAction::Collapse { replacement: note } => {
1071                    replacement.push(Message::SystemNotice(SystemNoticeMessage::new(
1072                        SystemNoticeKind::Generic,
1073                        format!(
1074                            "[hygienist] collapsed {} messages: {note}",
1075                            op.end - op.start
1076                        ),
1077                    )));
1078                }
1079            }
1080            index = op.end;
1081        } else {
1082            replacement.push(messages[index].clone());
1083            index += 1;
1084        }
1085    }
1086    Some((hull_start, hull_end, replacement))
1087}
1088
1089// ---------------------------------------------------------------------------
1090// Engine
1091// ---------------------------------------------------------------------------
1092
1093/// What triggered a hygiene pass.
1094#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1095pub enum HygieneCause {
1096    /// Sequenced behind the distiller's compaction harvest (or directly
1097    /// off the compaction event when no distiller is deployed).
1098    PostCompaction,
1099    OnDemand,
1100}
1101
1102impl HygieneCause {
1103    pub fn as_str(&self) -> &'static str {
1104        match self {
1105            Self::PostCompaction => "post_compaction",
1106            Self::OnDemand => "on_demand",
1107        }
1108    }
1109}
1110
1111/// Outcome of one `hygiene_now` call, for logs and tests.
1112#[derive(Debug, Clone, PartialEq, Eq)]
1113pub enum HygieneOutcome {
1114    Skipped {
1115        reason: String,
1116    },
1117    Blocked {
1118        reason: String,
1119    },
1120    Applied {
1121        run_id: String,
1122        revision: AppliedRevision,
1123        ops: usize,
1124        flagged_active_records: Vec<String>,
1125    },
1126}
1127
1128pub struct HygienistEngine {
1129    profile: HygienistProfile,
1130    config: HygienistConfig,
1131    handle: Arc<dyn HygienistClientHandle>,
1132    seam: Arc<dyn TranscriptRevisionSeam>,
1133    spans: Arc<dyn SpanReferenceSource>,
1134    gate: Option<Arc<dyn DistillationGate>>,
1135    budget: BackgroundBudget,
1136    realm: String,
1137    events: Mutex<Option<Arc<dyn MemoryEventSink>>>,
1138    run_counter: std::sync::atomic::AtomicU64,
1139}
1140
1141impl HygienistEngine {
1142    pub fn new(
1143        profile: HygienistProfile,
1144        config: HygienistConfig,
1145        handle: Arc<dyn HygienistClientHandle>,
1146        seam: Arc<dyn TranscriptRevisionSeam>,
1147        spans: Arc<dyn SpanReferenceSource>,
1148        gate: Option<Arc<dyn DistillationGate>>,
1149        realm: impl Into<String>,
1150    ) -> Self {
1151        // Curation concurrency is 1 per realm; runs/day is the window cap.
1152        let budget = BackgroundBudget::new(BackgroundBudgetConfig {
1153            runs_per_window: config.runs_per_day,
1154            // `Duration::from_days` is unstable (duration_constructors);
1155            // clippy 1.96 suggests it, so allow the units lint here.
1156            #[allow(clippy::duration_suboptimal_units)]
1157            window: Duration::from_secs(24 * 60 * 60),
1158            max_concurrent: 1,
1159        });
1160        Self {
1161            profile,
1162            config,
1163            handle,
1164            seam,
1165            spans,
1166            gate,
1167            budget,
1168            realm: realm.into(),
1169            events: Mutex::new(None),
1170            run_counter: std::sync::atomic::AtomicU64::new(0),
1171        }
1172    }
1173
1174    pub fn config(&self) -> &HygienistConfig {
1175        &self.config
1176    }
1177
1178    /// Wire the §9.3 timeline sink; also threads it into the budget guard.
1179    pub fn set_event_sink(&self, sink: Arc<dyn MemoryEventSink>) {
1180        self.budget.set_event_sink(sink.clone());
1181        *self
1182            .events
1183            .lock()
1184            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sink);
1185    }
1186
1187    fn emit(&self, event: MemoryTimelineEvent) {
1188        if let Some(sink) = self
1189            .events
1190            .lock()
1191            .unwrap_or_else(std::sync::PoisonError::into_inner)
1192            .as_ref()
1193        {
1194            sink.emit(event);
1195        }
1196    }
1197
1198    fn mint_run_id(&self) -> String {
1199        let seq = self
1200            .run_counter
1201            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1202        format!("hygiene-{}-{seq}", now_ms())
1203    }
1204
1205    /// One curation pass over `session_key`'s live transcript. Never on a
1206    /// delivery path; a mid-turn session is refused by the seam.
1207    pub async fn hygiene_now(
1208        self: &Arc<Self>,
1209        identity: &str,
1210        session_key: &str,
1211        cause: HygieneCause,
1212    ) -> HygieneOutcome {
1213        let outcome = self.hygiene_inner(identity, session_key, cause).await;
1214        match &outcome {
1215            HygieneOutcome::Skipped { reason } => {
1216                tracing::debug!(
1217                    identity,
1218                    session_key,
1219                    cause = cause.as_str(),
1220                    reason,
1221                    "agent memory hygienist: pass skipped"
1222                );
1223                self.emit(MemoryTimelineEvent::HygieneSkipped {
1224                    identity: identity.to_string(),
1225                    session_key: session_key.to_string(),
1226                    cause: cause.as_str().to_string(),
1227                    reason: reason.clone(),
1228                });
1229            }
1230            HygieneOutcome::Blocked { reason } => {
1231                tracing::warn!(
1232                    identity,
1233                    session_key,
1234                    cause = cause.as_str(),
1235                    reason,
1236                    "agent memory hygienist: revision blocked"
1237                );
1238                self.emit(MemoryTimelineEvent::HygieneBlocked {
1239                    identity: identity.to_string(),
1240                    session_key: session_key.to_string(),
1241                    cause: cause.as_str().to_string(),
1242                    reason: reason.clone(),
1243                });
1244            }
1245            HygieneOutcome::Applied {
1246                run_id,
1247                revision,
1248                ops,
1249                flagged_active_records,
1250            } => {
1251                tracing::info!(
1252                    identity,
1253                    session_key,
1254                    cause = cause.as_str(),
1255                    run_id,
1256                    ops,
1257                    revision = %revision.revision,
1258                    "agent memory hygienist: revision applied"
1259                );
1260                self.emit(MemoryTimelineEvent::HygieneApplied {
1261                    identity: identity.to_string(),
1262                    session_key: session_key.to_string(),
1263                    cause: cause.as_str().to_string(),
1264                    parent_revision: revision.parent_revision.clone(),
1265                    revision: revision.revision.clone(),
1266                    ops: *ops,
1267                    flagged_active_records: flagged_active_records.clone(),
1268                });
1269            }
1270        }
1271        outcome
1272    }
1273
1274    async fn hygiene_inner(
1275        self: &Arc<Self>,
1276        identity: &str,
1277        session_key: &str,
1278        cause: HygieneCause,
1279    ) -> HygieneOutcome {
1280        // Reads come before the budget gate: an empty transcript must not
1281        // burn a budgeted run.
1282        let (messages, head_revision) = match self.seam.read_messages(session_key).await {
1283            Ok(Some(read)) => read,
1284            Ok(None) => {
1285                return HygieneOutcome::Skipped {
1286                    reason: "session not found".to_string(),
1287                };
1288            }
1289            Err(err) => {
1290                return HygieneOutcome::Skipped {
1291                    reason: format!("transcript read failed: {err}"),
1292                };
1293            }
1294        };
1295        if messages.is_empty() {
1296            return HygieneOutcome::Skipped {
1297                reason: "empty transcript".to_string(),
1298            };
1299        }
1300        let spans = match self.spans.span_references(identity, session_key).await {
1301            Ok(spans) => spans,
1302            Err(err) => {
1303                // Fail closed: without the span facts the §8.6 hard-block
1304                // cannot be checked, so no revision happens.
1305                return HygieneOutcome::Blocked {
1306                    reason: format!("span references unavailable: {err}"),
1307                };
1308            }
1309        };
1310
1311        let _permit = match self.budget.try_acquire(&self.realm, "hygienist") {
1312            Ok(permit) => permit,
1313            Err(denied) => {
1314                return HygieneOutcome::Skipped {
1315                    reason: format!("budget denied: {denied}"),
1316                };
1317            }
1318        };
1319
1320        let client = match self.handle.client().await {
1321            Ok(client) => client,
1322            Err(err) => {
1323                return HygieneOutcome::Skipped {
1324                    reason: format!("client acquisition failed: {err}"),
1325                };
1326            }
1327        };
1328        let prompt = render_prompt(&self.profile, &messages, &spans);
1329        let reply = match complete_text(&*client, &self.profile, prompt.clone()).await {
1330            Ok(reply) => reply,
1331            Err(HygienistError::Auth(message)) => {
1332                // One re-resolve, mirroring the other stages' auth containment.
1333                tracing::warn!(error = %message, "hygienist auth failure; re-resolving client");
1334                self.handle.invalidate();
1335                let retried = match self.handle.client().await {
1336                    Ok(client) => complete_text(&*client, &self.profile, prompt).await,
1337                    Err(err) => Err(err),
1338                };
1339                match retried {
1340                    Ok(reply) => reply,
1341                    Err(err) => {
1342                        return HygieneOutcome::Skipped {
1343                            reason: format!("completion failed: {err}"),
1344                        };
1345                    }
1346                }
1347            }
1348            Err(err) => {
1349                return HygieneOutcome::Skipped {
1350                    reason: format!("completion failed: {err}"),
1351                };
1352            }
1353        };
1354        let proposal = match parse_revision_reply(&reply) {
1355            Ok(proposal) => proposal,
1356            Err(err) => {
1357                return HygieneOutcome::Skipped {
1358                    reason: format!("reply did not parse: {err}"),
1359                };
1360            }
1361        };
1362        if proposal.ops.is_empty() {
1363            return HygieneOutcome::Skipped {
1364                reason: "no-op judgment (preferred output)".to_string(),
1365            };
1366        }
1367        let roles: Vec<HygieneRole> = messages.iter().map(HygieneRole::of).collect();
1368        let ordering = match cause {
1369            HygieneCause::PostCompaction => OrderingContext::SequencedAfterHarvest,
1370            HygieneCause::OnDemand => OrderingContext::Cursor(
1371                self.gate
1372                    .as_ref()
1373                    .map(|gate| gate.distilled_through(identity, session_key)),
1374            ),
1375        };
1376        let validated = match validate_revision(&proposal, &roles, &spans, ordering) {
1377            Ok(validated) => validated,
1378            Err(reject) => {
1379                return HygieneOutcome::Blocked {
1380                    reason: reject.to_string(),
1381                };
1382            }
1383        };
1384        let run_id = self.mint_run_id();
1385        self.emit(MemoryTimelineEvent::HygieneProposed {
1386            identity: identity.to_string(),
1387            session_key: session_key.to_string(),
1388            cause: cause.as_str().to_string(),
1389            ops: validated.ops.len(),
1390            flagged_active_records: validated.flagged_active_records.clone(),
1391        });
1392        let Some((hull_start, hull_end, replacement)) =
1393            build_replacement(&messages, &validated.ops)
1394        else {
1395            return HygieneOutcome::Skipped {
1396                reason: "validated proposal had no ops".to_string(),
1397            };
1398        };
1399        let rationales = validated
1400            .ops
1401            .iter()
1402            .map(|op| op.rationale.as_str())
1403            .collect::<Vec<_>>()
1404            .join("; ");
1405        let note = truncate_utf8_boundary(&format!("{run_id}: {rationales}"), 512);
1406        match self
1407            .seam
1408            .rewrite(
1409                session_key,
1410                hull_start,
1411                hull_end,
1412                replacement,
1413                &note,
1414                head_revision,
1415            )
1416            .await
1417        {
1418            Ok(revision) => HygieneOutcome::Applied {
1419                run_id,
1420                revision,
1421                ops: validated.ops.len(),
1422                flagged_active_records: validated.flagged_active_records,
1423            },
1424            Err(err) => HygieneOutcome::Skipped {
1425                reason: format!("revision apply refused: {err}"),
1426            },
1427        }
1428    }
1429
1430    /// Detached pass (trigger paths). Never on any critical path.
1431    pub fn spawn_detached(
1432        self: &Arc<Self>,
1433        identity: &str,
1434        session_key: &str,
1435        cause: HygieneCause,
1436    ) {
1437        let engine = self.clone();
1438        let identity = identity.to_string();
1439        let session_key = session_key.to_string();
1440        tokio::spawn(async move {
1441            engine.hygiene_now(&identity, &session_key, cause).await;
1442        });
1443    }
1444}
1445
1446/// The §8.6 trigger-sequencing glue: a [`crate::memory::distiller::CompactionFollowUp`]
1447/// that runs hygiene strictly AFTER the distiller's compaction harvest for
1448/// the boundary, and only when the harvest left nothing unharvested
1449/// (`DistillOutcome::compaction_harvest_satisfied` — budget denials and
1450/// read/extraction failures block hygiene loudly instead).
1451pub fn distiller_follow_up(engine: Arc<HygienistEngine>) -> CompactionFollowUp {
1452    Arc::new(
1453        move |identity: &str, session_key: &str, outcome: &DistillOutcome| {
1454            if outcome.compaction_harvest_satisfied() {
1455                engine.spawn_detached(identity, session_key, HygieneCause::PostCompaction);
1456            } else {
1457                let reason = match outcome {
1458                    DistillOutcome::Skipped { reason } => reason.clone(),
1459                    DistillOutcome::Completed { .. } => unreachable!("completed harvests satisfy"),
1460                };
1461                tracing::warn!(
1462                    identity,
1463                    session_key,
1464                    reason,
1465                    "agent memory hygienist: post-compaction pass withheld (harvest incomplete)"
1466                );
1467                engine.emit(MemoryTimelineEvent::HygieneSkipped {
1468                    identity: identity.to_string(),
1469                    session_key: session_key.to_string(),
1470                    cause: HygieneCause::PostCompaction.as_str().to_string(),
1471                    reason: format!("distiller harvest incomplete: {reason}"),
1472                });
1473            }
1474        },
1475    )
1476}
1477
1478// ---------------------------------------------------------------------------
1479// Observe-stream trigger (deployments without a distiller)
1480// ---------------------------------------------------------------------------
1481
1482/// Compaction trigger for deployments where no distiller is enabled: rides
1483/// the same member-event observer. When a distiller IS enabled, use
1484/// [`distiller_follow_up`] instead — registering both would race the §8.4
1485/// ordering this exists to preserve.
1486pub struct HygienistTriggers {
1487    engine: Arc<HygienistEngine>,
1488}
1489
1490impl HygienistTriggers {
1491    pub fn new(engine: Arc<HygienistEngine>) -> Self {
1492        Self { engine }
1493    }
1494}
1495
1496impl MemberAgentEventSink for HygienistTriggers {
1497    fn observe(&self, identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) {
1498        // Scope keys are LOGICAL identities (task #53) - same fixed-point
1499        // re-normalization as the distiller sink.
1500        let identity = crate::member_comms_id::logical_memory_identity(identity);
1501        let identity = identity.as_str();
1502        if let AgentEvent::CompactionCompleted { .. } = &envelope.payload {
1503            match &envelope.source {
1504                meerkat_core::event::EventSourceIdentity::Session { session_id } => {
1505                    self.engine.spawn_detached(
1506                        identity,
1507                        &session_id.to_string(),
1508                        HygieneCause::PostCompaction,
1509                    );
1510                }
1511                _ => {
1512                    tracing::warn!(
1513                        identity,
1514                        "agent memory hygienist: compaction event without session \
1515                         attribution; pass skipped"
1516                    );
1517                }
1518            }
1519        }
1520    }
1521}
1522
1523// ---------------------------------------------------------------------------
1524// LLM call
1525// ---------------------------------------------------------------------------
1526
1527/// One bounded completion against the profile's model/params.
1528pub async fn complete_text(
1529    client: &dyn LlmClient,
1530    profile: &HygienistProfile,
1531    prompt: String,
1532) -> Result<String, HygienistError> {
1533    let request = LlmRequest::new(
1534        &profile.model,
1535        vec![Message::User(UserMessage::text(prompt))],
1536    )
1537    .with_max_tokens(profile.params.max_output_tokens)
1538    .with_temperature(profile.params.temperature);
1539    let mut stream = client.stream(&request);
1540    let mut text = String::new();
1541    while let Some(event) = stream.next().await {
1542        match event.map_err(classify_llm_error)? {
1543            LlmEvent::TextDelta { delta, .. } => text.push_str(&delta),
1544            LlmEvent::Done { outcome } => match outcome {
1545                LlmDoneOutcome::Success { .. } => break,
1546                LlmDoneOutcome::Error { error } => return Err(classify_llm_error(error)),
1547            },
1548            _ => {}
1549        }
1550    }
1551    Ok(text)
1552}
1553
1554fn classify_llm_error(error: LlmError) -> HygienistError {
1555    match error {
1556        LlmError::AuthenticationFailed { .. } | LlmError::InvalidApiKey => {
1557            HygienistError::Auth(error.to_string())
1558        }
1559        other => HygienistError::Client(other.to_string()),
1560    }
1561}
1562
1563fn now_ms() -> u64 {
1564    std::time::SystemTime::now()
1565        .duration_since(std::time::UNIX_EPOCH)
1566        .map(|duration| duration.as_millis() as u64)
1567        .unwrap_or(0)
1568}
1569
1570#[cfg(test)]
1571#[allow(clippy::expect_used, clippy::panic)]
1572mod tests {
1573    use super::*;
1574    use meerkat_core::StopReason;
1575    use meerkat_core::types::{AssistantBlock, BlockAssistantMessage, ToolResult};
1576    use std::sync::Mutex as StdMutex;
1577
1578    fn user(text: &str) -> Message {
1579        Message::User(UserMessage::text(text))
1580    }
1581
1582    fn assistant(text: &str) -> Message {
1583        Message::BlockAssistant(BlockAssistantMessage::new(
1584            vec![AssistantBlock::Text {
1585                text: text.to_string(),
1586                meta: None,
1587            }],
1588            StopReason::EndTurn,
1589        ))
1590    }
1591
1592    fn assistant_tool_call(id: &str) -> Message {
1593        let args = serde_json::value::RawValue::from_string(r#"{"cmd":"ls"}"#.to_string())
1594            .expect("raw args");
1595        Message::BlockAssistant(BlockAssistantMessage::new(
1596            vec![AssistantBlock::ToolUse {
1597                id: id.to_string(),
1598                name: "shell".to_string(),
1599                args,
1600                meta: None,
1601            }],
1602            StopReason::ToolUse,
1603        ))
1604    }
1605
1606    fn tool_results(id: &str, text: &str) -> Message {
1607        Message::tool_results(vec![ToolResult::new(
1608            id.to_string(),
1609            text.to_string(),
1610            false,
1611        )])
1612    }
1613
1614    fn transcript() -> Vec<Message> {
1615        vec![
1616            user("please check the logs"),               // 0
1617            assistant_tool_call("call-1"),               // 1
1618            tool_results("call-1", "3000 lines of log"), // 2
1619            assistant("logs are clean; decision: ship"), // 3
1620            user("scaffold notice"),                     // 4
1621            user("scaffold notice"),                     // 5
1622            user("scaffold notice"),                     // 6
1623            assistant("done"),                           // 7
1624        ]
1625    }
1626
1627    fn roles(messages: &[Message]) -> Vec<HygieneRole> {
1628        messages.iter().map(HygieneRole::of).collect()
1629    }
1630
1631    fn prune(start: usize, end: usize) -> RevisionOp {
1632        RevisionOp {
1633            action: RevisionAction::PruneToolResults,
1634            start,
1635            end,
1636            rationale: "dead output".to_string(),
1637        }
1638    }
1639
1640    fn collapse(start: usize, end: usize) -> RevisionOp {
1641        RevisionOp {
1642            action: RevisionAction::Collapse {
1643                replacement: "repeated scaffolding".to_string(),
1644            },
1645            start,
1646            end,
1647            rationale: "scaffolding".to_string(),
1648        }
1649    }
1650
1651    // -- parse ---------------------------------------------------------------
1652
1653    #[test]
1654    fn parse_accepts_ops_and_rejects_unknown() {
1655        let proposal = parse_revision_reply(
1656            r#"{"ops": [
1657                {"op": "prune_tool_results", "range": [2, 3], "rationale": "dead"},
1658                {"op": "collapse", "range": [4, 7], "replacement": "notices", "rationale": "dup"}
1659            ]}"#,
1660        )
1661        .expect("parses");
1662        assert_eq!(proposal.ops.len(), 2);
1663        assert_eq!(proposal.ops[0].action, RevisionAction::PruneToolResults);
1664        assert!(matches!(
1665            proposal.ops[1].action,
1666            RevisionAction::Collapse { .. }
1667        ));
1668
1669        assert!(parse_revision_reply(r#"{"ops": [{"op": "delete", "range": [0, 1]}]}"#).is_err());
1670        assert!(
1671            parse_revision_reply(r#"{"ops": [{"op": "collapse", "range": [0, 1]}]}"#).is_err(),
1672            "collapse without replacement must not parse"
1673        );
1674        assert_eq!(
1675            parse_revision_reply(r#"{"ops": []}"#).expect("noop parses"),
1676            RevisionProposal::default()
1677        );
1678        // Prose tolerance: the JSON object is sliced out.
1679        assert!(parse_revision_reply("Sure! {\"ops\": []} Done.").is_ok());
1680    }
1681
1682    // -- validator matrix ------------------------------------------------------
1683
1684    #[test]
1685    fn validator_accepts_legal_prune_and_collapse() {
1686        let messages = transcript();
1687        let proposal = RevisionProposal {
1688            ops: vec![prune(2, 3), collapse(4, 7)],
1689        };
1690        let validated = validate_revision(
1691            &proposal,
1692            &roles(&messages),
1693            &[],
1694            OrderingContext::SequencedAfterHarvest,
1695        )
1696        .expect("legal ops validate");
1697        assert_eq!(validated.ops.len(), 2);
1698        assert!(validated.flagged_active_records.is_empty());
1699    }
1700
1701    #[test]
1702    fn validator_rejects_malformed_ranges() {
1703        let messages = transcript();
1704        let roles = roles(&messages);
1705        for (proposal, name) in [
1706            (
1707                RevisionProposal {
1708                    ops: vec![prune(3, 3)],
1709                },
1710                "empty",
1711            ),
1712            (
1713                RevisionProposal {
1714                    ops: vec![prune(2, 99)],
1715                },
1716                "out of bounds",
1717            ),
1718            (
1719                RevisionProposal {
1720                    ops: vec![collapse(4, 7), collapse(5, 8)],
1721                },
1722                "overlap",
1723            ),
1724        ] {
1725            let result = validate_revision(
1726                &proposal,
1727                &roles,
1728                &[],
1729                OrderingContext::SequencedAfterHarvest,
1730            );
1731            assert!(
1732                matches!(result, Err(RevisionReject::InvalidRange { .. })),
1733                "{name}: {result:?}"
1734            );
1735        }
1736    }
1737
1738    #[test]
1739    fn validator_enforces_role_law() {
1740        let messages = transcript();
1741        let roles = roles(&messages);
1742        // Prune over a non-tool message.
1743        let result = validate_revision(
1744            &RevisionProposal {
1745                ops: vec![prune(2, 4)],
1746            },
1747            &roles,
1748            &[],
1749            OrderingContext::SequencedAfterHarvest,
1750        );
1751        assert!(matches!(result, Err(RevisionReject::IllegalRole { .. })));
1752        // Collapse over an assistant tool call (would orphan the pairing).
1753        let result = validate_revision(
1754            &RevisionProposal {
1755                ops: vec![collapse(1, 3)],
1756            },
1757            &roles,
1758            &[],
1759            OrderingContext::SequencedAfterHarvest,
1760        );
1761        assert!(matches!(result, Err(RevisionReject::IllegalRole { .. })));
1762    }
1763
1764    #[test]
1765    fn validator_hard_blocks_quarantine_referenced_spans() {
1766        let messages = transcript();
1767        let spans = vec![SpanReference {
1768            record_id: "mem-q".to_string(),
1769            quarantined: true,
1770            range: Some((2, 2)),
1771        }];
1772        let result = validate_revision(
1773            &RevisionProposal {
1774                ops: vec![prune(2, 3)],
1775            },
1776            &roles(&messages),
1777            &spans,
1778            OrderingContext::SequencedAfterHarvest,
1779        );
1780        assert!(
1781            matches!(
1782                result,
1783                Err(RevisionReject::QuarantineReferenced { ref record_id }) if record_id == "mem-q"
1784            ),
1785            "{result:?}"
1786        );
1787        // A rangeless quarantined citation blocks the whole session.
1788        let spans = vec![SpanReference {
1789            record_id: "mem-q2".to_string(),
1790            quarantined: true,
1791            range: None,
1792        }];
1793        let result = validate_revision(
1794            &RevisionProposal {
1795                ops: vec![collapse(4, 7)],
1796            },
1797            &roles(&messages),
1798            &spans,
1799            OrderingContext::SequencedAfterHarvest,
1800        );
1801        assert!(matches!(
1802            result,
1803            Err(RevisionReject::QuarantineReferenced { .. })
1804        ));
1805    }
1806
1807    #[test]
1808    fn validator_flags_active_spans_without_blocking() {
1809        let messages = transcript();
1810        let spans = vec![
1811            SpanReference {
1812                record_id: "mem-a".to_string(),
1813                quarantined: false,
1814                range: Some((2, 2)),
1815            },
1816            SpanReference {
1817                record_id: "mem-elsewhere".to_string(),
1818                quarantined: false,
1819                range: Some((7, 7)),
1820            },
1821        ];
1822        let validated = validate_revision(
1823            &RevisionProposal {
1824                ops: vec![prune(2, 3)],
1825            },
1826            &roles(&messages),
1827            &spans,
1828            OrderingContext::SequencedAfterHarvest,
1829        )
1830        .expect("active spans flag, not block");
1831        assert_eq!(validated.flagged_active_records, vec!["mem-a".to_string()]);
1832    }
1833
1834    #[test]
1835    fn validator_refuses_when_ordering_invariant_unmet() {
1836        let messages = transcript();
1837        let result = validate_revision(
1838            &RevisionProposal {
1839                ops: vec![prune(2, 3)],
1840            },
1841            &roles(&messages),
1842            &[],
1843            OrderingContext::Cursor(Some(1)),
1844        );
1845        assert!(
1846            matches!(
1847                result,
1848                Err(RevisionReject::OrderingUnmet {
1849                    cursor: 1,
1850                    needed: 3
1851                })
1852            ),
1853            "{result:?}"
1854        );
1855        // Cursor beyond the hull passes; no gate (no distiller) passes.
1856        assert!(
1857            validate_revision(
1858                &RevisionProposal {
1859                    ops: vec![prune(2, 3)],
1860                },
1861                &roles(&messages),
1862                &[],
1863                OrderingContext::Cursor(Some(3)),
1864            )
1865            .is_ok()
1866        );
1867        assert!(
1868            validate_revision(
1869                &RevisionProposal {
1870                    ops: vec![prune(2, 3)],
1871                },
1872                &roles(&messages),
1873                &[],
1874                OrderingContext::Cursor(None),
1875            )
1876            .is_ok()
1877        );
1878    }
1879
1880    // -- replacement construction ----------------------------------------------
1881
1882    #[test]
1883    fn replacement_preserves_pairing_and_collapses_runs() {
1884        let messages = transcript();
1885        let ops = vec![prune(2, 3), collapse(4, 7)];
1886        let (start, end, replacement) =
1887            build_replacement(&messages, &ops).expect("ops produce a hull");
1888        assert_eq!((start, end), (2, 7));
1889        // [2] pruned tool results, [3] untouched assistant, [4..7) → one notice.
1890        assert_eq!(replacement.len(), 3);
1891        match &replacement[0] {
1892            Message::ToolResults { results, .. } => {
1893                assert_eq!(results[0].tool_use_id, "call-1");
1894                let text = meerkat_core::types::text_content(&results[0].content);
1895                assert!(text.contains("[pruned by hygienist"), "{text}");
1896            }
1897            other => panic!("expected tool results, got {other:?}"),
1898        }
1899        match &replacement[1] {
1900            Message::BlockAssistant(assistant) => {
1901                assert!(assistant.text_blocks().any(|text| text.contains("ship")));
1902            }
1903            other => panic!("expected assistant, got {other:?}"),
1904        }
1905        match &replacement[2] {
1906            Message::SystemNotice(notice) => {
1907                let body = notice.body.as_deref().unwrap_or_default();
1908                assert!(body.contains("collapsed 3 messages"), "{body}");
1909                assert!(body.contains("repeated scaffolding"), "{body}");
1910            }
1911            other => panic!("expected system notice, got {other:?}"),
1912        }
1913    }
1914
1915    // -- engine flow -------------------------------------------------------------
1916
1917    struct ScriptedSeam {
1918        messages: Vec<Message>,
1919        rewrites: StdMutex<Vec<(usize, usize, usize)>>,
1920        refuse: bool,
1921        /// CAS value the engine forwarded from read_messages to rewrite. The
1922        /// outer `Option` records whether rewrite was called at all; the inner
1923        /// is the forwarded `expected_parent_revision` — two distinct facts.
1924        #[allow(clippy::option_option)]
1925        last_expected_parent: StdMutex<Option<Option<String>>>,
1926    }
1927
1928    // The head this scripted seam reports at read time — the CAS value the
1929    // engine must forward verbatim to the rewrite.
1930    const SCRIPTED_HEAD_REVISION: &str = "rev-head-at-read";
1931
1932    #[async_trait]
1933    impl TranscriptRevisionSeam for ScriptedSeam {
1934        async fn read_messages(
1935            &self,
1936            _session_key: &str,
1937        ) -> Result<Option<(Vec<Message>, Option<String>)>, String> {
1938            Ok(Some((
1939                self.messages.clone(),
1940                Some(SCRIPTED_HEAD_REVISION.to_string()),
1941            )))
1942        }
1943
1944        async fn rewrite(
1945            &self,
1946            _session_key: &str,
1947            start: usize,
1948            end: usize,
1949            replacement: Vec<Message>,
1950            _note: &str,
1951            expected_parent_revision: Option<String>,
1952        ) -> Result<AppliedRevision, String> {
1953            *self
1954                .last_expected_parent
1955                .lock()
1956                .unwrap_or_else(std::sync::PoisonError::into_inner) =
1957                Some(expected_parent_revision);
1958            if self.refuse {
1959                return Err("session is running".to_string());
1960            }
1961            self.rewrites
1962                .lock()
1963                .unwrap_or_else(std::sync::PoisonError::into_inner)
1964                .push((start, end, replacement.len()));
1965            Ok(AppliedRevision {
1966                parent_revision: "rev-parent".to_string(),
1967                revision: "rev-new".to_string(),
1968                message_count: self.messages.len() - (end - start) + replacement.len(),
1969            })
1970        }
1971    }
1972
1973    struct ScriptedSpans(Vec<SpanReference>);
1974
1975    #[async_trait]
1976    impl SpanReferenceSource for ScriptedSpans {
1977        async fn span_references(
1978            &self,
1979            _identity: &str,
1980            _session_key: &str,
1981        ) -> Result<Vec<SpanReference>, String> {
1982            Ok(self.0.clone())
1983        }
1984    }
1985
1986    struct ScriptedLlm {
1987        reply: String,
1988    }
1989
1990    #[async_trait]
1991    impl LlmClient for ScriptedLlm {
1992        fn stream<'a>(&'a self, _request: &'a LlmRequest) -> meerkat_client::types::LlmStream<'a> {
1993            let reply = self.reply.clone();
1994            Box::pin(futures::stream::iter(vec![
1995                Ok(LlmEvent::TextDelta {
1996                    delta: reply,
1997                    meta: None,
1998                }),
1999                Ok(LlmEvent::Done {
2000                    outcome: LlmDoneOutcome::Success {
2001                        stop_reason: meerkat_core::StopReason::EndTurn,
2002                    },
2003                }),
2004            ]))
2005        }
2006
2007        fn provider(&self) -> Provider {
2008            Provider::Other
2009        }
2010
2011        async fn health_check(&self) -> Result<(), LlmError> {
2012            Ok(())
2013        }
2014    }
2015
2016    struct ScriptedHandle {
2017        reply: String,
2018    }
2019
2020    #[async_trait]
2021    impl HygienistClientHandle for ScriptedHandle {
2022        async fn client(&self) -> Result<Arc<dyn LlmClient>, HygienistError> {
2023            Ok(Arc::new(ScriptedLlm {
2024                reply: self.reply.clone(),
2025            }))
2026        }
2027    }
2028
2029    struct FixedGate(u64);
2030
2031    impl DistillationGate for FixedGate {
2032        fn distilled_through(&self, _identity: &str, _session_key: &str) -> u64 {
2033            self.0
2034        }
2035    }
2036
2037    fn engine_with(
2038        reply: &str,
2039        seam: Arc<ScriptedSeam>,
2040        spans: Vec<SpanReference>,
2041        gate: Option<Arc<dyn DistillationGate>>,
2042        runs_per_day: u32,
2043    ) -> Arc<HygienistEngine> {
2044        Arc::new(HygienistEngine::new(
2045            HygienistProfile::embedded_default(),
2046            HygienistConfig {
2047                enabled: true,
2048                runs_per_day,
2049                model: None,
2050            },
2051            Arc::new(ScriptedHandle {
2052                reply: reply.to_string(),
2053            }),
2054            seam,
2055            Arc::new(ScriptedSpans(spans)),
2056            gate,
2057            "family",
2058        ))
2059    }
2060
2061    fn seam() -> Arc<ScriptedSeam> {
2062        Arc::new(ScriptedSeam {
2063            messages: transcript(),
2064            rewrites: StdMutex::new(Vec::new()),
2065            refuse: false,
2066            last_expected_parent: StdMutex::new(None),
2067        })
2068    }
2069
2070    const PRUNE_REPLY: &str =
2071        r#"{"ops": [{"op": "prune_tool_results", "range": [2, 3], "rationale": "dead"}]}"#;
2072
2073    #[tokio::test]
2074    async fn engine_applies_validated_revision_and_emits_events() {
2075        let scripted = seam();
2076        let engine = engine_with(PRUNE_REPLY, scripted.clone(), Vec::new(), None, 2);
2077        let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2078        engine.set_event_sink(sink.clone());
2079        let outcome = engine
2080            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2081            .await;
2082        match outcome {
2083            HygieneOutcome::Applied { revision, ops, .. } => {
2084                assert_eq!(revision.revision, "rev-new");
2085                assert_eq!(ops, 1);
2086            }
2087            other => panic!("expected Applied, got {other:?}"),
2088        }
2089        assert_eq!(
2090            scripted
2091                .rewrites
2092                .lock()
2093                .unwrap_or_else(std::sync::PoisonError::into_inner)
2094                .as_slice(),
2095            &[(2, 3, 1)]
2096        );
2097        // Ask 4 refinement: the head observed at read time is forwarded to the
2098        // rewrite as the compare-and-swap parent (no longer None).
2099        assert_eq!(
2100            scripted
2101                .last_expected_parent
2102                .lock()
2103                .unwrap_or_else(std::sync::PoisonError::into_inner)
2104                .clone(),
2105            Some(Some(SCRIPTED_HEAD_REVISION.to_string())),
2106            "hygiene must CAS against the head it read"
2107        );
2108        assert_eq!(
2109            sink.types(),
2110            vec!["memory.hygiene.proposed", "memory.hygiene.applied"]
2111        );
2112    }
2113
2114    #[tokio::test]
2115    async fn engine_blocks_quarantine_referenced_revision() {
2116        let scripted = seam();
2117        let spans = vec![SpanReference {
2118            record_id: "mem-q".to_string(),
2119            quarantined: true,
2120            range: Some((2, 2)),
2121        }];
2122        let engine = engine_with(PRUNE_REPLY, scripted.clone(), spans, None, 2);
2123        let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2124        engine.set_event_sink(sink.clone());
2125        let outcome = engine
2126            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2127            .await;
2128        assert!(
2129            matches!(outcome, HygieneOutcome::Blocked { .. }),
2130            "{outcome:?}"
2131        );
2132        assert!(
2133            scripted
2134                .rewrites
2135                .lock()
2136                .unwrap_or_else(std::sync::PoisonError::into_inner)
2137                .is_empty(),
2138            "blocked revision must not reach the seam"
2139        );
2140        assert_eq!(sink.types(), vec!["memory.hygiene.blocked"]);
2141    }
2142
2143    #[tokio::test]
2144    async fn engine_refuses_on_demand_beyond_distiller_cursor() {
2145        let scripted = seam();
2146        let engine = engine_with(
2147            PRUNE_REPLY,
2148            scripted.clone(),
2149            Vec::new(),
2150            Some(Arc::new(FixedGate(1))),
2151            2,
2152        );
2153        let outcome = engine
2154            .hygiene_now("identity:luka", "sess-1", HygieneCause::OnDemand)
2155            .await;
2156        assert!(
2157            matches!(outcome, HygieneOutcome::Blocked { .. }),
2158            "{outcome:?}"
2159        );
2160        // The same pass sequenced behind the harvest is fine.
2161        let outcome = engine
2162            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2163            .await;
2164        assert!(
2165            matches!(outcome, HygieneOutcome::Applied { .. }),
2166            "{outcome:?}"
2167        );
2168    }
2169
2170    #[tokio::test]
2171    async fn engine_skips_noop_and_respects_budget() {
2172        let scripted = seam();
2173        let engine = engine_with(r#"{"ops": []}"#, scripted.clone(), Vec::new(), None, 1);
2174        let outcome = engine
2175            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2176            .await;
2177        assert!(
2178            matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("no-op")),
2179            "{outcome:?}"
2180        );
2181        // The no-op burned the single budgeted run; the next pass is denied.
2182        let outcome = engine
2183            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2184            .await;
2185        assert!(
2186            matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("budget denied")),
2187            "{outcome:?}"
2188        );
2189    }
2190
2191    #[tokio::test]
2192    async fn engine_reports_seam_refusal_as_skip() {
2193        let scripted = Arc::new(ScriptedSeam {
2194            messages: transcript(),
2195            rewrites: StdMutex::new(Vec::new()),
2196            refuse: true,
2197            last_expected_parent: StdMutex::new(None),
2198        });
2199        let engine = engine_with(PRUNE_REPLY, scripted, Vec::new(), None, 2);
2200        let outcome = engine
2201            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2202            .await;
2203        assert!(
2204            matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("apply refused")),
2205            "{outcome:?}"
2206        );
2207    }
2208
2209    // -- trigger sequencing -------------------------------------------------------
2210
2211    #[tokio::test]
2212    async fn follow_up_runs_after_satisfied_harvest_and_withholds_otherwise() {
2213        let scripted = seam();
2214        let engine = engine_with(PRUNE_REPLY, scripted.clone(), Vec::new(), None, 4);
2215        let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2216        engine.set_event_sink(sink.clone());
2217        let follow_up = distiller_follow_up(engine);
2218
2219        follow_up(
2220            "identity:luka",
2221            "sess-1",
2222            &DistillOutcome::Skipped {
2223                reason: "budget denied: window budget exhausted (2/2 runs)".to_string(),
2224            },
2225        );
2226        // The withheld pass emits synchronously; nothing was spawned.
2227        assert_eq!(sink.types(), vec!["memory.hygiene.skipped"]);
2228
2229        follow_up(
2230            "identity:luka",
2231            "sess-1",
2232            &DistillOutcome::Completed {
2233                run_id: "distill-1".to_string(),
2234                written: 0,
2235                quarantined: 0,
2236            },
2237        );
2238        // The satisfied harvest spawns a detached pass; wait for it.
2239        for _ in 0..100 {
2240            if scripted
2241                .rewrites
2242                .lock()
2243                .unwrap_or_else(std::sync::PoisonError::into_inner)
2244                .len()
2245                == 1
2246            {
2247                break;
2248            }
2249            tokio::time::sleep(Duration::from_millis(10)).await;
2250        }
2251        assert_eq!(
2252            scripted
2253                .rewrites
2254                .lock()
2255                .unwrap_or_else(std::sync::PoisonError::into_inner)
2256                .len(),
2257            1,
2258            "satisfied harvest must trigger the pass"
2259        );
2260    }
2261
2262    // -- profile ----------------------------------------------------------------
2263
2264    #[test]
2265    fn embedded_prompt_matches_calibration_bundle() -> Result<(), Box<dyn std::error::Error>> {
2266        let bundle =
2267            Path::new(env!("CARGO_MANIFEST_DIR")).join("../memory-evals/prompts/hygienist-v0.md");
2268        if !bundle.is_file() {
2269            return Ok(());
2270        }
2271        let text = std::fs::read_to_string(bundle)?;
2272        assert_eq!(
2273            text, EMBEDDED_PROMPT_V0,
2274            "memory-evals/prompts/hygienist-v0.md and src/memory/hygienist_prompt_v0.md have drifted"
2275        );
2276        Ok(())
2277    }
2278
2279    #[test]
2280    fn model_override_is_fail_loud() {
2281        let profile = HygienistProfile::embedded_default();
2282        assert!(profile.clone().with_model_override("").is_err());
2283        assert!(
2284            profile
2285                .clone()
2286                .with_model_override("not-a-real-model-xyz")
2287                .is_err()
2288        );
2289        let overridden = profile
2290            .with_model_override("claude-haiku-4-5")
2291            .expect("catalog model accepted");
2292        assert_eq!(overridden.model, "claude-haiku-4-5");
2293    }
2294}