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::distiller::{CompactionFollowUp, DistillOutcome, DistillerEngine};
46use crate::memory::events::{MemoryEventSink, MemoryTimelineEvent};
47use crate::memory::guards::{BackgroundBudget, BackgroundBudgetConfig};
48use crate::memory::records::{ManifestTier, MemoryScope, RecordStatus};
49use crate::memory::selector::FactorySelectorHandle;
50use crate::memory::sqlite_store::SqliteAgentMemoryStore;
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 the bundled store's existing readers: the realm's
547/// quarantine queue (hard-block set) plus the identity/realm-scope active
548/// manifests resolved to full records (audit-flag set). Mob-scope records
549/// do not carry session evidence (promotion copies drop evidence refs), so
550/// identity + realm scopes are the complete evidence-citing population.
551pub struct StoreSpanReferenceSource {
552    store: Arc<SqliteAgentMemoryStore>,
553    realm: String,
554}
555
556impl StoreSpanReferenceSource {
557    pub fn new(store: Arc<SqliteAgentMemoryStore>, realm: impl Into<String>) -> Self {
558        Self {
559            store,
560            realm: realm.into(),
561        }
562    }
563}
564
565#[async_trait]
566impl SpanReferenceSource for StoreSpanReferenceSource {
567    async fn span_references(
568        &self,
569        identity: &str,
570        session_key: &str,
571    ) -> Result<Vec<SpanReference>, String> {
572        use crate::identity_first::agent_memory::AgentMemoryProvider;
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        if let AgentEvent::CompactionCompleted { .. } = &envelope.payload {
1499            match &envelope.source {
1500                meerkat_core::event::EventSourceIdentity::Session { session_id } => {
1501                    self.engine.spawn_detached(
1502                        identity,
1503                        &session_id.to_string(),
1504                        HygieneCause::PostCompaction,
1505                    );
1506                }
1507                _ => {
1508                    tracing::warn!(
1509                        identity,
1510                        "agent memory hygienist: compaction event without session \
1511                         attribution; pass skipped"
1512                    );
1513                }
1514            }
1515        }
1516    }
1517}
1518
1519// ---------------------------------------------------------------------------
1520// LLM call
1521// ---------------------------------------------------------------------------
1522
1523/// One bounded completion against the profile's model/params.
1524pub async fn complete_text(
1525    client: &dyn LlmClient,
1526    profile: &HygienistProfile,
1527    prompt: String,
1528) -> Result<String, HygienistError> {
1529    let request = LlmRequest::new(
1530        &profile.model,
1531        vec![Message::User(UserMessage::text(prompt))],
1532    )
1533    .with_max_tokens(profile.params.max_output_tokens)
1534    .with_temperature(profile.params.temperature);
1535    let mut stream = client.stream(&request);
1536    let mut text = String::new();
1537    while let Some(event) = stream.next().await {
1538        match event.map_err(classify_llm_error)? {
1539            LlmEvent::TextDelta { delta, .. } => text.push_str(&delta),
1540            LlmEvent::Done { outcome } => match outcome {
1541                LlmDoneOutcome::Success { .. } => break,
1542                LlmDoneOutcome::Error { error } => return Err(classify_llm_error(error)),
1543            },
1544            _ => {}
1545        }
1546    }
1547    Ok(text)
1548}
1549
1550fn classify_llm_error(error: LlmError) -> HygienistError {
1551    match error {
1552        LlmError::AuthenticationFailed { .. } | LlmError::InvalidApiKey => {
1553            HygienistError::Auth(error.to_string())
1554        }
1555        other => HygienistError::Client(other.to_string()),
1556    }
1557}
1558
1559fn now_ms() -> u64 {
1560    std::time::SystemTime::now()
1561        .duration_since(std::time::UNIX_EPOCH)
1562        .map(|duration| duration.as_millis() as u64)
1563        .unwrap_or(0)
1564}
1565
1566#[cfg(test)]
1567#[allow(clippy::expect_used, clippy::panic)]
1568mod tests {
1569    use super::*;
1570    use meerkat_core::StopReason;
1571    use meerkat_core::types::{AssistantBlock, BlockAssistantMessage, ToolResult};
1572    use std::sync::Mutex as StdMutex;
1573
1574    fn user(text: &str) -> Message {
1575        Message::User(UserMessage::text(text))
1576    }
1577
1578    fn assistant(text: &str) -> Message {
1579        Message::BlockAssistant(BlockAssistantMessage::new(
1580            vec![AssistantBlock::Text {
1581                text: text.to_string(),
1582                meta: None,
1583            }],
1584            StopReason::EndTurn,
1585        ))
1586    }
1587
1588    fn assistant_tool_call(id: &str) -> Message {
1589        let args = serde_json::value::RawValue::from_string(r#"{"cmd":"ls"}"#.to_string())
1590            .expect("raw args");
1591        Message::BlockAssistant(BlockAssistantMessage::new(
1592            vec![AssistantBlock::ToolUse {
1593                id: id.to_string(),
1594                name: "shell".to_string(),
1595                args,
1596                meta: None,
1597            }],
1598            StopReason::ToolUse,
1599        ))
1600    }
1601
1602    fn tool_results(id: &str, text: &str) -> Message {
1603        Message::tool_results(vec![ToolResult::new(
1604            id.to_string(),
1605            text.to_string(),
1606            false,
1607        )])
1608    }
1609
1610    fn transcript() -> Vec<Message> {
1611        vec![
1612            user("please check the logs"),               // 0
1613            assistant_tool_call("call-1"),               // 1
1614            tool_results("call-1", "3000 lines of log"), // 2
1615            assistant("logs are clean; decision: ship"), // 3
1616            user("scaffold notice"),                     // 4
1617            user("scaffold notice"),                     // 5
1618            user("scaffold notice"),                     // 6
1619            assistant("done"),                           // 7
1620        ]
1621    }
1622
1623    fn roles(messages: &[Message]) -> Vec<HygieneRole> {
1624        messages.iter().map(HygieneRole::of).collect()
1625    }
1626
1627    fn prune(start: usize, end: usize) -> RevisionOp {
1628        RevisionOp {
1629            action: RevisionAction::PruneToolResults,
1630            start,
1631            end,
1632            rationale: "dead output".to_string(),
1633        }
1634    }
1635
1636    fn collapse(start: usize, end: usize) -> RevisionOp {
1637        RevisionOp {
1638            action: RevisionAction::Collapse {
1639                replacement: "repeated scaffolding".to_string(),
1640            },
1641            start,
1642            end,
1643            rationale: "scaffolding".to_string(),
1644        }
1645    }
1646
1647    // -- parse ---------------------------------------------------------------
1648
1649    #[test]
1650    fn parse_accepts_ops_and_rejects_unknown() {
1651        let proposal = parse_revision_reply(
1652            r#"{"ops": [
1653                {"op": "prune_tool_results", "range": [2, 3], "rationale": "dead"},
1654                {"op": "collapse", "range": [4, 7], "replacement": "notices", "rationale": "dup"}
1655            ]}"#,
1656        )
1657        .expect("parses");
1658        assert_eq!(proposal.ops.len(), 2);
1659        assert_eq!(proposal.ops[0].action, RevisionAction::PruneToolResults);
1660        assert!(matches!(
1661            proposal.ops[1].action,
1662            RevisionAction::Collapse { .. }
1663        ));
1664
1665        assert!(parse_revision_reply(r#"{"ops": [{"op": "delete", "range": [0, 1]}]}"#).is_err());
1666        assert!(
1667            parse_revision_reply(r#"{"ops": [{"op": "collapse", "range": [0, 1]}]}"#).is_err(),
1668            "collapse without replacement must not parse"
1669        );
1670        assert_eq!(
1671            parse_revision_reply(r#"{"ops": []}"#).expect("noop parses"),
1672            RevisionProposal::default()
1673        );
1674        // Prose tolerance: the JSON object is sliced out.
1675        assert!(parse_revision_reply("Sure! {\"ops\": []} Done.").is_ok());
1676    }
1677
1678    // -- validator matrix ------------------------------------------------------
1679
1680    #[test]
1681    fn validator_accepts_legal_prune_and_collapse() {
1682        let messages = transcript();
1683        let proposal = RevisionProposal {
1684            ops: vec![prune(2, 3), collapse(4, 7)],
1685        };
1686        let validated = validate_revision(
1687            &proposal,
1688            &roles(&messages),
1689            &[],
1690            OrderingContext::SequencedAfterHarvest,
1691        )
1692        .expect("legal ops validate");
1693        assert_eq!(validated.ops.len(), 2);
1694        assert!(validated.flagged_active_records.is_empty());
1695    }
1696
1697    #[test]
1698    fn validator_rejects_malformed_ranges() {
1699        let messages = transcript();
1700        let roles = roles(&messages);
1701        for (proposal, name) in [
1702            (
1703                RevisionProposal {
1704                    ops: vec![prune(3, 3)],
1705                },
1706                "empty",
1707            ),
1708            (
1709                RevisionProposal {
1710                    ops: vec![prune(2, 99)],
1711                },
1712                "out of bounds",
1713            ),
1714            (
1715                RevisionProposal {
1716                    ops: vec![collapse(4, 7), collapse(5, 8)],
1717                },
1718                "overlap",
1719            ),
1720        ] {
1721            let result = validate_revision(
1722                &proposal,
1723                &roles,
1724                &[],
1725                OrderingContext::SequencedAfterHarvest,
1726            );
1727            assert!(
1728                matches!(result, Err(RevisionReject::InvalidRange { .. })),
1729                "{name}: {result:?}"
1730            );
1731        }
1732    }
1733
1734    #[test]
1735    fn validator_enforces_role_law() {
1736        let messages = transcript();
1737        let roles = roles(&messages);
1738        // Prune over a non-tool message.
1739        let result = validate_revision(
1740            &RevisionProposal {
1741                ops: vec![prune(2, 4)],
1742            },
1743            &roles,
1744            &[],
1745            OrderingContext::SequencedAfterHarvest,
1746        );
1747        assert!(matches!(result, Err(RevisionReject::IllegalRole { .. })));
1748        // Collapse over an assistant tool call (would orphan the pairing).
1749        let result = validate_revision(
1750            &RevisionProposal {
1751                ops: vec![collapse(1, 3)],
1752            },
1753            &roles,
1754            &[],
1755            OrderingContext::SequencedAfterHarvest,
1756        );
1757        assert!(matches!(result, Err(RevisionReject::IllegalRole { .. })));
1758    }
1759
1760    #[test]
1761    fn validator_hard_blocks_quarantine_referenced_spans() {
1762        let messages = transcript();
1763        let spans = vec![SpanReference {
1764            record_id: "mem-q".to_string(),
1765            quarantined: true,
1766            range: Some((2, 2)),
1767        }];
1768        let result = validate_revision(
1769            &RevisionProposal {
1770                ops: vec![prune(2, 3)],
1771            },
1772            &roles(&messages),
1773            &spans,
1774            OrderingContext::SequencedAfterHarvest,
1775        );
1776        assert!(
1777            matches!(
1778                result,
1779                Err(RevisionReject::QuarantineReferenced { ref record_id }) if record_id == "mem-q"
1780            ),
1781            "{result:?}"
1782        );
1783        // A rangeless quarantined citation blocks the whole session.
1784        let spans = vec![SpanReference {
1785            record_id: "mem-q2".to_string(),
1786            quarantined: true,
1787            range: None,
1788        }];
1789        let result = validate_revision(
1790            &RevisionProposal {
1791                ops: vec![collapse(4, 7)],
1792            },
1793            &roles(&messages),
1794            &spans,
1795            OrderingContext::SequencedAfterHarvest,
1796        );
1797        assert!(matches!(
1798            result,
1799            Err(RevisionReject::QuarantineReferenced { .. })
1800        ));
1801    }
1802
1803    #[test]
1804    fn validator_flags_active_spans_without_blocking() {
1805        let messages = transcript();
1806        let spans = vec![
1807            SpanReference {
1808                record_id: "mem-a".to_string(),
1809                quarantined: false,
1810                range: Some((2, 2)),
1811            },
1812            SpanReference {
1813                record_id: "mem-elsewhere".to_string(),
1814                quarantined: false,
1815                range: Some((7, 7)),
1816            },
1817        ];
1818        let validated = validate_revision(
1819            &RevisionProposal {
1820                ops: vec![prune(2, 3)],
1821            },
1822            &roles(&messages),
1823            &spans,
1824            OrderingContext::SequencedAfterHarvest,
1825        )
1826        .expect("active spans flag, not block");
1827        assert_eq!(validated.flagged_active_records, vec!["mem-a".to_string()]);
1828    }
1829
1830    #[test]
1831    fn validator_refuses_when_ordering_invariant_unmet() {
1832        let messages = transcript();
1833        let result = validate_revision(
1834            &RevisionProposal {
1835                ops: vec![prune(2, 3)],
1836            },
1837            &roles(&messages),
1838            &[],
1839            OrderingContext::Cursor(Some(1)),
1840        );
1841        assert!(
1842            matches!(
1843                result,
1844                Err(RevisionReject::OrderingUnmet {
1845                    cursor: 1,
1846                    needed: 3
1847                })
1848            ),
1849            "{result:?}"
1850        );
1851        // Cursor beyond the hull passes; no gate (no distiller) passes.
1852        assert!(
1853            validate_revision(
1854                &RevisionProposal {
1855                    ops: vec![prune(2, 3)],
1856                },
1857                &roles(&messages),
1858                &[],
1859                OrderingContext::Cursor(Some(3)),
1860            )
1861            .is_ok()
1862        );
1863        assert!(
1864            validate_revision(
1865                &RevisionProposal {
1866                    ops: vec![prune(2, 3)],
1867                },
1868                &roles(&messages),
1869                &[],
1870                OrderingContext::Cursor(None),
1871            )
1872            .is_ok()
1873        );
1874    }
1875
1876    // -- replacement construction ----------------------------------------------
1877
1878    #[test]
1879    fn replacement_preserves_pairing_and_collapses_runs() {
1880        let messages = transcript();
1881        let ops = vec![prune(2, 3), collapse(4, 7)];
1882        let (start, end, replacement) =
1883            build_replacement(&messages, &ops).expect("ops produce a hull");
1884        assert_eq!((start, end), (2, 7));
1885        // [2] pruned tool results, [3] untouched assistant, [4..7) → one notice.
1886        assert_eq!(replacement.len(), 3);
1887        match &replacement[0] {
1888            Message::ToolResults { results, .. } => {
1889                assert_eq!(results[0].tool_use_id, "call-1");
1890                let text = meerkat_core::types::text_content(&results[0].content);
1891                assert!(text.contains("[pruned by hygienist"), "{text}");
1892            }
1893            other => panic!("expected tool results, got {other:?}"),
1894        }
1895        match &replacement[1] {
1896            Message::BlockAssistant(assistant) => {
1897                assert!(assistant.text_blocks().any(|text| text.contains("ship")));
1898            }
1899            other => panic!("expected assistant, got {other:?}"),
1900        }
1901        match &replacement[2] {
1902            Message::SystemNotice(notice) => {
1903                let body = notice.body.as_deref().unwrap_or_default();
1904                assert!(body.contains("collapsed 3 messages"), "{body}");
1905                assert!(body.contains("repeated scaffolding"), "{body}");
1906            }
1907            other => panic!("expected system notice, got {other:?}"),
1908        }
1909    }
1910
1911    // -- engine flow -------------------------------------------------------------
1912
1913    struct ScriptedSeam {
1914        messages: Vec<Message>,
1915        rewrites: StdMutex<Vec<(usize, usize, usize)>>,
1916        refuse: bool,
1917        /// CAS value the engine forwarded from read_messages to rewrite. The
1918        /// outer `Option` records whether rewrite was called at all; the inner
1919        /// is the forwarded `expected_parent_revision` — two distinct facts.
1920        #[allow(clippy::option_option)]
1921        last_expected_parent: StdMutex<Option<Option<String>>>,
1922    }
1923
1924    // The head this scripted seam reports at read time — the CAS value the
1925    // engine must forward verbatim to the rewrite.
1926    const SCRIPTED_HEAD_REVISION: &str = "rev-head-at-read";
1927
1928    #[async_trait]
1929    impl TranscriptRevisionSeam for ScriptedSeam {
1930        async fn read_messages(
1931            &self,
1932            _session_key: &str,
1933        ) -> Result<Option<(Vec<Message>, Option<String>)>, String> {
1934            Ok(Some((
1935                self.messages.clone(),
1936                Some(SCRIPTED_HEAD_REVISION.to_string()),
1937            )))
1938        }
1939
1940        async fn rewrite(
1941            &self,
1942            _session_key: &str,
1943            start: usize,
1944            end: usize,
1945            replacement: Vec<Message>,
1946            _note: &str,
1947            expected_parent_revision: Option<String>,
1948        ) -> Result<AppliedRevision, String> {
1949            *self
1950                .last_expected_parent
1951                .lock()
1952                .unwrap_or_else(std::sync::PoisonError::into_inner) =
1953                Some(expected_parent_revision);
1954            if self.refuse {
1955                return Err("session is running".to_string());
1956            }
1957            self.rewrites
1958                .lock()
1959                .unwrap_or_else(std::sync::PoisonError::into_inner)
1960                .push((start, end, replacement.len()));
1961            Ok(AppliedRevision {
1962                parent_revision: "rev-parent".to_string(),
1963                revision: "rev-new".to_string(),
1964                message_count: self.messages.len() - (end - start) + replacement.len(),
1965            })
1966        }
1967    }
1968
1969    struct ScriptedSpans(Vec<SpanReference>);
1970
1971    #[async_trait]
1972    impl SpanReferenceSource for ScriptedSpans {
1973        async fn span_references(
1974            &self,
1975            _identity: &str,
1976            _session_key: &str,
1977        ) -> Result<Vec<SpanReference>, String> {
1978            Ok(self.0.clone())
1979        }
1980    }
1981
1982    struct ScriptedLlm {
1983        reply: String,
1984    }
1985
1986    #[async_trait]
1987    impl LlmClient for ScriptedLlm {
1988        fn stream<'a>(&'a self, _request: &'a LlmRequest) -> meerkat_client::types::LlmStream<'a> {
1989            let reply = self.reply.clone();
1990            Box::pin(futures::stream::iter(vec![
1991                Ok(LlmEvent::TextDelta {
1992                    delta: reply,
1993                    meta: None,
1994                }),
1995                Ok(LlmEvent::Done {
1996                    outcome: LlmDoneOutcome::Success {
1997                        stop_reason: meerkat_core::StopReason::EndTurn,
1998                    },
1999                }),
2000            ]))
2001        }
2002
2003        fn provider(&self) -> Provider {
2004            Provider::Other
2005        }
2006
2007        async fn health_check(&self) -> Result<(), LlmError> {
2008            Ok(())
2009        }
2010    }
2011
2012    struct ScriptedHandle {
2013        reply: String,
2014    }
2015
2016    #[async_trait]
2017    impl HygienistClientHandle for ScriptedHandle {
2018        async fn client(&self) -> Result<Arc<dyn LlmClient>, HygienistError> {
2019            Ok(Arc::new(ScriptedLlm {
2020                reply: self.reply.clone(),
2021            }))
2022        }
2023    }
2024
2025    struct FixedGate(u64);
2026
2027    impl DistillationGate for FixedGate {
2028        fn distilled_through(&self, _identity: &str, _session_key: &str) -> u64 {
2029            self.0
2030        }
2031    }
2032
2033    fn engine_with(
2034        reply: &str,
2035        seam: Arc<ScriptedSeam>,
2036        spans: Vec<SpanReference>,
2037        gate: Option<Arc<dyn DistillationGate>>,
2038        runs_per_day: u32,
2039    ) -> Arc<HygienistEngine> {
2040        Arc::new(HygienistEngine::new(
2041            HygienistProfile::embedded_default(),
2042            HygienistConfig {
2043                enabled: true,
2044                runs_per_day,
2045                model: None,
2046            },
2047            Arc::new(ScriptedHandle {
2048                reply: reply.to_string(),
2049            }),
2050            seam,
2051            Arc::new(ScriptedSpans(spans)),
2052            gate,
2053            "family",
2054        ))
2055    }
2056
2057    fn seam() -> Arc<ScriptedSeam> {
2058        Arc::new(ScriptedSeam {
2059            messages: transcript(),
2060            rewrites: StdMutex::new(Vec::new()),
2061            refuse: false,
2062            last_expected_parent: StdMutex::new(None),
2063        })
2064    }
2065
2066    const PRUNE_REPLY: &str =
2067        r#"{"ops": [{"op": "prune_tool_results", "range": [2, 3], "rationale": "dead"}]}"#;
2068
2069    #[tokio::test]
2070    async fn engine_applies_validated_revision_and_emits_events() {
2071        let scripted = seam();
2072        let engine = engine_with(PRUNE_REPLY, scripted.clone(), Vec::new(), None, 2);
2073        let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2074        engine.set_event_sink(sink.clone());
2075        let outcome = engine
2076            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2077            .await;
2078        match outcome {
2079            HygieneOutcome::Applied { revision, ops, .. } => {
2080                assert_eq!(revision.revision, "rev-new");
2081                assert_eq!(ops, 1);
2082            }
2083            other => panic!("expected Applied, got {other:?}"),
2084        }
2085        assert_eq!(
2086            scripted
2087                .rewrites
2088                .lock()
2089                .unwrap_or_else(std::sync::PoisonError::into_inner)
2090                .as_slice(),
2091            &[(2, 3, 1)]
2092        );
2093        // Ask 4 refinement: the head observed at read time is forwarded to the
2094        // rewrite as the compare-and-swap parent (no longer None).
2095        assert_eq!(
2096            scripted
2097                .last_expected_parent
2098                .lock()
2099                .unwrap_or_else(std::sync::PoisonError::into_inner)
2100                .clone(),
2101            Some(Some(SCRIPTED_HEAD_REVISION.to_string())),
2102            "hygiene must CAS against the head it read"
2103        );
2104        assert_eq!(
2105            sink.types(),
2106            vec!["memory.hygiene.proposed", "memory.hygiene.applied"]
2107        );
2108    }
2109
2110    #[tokio::test]
2111    async fn engine_blocks_quarantine_referenced_revision() {
2112        let scripted = seam();
2113        let spans = vec![SpanReference {
2114            record_id: "mem-q".to_string(),
2115            quarantined: true,
2116            range: Some((2, 2)),
2117        }];
2118        let engine = engine_with(PRUNE_REPLY, scripted.clone(), spans, None, 2);
2119        let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2120        engine.set_event_sink(sink.clone());
2121        let outcome = engine
2122            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2123            .await;
2124        assert!(
2125            matches!(outcome, HygieneOutcome::Blocked { .. }),
2126            "{outcome:?}"
2127        );
2128        assert!(
2129            scripted
2130                .rewrites
2131                .lock()
2132                .unwrap_or_else(std::sync::PoisonError::into_inner)
2133                .is_empty(),
2134            "blocked revision must not reach the seam"
2135        );
2136        assert_eq!(sink.types(), vec!["memory.hygiene.blocked"]);
2137    }
2138
2139    #[tokio::test]
2140    async fn engine_refuses_on_demand_beyond_distiller_cursor() {
2141        let scripted = seam();
2142        let engine = engine_with(
2143            PRUNE_REPLY,
2144            scripted.clone(),
2145            Vec::new(),
2146            Some(Arc::new(FixedGate(1))),
2147            2,
2148        );
2149        let outcome = engine
2150            .hygiene_now("identity:luka", "sess-1", HygieneCause::OnDemand)
2151            .await;
2152        assert!(
2153            matches!(outcome, HygieneOutcome::Blocked { .. }),
2154            "{outcome:?}"
2155        );
2156        // The same pass sequenced behind the harvest is fine.
2157        let outcome = engine
2158            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2159            .await;
2160        assert!(
2161            matches!(outcome, HygieneOutcome::Applied { .. }),
2162            "{outcome:?}"
2163        );
2164    }
2165
2166    #[tokio::test]
2167    async fn engine_skips_noop_and_respects_budget() {
2168        let scripted = seam();
2169        let engine = engine_with(r#"{"ops": []}"#, scripted.clone(), Vec::new(), None, 1);
2170        let outcome = engine
2171            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2172            .await;
2173        assert!(
2174            matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("no-op")),
2175            "{outcome:?}"
2176        );
2177        // The no-op burned the single budgeted run; the next pass is denied.
2178        let outcome = engine
2179            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2180            .await;
2181        assert!(
2182            matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("budget denied")),
2183            "{outcome:?}"
2184        );
2185    }
2186
2187    #[tokio::test]
2188    async fn engine_reports_seam_refusal_as_skip() {
2189        let scripted = Arc::new(ScriptedSeam {
2190            messages: transcript(),
2191            rewrites: StdMutex::new(Vec::new()),
2192            refuse: true,
2193            last_expected_parent: StdMutex::new(None),
2194        });
2195        let engine = engine_with(PRUNE_REPLY, scripted, Vec::new(), None, 2);
2196        let outcome = engine
2197            .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2198            .await;
2199        assert!(
2200            matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("apply refused")),
2201            "{outcome:?}"
2202        );
2203    }
2204
2205    // -- trigger sequencing -------------------------------------------------------
2206
2207    #[tokio::test]
2208    async fn follow_up_runs_after_satisfied_harvest_and_withholds_otherwise() {
2209        let scripted = seam();
2210        let engine = engine_with(PRUNE_REPLY, scripted.clone(), Vec::new(), None, 4);
2211        let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2212        engine.set_event_sink(sink.clone());
2213        let follow_up = distiller_follow_up(engine);
2214
2215        follow_up(
2216            "identity:luka",
2217            "sess-1",
2218            &DistillOutcome::Skipped {
2219                reason: "budget denied: window budget exhausted (2/2 runs)".to_string(),
2220            },
2221        );
2222        // The withheld pass emits synchronously; nothing was spawned.
2223        assert_eq!(sink.types(), vec!["memory.hygiene.skipped"]);
2224
2225        follow_up(
2226            "identity:luka",
2227            "sess-1",
2228            &DistillOutcome::Completed {
2229                run_id: "distill-1".to_string(),
2230                written: 0,
2231                quarantined: 0,
2232            },
2233        );
2234        // The satisfied harvest spawns a detached pass; wait for it.
2235        for _ in 0..100 {
2236            if scripted
2237                .rewrites
2238                .lock()
2239                .unwrap_or_else(std::sync::PoisonError::into_inner)
2240                .len()
2241                == 1
2242            {
2243                break;
2244            }
2245            tokio::time::sleep(Duration::from_millis(10)).await;
2246        }
2247        assert_eq!(
2248            scripted
2249                .rewrites
2250                .lock()
2251                .unwrap_or_else(std::sync::PoisonError::into_inner)
2252                .len(),
2253            1,
2254            "satisfied harvest must trigger the pass"
2255        );
2256    }
2257
2258    // -- profile ----------------------------------------------------------------
2259
2260    #[test]
2261    fn embedded_prompt_matches_calibration_bundle() -> Result<(), Box<dyn std::error::Error>> {
2262        let bundle =
2263            Path::new(env!("CARGO_MANIFEST_DIR")).join("../memory-evals/prompts/hygienist-v0.md");
2264        if !bundle.is_file() {
2265            return Ok(());
2266        }
2267        let text = std::fs::read_to_string(bundle)?;
2268        assert_eq!(
2269            text, EMBEDDED_PROMPT_V0,
2270            "memory-evals/prompts/hygienist-v0.md and src/memory/hygienist_prompt_v0.md have drifted"
2271        );
2272        Ok(())
2273    }
2274
2275    #[test]
2276    fn model_override_is_fail_loud() {
2277        let profile = HygienistProfile::embedded_default();
2278        assert!(profile.clone().with_model_override("").is_err());
2279        assert!(
2280            profile
2281                .clone()
2282                .with_model_override("not-a-real-model-xyz")
2283                .is_err()
2284        );
2285        let overridden = profile
2286            .with_model_override("claude-haiku-4-5")
2287            .expect("catalog model accepted");
2288        assert_eq!(overridden.model, "claude-haiku-4-5");
2289    }
2290}