Skip to main content

ai_memory/mcp/tools/
atomise.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! MCP `memory_atomise` handler — v0.7.0 WT-1-C.
5//!
6//! Wraps the [`crate::atomisation::Atomiser`] substrate primitive from
7//! WT-1-B (the engine that decomposes a coarse-grained memory into
8//! atomic propositions and re-writes them as first-class memories with
9//! `derives_from` provenance) as a curator-pass MCP tool in the same
10//! family / profile group as `memory_consolidate` and `memory_reflect`.
11//!
12//! # Tier gating
13//!
14//! Atomisation requires the curator LLM, so:
15//!
16//! * `Keyword` → returns the "tier-locked" advisory envelope
17//!   (informational; not a JSON-RPC error). The advisory shape mirrors
18//!   the rest of the v0.7 tier-gated tools.
19//! * `Semantic` / `Smart` / `Autonomous` → forwarded to
20//!   `atomiser.atomise()`. The atomiser itself short-circuits with
21//!   `AtomiseError::TierLocked` when the engine's resolved tier is
22//!   `Keyword`, but the dispatcher only calls into this handler with
23//!   an Atomiser if the daemon has LLM, so the second guard is the
24//!   defense-in-depth layer.
25//!
26//! # Error mapping
27//!
28//! Per the WT-1-C brief:
29//!
30//! * `NotFound` → `Err("MEMORY_NOT_FOUND: ...")` (collapses to MCP
31//!   `isError: true` per the spec; the prefix is the wire-stable
32//!   code clients branch on).
33//! * `AlreadyAtomised` → 200 OK with `{already_atomised: true,
34//!   existing_atom_ids: [...]}` — INFORMATIONAL, not an error. This is
35//!   load-bearing: idempotent re-calls are the happy path on the
36//!   curator-pass retry contract.
37//! * `TierLocked` → 200 OK with a tier-locked advisory envelope
38//!   (`{tier-locked: "memory_atomise requires smart tier or higher",
39//!   current_tier, required_tier}`).
40//! * `CuratorFailed` → `Err("CURATOR_FAILED: ...")` with the parser
41//!   diagnostic.
42//! * `SourceTooSmall` → 200 OK with `{source_too_small: true,
43//!   message}`.
44//! * `GovernanceRefused` → `Err("GOVERNANCE_REFUSED atom[N]: ...")`
45//!   carrying the refused atom index. Prior atoms in the batch were
46//!   committed; the caller can list them via `memory_atomise`'s next
47//!   call with `force_re_atomise=false` (the AlreadyAtomised path) if
48//!   it wants to see what made it through before the refusal.
49//!
50//! # MCP error-envelope convention
51//!
52//! Per the v0.7 MCP-spec compliance work (see `mcp::handle_request`'s
53//! 2025-03-26 §"Tool result" comment), handler-level errors collapse
54//! to a successful JSON-RPC result with `isError: true` and a text
55//! body. The string codes (`MEMORY_NOT_FOUND`, `CURATOR_FAILED`,
56//! `GOVERNANCE_REFUSED`) are the wire-stable discriminators clients
57//! key off. The brief's reference to "JSON-RPC -32602/-32603" is the
58//! pre-MCP-spec semantic intent; the on-wire shape follows
59//! `crate::mcp::tools::reflect`'s `REFLECTION_DEPTH_EXCEEDED`
60//! convention.
61
62use crate::models::field_names;
63use std::sync::Arc;
64
65use serde_json::{Value, json};
66
67use crate::atomisation::{AtomiseError, Atomiser};
68use crate::config::FeatureTier;
69use crate::mcp::param_names;
70
71/// Handler-side bundle. Keeps the [`Atomiser`] (the WT-1-B engine)
72/// behind an `Arc` so the dispatcher can construct one at server boot
73/// and re-use it across every `memory_atomise` call.
74///
75/// `tier` is the daemon's resolved feature tier. The handler consults
76/// it BEFORE asking the atomiser to do any work so the keyword-tier
77/// short-circuit doesn't need a DB read.
78pub struct AtomiseToolHandler {
79    pub atomiser: Arc<Atomiser>,
80    /// Daemon's resolved feature tier. Retained as defense-in-depth
81    /// so a future caller that wires the handler outside the
82    /// MCP-server context (e.g. an HTTP daemon surface) still has the
83    /// tier available without re-plumbing the resolver. The MCP path
84    /// passes its own `tier` to [`handle_atomise`] which short-
85    /// circuits BEFORE consulting the handler, so the two values are
86    /// kept in sync by construction.
87    #[allow(dead_code)]
88    pub tier: FeatureTier,
89}
90
91impl AtomiseToolHandler {
92    /// Construct a handler with the supplied atomiser and tier.
93    #[must_use]
94    pub fn new(atomiser: Arc<Atomiser>, tier: FeatureTier) -> Self {
95        Self { atomiser, tier }
96    }
97}
98
99/// Required-tier label for the tier-locked advisory envelope. Surfaced
100/// in the response so an agent on the keyword tier knows which tier
101/// hint to pass on restart.
102const REQUIRED_TIER: &str = "smart";
103
104/// Handle a `memory_atomise` MCP tool call.
105///
106/// The handler shape mirrors the other curator-pass tools
107/// (`memory_consolidate`, `memory_reflect`): synchronous + threaded
108/// `&rusqlite::Connection`, params bag is a `&Value`. Errors are
109/// returned as `Err(String)`; the dispatcher wraps them into the
110/// MCP `isError: true` envelope.
111///
112/// # Arguments
113///
114/// * `conn` — substrate connection (write path).
115/// * `params` — the JSON-RPC `arguments` object. Schema:
116///   `{ memory_id: string, max_atom_tokens?: int, force_re_atomise?: bool }`.
117/// * `handler` — pre-built handler (or `None` when the daemon has no
118///   LLM, which collapses to the tier-locked advisory).
119/// * `tier` — fallback tier when `handler` is `None` (so the
120///   tier-locked envelope still carries the correct `current_tier`).
121/// * `mcp_client` — captured `clientInfo.name` for the calling-agent
122///   resolution chain.
123pub fn handle_atomise(
124    conn: &rusqlite::Connection,
125    params: &Value,
126    handler: Option<&AtomiseToolHandler>,
127    tier: FeatureTier,
128    mcp_client: Option<&str>,
129) -> Result<Value, String> {
130    // ── Argument validation ─────────────────────────────────────────
131    let memory_id = params
132        .get(param_names::MEMORY_ID)
133        .ok_or(crate::errors::msg::MEMORY_ID_REQUIRED)?
134        .as_str()
135        .ok_or("memory_id must be a string")?;
136    if memory_id.is_empty() {
137        return Err("memory_id must not be empty".to_string());
138    }
139
140    // max_atom_tokens — default 200, range [50, 1000]. We reject
141    // non-integer (string, bool, null) values explicitly so the agent
142    // sees a clean validation error rather than a silent default.
143    let max_atom_tokens: u32 = if let Some(v) = params.get(param_names::MAX_ATOM_TOKENS) {
144        if v.is_null() {
145            crate::atomisation::DEFAULT_ATOM_TOKENS
146        } else {
147            let n = v.as_i64().ok_or_else(|| {
148                format!(
149                    "max_atom_tokens must be an integer in [{}, {}] (default {})",
150                    crate::atomisation::MIN_ATOM_TOKENS,
151                    crate::atomisation::MAX_ATOM_TOKENS,
152                    crate::atomisation::DEFAULT_ATOM_TOKENS
153                )
154            })?;
155            if !(i64::from(crate::atomisation::MIN_ATOM_TOKENS)
156                ..=i64::from(crate::atomisation::MAX_ATOM_TOKENS))
157                .contains(&n)
158            {
159                return Err(format!(
160                    "max_atom_tokens out of range [{}, {}]: {n}",
161                    crate::atomisation::MIN_ATOM_TOKENS,
162                    crate::atomisation::MAX_ATOM_TOKENS
163                ));
164            }
165            u32::try_from(n).expect("range-checked above")
166        }
167    } else {
168        crate::atomisation::DEFAULT_ATOM_TOKENS
169    };
170
171    // force_re_atomise — default false. Type-strict: reject anything
172    // that isn't a JSON bool (the brief calls out `force_re_atomise="yes"`
173    // as an explicit rejection case).
174    let force_re_atomise: bool = if let Some(v) = params.get(param_names::FORCE_RE_ATOMISE) {
175        if v.is_null() {
176            false
177        } else {
178            v.as_bool().ok_or("force_re_atomise must be a boolean")?
179        }
180    } else {
181        false
182    };
183
184    // ── Tier gate (keyword short-circuit) ───────────────────────────
185    // Resolved BEFORE the handler dispatch so the keyword tier never
186    // touches the DB. The advisory envelope is the substrate-wide
187    // tier-locked shape (200 OK, NOT JSON-RPC error — per the brief
188    // and the rest of the v0.7 tier-gated surface).
189    if tier == FeatureTier::Keyword || handler.is_none() {
190        return Ok(json!({
191            (field_names::TIER_LOCKED): "memory_atomise requires smart tier or higher",
192            (field_names::CURRENT_TIER): tier.as_str(),
193            (field_names::REQUIRED_TIER): REQUIRED_TIER,
194        }));
195    }
196    let handler = handler.expect("checked above");
197
198    // ── Calling agent resolution (NHI) ──────────────────────────────
199    let explicit_agent_id = params.get(param_names::AGENT_ID).and_then(Value::as_str);
200    let calling_agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
201        .map_err(|e| e.to_string())?;
202
203    // ── Engine dispatch ─────────────────────────────────────────────
204    match handler.atomiser.atomise_sync(
205        conn,
206        memory_id,
207        max_atom_tokens,
208        force_re_atomise,
209        &calling_agent_id,
210    ) {
211        Ok(result) => Ok(json!({
212            "source_id": result.source_id,
213            "atom_ids": result.atom_ids,
214            (field_names::ATOM_COUNT): result.atom_count,
215            (field_names::ARCHIVED_AT): result.archived_at,
216        })),
217        Err(AtomiseError::NotFound) => Err(format!("MEMORY_NOT_FOUND: {memory_id}")),
218        Err(AtomiseError::AlreadyAtomised {
219            source_id,
220            existing_atom_ids,
221        }) => Ok(json!({
222            "already_atomised": true,
223            "source_id": source_id,
224            "existing_atom_ids": existing_atom_ids,
225            (field_names::ATOM_COUNT): existing_atom_ids.len(),
226        })),
227        Err(AtomiseError::TierLocked) => Ok(json!({
228            (field_names::TIER_LOCKED): "memory_atomise requires smart tier or higher",
229            (field_names::CURRENT_TIER): tier.as_str(),
230            (field_names::REQUIRED_TIER): REQUIRED_TIER,
231        })),
232        Err(AtomiseError::CuratorFailed(detail)) => Err(format!("CURATOR_FAILED: {detail}")),
233        Err(AtomiseError::SourceTooSmall) => Ok(json!({
234            "source_too_small": true,
235            "source_id": memory_id,
236            "message": "source body is already at or under max_atom_tokens — no decomposition possible",
237        })),
238        Err(AtomiseError::GovernanceRefused(detail)) => {
239            // The atomiser embeds the failing atom index in the
240            // diagnostic as `atom[N]: <reason>` (see
241            // `Atomiser::atomise_sync` step 8). We surface it
242            // verbatim so the operator's log analyser sees the
243            // index without a second roundtrip.
244            Err(format!("GOVERNANCE_REFUSED: {detail}"))
245        }
246        Err(AtomiseError::SignerError(detail)) => Err(format!("SIGNER_ERROR: {detail}")),
247        Err(AtomiseError::DbError(detail)) => Err(format!("DB_ERROR: {detail}")),
248        // ARCH-5 (FX-6) — recursive-primitive refusal mirroring the
249        // `REFLECTION_DEPTH_EXCEEDED` / `SYNTHESIS_DEPTH_EXCEEDED` wire
250        // shape on the MCP surface (Err string prefixed with the stable
251        // slug). Downstream MCP clients can switch on the slug without
252        // parsing the prose.
253        Err(AtomiseError::DepthExceeded { attempted, cap }) => Err(format!(
254            "ATOMISATION_DEPTH_EXCEEDED: atomisation depth {attempted} would exceed \
255             compiled max_atomisation_depth {cap}"
256        )),
257    }
258}
259
260// --- D1.5 (#986): per-tool McpTool impl for memory_atomise ---
261
262use crate::mcp::registry::McpTool;
263use schemars::JsonSchema;
264use serde::Deserialize;
265
266/// v0.7.0 #972 D1.5 (#986) — request body for `memory_atomise`.
267#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
268#[allow(dead_code)]
269pub struct AtomiseRequest {
270    /// Source memory UUID.
271    pub memory_id: String,
272
273    /// Per-atom cl100k budget.
274    #[serde(default)]
275    pub max_atom_tokens: Option<i64>,
276
277    /// Skip idempotency; mint fresh atoms (old retained).
278    #[serde(default)]
279    pub force_re_atomise: Option<bool>,
280}
281
282/// v0.7.0 #972 D1.5 (#986) — `McpTool` impl for `memory_atomise`.
283#[allow(dead_code)]
284pub struct AtomiseTool;
285
286impl McpTool for AtomiseTool {
287    fn name() -> &'static str {
288        crate::mcp::registry::tool_names::MEMORY_ATOMISE
289    }
290    fn description() -> &'static str {
291        "Decompose a memory into 2-10 atomic propositions; source archived. Smart+ tier."
292    }
293    fn docs() -> &'static str {
294        "WT-1-C: atomise via WT-1-B engine. Atoms = Observation memories with metadata.atom_source_id + derives_from link. Source archived (atomised_into=N). Returns {source_id, atom_ids, atom_count, archived_at}. Idempotent (use force_re_atomise to mint fresh). Too-small sources => {source_too_small:true}. Failures => CURATOR_FAILED / GOVERNANCE_REFUSED envelopes."
295    }
296    fn input_schema() -> Value {
297        crate::mcp::registry::input_schema_for::<AtomiseRequest>()
298    }
299    fn family() -> &'static str {
300        crate::profile::Family::Power.name()
301    }
302}
303
304#[cfg(test)]
305mod d1_5_986_tests {
306    //! D1.5 (#986) — schema parity for `memory_atomise`.
307    //! Shared helpers live at [`crate::mcp::parity_test_helpers`].
308    use super::*;
309    use crate::mcp::parity_test_helpers::{
310        assert_descriptions_match, assert_property_set_parity, derived_props_for,
311    };
312
313    #[test]
314    fn atomise_parity_986() {
315        let derived = derived_props_for::<AtomiseRequest>();
316        assert_property_set_parity("memory_atomise", &derived);
317        assert_descriptions_match("memory_atomise", &derived);
318    }
319
320    #[test]
321    fn atomise_tool_metadata_986() {
322        assert_eq!(AtomiseTool::name(), "memory_atomise");
323        assert_eq!(AtomiseTool::family(), "power");
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Unit tests — focus on the argument-parsing and tier-gate branches
329// (which do NOT require a live atomiser / DB). The full happy-path
330// and error-path coverage lives in the integration suite at
331// `tests/wt1c_mcp_atomise.rs`.
332// ---------------------------------------------------------------------------
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::atomisation::AtomiserConfig;
338    use crate::atomisation::curator::{Atom, Curator, CuratorError};
339    use crate::storage as db;
340    use std::sync::Mutex;
341    use tempfile::NamedTempFile;
342
343    /// Deterministic mock — pops a canned response queue. Mirrors the
344    /// shape used by `tests/atomisation/core.rs` so the engine sees a
345    /// familiar trait object.
346    struct MockCurator {
347        responses: Mutex<Vec<Result<Vec<Atom>, CuratorError>>>,
348    }
349
350    impl MockCurator {
351        fn new(responses: Vec<Result<Vec<Atom>, CuratorError>>) -> Self {
352            Self {
353                responses: Mutex::new(responses),
354            }
355        }
356    }
357
358    impl Curator for MockCurator {
359        fn decompose(
360            &self,
361            _body: &str,
362            _max_atom_tokens: u32,
363            _max_retries: u32,
364        ) -> Result<Vec<Atom>, CuratorError> {
365            let mut q = self.responses.lock().unwrap();
366            if q.is_empty() {
367                return Err(CuratorError::MalformedResponse(
368                    "mock: queue exhausted".into(),
369                ));
370            }
371            q.remove(0)
372        }
373    }
374
375    fn fresh_db() -> (NamedTempFile, rusqlite::Connection) {
376        let tmp = NamedTempFile::new().expect("tempfile");
377        let conn = db::open(tmp.path()).expect("db::open");
378        (tmp, conn)
379    }
380
381    fn handler(tier: FeatureTier) -> AtomiseToolHandler {
382        let curator: Box<dyn Curator> = Box::new(MockCurator::new(vec![]));
383        let atomiser = Arc::new(Atomiser::new(
384            curator,
385            None,
386            AtomiserConfig::default(),
387            tier,
388        ));
389        AtomiseToolHandler::new(atomiser, tier)
390    }
391
392    #[test]
393    fn missing_memory_id_errors() {
394        let (_tmp, conn) = fresh_db();
395        let h = handler(FeatureTier::Smart);
396        let err =
397            handle_atomise(&conn, &json!({}), Some(&h), FeatureTier::Smart, None).unwrap_err();
398        assert!(err.contains("memory_id is required"), "got: {err}");
399    }
400
401    #[test]
402    fn non_string_memory_id_errors() {
403        let (_tmp, conn) = fresh_db();
404        let h = handler(FeatureTier::Smart);
405        let err = handle_atomise(
406            &conn,
407            &json!({"memory_id": 42}),
408            Some(&h),
409            FeatureTier::Smart,
410            None,
411        )
412        .unwrap_err();
413        assert!(err.contains("must be a string"), "got: {err}");
414    }
415
416    #[test]
417    fn empty_memory_id_errors() {
418        let (_tmp, conn) = fresh_db();
419        let h = handler(FeatureTier::Smart);
420        let err = handle_atomise(
421            &conn,
422            &json!({"memory_id": ""}),
423            Some(&h),
424            FeatureTier::Smart,
425            None,
426        )
427        .unwrap_err();
428        assert!(err.contains("must not be empty"), "got: {err}");
429    }
430
431    #[test]
432    fn max_atom_tokens_zero_rejected() {
433        let (_tmp, conn) = fresh_db();
434        let h = handler(FeatureTier::Smart);
435        let err = handle_atomise(
436            &conn,
437            &json!({"memory_id": "11111111-2222-3333-4444-555555555555", "max_atom_tokens": 0}),
438            Some(&h),
439            FeatureTier::Smart,
440            None,
441        )
442        .unwrap_err();
443        assert!(err.contains("out of range"), "got: {err}");
444    }
445
446    #[test]
447    fn max_atom_tokens_below_range_rejected() {
448        let (_tmp, conn) = fresh_db();
449        let h = handler(FeatureTier::Smart);
450        let err = handle_atomise(
451            &conn,
452            &json!({"memory_id": "11111111-2222-3333-4444-555555555555", "max_atom_tokens": 49}),
453            Some(&h),
454            FeatureTier::Smart,
455            None,
456        )
457        .unwrap_err();
458        assert!(err.contains("out of range"), "got: {err}");
459    }
460
461    #[test]
462    fn max_atom_tokens_above_range_rejected() {
463        let (_tmp, conn) = fresh_db();
464        let h = handler(FeatureTier::Smart);
465        let err = handle_atomise(
466            &conn,
467            &json!({"memory_id": "11111111-2222-3333-4444-555555555555", "max_atom_tokens": 1001}),
468            Some(&h),
469            FeatureTier::Smart,
470            None,
471        )
472        .unwrap_err();
473        assert!(err.contains("out of range"), "got: {err}");
474    }
475
476    #[test]
477    fn max_atom_tokens_non_int_rejected() {
478        let (_tmp, conn) = fresh_db();
479        let h = handler(FeatureTier::Smart);
480        let err = handle_atomise(
481            &conn,
482            &json!({
483                "memory_id": "11111111-2222-3333-4444-555555555555",
484                "max_atom_tokens": "200"
485            }),
486            Some(&h),
487            FeatureTier::Smart,
488            None,
489        )
490        .unwrap_err();
491        assert!(err.contains("integer"), "got: {err}");
492    }
493
494    #[test]
495    fn force_re_atomise_string_rejected() {
496        let (_tmp, conn) = fresh_db();
497        let h = handler(FeatureTier::Smart);
498        let err = handle_atomise(
499            &conn,
500            &json!({
501                "memory_id": "11111111-2222-3333-4444-555555555555",
502                "force_re_atomise": "yes"
503            }),
504            Some(&h),
505            FeatureTier::Smart,
506            None,
507        )
508        .unwrap_err();
509        assert!(err.contains("boolean"), "got: {err}");
510    }
511
512    #[test]
513    fn keyword_tier_returns_tier_locked_advisory() {
514        let (_tmp, conn) = fresh_db();
515        let resp = handle_atomise(
516            &conn,
517            &json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
518            None,
519            FeatureTier::Keyword,
520            None,
521        )
522        .expect("tier-locked is informational, not an error");
523        assert_eq!(
524            resp["tier-locked"].as_str(),
525            Some("memory_atomise requires smart tier or higher")
526        );
527        assert_eq!(resp["current_tier"].as_str(), Some("keyword"));
528        assert_eq!(resp["required_tier"].as_str(), Some("smart"));
529    }
530
531    #[test]
532    fn handler_none_at_higher_tier_still_tier_locked() {
533        // Defense in depth — if the dispatcher fails to construct a
534        // handler (no LLM available even at semantic tier), surface
535        // the tier-locked envelope rather than a panic.
536        let (_tmp, conn) = fresh_db();
537        let resp = handle_atomise(
538            &conn,
539            &json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
540            None,
541            FeatureTier::Semantic,
542            None,
543        )
544        .expect("absence of handler collapses to tier-locked, not error");
545        assert!(resp["tier-locked"].is_string());
546    }
547
548    #[test]
549    fn memory_not_found_returns_typed_error() {
550        let (_tmp, conn) = fresh_db();
551        let h = handler(FeatureTier::Smart);
552        // No row inserted; the engine will return NotFound.
553        let err = handle_atomise(
554            &conn,
555            &json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
556            Some(&h),
557            FeatureTier::Smart,
558            None,
559        )
560        .unwrap_err();
561        assert!(err.starts_with("MEMORY_NOT_FOUND:"), "got: {err}");
562    }
563
564    // ------------------------------------------------------------------
565    // Engine-dispatch result-arm coverage (2026-06-11): drive the match
566    // arms of handle_atomise that map AtomiseError / AtomiseResult onto
567    // the MCP wire shape. Each uses a MockCurator with a canned response
568    // + a seeded source memory.
569    // ------------------------------------------------------------------
570
571    /// Build a handler whose atomiser uses the supplied canned curator
572    /// responses.
573    fn handler_with(
574        tier: FeatureTier,
575        responses: Vec<Result<Vec<Atom>, CuratorError>>,
576    ) -> AtomiseToolHandler {
577        let curator: Box<dyn Curator> = Box::new(MockCurator::new(responses));
578        let atomiser = Arc::new(Atomiser::new(
579            curator,
580            None,
581            AtomiserConfig::default(),
582            tier,
583        ));
584        AtomiseToolHandler::new(atomiser, tier)
585    }
586
587    /// Seed a source memory whose body is comfortably over the atom
588    /// token budget so the engine's pre-flight check does not
589    /// short-circuit to SourceTooSmall.
590    fn seed_big(conn: &rusqlite::Connection, ns: &str) -> String {
591        use crate::models::{Memory, MemoryKind, Tier};
592        let now = chrono::Utc::now().to_rfc3339();
593        let mem = Memory {
594            id: uuid::Uuid::new_v4().to_string(),
595            tier: Tier::Mid,
596            namespace: ns.to_string(),
597            title: format!("src-{}", uuid::Uuid::new_v4().simple()),
598            content: "proposition token padding here. ".repeat(400),
599            created_at: now.clone(),
600            updated_at: now,
601            metadata: serde_json::json!({"agent_id": "ai:test"}),
602            memory_kind: MemoryKind::Observation,
603            ..Default::default()
604        };
605        db::insert(conn, &mem).unwrap()
606    }
607
608    #[test]
609    fn successful_atomise_returns_atom_ids_and_count() {
610        let (_tmp, conn) = fresh_db();
611        let id = seed_big(&conn, "atomise-ok");
612        let h = handler_with(
613            FeatureTier::Smart,
614            vec![Ok(vec![
615                Atom {
616                    text: "first proposition".into(),
617                },
618                Atom {
619                    text: "second proposition".into(),
620                },
621            ])],
622        );
623        let resp = handle_atomise(
624            &conn,
625            &json!({"memory_id": id, "max_atom_tokens": 50}),
626            Some(&h),
627            FeatureTier::Smart,
628            None,
629        )
630        .expect("atomise ok");
631        assert_eq!(resp["source_id"].as_str(), Some(id.as_str()));
632        assert!(resp["atom_count"].as_u64().unwrap() >= 2);
633        assert!(resp["atom_ids"].as_array().unwrap().len() >= 2);
634    }
635
636    #[test]
637    fn already_atomised_returns_existing_envelope() {
638        let (_tmp, conn) = fresh_db();
639        let id = seed_big(&conn, "atomise-twice");
640        // First pass atomises; second (force=false) hits AlreadyAtomised.
641        let h = handler_with(
642            FeatureTier::Smart,
643            vec![Ok(vec![
644                Atom {
645                    text: "a one".into(),
646                },
647                Atom {
648                    text: "a two".into(),
649                },
650            ])],
651        );
652        handle_atomise(
653            &conn,
654            &json!({"memory_id": id, "max_atom_tokens": 50}),
655            Some(&h),
656            FeatureTier::Smart,
657            None,
658        )
659        .expect("first atomise ok");
660        // Second call: handler's curator queue is now empty, but the
661        // idempotency check fires BEFORE the curator round-trip, so we
662        // get the AlreadyAtomised envelope.
663        let resp = handle_atomise(
664            &conn,
665            &json!({"memory_id": id, "max_atom_tokens": 50}),
666            Some(&h),
667            FeatureTier::Smart,
668            None,
669        )
670        .expect("already-atomised is informational");
671        assert_eq!(resp["already_atomised"].as_bool(), Some(true));
672        assert_eq!(resp["source_id"].as_str(), Some(id.as_str()));
673        assert!(resp["existing_atom_ids"].as_array().unwrap().len() >= 2);
674    }
675
676    #[test]
677    fn source_too_small_returns_advisory() {
678        let (_tmp, conn) = fresh_db();
679        use crate::models::{Memory, MemoryKind, Tier};
680        let now = chrono::Utc::now().to_rfc3339();
681        let mem = Memory {
682            id: uuid::Uuid::new_v4().to_string(),
683            tier: Tier::Mid,
684            namespace: "tiny".into(),
685            title: "tiny-src".into(),
686            content: "tiny".into(),
687            created_at: now.clone(),
688            updated_at: now,
689            metadata: serde_json::json!({"agent_id": "ai:test"}),
690            memory_kind: MemoryKind::Observation,
691            ..Default::default()
692        };
693        let id = db::insert(&conn, &mem).unwrap();
694        let h = handler_with(FeatureTier::Smart, vec![]);
695        let resp = handle_atomise(
696            &conn,
697            &json!({"memory_id": id, "max_atom_tokens": 200}),
698            Some(&h),
699            FeatureTier::Smart,
700            None,
701        )
702        .expect("source-too-small is informational");
703        assert_eq!(resp["source_too_small"].as_bool(), Some(true));
704        assert_eq!(resp["source_id"].as_str(), Some(id.as_str()));
705    }
706
707    #[test]
708    fn curator_failure_returns_typed_error() {
709        let (_tmp, conn) = fresh_db();
710        let id = seed_big(&conn, "atomise-curfail");
711        let h = handler_with(
712            FeatureTier::Smart,
713            vec![Err(CuratorError::LlmUnavailable("down".into()))],
714        );
715        let err = handle_atomise(
716            &conn,
717            &json!({"memory_id": id, "max_atom_tokens": 50}),
718            Some(&h),
719            FeatureTier::Smart,
720            None,
721        )
722        .unwrap_err();
723        assert!(err.starts_with("CURATOR_FAILED:"), "got: {err}");
724    }
725
726    #[test]
727    fn null_max_atom_tokens_uses_default() {
728        // Exercises the `v.is_null()` branch of the max_atom_tokens
729        // parser (explicit JSON null → compiled default).
730        let (_tmp, conn) = fresh_db();
731        let id = seed_big(&conn, "atomise-null");
732        let h = handler_with(
733            FeatureTier::Smart,
734            vec![Ok(vec![
735                Atom {
736                    text: "n one".into(),
737                },
738                Atom {
739                    text: "n two".into(),
740                },
741            ])],
742        );
743        let resp = handle_atomise(
744            &conn,
745            &json!({"memory_id": id, "max_atom_tokens": null, "force_re_atomise": null}),
746            Some(&h),
747            FeatureTier::Smart,
748            None,
749        )
750        .expect("null tokens defaults cleanly");
751        assert!(resp["atom_count"].as_u64().unwrap() >= 2);
752    }
753}