Skip to main content

capsule_runtime/
conversation.rs

1//! The durable conversation record: `~/.murmur/conversations/<record>/<context-id>/conversation.jsonl`,
2//! one JSON object per line, in the order the messages entered the context.
3//!
4//! Three properties decide the layout:
5//!
6//! * **Outside every workdir, and never preopened.** A record outlives the session that wrote it,
7//!   so it cannot live in a session directory; and it is the whole conversation, so no artifact
8//!   gets it through a filesystem grant. `murmur:conversation/read` is the only way in.
9//! * **Keyed by capsule and context, not by workdir.** `<record>` defaults to the capsule name and
10//!   is overridable with `context.record_store`; `<context-id>` is the task's context id, which is
11//!   client-supplied over A2A and settable with `mur run --context`. Two runs sharing a context id
12//!   share one record.
13//! * **Append-only, and never fatal.** The runtime is the only writer. A directory that cannot be
14//!   created, a line that cannot be appended and a line that will not parse are each reported once
15//!   and then survived: a capsule whose record is unwritable must still do its work.
16//!
17//! Resolution and creation are separate, as they are for [`crate::state_store`]: [`record_root`]
18//! resolves and validates without touching the filesystem, and only the first append creates
19//! anything.
20
21use std::collections::HashSet;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use serde_json::Value;
26
27use crate::bindings::hook::murmur::conversation::read::{Message, MessagePage};
28use crate::errors::RuntimeError;
29
30/// Directory under the murmur home holding every record, one subdirectory per record name.
31pub(crate) const CONVERSATION_ROOT_DIR: &str = "conversations";
32
33/// The record file inside `<record>/<context-id>/`.
34pub(crate) const RECORD_FILE_NAME: &str = "conversation.jsonl";
35
36/// The `type` value on a record's header line, and the only value a header is recognised by.
37pub(crate) const RECORD_HEADER_TYPE: &str = "murmur.record";
38
39/// Mode applied to the conversation root, each record directory and each context directory:
40/// owner-only, because a record is the whole of one capsule's conversation.
41const RECORD_DIR_MODE: u32 = 0o700;
42
43/// Mode every record file is held at, on each append and each rewrite.
44pub(crate) const RECORD_FILE_MODE: u32 = 0o600;
45
46/// Prefix on every host-minted cursor, so a value the host did not mint is refused rather than
47/// parsed into a position.
48const CURSOR_PREFIX: &str = "mc_";
49
50/// Inclusive ceiling on `read-messages`' `limit`. A page is copied into the guest's memory whole,
51/// so the host caps it rather than trusting a caller's number.
52const MAX_PAGE_LIMIT: u32 = 100;
53
54/// What every refusal of a record or context name says. The rule itself is
55/// [`crate::state_store::validate_store_name`] — the one definition of "a single usable path
56/// segment" — and this is the wording an operator gets, which names all of it at once because the
57/// value they wrote is in front of them.
58const SEGMENT_RULE: &str = "must be a single path segment: no '/', no '.' or '..', not absolute, \
59                            and not starting with a dot";
60
61/// What one record has already dropped from its front.
62///
63/// Cumulative over the record's life: `dropped` is every message truncation has ever taken from
64/// this record, not what the last one took.
65#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
66pub struct TruncationMarker {
67    pub dropped: u64,
68    /// The `msg_` id of the oldest message still in the file.
69    pub oldest_surviving_id: String,
70    /// The `msg_` id of the newest message that went. A reference to any message minted at or
71    /// before this one reads as truncated rather than as unknown — see
72    /// [`crate::retention::locate_message`].
73    pub last_dropped_id: String,
74    /// When the most recent truncation ran, in milliseconds since the epoch.
75    pub at_ms: u64,
76}
77
78/// The first line of a record: a JSON object with a `type` key and no `role`.
79///
80/// Not a message: every reader of a record skips a line that is not a JSON object carrying a
81/// string `role`, so a header is invisible to `murmur:conversation/read`, to the `threaded`
82/// reload, and to the `total` a page reports. Not a sidecar: a sidecar needs a second write that
83/// a crash could desynchronise from the rename, where a header makes the whole truncation
84/// exactly one atomic rename of one file.
85///
86/// [`Self::capsule`] is what makes automatic record pruning store-safe: two capsules pointed at
87/// one `context.record_store` each own only the records their own name is on.
88#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89pub struct RecordHeader {
90    /// Always [`RECORD_HEADER_TYPE`]. A first line carrying anything else is not a header.
91    #[serde(rename = "type")]
92    pub kind: String,
93    /// The capsule that owns this record — `name:` from its manifest, never the record store.
94    pub capsule: String,
95    /// When the header was written, in milliseconds since the epoch. On an adopted record, that
96    /// is the adoption, not the conversation's first message.
97    pub created_ms: u64,
98    /// Absent until this record has been truncated.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub truncated: Option<TruncationMarker>,
101}
102
103/// `header` as the one line it occupies, newline included.
104pub(crate) fn header_line(header: &RecordHeader) -> Result<String, String> {
105    let mut line = serde_json::to_string(header)
106        .map_err(|err| format!("failed to encode the record header: {err}"))?;
107    line.push('\n');
108    Ok(line)
109}
110
111/// The header `path` carries, or `None` for a record whose first line is not one — an unowned
112/// record, and every file that does not exist.
113pub(crate) fn read_header(path: &Path) -> Option<RecordHeader> {
114    let raw = std::fs::read_to_string(path).ok()?;
115    parse_header(raw.lines().next()?)
116}
117
118/// One line as a record header, or `None` when it is not one.
119pub(crate) fn parse_header(line: &str) -> Option<RecordHeader> {
120    let header: RecordHeader = serde_json::from_str(line).ok()?;
121    (header.kind == RECORD_HEADER_TYPE).then_some(header)
122}
123
124/// The millisecond timestamp inside a `msg_` id, or `None` when the id is not one the runtime
125/// minted. Message ids are uuid v7 in simple form, so the first 12 hex characters are the mint
126/// time — which is what lets a dropped id be placed against a truncation marker without the
127/// messages themselves.
128pub(crate) fn message_id_timestamp_ms(id: &str) -> Option<u64> {
129    let hex = id.strip_prefix("msg_")?;
130    if hex.len() < 12 {
131        return None;
132    }
133    u64::from_str_radix(&hex[..12], 16).ok()
134}
135
136/// Whether `value` is usable as one directory segment of a record path.
137///
138/// `field` names where the value came from (`context.record_store`, `--context`), because that is
139/// what an operator has to go and change. Refused at staging, before anything is created.
140pub(crate) fn validate_record_segment(field: &str, value: &str) -> Result<(), RuntimeError> {
141    crate::state_store::validate_store_name(value).map_err(|_| {
142        RuntimeError::InvalidConversationRecord {
143            field: field.to_string(),
144            value: value.to_string(),
145            message: SEGMENT_RULE.to_string(),
146        }
147    })
148}
149
150/// The record name a capsule's `context:` block resolves to: what it declared, or the capsule
151/// name when it declared nothing. `None` when `context.record: off` — the switch wins over a
152/// `record_store` declared beside it.
153pub(crate) fn resolve_record_name(
154    context: Option<&murmur_artifact::ContextConfig>,
155    capsule_name: &str,
156) -> Option<String> {
157    match context {
158        Some(context) if !context.record => None,
159        Some(context) => Some(
160            context
161                .record_store
162                .clone()
163                .unwrap_or_else(|| capsule_name.to_string()),
164        ),
165        None => Some(capsule_name.to_string()),
166    }
167}
168
169/// Where `record`'s conversations live. Resolves only — nothing is created and nothing is checked
170/// for existence — so a launch that never appends a message leaves no directory behind.
171pub(crate) fn record_root(record: &str) -> Result<PathBuf, String> {
172    validate_record_segment("context.record_store", record).map_err(|err| err.to_string())?;
173    Ok(crate::state_store::murmur_home_dir()?
174        .join(CONVERSATION_ROOT_DIR)
175        .join(record))
176}
177
178/// The record file one context writes under `root`. Resolves only, like [`record_root`]: nothing
179/// is created and nothing is checked for existence.
180///
181/// `mur run --resume` asks whether this path exists before staging goes any further, which is the
182/// one question that separates "continue this conversation" from "start fresh and say nothing".
183pub(crate) fn record_file(root: &Path, context_id: &str) -> PathBuf {
184    root.join(context_id).join(RECORD_FILE_NAME)
185}
186
187/// One context's durable record, and the ids it already holds.
188///
189/// Held by the agent loop for one attempt. Every message the loop puts in the context goes through
190/// [`Self::append`], which is what makes the record the loop's own message list rather than a
191/// summary of it.
192pub(crate) struct ConversationRecord {
193    /// `<home>/.murmur/conversations/<record>/<context-id>`.
194    dir: PathBuf,
195    /// Session directory the failures are reported into, beside stderr.
196    workdir: PathBuf,
197    /// The capsule this record's header names, or `None` for a capsule that declares no
198    /// `context.retain` — which writes no header at all. See [`Self::ensure_header`].
199    capsule: Option<String>,
200    /// Whether the header has been settled for this instance. One check per attempt, not one per
201    /// message: the header is written or adopted at most once and then never looked at again.
202    header_ensured: bool,
203    /// Ids already on disk: those loaded from the record and those this attempt appended. A
204    /// compaction hook that hands a message back verbatim returns its id with it, and a threaded
205    /// reload starts from messages that are already lines — neither may be written twice.
206    recorded: HashSet<String>,
207    /// A write failure is reported once, then survived silently: one full disk must not produce
208    /// one stderr line per message.
209    failure_reported: bool,
210    /// A malformed line is reported once for this record, however many times it is read.
211    malformed_reported: bool,
212}
213
214impl ConversationRecord {
215    /// The record for `context_id` under `root`, or `None` when the context id is not a usable
216    /// path segment.
217    ///
218    /// A context id arrives from an A2A client as well as from `mur run --context`, so it is
219    /// checked here rather than trusted: a rejected id costs that conversation its record and
220    /// nothing else.
221    pub(crate) fn open(
222        root: &Path,
223        context_id: &str,
224        workdir: &Path,
225        capsule: Option<&str>,
226    ) -> Option<Self> {
227        if crate::state_store::validate_store_name(context_id).is_err() {
228            report(
229                workdir,
230                &format!(
231                    "[conversation] context id '{context_id}' {SEGMENT_RULE}; this conversation \
232                     is not recorded"
233                ),
234            );
235            return None;
236        }
237        Some(Self {
238            dir: root.join(context_id),
239            workdir: workdir.to_path_buf(),
240            capsule: capsule.map(str::to_string),
241            header_ensured: false,
242            recorded: HashSet::new(),
243            failure_reported: false,
244            malformed_reported: false,
245        })
246    }
247
248    pub(crate) fn path(&self) -> PathBuf {
249        self.dir.join(RECORD_FILE_NAME)
250    }
251
252    /// Every message the record already holds, oldest first, and never appended again.
253    ///
254    /// The agent loop starts a `threaded` task's message list from this. Each message keeps the
255    /// `id` its line carries, byte for byte.
256    pub(crate) fn load(&mut self) -> Vec<Value> {
257        let path = self.path();
258        let messages = match read_record(&path, &self.workdir, &mut self.malformed_reported) {
259            Ok(messages) => messages,
260            Err(reason) => {
261                report(
262                    &self.workdir,
263                    &format!(
264                        "[conversation] {} could not be read ({reason}); this task starts with no \
265                         prior messages",
266                        path.display()
267                    ),
268                );
269                Vec::new()
270            }
271        };
272        for message in &messages {
273            if let Some(id) = crate::agent::message_id(message) {
274                self.recorded.insert(id.to_string());
275            }
276        }
277        messages
278    }
279
280    /// Append every message not already in this record, in the order given.
281    ///
282    /// Called at each point a message enters the agent loop's context. A message whose id is
283    /// already recorded is skipped, which is what a compaction hook returning part of the context
284    /// verbatim produces.
285    ///
286    /// An id joins `recorded` only once its line is on disk. A failure the caller survives — a
287    /// disk that fills and is freed again, a descriptor limit that passes — is followed by an
288    /// append carrying the same messages, and a message marked as written before its write
289    /// succeeded would be skipped by that retry and lost from the record for good.
290    pub(crate) fn append(&mut self, messages: &[Value]) {
291        for message in messages {
292            let id = crate::agent::message_id(message).map(str::to_string);
293            if let Some(id) = id.as_deref() {
294                if self.recorded.contains(id) {
295                    continue;
296                }
297            }
298            if let Err(reason) = self.append_line(message) {
299                if !self.failure_reported {
300                    self.failure_reported = true;
301                    report(
302                        &self.workdir,
303                        &format!(
304                            "[conversation] {} could not be written ({reason}); this task \
305                             continues unrecorded",
306                            self.path().display()
307                        ),
308                    );
309                }
310                return;
311            }
312            if let Some(id) = id {
313                self.recorded.insert(id);
314            }
315        }
316    }
317
318    /// Create the directory chain if it is missing and append one line.
319    ///
320    /// Creation happens here rather than at staging so `context.record: off`, and a launch that
321    /// never reaches a message, both leave nothing behind.
322    fn append_line(&mut self, message: &Value) -> Result<(), String> {
323        let mut line = serde_json::to_string(message)
324            .map_err(|err| format!("failed to encode the message: {err}"))?;
325        line.push('\n');
326
327        self.ensure_dirs()?;
328        self.ensure_header()?;
329
330        let mut file = open_record_file(&self.path(), false)?;
331        file.write_all(line.as_bytes())
332            .map_err(|err| err.to_string())
333    }
334
335    /// Create the record root, the record directory and the context directory if they are
336    /// missing, oldest ancestor first, so a context directory is never reachable through a parent
337    /// a wider mode left open.
338    ///
339    /// When the record root sits in `~/.murmur`, the home itself is held at `0700` first.
340    fn ensure_dirs(&self) -> Result<(), String> {
341        let mut chain = Vec::new();
342        let mut dir = Some(self.dir.as_path());
343        for _ in 0..3 {
344            if let Some(current) = dir {
345                chain.push(current);
346                dir = current.parent();
347            }
348        }
349        if let (Some(owner), Ok(home)) = (dir, crate::state_store::murmur_home_dir()) {
350            if owner == home {
351                crate::murmur_home::ensure_murmur_home()?;
352            }
353        }
354        for path in chain.into_iter().rev() {
355            crate::state_store::ensure_private_dir(path, RECORD_DIR_MODE)
356                .map_err(|reason| format!("{}: {reason}", path.display()))?;
357        }
358        Ok(())
359    }
360
361    /// Put a [`RecordHeader`] at the front of this record, once, before its first new line.
362    ///
363    /// A capsule that declares no `context.retain` writes no header: there is nothing to own a
364    /// record for, and a record that gains a line on upgrade is a behaviour change an operator
365    /// did not ask for. Everything below is therefore conditional on [`Self::capsule`].
366    ///
367    /// Three cases, and the third is the whole reason this is not just a write:
368    ///
369    /// * A record that does not exist yet gets its header as its first line.
370    /// * A record that already carries one is left exactly as it is.
371    /// * A record written before the header existed is **adopted**: the header is prepended
372    ///   through the same atomic temp-and-rename rewrite a truncation uses, so a crash leaves the
373    ///   original whole. Until that adoption the record is unowned and automatic pruning skips
374    ///   it, however old it is.
375    ///
376    /// Adoption is what brings a headerless record under a policy without ever guessing at
377    /// ownership: the capsule that writes to a record is the capsule that owns it.
378    fn ensure_header(&mut self) -> Result<(), String> {
379        if self.header_ensured {
380            return Ok(());
381        }
382        let Some(capsule) = self.capsule.clone() else {
383            self.header_ensured = true;
384            return Ok(());
385        };
386        let path = self.path();
387        let existing = match std::fs::read_to_string(&path) {
388            Ok(raw) => raw,
389            Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
390            Err(err) => return Err(format!("{}: {err}", path.display())),
391        };
392        if existing.lines().next().and_then(parse_header).is_some() {
393            self.header_ensured = true;
394            return Ok(());
395        }
396
397        let header = RecordHeader {
398            kind: RECORD_HEADER_TYPE.to_string(),
399            capsule,
400            created_ms: crate::retention::now_ms(),
401            truncated: None,
402        };
403        let line = header_line(&header)?;
404        // The flag is set only once the header is on disk: a failure the caller survives is
405        // followed by an append carrying the same messages, and a record marked as headered
406        // before its header was written would take those messages headerless and stay unowned.
407        if existing.is_empty() {
408            open_record_file(&path, true)?
409                .write_all(line.as_bytes())
410                .map_err(|err| format!("{}: {err}", path.display()))?;
411        } else {
412            let mut contents = line;
413            contents.push_str(&existing);
414            crate::retention::StagedRewrite::stage_with_mode(
415                &path,
416                contents.as_bytes(),
417                Some(RECORD_FILE_MODE),
418            )?
419            .commit()?;
420        }
421        self.header_ensured = true;
422        Ok(())
423    }
424}
425
426/// Open a record file for appending, or for replacing when `truncate`, held at
427/// [`RECORD_FILE_MODE`] whether or not this call created it.
428fn open_record_file(path: &Path, truncate: bool) -> Result<std::fs::File, String> {
429    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
430
431    let mut options = std::fs::OpenOptions::new();
432    options.create(true).mode(RECORD_FILE_MODE);
433    if truncate {
434        options.write(true).truncate(true);
435    } else {
436        options.append(true);
437    }
438    let file = options
439        .open(path)
440        .map_err(|err| format!("{}: {err}", path.display()))?;
441    file.set_permissions(std::fs::Permissions::from_mode(RECORD_FILE_MODE))
442        .map_err(|err| format!("{}: {err}", path.display()))?;
443    Ok(file)
444}
445
446/// The parsed record one reader holds between pages.
447///
448/// A hook pages a record it does not shrink and cannot write, so re-reading and re-parsing the
449/// whole file for each page makes a walk quadratic in a file designed to grow without bound. One
450/// cache serves a whole launch: [`crate::conversation_import::ConversationState`] holds it across
451/// every task, which is why the key carries the path as well as the length.
452#[derive(Default)]
453pub(crate) struct RecordCache {
454    /// Record the messages below were parsed from, and `None` while no parse is trusted.
455    path: Option<PathBuf>,
456    /// Length of that file at the moment it was stat'd for the parse below.
457    len: u64,
458    /// The record, oldest first, as [`read_record`] returned it.
459    messages: Vec<Value>,
460    /// A malformed line is reported once for this reader, however many pages it walks.
461    malformed_reported: bool,
462    /// Reads of the record file that reached the filesystem. What pins the cost of a paging loop.
463    #[cfg(test)]
464    reads: u32,
465}
466
467impl RecordCache {
468    /// The whole record, oldest first, parsed at most once per length the file has held.
469    ///
470    /// The runtime is the record's only writer and only ever appends, so a file whose length is
471    /// unchanged holds the same bytes and the parse above it still stands. A missing file is an
472    /// empty record of length 0, exactly as a read of one is.
473    fn messages(&mut self, path: &Path, workdir: &Path) -> Result<&[Value], String> {
474        let len = match std::fs::metadata(path) {
475            Ok(metadata) => metadata.len(),
476            Err(err) if err.kind() == std::io::ErrorKind::NotFound => 0,
477            Err(err) => return Err(err.to_string()),
478        };
479        if self.path.as_deref() == Some(path) && self.len == len {
480            return Ok(&self.messages);
481        }
482
483        // Cleared before the read and set again only after the messages are in place, so a read
484        // that fails leaves a cache that re-reads rather than one record's messages under
485        // another's key.
486        self.path = None;
487        #[cfg(test)]
488        {
489            self.reads += 1;
490        }
491        self.messages = read_record(path, workdir, &mut self.malformed_reported)?;
492        self.len = len;
493        self.path = Some(path.to_path_buf());
494        Ok(&self.messages)
495    }
496
497    #[cfg(test)]
498    pub(crate) fn reads(&self) -> u32 {
499        self.reads
500    }
501}
502
503/// One page of a record, newest first, as `murmur:conversation/read` serves it.
504///
505/// `path` is `None` for a session that writes no record — `context.record: off`, a
506/// `process`-transport capsule, or a task whose context id was refused — and reads as an empty
507/// page rather than an error: there is nothing wrong with a conversation that has said nothing
508/// yet. That case leaves `cache` alone, so a hook dispatched between two tasks does not cost the
509/// next one its parse.
510pub(crate) fn page(
511    cache: &mut RecordCache,
512    path: Option<&Path>,
513    workdir: &Path,
514    cursor: Option<&str>,
515    limit: u32,
516) -> Result<MessagePage, String> {
517    let Some(path) = path else {
518        return Ok(empty_page());
519    };
520    let messages = cache
521        .messages(path, workdir)
522        .map_err(|reason| format!("unavailable: {reason}"))?;
523
524    // A cursor is the exclusive upper bound of the next page, counted from the oldest message, so
525    // it stays valid while the runtime appends to the record underneath a paging hook.
526    let end = match cursor {
527        None => messages.len(),
528        Some(cursor) => decode_cursor(cursor)
529            .filter(|end| *end <= messages.len())
530            .ok_or_else(|| "invalid-cursor".to_string())?,
531    };
532    let limit = limit.clamp(1, MAX_PAGE_LIMIT) as usize;
533    let start = end.saturating_sub(limit);
534
535    let mut window: Vec<Value> = messages[start..end].to_vec();
536    window.reverse();
537    // `murmur:conversation/read` re-uses `murmur:hook/lifecycle`'s `message`, but bindgen
538    // generates the imported and exported views of it as two Rust types. The lowering itself is
539    // `to_wit_messages`, so a "tool" message reaches a reader folded exactly as it reaches a
540    // compaction hook.
541    Ok(MessagePage {
542        messages: crate::agent::to_wit_messages(&window)
543            .into_iter()
544            .map(|message| Message {
545                role: message.role,
546                content: message.content,
547                id: message.id,
548                source_id: message.source_id,
549            })
550            .collect(),
551        next_cursor: (start > 0).then(|| encode_cursor(start)),
552        total: u32::try_from(messages.len()).unwrap_or(u32::MAX),
553    })
554}
555
556/// What a record with nothing in it reads as.
557fn empty_page() -> MessagePage {
558    MessagePage {
559        messages: Vec::new(),
560        next_cursor: None,
561        total: 0,
562    }
563}
564
565/// Every message in `path`, oldest first.
566///
567/// The single parser both readers go through — the `murmur:conversation/read` page and the
568/// `threaded` reload — so a line the hook can see is a line the next task will load. A line that
569/// is not a JSON object with a string `role` is skipped; a truncated final line is exactly that
570/// case. A missing file is an empty record, not an error.
571///
572/// `reported` is the reader's own once-only flag: the first skipped line it meets is reported
573/// naming the path and the 1-based line number, and every line after it, in this read and in the
574/// reader's later ones, is silent. One such line is worth one log entry however many times the
575/// reader goes back to the file.
576fn read_record(path: &Path, workdir: &Path, reported: &mut bool) -> Result<Vec<Value>, String> {
577    let raw = match std::fs::read_to_string(path) {
578        Ok(raw) => raw,
579        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
580        Err(err) => return Err(err.to_string()),
581    };
582
583    let mut messages = Vec::new();
584    for (index, line) in raw.lines().enumerate() {
585        if line.trim().is_empty() {
586            continue;
587        }
588        match parse_message_line(line) {
589            Some(message) => messages.push(message),
590            None => {
591                if !*reported {
592                    *reported = true;
593                    report(
594                        workdir,
595                        &format!(
596                            "[conversation] {}: line {} is not a message and was skipped",
597                            path.display(),
598                            index + 1
599                        ),
600                    );
601                }
602            }
603        }
604    }
605    Ok(messages)
606}
607
608/// One record line as a message, or `None` when it is not one. A message is a JSON object
609/// carrying a string `role`: everything the runtime writes is, and nothing else can be lowered
610/// into the WIT `message` record. The header line is the deliberate non-message — it is how a
611/// truncation marker rides in the file without being visible to anything that reads messages.
612pub(crate) fn parse_message_line(line: &str) -> Option<Value> {
613    let value: Value = serde_json::from_str(line).ok()?;
614    value
615        .get("role")
616        .and_then(Value::as_str)
617        .is_some()
618        .then_some(value)
619}
620
621fn encode_cursor(position: usize) -> String {
622    format!("{CURSOR_PREFIX}{position}")
623}
624
625fn decode_cursor(cursor: &str) -> Option<usize> {
626    cursor.strip_prefix(CURSOR_PREFIX)?.parse().ok()
627}
628
629/// Both places an operator looks for what the runtime did with a record. Never fatal.
630fn report(workdir: &Path, message: &str) {
631    eprintln!("[capsule-runtime] {message}");
632    crate::agent::append_bootstrap_log(workdir, message);
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use serde_json::json;
639
640    /// The capsule every record below is opened for, and therefore the name its header carries.
641    const CAPSULE: &str = "capsule";
642
643    fn message(id: &str, role: &str, text: &str) -> Value {
644        json!({"role": role, "content": [{"type": "text", "text": text}], "id": id})
645    }
646
647    fn id_at(id: usize) -> String {
648        format!("msg_{:032x}", id)
649    }
650
651    #[test]
652    fn record_file_is_held_owner_only_on_append() {
653        let home = tempfile::tempdir().unwrap();
654        let workdir = tempfile::tempdir().unwrap();
655        let root = home.path().join("conversations/shared");
656        crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
657        let path = root.join("ctx_1").join(RECORD_FILE_NAME);
658        crate::murmur_home::wide_file(&path, "", 0o644);
659
660        let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
661        record.append(&[message(&id_at(1), "user", "hello")]);
662
663        assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
664        assert_eq!(crate::murmur_home::mode_of(&root.join("ctx_1")), 0o700);
665        assert_eq!(std::fs::read_to_string(&path).unwrap().lines().count(), 1);
666    }
667
668    #[test]
669    fn record_file_is_created_owner_only_with_its_header() {
670        let home = tempfile::tempdir().unwrap();
671        let workdir = tempfile::tempdir().unwrap();
672        let root = home.path().join("conversations/shared");
673
674        let mut record =
675            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
676        record.append(&[message(&id_at(1), "user", "hello")]);
677
678        assert_eq!(crate::murmur_home::mode_of(&record.path()), 0o600);
679    }
680
681    /// Adopting a headerless record rewrites it by rename; the replacement is owner-only too.
682    #[test]
683    fn record_rewrite_keeps_the_file_owner_only() {
684        let home = tempfile::tempdir().unwrap();
685        let workdir = tempfile::tempdir().unwrap();
686        let root = home.path().join("conversations/shared");
687        crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
688        let path = root.join("ctx_1").join(RECORD_FILE_NAME);
689        let old = serde_json::to_string(&message(&id_at(1), "user", "old")).unwrap();
690        crate::murmur_home::wide_file(&path, &format!("{old}\n"), 0o644);
691
692        let mut record =
693            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
694        record.append(&[message(&id_at(2), "user", "new")]);
695
696        assert!(read_header(&path).is_some(), "the record was adopted");
697        assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
698    }
699
700    #[test]
701    fn record_root_under_a_wide_murmur_home_holds_the_home_owner_only() {
702        let home = tempfile::tempdir().unwrap();
703        crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
704        crate::murmur_home::run_with_home(
705            "conversation::tests::inner_append_under_scratch_home",
706            home.path(),
707        );
708        assert_eq!(
709            crate::murmur_home::mode_of(&home.path().join(".murmur")),
710            0o700
711        );
712        let record = home
713            .path()
714            .join(".murmur/conversations/shared/ctx_1")
715            .join(RECORD_FILE_NAME);
716        assert_eq!(crate::murmur_home::mode_of(&record), 0o600);
717    }
718
719    #[test]
720    #[ignore = "run by record_root_under_a_wide_murmur_home_holds_the_home_owner_only"]
721    fn inner_append_under_scratch_home() {
722        if !crate::murmur_home::in_scratch_home() {
723            return;
724        }
725        let workdir = tempfile::tempdir().unwrap();
726        let root = record_root("shared").unwrap();
727        let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
728        record.append(&[message(&id_at(1), "user", "hello")]);
729        assert!(record.path().exists());
730    }
731
732    /// The header is the first line a new record gets, and it names the capsule that wrote it —
733    /// which is what makes automatic pruning store-safe when two capsules share one record store.
734    #[test]
735    fn the_first_append_writes_a_header_naming_the_capsule() {
736        let home = tempfile::tempdir().unwrap();
737        let workdir = tempfile::tempdir().unwrap();
738        let root = home.path().join("conversations/shared");
739
740        let mut record =
741            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
742        record.append(&[message(&id_at(1), "user", "hello")]);
743
744        let raw = std::fs::read_to_string(record.path()).unwrap();
745        let lines: Vec<&str> = raw.lines().collect();
746        assert_eq!(lines.len(), 2, "a header and one message: {lines:?}");
747        let header = parse_header(lines[0]).expect("the first line is a header");
748        assert_eq!(header.kind, RECORD_HEADER_TYPE);
749        assert_eq!(header.capsule, CAPSULE);
750        assert!(header.truncated.is_none(), "nothing has been dropped yet");
751        assert!(header.created_ms > 0);
752    }
753
754    /// The header is invisible to everything that reads messages: `read_record` skips it, so the
755    /// `total` a `murmur:conversation/read` page reports counts messages and nothing else.
756    #[test]
757    fn a_header_line_changes_neither_the_loaded_messages_nor_total() {
758        let home = tempfile::tempdir().unwrap();
759        let workdir = tempfile::tempdir().unwrap();
760        let root = home.path().join("conversations/c");
761
762        let mut record =
763            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
764        record.append(&[
765            message(&id_at(1), "user", "one"),
766            message(&id_at(2), "assistant", "two"),
767        ]);
768        let path = record.path();
769
770        let mut reader =
771            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
772        let loaded = reader.load();
773        assert_eq!(loaded.len(), 2, "the header is not a message: {loaded:?}");
774
775        let mut cache = RecordCache::default();
776        let page = page(&mut cache, Some(&path), workdir.path(), None, 100).unwrap();
777        assert_eq!(page.total, 2);
778        assert_eq!(page.messages.len(), 2);
779    }
780
781    /// A record written before the header existed is unowned, and is adopted on the next append
782    /// by the capsule that writes to it — through the same atomic rewrite a truncation uses, so
783    /// every line it already held survives byte for byte.
784    #[test]
785    fn a_headerless_record_is_adopted_on_the_next_append() {
786        let home = tempfile::tempdir().unwrap();
787        let workdir = tempfile::tempdir().unwrap();
788        let root = home.path().join("conversations/c");
789        let dir = root.join("ctx_1");
790        std::fs::create_dir_all(&dir).unwrap();
791        let path = dir.join(RECORD_FILE_NAME);
792        let headerless = format!(
793            "{}
794{}
795",
796            serde_json::to_string(&message(&id_at(1), "user", "one")).unwrap(),
797            serde_json::to_string(&message(&id_at(2), "assistant", "two")).unwrap()
798        );
799        std::fs::write(&path, &headerless).unwrap();
800        assert!(read_header(&path).is_none(), "unowned before the append");
801
802        let mut record =
803            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
804        record.append(&[message(&id_at(3), "user", "three")]);
805
806        let raw = std::fs::read_to_string(&path).unwrap();
807        assert_eq!(read_header(&path).unwrap().capsule, CAPSULE);
808        assert!(
809            raw.contains(headerless.trim_end()),
810            "every line it already held survives byte for byte: {raw}"
811        );
812        assert_eq!(raw.lines().count(), 4, "a header and three messages");
813    }
814
815    /// A record already carrying a header is left exactly as it is — the header is written once,
816    /// not once per launch, and a truncation marker on it is never cleared by an append.
817    #[test]
818    fn an_owned_record_keeps_the_header_it_already_has() {
819        let home = tempfile::tempdir().unwrap();
820        let workdir = tempfile::tempdir().unwrap();
821        let root = home.path().join("conversations/c");
822        let dir = root.join("ctx_1");
823        std::fs::create_dir_all(&dir).unwrap();
824        let path = dir.join(RECORD_FILE_NAME);
825        let header = RecordHeader {
826            kind: RECORD_HEADER_TYPE.to_string(),
827            capsule: "someone-else".to_string(),
828            created_ms: 1_756_400_000_000,
829            truncated: Some(TruncationMarker {
830                dropped: 5,
831                oldest_surviving_id: id_at(6),
832                last_dropped_id: id_at(5),
833                at_ms: 1_756_400_000_001,
834            }),
835        };
836        std::fs::write(&path, header_line(&header).unwrap()).unwrap();
837
838        let mut record =
839            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
840        record.append(&[message(&id_at(7), "user", "seven")]);
841
842        assert_eq!(read_header(&path).unwrap(), header);
843    }
844
845    /// A record written by one session and read back by the next: the ids are the same bytes, and
846    /// nothing loaded is appended a second time.
847    #[test]
848    fn a_reloaded_message_keeps_its_id_and_is_not_written_twice() {
849        let home = tempfile::tempdir().unwrap();
850        let workdir = tempfile::tempdir().unwrap();
851        let root = home.path().join("conversations/capsule");
852
853        let mut first =
854            ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
855        first.append(&[message(&id_at(1), "user", "hello")]);
856
857        let mut second =
858            ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
859        let loaded = second.load();
860        assert_eq!(loaded.len(), 1);
861        assert_eq!(
862            crate::agent::message_id(&loaded[0]),
863            Some(id_at(1).as_str())
864        );
865
866        second.append(&loaded);
867        second.append(&[message(&id_at(2), "assistant", "hi")]);
868        assert_eq!(
869            second.load().len(),
870            2,
871            "a reloaded message is never re-appended"
872        );
873    }
874
875    /// Every directory on the record path is owner-only, and none of them exists before the first
876    /// message is appended.
877    #[test]
878    fn the_directory_chain_is_created_at_0700_on_first_write() {
879        use std::os::unix::fs::PermissionsExt;
880
881        let home = tempfile::tempdir().unwrap();
882        let workdir = tempfile::tempdir().unwrap();
883        let conversations = home.path().join("conversations");
884        let root = conversations.join("capsule");
885
886        let mut record =
887            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
888        assert!(!conversations.exists(), "opening a record creates nothing");
889
890        record.append(&[message(&id_at(1), "user", "hello")]);
891        for dir in [&conversations, &root, &root.join("ctx_1")] {
892            let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
893            assert_eq!(
894                mode,
895                0o700,
896                "{} must be 0700, got {mode:04o}",
897                dir.display()
898            );
899        }
900    }
901
902    /// A context id that is not one path segment costs that conversation its record, and refuses
903    /// before any path is built from it.
904    #[test]
905    fn a_context_id_that_is_not_a_segment_records_nothing() {
906        let home = tempfile::tempdir().unwrap();
907        let workdir = tempfile::tempdir().unwrap();
908        for context_id in ["../escape", "a/b", "", ".hidden"] {
909            assert!(
910                ConversationRecord::open(
911                    &home.path().join("conversations/c"),
912                    context_id,
913                    workdir.path(),
914                    Some(CAPSULE)
915                )
916                .is_none(),
917                "'{context_id}' must not open a record"
918            );
919        }
920        assert!(!home.path().join("conversations").exists());
921    }
922
923    /// Newest first, paged, with a cursor that neither repeats nor skips a message.
924    #[test]
925    fn paging_walks_the_record_newest_first_without_repeating_a_message() {
926        let home = tempfile::tempdir().unwrap();
927        let workdir = tempfile::tempdir().unwrap();
928        let root = home.path().join("conversations/capsule");
929        let mut record =
930            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
931        let written: Vec<Value> = (1..=5)
932            .map(|n| message(&id_at(n), "user", &format!("m{n}")))
933            .collect();
934        record.append(&written);
935        let path = record.path();
936
937        let mut seen = Vec::new();
938        let mut cursor: Option<String> = None;
939        loop {
940            let page = page(
941                &mut RecordCache::default(),
942                Some(&path),
943                workdir.path(),
944                cursor.as_deref(),
945                2,
946            )
947            .unwrap();
948            assert_eq!(page.total, 5);
949            seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
950            match page.next_cursor {
951                Some(next) => cursor = Some(next),
952                None => break,
953            }
954        }
955        assert_eq!(
956            seen,
957            (1..=5).rev().map(id_at).collect::<Vec<_>>(),
958            "the whole record, newest first, exactly once each"
959        );
960    }
961
962    /// `limit` is the host's to bound: `0` still makes progress and a huge value cannot ask for
963    /// more than the ceiling.
964    #[test]
965    fn limit_is_clamped_at_both_ends() {
966        let home = tempfile::tempdir().unwrap();
967        let workdir = tempfile::tempdir().unwrap();
968        let root = home.path().join("conversations/capsule");
969        let mut record =
970            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
971        record.append(
972            &(1..=120)
973                .map(|n| message(&id_at(n), "user", "m"))
974                .collect::<Vec<_>>(),
975        );
976        let path = record.path();
977
978        assert_eq!(
979            page(
980                &mut RecordCache::default(),
981                Some(&path),
982                workdir.path(),
983                None,
984                0
985            )
986            .unwrap()
987            .messages
988            .len(),
989            1
990        );
991        assert_eq!(
992            page(
993                &mut RecordCache::default(),
994                Some(&path),
995                workdir.path(),
996                None,
997                u32::MAX
998            )
999            .unwrap()
1000            .messages
1001            .len(),
1002            MAX_PAGE_LIMIT as usize
1003        );
1004    }
1005
1006    /// A value that is not `mc_<number>`, and a position past the end of the record, are both
1007    /// `invalid-cursor` rather than a page.
1008    #[test]
1009    fn a_foreign_cursor_is_refused() {
1010        let home = tempfile::tempdir().unwrap();
1011        let workdir = tempfile::tempdir().unwrap();
1012        let root = home.path().join("conversations/capsule");
1013        let mut record =
1014            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1015        record.append(&[message(&id_at(1), "user", "m")]);
1016        let path = record.path();
1017
1018        for cursor in ["7", "mc_", "mc_nine", "mc_9", "cur_1"] {
1019            assert_eq!(
1020                page(
1021                    &mut RecordCache::default(),
1022                    Some(&path),
1023                    workdir.path(),
1024                    Some(cursor),
1025                    10
1026                )
1027                .err(),
1028                Some("invalid-cursor".to_string()),
1029                "cursor '{cursor}'"
1030            );
1031        }
1032    }
1033
1034    /// A record that was never written, and a session with no record at all, are both an empty
1035    /// page — never an error.
1036    #[test]
1037    fn an_absent_record_is_an_empty_page() {
1038        let home = tempfile::tempdir().unwrap();
1039        let workdir = tempfile::tempdir().unwrap();
1040        let missing = home
1041            .path()
1042            .join("conversations/capsule/ctx_1/conversation.jsonl");
1043
1044        for path in [None, Some(missing.as_path())] {
1045            let page = page(&mut RecordCache::default(), path, workdir.path(), None, 10).unwrap();
1046            assert!(page.messages.is_empty());
1047            assert_eq!(page.next_cursor, None);
1048            assert_eq!(page.total, 0);
1049        }
1050    }
1051
1052    /// A read for a session that keeps no record leaves the cache as it found it, so a hook
1053    /// dispatched between two tasks does not cost the next one its parse.
1054    #[test]
1055    fn a_read_with_no_record_leaves_the_cache_alone() {
1056        let home = tempfile::tempdir().unwrap();
1057        let workdir = tempfile::tempdir().unwrap();
1058        let root = home.path().join("conversations/capsule");
1059        let mut record =
1060            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1061        record.append(&[message(&id_at(1), "user", "a")]);
1062        let path = record.path();
1063
1064        let mut cache = RecordCache::default();
1065        assert_eq!(
1066            page(&mut cache, Some(&path), workdir.path(), None, 10)
1067                .unwrap()
1068                .total,
1069            1
1070        );
1071        assert!(page(&mut cache, None, workdir.path(), None, 10)
1072            .unwrap()
1073            .messages
1074            .is_empty());
1075        assert_eq!(
1076            page(&mut cache, Some(&path), workdir.path(), None, 10)
1077                .unwrap()
1078                .total,
1079            1
1080        );
1081        assert_eq!(cache.reads(), 1, "the parse survived the empty read");
1082    }
1083
1084    /// A malformed line is skipped, reported once naming the path and its 1-based number, and
1085    /// costs the read nothing else.
1086    #[test]
1087    fn a_malformed_line_is_skipped_and_reported() {
1088        let home = tempfile::tempdir().unwrap();
1089        let workdir = tempfile::tempdir().unwrap();
1090        let dir = home.path().join("conversations/capsule/ctx_1");
1091        std::fs::create_dir_all(&dir).unwrap();
1092        let path = dir.join(RECORD_FILE_NAME);
1093        let mut raw = String::new();
1094        for n in 1..=3 {
1095            raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
1096            raw.push('\n');
1097        }
1098        raw.push_str("{\"role\":\"user\"");
1099        std::fs::write(&path, raw).unwrap();
1100
1101        let page = page(
1102            &mut RecordCache::default(),
1103            Some(&path),
1104            workdir.path(),
1105            None,
1106            10,
1107        )
1108        .unwrap();
1109        assert_eq!(page.total, 3);
1110        assert_eq!(page.messages.len(), 3);
1111
1112        let log =
1113            std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
1114        assert_eq!(
1115            log.lines()
1116                .filter(|line| line.contains("line 4 is not a message"))
1117                .count(),
1118            1,
1119            "reported once, naming the line: {log}"
1120        );
1121    }
1122
1123    /// A write that fails and then can succeed leaves the record whole: the message the failure
1124    /// cost is written by the next append that carries it, in its own order.
1125    ///
1126    /// The failure is a regular file sitting where the context directory has to go, which no
1127    /// permission bit is involved in — the suite reads the same as root and as anyone else.
1128    #[test]
1129    fn a_failed_write_is_retried_and_leaves_no_hole() {
1130        let home = tempfile::tempdir().unwrap();
1131        let workdir = tempfile::tempdir().unwrap();
1132        let root = home.path().join("conversations/capsule");
1133        let blocker = root.join("ctx_1");
1134        std::fs::create_dir_all(&root).unwrap();
1135        std::fs::write(&blocker, "").unwrap();
1136
1137        let mut record =
1138            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1139        let first = message(&id_at(1), "user", "a");
1140        let second = message(&id_at(2), "assistant", "b");
1141        record.append(std::slice::from_ref(&first));
1142        assert!(
1143            blocker.is_file(),
1144            "the directory chain could not be created"
1145        );
1146
1147        std::fs::remove_file(&blocker).unwrap();
1148        record.append(&[first, second]);
1149
1150        let written: Vec<Value> = std::fs::read_to_string(record.path())
1151            .unwrap()
1152            .lines()
1153            .filter_map(parse_message_line)
1154            .collect();
1155        assert_eq!(
1156            written
1157                .iter()
1158                .map(|m| crate::agent::message_id(m).unwrap())
1159                .collect::<Vec<_>>(),
1160            vec![id_at(1), id_at(2)],
1161            "the message the failure cost is on disk, before the one that followed it"
1162        );
1163    }
1164
1165    /// A whole paging walk costs one read of the record and one parse of it.
1166    #[test]
1167    fn a_paging_loop_reads_the_record_once() {
1168        let home = tempfile::tempdir().unwrap();
1169        let workdir = tempfile::tempdir().unwrap();
1170        let root = home.path().join("conversations/capsule");
1171        let mut record =
1172            ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1173        record.append(
1174            &(1..=250)
1175                .map(|n| message(&id_at(n), "user", "m"))
1176                .collect::<Vec<_>>(),
1177        );
1178        let path = record.path();
1179        let mut cache = RecordCache::default();
1180
1181        let mut pages = 0;
1182        let mut seen = Vec::new();
1183        let mut cursor: Option<String> = None;
1184        loop {
1185            let page = page(
1186                &mut cache,
1187                Some(&path),
1188                workdir.path(),
1189                cursor.as_deref(),
1190                25,
1191            )
1192            .unwrap();
1193            pages += 1;
1194            seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
1195            match page.next_cursor {
1196                Some(next) => cursor = Some(next),
1197                None => break,
1198            }
1199        }
1200        assert_eq!(pages, 10);
1201        assert_eq!(seen, (1..=250).rev().map(id_at).collect::<Vec<_>>());
1202        assert_eq!(cache.reads(), 1, "one read for the whole walk");
1203
1204        // The record grows underneath the same reader: the next walk sees the new messages and
1205        // pays for exactly one more read.
1206        record.append(
1207            &(251..=260)
1208                .map(|n| message(&id_at(n), "user", "m"))
1209                .collect::<Vec<_>>(),
1210        );
1211        let mut seen = Vec::new();
1212        let mut cursor: Option<String> = None;
1213        let mut first_total = None;
1214        loop {
1215            let page = page(
1216                &mut cache,
1217                Some(&path),
1218                workdir.path(),
1219                cursor.as_deref(),
1220                25,
1221            )
1222            .unwrap();
1223            first_total.get_or_insert(page.total);
1224            seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
1225            match page.next_cursor {
1226                Some(next) => cursor = Some(next),
1227                None => break,
1228            }
1229        }
1230        assert_eq!(first_total, Some(260));
1231        assert_eq!(seen, (1..=260).rev().map(id_at).collect::<Vec<_>>());
1232        assert_eq!(cache.reads(), 2, "one further read for the second walk");
1233    }
1234
1235    /// One truncated line is one log entry, however many pages are walked over it.
1236    #[test]
1237    fn a_malformed_line_is_reported_once_for_a_whole_walk() {
1238        let home = tempfile::tempdir().unwrap();
1239        let workdir = tempfile::tempdir().unwrap();
1240        let dir = home.path().join("conversations/capsule/ctx_1");
1241        std::fs::create_dir_all(&dir).unwrap();
1242        let path = dir.join(RECORD_FILE_NAME);
1243        let mut raw = String::new();
1244        for n in 1..=10 {
1245            raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
1246            raw.push('\n');
1247        }
1248        raw.push_str("{\"role\":\"user\"");
1249        std::fs::write(&path, raw).unwrap();
1250
1251        let mut cache = RecordCache::default();
1252        let mut cursor: Option<String> = None;
1253        let mut pages = 0;
1254        loop {
1255            let page = page(
1256                &mut cache,
1257                Some(&path),
1258                workdir.path(),
1259                cursor.as_deref(),
1260                2,
1261            )
1262            .unwrap();
1263            pages += 1;
1264            assert_eq!(page.total, 10);
1265            match page.next_cursor {
1266                Some(next) => cursor = Some(next),
1267                None => break,
1268            }
1269        }
1270        assert_eq!(pages, 5);
1271
1272        let log =
1273            std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
1274        assert_eq!(
1275            log.lines()
1276                .filter(|line| line.contains("line 11 is not a message"))
1277                .count(),
1278            1,
1279            "one entry for the whole walk: {log}"
1280        );
1281    }
1282
1283    /// `context.record: off` resolves to no record name at all, whatever `record_store` says
1284    /// beside it.
1285    #[test]
1286    fn record_off_wins_over_a_declared_store() {
1287        let off = murmur_artifact::ContextConfig {
1288            max_tokens: None,
1289            record: false,
1290            record_store: Some("shey".to_string()),
1291            seed_budget: murmur_artifact::DEFAULT_SEED_BUDGET,
1292            seed_overflow_margin: murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN,
1293            retain: None,
1294        };
1295        assert_eq!(resolve_record_name(Some(&off), "capsule"), None);
1296
1297        let on = murmur_artifact::ContextConfig {
1298            record: true,
1299            ..off.clone()
1300        };
1301        assert_eq!(
1302            resolve_record_name(Some(&on), "capsule"),
1303            Some("shey".to_string())
1304        );
1305        assert_eq!(
1306            resolve_record_name(None, "capsule"),
1307            Some("capsule".to_string()),
1308            "a capsule declaring no context block still records"
1309        );
1310    }
1311
1312    /// Every refusal names the value the operator wrote and the rule it broke.
1313    #[test]
1314    fn a_malformed_record_name_is_refused_by_name() {
1315        for value in ["", "../escape", "/abs", "a/b", ".hidden"] {
1316            let err = validate_record_segment("context.record_store", value)
1317                .expect_err("'{value}' must be refused");
1318            let message = err.to_string();
1319            assert!(message.contains(&format!("'{value}'")), "{message}");
1320            assert!(message.contains("single path segment"), "{message}");
1321            assert!(message.contains("context.record_store"), "{message}");
1322        }
1323        validate_record_segment("--context", "ctx_fixed").unwrap();
1324    }
1325}