Skip to main content

agent_core/core/
context_archive.rs

1//! Durable, disclosure-filtered context segments; never a summary or a session log.
2//!
3//! The trusted host supplies an absolute base directory, scope key and logical
4//! conversation id (never model/tool path arguments). Scope and logical id are
5//! hashed; project scope determines the directory name. Only user/assistant text
6//! and tool-call structure
7//! are retained. System/developer messages, reasoning, unknown blocks, binary,
8//! non-normal sensitivity and restricted/unknown disclosure are omitted.
9//! Arguments/results are WITHHELD unless a trusted redactor is installed. All
10//! retained strings pass the disclosure gate and a conservative credential
11//! screen. This screen is not a universal secret detector: host classification
12//! and the optional redactor remain the authority for unlabelled secrets.
13//!
14//! Each segment contains its hidden, screened note and source indices in ONE
15//! atomic file. Notes are not searched or returned by message fetch. Eligible
16//! text is otherwise exact: no truncation, whitespace rewriting or summarizing.
17//! `fetch_with_provenance` reports indices into the original `seal` slice.
18//!
19//! Fixed slots avoid unbounded directory enumeration/indexes: at most 128
20//! segments (including permanent tombstones), 256 MiB total, 16 MiB per file.
21//! Input is limited to 4096 messages, 16 MiB of eligible strings/structure,
22//! 65536 visited nodes and depth 32; excluded payload descendants are opaque.
23//! Notes are limited to 8 KiB. Fetch returns <=128 messages and <=16 MiB;
24//! search queries <=512 bytes, results <=32 with <=256-byte snippets. Every
25//! operation scans at most 128 files / 256 MiB; lock acquisition is nonblocking.
26//! Budget exhaustion and corruption fail closed, never silently truncate seal.
27//!
28//! Unix-only private, handle-relative no-symlink filesystem; other platforms
29//! fail Unsupported. Live records are immutable; forget atomically replaces a
30//! record with a content-free id/digest/scope tombstone. The digest covers ONLY
31//! the eligible projection and source indices, not the note or forbidden data.
32//! Identical projections in one logical conversation deduplicate permanently;
33//! a changed note does not create a new segment. Retrying a tombstoned projection
34//! returns PermissionDenied. A successful seal fsyncs data and directory before
35//! returning; callers MUST keep source history on any error (even if publication
36//! happened before a directory-fsync error). Advisory locks serialize cooperating
37//! processes, not malicious processes running as the same OS user.
38
39use crate::core::disclosure::{gate_for_model, may_persist, DisclosureClass, ModelVisibility};
40use crate::core::stream_types::SharedMessage;
41use serde::{Deserialize, Serialize};
42use serde_json::{json, Value};
43use sha2::{Digest, Sha256};
44use std::io::{self, Read, Write};
45use std::path::Path;
46use std::sync::Arc;
47
48pub const MAX_SEGMENTS: usize = 128;
49pub const MAX_SEGMENT_BYTES: usize = 16 * 1024 * 1024;
50pub const MAX_STORE_BYTES: usize = 256 * 1024 * 1024;
51pub const MAX_INPUT_BYTES: usize = 16 * 1024 * 1024;
52pub const MAX_MESSAGES: usize = 4096;
53pub const MAX_NOTE_BYTES: usize = 8192;
54pub const MAX_FETCH_MESSAGES: usize = 128;
55pub const MAX_SEARCH_RESULTS: usize = 32;
56const WITHHELD: &str = "[archive: withheld]";
57
58/// Host-owned sanitizer, never supplied by model input. Must not expand output
59/// beyond the archive budgets. Tool arguments are passed as serialized JSON;
60/// if the returned text is not JSON the entire argument payload is withheld.
61pub type ArchiveRedactor = dyn Fn(&str) -> String + Send + Sync;
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ArchiveRef {
65    pub id: String,
66    /// Eligible messages, not the length of the source slice.
67    pub message_count: usize,
68    pub source_message_count: usize,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ArchiveDescriptor {
73    pub id: String,
74    pub message_count: usize,
75    pub source_message_count: usize,
76    /// A bounded excerpt of eligible source JSON, never the hidden note.
77    pub snippet: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct ArchivedMessage {
83    /// Zero-based offset in the slice passed to seal, including omitted messages.
84    pub source_index: usize,
85    /// Original content-array indices; empty for string content.
86    pub block_indices: Vec<usize>,
87    pub message: SharedMessage,
88}
89
90/// Eligible, screened source evidence without filesystem state. `logical_id`
91/// is the legacy 64-hex hash of the raw host session ID; the digest deliberately
92/// excludes the hidden note and omitted content. Never log this payload.
93#[derive(Clone, PartialEq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ArchiveProjection {
96    pub logical_id: String,
97    pub source_message_count: usize,
98    pub messages: Vec<ArchivedMessage>,
99    pub note: String,
100    pub digest: String,
101}
102
103/// Pure projection: no path discovery, directory creation, or archive I/O.
104/// The trusted host supplies the raw logical session ID and optional sanitizer.
105pub fn project_archive(
106    logical_id: &str,
107    messages: &[SharedMessage],
108    note: &str,
109    redactor: Option<Arc<ArchiveRedactor>>,
110) -> io::Result<ArchiveProjection> {
111    validate_key(logical_id)?;
112    project_hashed(
113        &digest_parts(&[logical_id.as_bytes()]),
114        messages,
115        note,
116        redactor,
117    )
118}
119
120fn project_hashed(
121    logical_id: &str,
122    messages: &[SharedMessage],
123    note: &str,
124    redactor: Option<Arc<ArchiveRedactor>>,
125) -> io::Result<ArchiveProjection> {
126    if messages.len() > MAX_MESSAGES || note.len() > MAX_NOTE_BYTES {
127        return Err(invalid("archive message/note budget exceeded"));
128    }
129    let mut budget = InputBudget { bytes: 0, nodes: 0 };
130    for message in messages {
131        budget.message(message, redactor.is_some())?;
132    }
133    let projector = Projector { redactor };
134    let mut eligible = Vec::new();
135    for (source_index, message) in messages.iter().enumerate() {
136        if let Some((message, block_indices)) = projector.project(message)? {
137            eligible.push(ArchivedMessage {
138                source_index,
139                block_indices,
140                message: Arc::new(message),
141            });
142        }
143    }
144    let digest = projection_digest(logical_id, messages.len(), &eligible)?;
145    let note = projector.screen(note, DisclosureClass::ModelVisible)?;
146    if note.len() > MAX_NOTE_BYTES {
147        return Err(invalid("redacted note exceeds archive budget"));
148    }
149    Ok(ArchiveProjection {
150        logical_id: logical_id.to_owned(),
151        source_message_count: messages.len(),
152        messages: eligible,
153        note,
154        digest,
155    })
156}
157
158struct Projector {
159    redactor: Option<Arc<ArchiveRedactor>>,
160}
161
162/// No Debug implementation: do not accidentally log redactor state or content.
163pub struct ArchiveStore {
164    fs: PrivateDir,
165    scope: String,
166    logical: String,
167    redactor: Option<Arc<ArchiveRedactor>>,
168}
169
170#[derive(Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172struct Record {
173    version: u32,
174    scope: String,
175    logical: String,
176    id: String,
177    digest: String,
178    body: Body,
179}
180
181#[derive(Serialize, Deserialize)]
182#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
183enum Body {
184    Live {
185        source_message_count: usize,
186        messages: Vec<ArchivedMessage>,
187        note: String,
188    },
189    Tombstone,
190}
191
192impl Record {
193    fn reference(&self) -> io::Result<ArchiveRef> {
194        match &self.body {
195            Body::Live {
196                source_message_count,
197                messages,
198                ..
199            } => Ok(ArchiveRef {
200                id: self.id.clone(),
201                message_count: messages.len(),
202                source_message_count: *source_message_count,
203            }),
204            Body::Tombstone => Err(not_found()),
205        }
206    }
207}
208
209impl ArchiveStore {
210    pub fn new(base: &Path, scope_key: &str, logical_id: &str) -> io::Result<Self> {
211        for key in [scope_key, logical_id] {
212            validate_key(key)?;
213        }
214        let scope = digest_parts(&[scope_key.as_bytes()]);
215        let logical = digest_parts(&[logical_id.as_bytes()]);
216        let fs = PrivateDir::new(base, &scope)?;
217        Ok(Self {
218            fs,
219            scope,
220            logical,
221            redactor: None,
222        })
223    }
224
225    /// Opt in to screened tool evidence. Install the SAME trusted policy when
226    /// reopening a store; retrieval never restores material withheld at seal.
227    pub fn with_redactor(mut self, redactor: Arc<ArchiveRedactor>) -> Self {
228        self.redactor = Some(redactor);
229        self
230    }
231
232    pub fn seal(&self, messages: &[SharedMessage], note: &str) -> io::Result<ArchiveRef> {
233        let projection = project_hashed(&self.logical, messages, note, self.redactor.clone())?;
234        let ArchiveProjection {
235            digest,
236            source_message_count,
237            messages: eligible,
238            note,
239            ..
240        } = projection;
241        let _lock = self.fs.lock()?;
242        let (records, used) = self.scan()?;
243        if let Some((_, record)) = records.iter().find(|(_, r)| r.digest == digest) {
244            if matches!(record.body, Body::Tombstone) {
245                return Err(io::Error::new(
246                    io::ErrorKind::PermissionDenied,
247                    "archive projection was forgotten",
248                ));
249            }
250            // A previous call may have failed AFTER rename but BEFORE fsync.
251            self.fs.sync()?;
252            return record.reference();
253        }
254        let slot = (0..MAX_SEGMENTS)
255            .find(|slot| !records.iter().any(|(s, _)| s == slot))
256            .ok_or_else(|| invalid("archive segment budget exhausted (tombstones retained)"))?;
257        let record = Record {
258            version: 1,
259            scope: self.scope.clone(),
260            logical: self.logical.clone(),
261            id: uuid::Uuid::new_v4().simple().to_string(),
262            digest,
263            body: Body::Live {
264                source_message_count,
265                messages: eligible,
266                note,
267            },
268        };
269        let bytes = bounded_json(&record, MAX_SEGMENT_BYTES)?;
270        if used + bytes.len() > MAX_STORE_BYTES {
271            return Err(invalid("archive storage byte budget exhausted"));
272        }
273        self.fs.publish(slot, &bytes)?;
274        record.reference()
275    }
276
277    pub fn fetch(&self, id: &str, start: usize, limit: usize) -> io::Result<Vec<SharedMessage>> {
278        Ok(self
279            .fetch_with_provenance(id, start, limit)?
280            .into_iter()
281            .map(|m| m.message)
282            .collect())
283    }
284
285    /// `start` indexes ELIGIBLE messages; source_index retains original offsets.
286    /// Oversized limits are rejected rather than clamped; start beyond end is empty.
287    pub fn fetch_with_provenance(
288        &self,
289        id: &str,
290        start: usize,
291        limit: usize,
292    ) -> io::Result<Vec<ArchivedMessage>> {
293        validate_id(id)?;
294        if limit > MAX_FETCH_MESSAGES {
295            return Err(invalid("archive fetch limit exceeds 128"));
296        }
297        let _lock = self.fs.lock()?;
298        let (records, _) = self.scan()?;
299        let record = find_record(records, id)?;
300        match record.body {
301            Body::Live { messages, .. } => {
302                Ok(messages.into_iter().skip(start).take(limit).collect())
303            }
304            Body::Tombstone => Err(not_found()),
305        }
306    }
307
308    /// Explicit host-only access to the hidden, screened note. Never include it
309    /// in search descriptors or inject it as system/developer instructions.
310    pub fn fetch_note(&self, id: &str) -> io::Result<String> {
311        validate_id(id)?;
312        let _lock = self.fs.lock()?;
313        let (records, _) = self.scan()?;
314        match find_record(records, id)?.body {
315            Body::Live { note, .. } => Ok(note),
316            Body::Tombstone => Err(not_found()),
317        }
318    }
319
320    /// Case-insensitive literal substring search of eligible source JSON, in
321    /// publication-slot order. Empty query lists descriptors. Not an exhaustive
322    /// ranking service: output is bounded by limit, with no hidden-note matches.
323    pub fn search(&self, query: &str, limit: usize) -> io::Result<Vec<ArchiveDescriptor>> {
324        if query.len() > 512 || limit > MAX_SEARCH_RESULTS {
325            return Err(invalid("archive search query/result budget exceeded"));
326        }
327        if limit == 0 {
328            return Ok(Vec::new());
329        }
330        let query = query.to_lowercase();
331        let _lock = self.fs.lock()?;
332        let (records, _) = self.scan()?;
333        let mut result = Vec::new();
334        for (_, record) in records {
335            if let Body::Live {
336                messages,
337                source_message_count,
338                ..
339            } = &record.body
340            {
341                let mut snippet = None;
342                for message in messages {
343                    let text = serde_json::to_string(&message.message).map_err(json_error)?;
344                    if query.is_empty() || text.to_lowercase().contains(&query) {
345                        snippet = Some(prefix(&text, 256).to_owned());
346                        break;
347                    }
348                }
349                if snippet.is_some() || (query.is_empty() && messages.is_empty()) {
350                    result.push(ArchiveDescriptor {
351                        id: record.id,
352                        message_count: messages.len(),
353                        source_message_count: *source_message_count,
354                        snippet: snippet.unwrap_or_default(),
355                    });
356                }
357            }
358            if result.len() == limit {
359                break;
360            }
361        }
362        Ok(result)
363    }
364
365    /// Durable, idempotent deletion. Replaces content in the SAME slot with a
366    /// marker; it never frees the slot/digest for reuse. Unknown ids are NotFound.
367    /// This is logical deletion, not secure erasure of filesystem snapshots.
368    pub fn forget(&self, id: &str) -> io::Result<()> {
369        validate_id(id)?;
370        let _lock = self.fs.lock()?;
371        let (records, _) = self.scan()?;
372        let (slot, mut record) = records
373            .into_iter()
374            .find(|(_, r)| r.id == id)
375            .ok_or_else(not_found)?;
376        if matches!(record.body, Body::Tombstone) {
377            return self.fs.sync();
378        }
379        record.body = Body::Tombstone;
380        self.fs
381            .publish(slot, &bounded_json(&record, MAX_SEGMENT_BYTES)?)
382    }
383
384    /// Migration-only export of validated eligible evidence and permanent
385    /// tombstones. Includes hidden notes: this is HOST data, never a model tool.
386    /// No files, directories, permissions or archive contents are changed.
387    pub fn export_records(&self) -> io::Result<Vec<Value>> {
388        let lock = self.fs.lock_existing()?;
389        let (records, _) = self.scan_with(true)?;
390        if lock.is_none() && !records.is_empty() {
391            return Err(corrupt());
392        }
393        Ok(records
394            .into_iter()
395            .map(|(_, record)| {
396                let (source_message_count, messages, note, tombstone) = match record.body {
397                    Body::Live {
398                        source_message_count,
399                        messages,
400                        note,
401                    } => (source_message_count, messages, note, false),
402                    Body::Tombstone => (0, Vec::new(), String::new(), true),
403                };
404                json!({
405                    "id": record.id, "digest": record.digest, "logical_id": record.logical,
406                    "source_message_count": source_message_count, "messages": messages,
407                    "note": note, "tombstone": tombstone,
408                })
409            })
410            .collect())
411    }
412
413    fn scan(&self) -> io::Result<(Vec<(usize, Record)>, usize)> {
414        self.scan_with(false)
415    }
416
417    fn scan_with(&self, readonly: bool) -> io::Result<(Vec<(usize, Record)>, usize)> {
418        let mut records = Vec::new();
419        let mut used = 0;
420        for slot in 0..MAX_SEGMENTS {
421            let Some(bytes) = self.fs.read_with(slot, MAX_STORE_BYTES - used, readonly)? else {
422                continue;
423            };
424            used += bytes.len();
425            let record: Record = serde_json::from_slice(&bytes).map_err(|_| corrupt())?;
426            if record.version != 1
427                || record.scope != self.scope
428                || record.logical.len() != 64
429                || !record.logical.bytes().all(|b| b.is_ascii_hexdigit())
430                || validate_id(&record.id).is_err()
431                || record.digest.len() != 64
432                || !record.digest.bytes().all(|b| b.is_ascii_hexdigit())
433            {
434                return Err(corrupt());
435            }
436            if let Body::Live {
437                messages,
438                source_message_count,
439                note,
440            } = &record.body
441            {
442                if messages.len() > MAX_MESSAGES
443                    || *source_message_count > MAX_MESSAGES
444                    || note.len() > MAX_NOTE_BYTES
445                    || messages
446                        .iter()
447                        .any(|m| m.source_index >= *source_message_count)
448                    || messages
449                        .windows(2)
450                        .any(|m| m[0].source_index >= m[1].source_index)
451                    || projection_digest(&record.logical, *source_message_count, messages)?
452                        != record.digest
453                {
454                    return Err(corrupt());
455                }
456            }
457            if records
458                .iter()
459                .any(|(_, r): &(usize, Record)| r.id == record.id || r.digest == record.digest)
460            {
461                return Err(corrupt());
462            }
463            records.push((slot, record));
464        }
465        Ok((records, used))
466    }
467}
468
469impl Projector {
470    fn screen(&self, text: &str, class: DisclosureClass) -> io::Result<String> {
471        if class == DisclosureClass::ModelVisibleAfterRedaction && self.redactor.is_none() {
472            return Ok(WITHHELD.to_owned());
473        }
474        let redactor = |text: &str| {
475            let text = self
476                .redactor
477                .as_ref()
478                .map_or_else(|| text.to_owned(), |r| r(text));
479            if credential_like(&text) {
480                WITHHELD.to_owned()
481            } else {
482                text
483            }
484        };
485        // All text is screened, including the ordinary model-visible class.
486        let text = redactor(text);
487        if text.len() > MAX_SEGMENT_BYTES {
488            return Err(invalid("redactor output exceeds archive budget"));
489        }
490        match gate_for_model(class, &text, false, Some(&|s| s.to_owned())) {
491            ModelVisibility::Visible(text) => Ok(text),
492            ModelVisibility::Withheld(_) => Ok(WITHHELD.to_owned()),
493        }
494    }
495
496    fn project(&self, message: &Value) -> io::Result<Option<(Value, Vec<usize>)>> {
497        let Some((class, role, content)) = projection_content(message) else {
498            return Ok(None);
499        };
500        if let Some(text) = content.as_str() {
501            return Ok(Some((
502                json!({"role": role, "content": self.screen(text, class)?}),
503                Vec::new(),
504            )));
505        }
506        let Some(blocks) = content.as_array() else {
507            return Ok(None);
508        };
509        let mut output = Vec::new();
510        let mut indices = Vec::new();
511        for (index, block) in blocks.iter().enumerate() {
512            let Some(mut block_class) = eligible_class(block) else {
513                continue;
514            };
515            if class == DisclosureClass::ModelVisibleAfterRedaction {
516                block_class = class;
517            }
518            let kept = match block.get("type").and_then(Value::as_str) {
519                Some("text") => block
520                    .get("text")
521                    .and_then(Value::as_str)
522                    .map(|text| {
523                        self.screen(text, block_class)
524                            .map(|text| json!({"type":"text", "text":text}))
525                    })
526                    .transpose()?,
527                Some("tool_use") if role == "assistant" => {
528                    if let (Some(id), Some(name)) =
529                        (identifier(block, "id"), identifier(block, "name"))
530                    {
531                        let input = if name == "context_checkpoint" {
532                            // The working note has its own hidden field. Do not
533                            // expose it a second time via tool arguments.
534                            json!({"_archive_withheld":true})
535                        } else if self.redactor.is_some() {
536                            self.tool_input(block.get("input"), block_class)?
537                        } else {
538                            json!({"_archive_withheld":true})
539                        };
540                        Some(json!({"type":"tool_use", "id":id, "name":name, "input":input}))
541                    } else {
542                        None
543                    }
544                }
545                Some("tool_result") if role == "user" => {
546                    if let Some(id) = identifier(block, "tool_use_id") {
547                        let content = if self.redactor.is_some() {
548                            self.tool_result(&block["content"], block_class)?
549                        } else {
550                            Value::String(WITHHELD.to_owned())
551                        };
552                        let mut result =
553                            json!({"type":"tool_result", "tool_use_id":id, "content":content});
554                        if let Some(error) = block.get("is_error").and_then(Value::as_bool) {
555                            result["is_error"] = json!(error);
556                        }
557                        Some(result)
558                    } else {
559                        None
560                    }
561                }
562                _ => None,
563            };
564            if let Some(kept) = kept {
565                output.push(kept);
566                indices.push(index);
567            }
568        }
569        if output.is_empty() {
570            Ok(None)
571        } else {
572            Ok(Some((json!({"role":role,"content":output}), indices)))
573        }
574    }
575
576    fn tool_input(&self, input: Option<&Value>, class: DisclosureClass) -> io::Result<Value> {
577        if let Some(input) = input {
578            // The full preflight already enforced aggregate limits. Reuse its
579            // guarded admission predicate so recursion and withholding cannot
580            // drift. JSON escaping expands at most sixfold (node allowance
581            // covers punctuation/numbers); redactors may shrink that scratch
582            // string below the unchanged final output limit.
583            let mut budget = InputBudget { bytes: 0, nodes: 0 };
584            if let Some(bytes) = budget.input(input, 3)? {
585                budget.charge(bytes)?;
586                let limit = bytes
587                    .checked_mul(6)
588                    .ok_or_else(|| invalid("archive input byte budget exceeded"))?;
589                let encoded = bounded_json(input, limit)?;
590                let text =
591                    self.screen(std::str::from_utf8(&encoded).map_err(|_| corrupt())?, class)?;
592                return Ok(serde_json::from_str(&text)
593                    .unwrap_or_else(|_| json!({"_archive_withheld":true})));
594            }
595        }
596        Ok(json!({"_archive_withheld":true}))
597    }
598
599    fn tool_result(&self, content: &Value, class: DisclosureClass) -> io::Result<Value> {
600        if let Some(text) = content.as_str() {
601            return Ok(json!(self.screen(text, class)?));
602        }
603        let mut output = Vec::new();
604        if let Some(blocks) = content.as_array() {
605            for block in blocks {
606                if let Some(class) = eligible_class(block) {
607                    if block["type"] == "text" {
608                        if let Some(text) = block["text"].as_str() {
609                            output.push(json!({"type":"text", "text":self.screen(text, class)?}));
610                        }
611                    }
612                }
613            }
614        }
615        if output.is_empty() {
616            Ok(json!(WITHHELD))
617        } else {
618            Ok(json!(output))
619        }
620    }
621}
622
623/// Read-only migration preview for an existing project archive. A missing
624/// archive is empty; corrupt, unsafe or locked paths fail closed. In particular,
625/// this never invokes `ArchiveStore::new`, mkdir, chmod, or lock-file creation.
626pub fn export_in(base: &Path, scope_key: &str) -> io::Result<Vec<Value>> {
627    validate_key(scope_key)?;
628    let scope = digest_parts(&[scope_key.as_bytes()]);
629    let fs = match PrivateDir::open_existing(base, &scope) {
630        Ok(fs) => fs,
631        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
632        Err(error) => return Err(error),
633    };
634    ArchiveStore {
635        fs,
636        scope,
637        logical: String::new(), // export scans all logical conversations in this scope
638        redactor: None,
639    }
640    .export_records()
641}
642
643/// Project-wide archive lookup; base and scope are trusted host values, never
644/// model-supplied paths. The same budgets and disclosure policy apply.
645pub fn project_search(
646    base: &Path,
647    scope_key: &str,
648    query: &str,
649    limit: usize,
650) -> io::Result<Vec<ArchiveDescriptor>> {
651    ArchiveStore::new(base, scope_key, "project-reader")?.search(query, limit)
652}
653
654/// Fetch an opaque reference from any logical conversation in this project.
655pub fn project_fetch(
656    base: &Path,
657    scope_key: &str,
658    id: &str,
659    start: usize,
660    limit: usize,
661) -> io::Result<Vec<SharedMessage>> {
662    ArchiveStore::new(base, scope_key, "project-reader")?.fetch(id, start, limit)
663}
664
665fn eligible_class(value: &Value) -> Option<DisclosureClass> {
666    // Provider/UI envelopes sometimes label private reasoning as assistant text.
667    // Do not rely on the role or content-block type alone.
668    if value
669        .get("channel")
670        .is_some_and(|v| !matches!(v.as_str(), Some("final")))
671        || value.get("content_class").is_some_and(|v| {
672            !matches!(
673                v.as_str(),
674                Some("user_text" | "assistant_text" | "tool_calls" | "tool_results")
675            )
676        })
677        || value
678            .get("private")
679            .is_some_and(|v| v != &Value::Bool(false))
680    {
681        return None;
682    }
683    for key in ["sensitivity", "retention", "retention_class"] {
684        if let Some(v) = value.get(key) {
685            if !matches!(v.as_str(), Some("normal" | "standard" | "model_visible")) {
686                return None;
687            }
688        }
689    }
690    let mut class = DisclosureClass::ModelVisible;
691    for key in ["disclosure", "disclosure_class"] {
692        if let Some(v) = value.get(key) {
693            let parsed = DisclosureClass::parse(v.as_str()?)?;
694            if !may_persist(parsed)
695                || !matches!(
696                    parsed,
697                    DisclosureClass::ModelVisible | DisclosureClass::ModelVisibleAfterRedaction
698                )
699            {
700                return None;
701            }
702            class = parsed;
703        }
704    }
705    Some(class)
706}
707
708/// Shared message-envelope selection for preflight and projection. Host
709/// continuation notes are derived working context, never source evidence.
710fn projection_content(message: &Value) -> Option<(DisclosureClass, &str, &Value)> {
711    if message.get("_synaps_context").is_some() {
712        return None;
713    }
714    let class = eligible_class(message)?;
715    let role @ ("user" | "assistant") = message.get("role")?.as_str()? else {
716        return None;
717    };
718    Some((class, role, message.get("content")?))
719}
720
721fn identifier<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
722    let text = value.get(key)?.as_str()?;
723    (!text.is_empty()
724        && text.len() <= 128
725        && !credential_like(text)
726        && text
727            .bytes()
728            .all(|b| b.is_ascii_alphanumeric() || b"_-.:".contains(&b)))
729    .then_some(text)
730}
731
732/// Conservatively withhold an entire field rather than risk partial redaction.
733fn credential_like(text: &str) -> bool {
734    let lower = text.to_ascii_lowercase();
735    [
736        "password",
737        "passwd",
738        "api_key",
739        "apikey",
740        "api-key",
741        "access_token",
742        "refresh_token",
743        "secret",
744        "authorization",
745        "bearer ",
746        "private key",
747        "sk-",
748        "ghp_",
749        "github_pat_",
750        "xoxb-",
751        "akia",
752        "eyjh",
753    ]
754    .iter()
755    .any(|needle| lower.contains(needle))
756}
757
758struct InputBudget {
759    bytes: usize,
760    nodes: usize,
761}
762impl InputBudget {
763    fn node(&mut self, depth: usize) -> io::Result<()> {
764        self.nodes += 1;
765        if depth > 32 || self.nodes > 65536 {
766            return Err(invalid("archive input structural budget exceeded"));
767        }
768        Ok(())
769    }
770
771    fn charge(&mut self, bytes: usize) -> io::Result<()> {
772        self.bytes = self.bytes.saturating_add(bytes);
773        if self.bytes > MAX_INPUT_BYTES {
774            return Err(invalid("archive input byte budget exceeded"));
775        }
776        Ok(())
777    }
778
779    // Only selected fields are charged; ignored metadata is opaque. Include the
780    // old per-node/key allowance, never more than the corresponding raw subtree.
781    fn field(&mut self, key: &str, text: &str, depth: usize) -> io::Result<()> {
782        self.node(depth)?;
783        self.charge(key.len().saturating_add(8).saturating_add(text.len()))
784    }
785
786    fn text_field(
787        &mut self,
788        key: &str,
789        text: &str,
790        class: DisclosureClass,
791        redactor: bool,
792        depth: usize,
793    ) -> io::Result<()> {
794        if class == DisclosureClass::ModelVisibleAfterRedaction && !redactor {
795            return self.node(depth); // screen() returns the fixed placeholder
796        }
797        self.field(key, text, depth)
798    }
799
800    /// Allocation-free semantic preflight. Complete for ALL messages before
801    /// invoking a redactor or materializing the projection. Keep the selected
802    /// branches in sync with Projector; skipped payload descendants are opaque.
803    fn message(&mut self, message: &Value, redactor: bool) -> io::Result<()> {
804        self.node(0)?;
805        let Some((class, role, content)) = projection_content(message) else {
806            return Ok(());
807        };
808        self.charge(8)?;
809        self.field("role", role, 1)?;
810        if let Some(text) = content.as_str() {
811            return self.text_field("content", text, class, redactor, 1);
812        }
813        self.node(1)?;
814        let Some(blocks) = content.as_array() else {
815            return Ok(());
816        };
817        self.charge("content".len() + 8)?;
818        for block in blocks {
819            self.node(2)?;
820            let Some(mut block_class) = eligible_class(block) else {
821                continue;
822            };
823            if class == DisclosureClass::ModelVisibleAfterRedaction {
824                block_class = class;
825            }
826            match block.get("type").and_then(Value::as_str) {
827                Some("text") => {
828                    if let Some(text) = block.get("text").and_then(Value::as_str) {
829                        self.charge(8)?;
830                        self.field("type", "text", 3)?;
831                        self.text_field("text", text, block_class, redactor, 3)?;
832                    }
833                }
834                Some("tool_use") if role == "assistant" => {
835                    if let (Some(id), Some(name)) =
836                        (identifier(block, "id"), identifier(block, "name"))
837                    {
838                        self.charge(8)?;
839                        self.field("type", "tool_use", 3)?;
840                        self.field("id", id, 3)?;
841                        self.field("name", name, 3)?;
842                        if name != "context_checkpoint" && redactor {
843                            if let Some(input) = block.get("input") {
844                                if let Some(bytes) = self.input(input, 3)? {
845                                    self.charge("input".len().saturating_add(bytes))?;
846                                }
847                            }
848                        }
849                    }
850                }
851                Some("tool_result") if role == "user" => {
852                    if let Some(id) = identifier(block, "tool_use_id") {
853                        self.charge(8)?;
854                        self.field("type", "tool_result", 3)?;
855                        self.field("tool_use_id", id, 3)?;
856                        if block.get("is_error").and_then(Value::as_bool).is_some() {
857                            self.field("is_error", "", 3)?;
858                        }
859                        if redactor {
860                            if let Some(content) = block.get("content") {
861                                self.tool_result(content, block_class)?;
862                            }
863                        }
864                    }
865                }
866                _ => {}
867            }
868        }
869        Ok(())
870    }
871
872    fn tool_result(&mut self, content: &Value, class: DisclosureClass) -> io::Result<()> {
873        if let Some(text) = content.as_str() {
874            return self.text_field("content", text, class, true, 3);
875        }
876        self.node(3)?;
877        if let Some(blocks) = content.as_array() {
878            self.charge("content".len() + 8)?;
879            for block in blocks {
880                self.node(4)?;
881                if let Some(class) = eligible_class(block) {
882                    if block["type"] == "text" {
883                        if let Some(text) = block["text"].as_str() {
884                            self.charge(8)?;
885                            self.field("type", "text", 5)?;
886                            self.text_field("text", text, class, true, 5)?;
887                        }
888                    }
889                }
890            }
891        }
892        Ok(())
893    }
894
895    /// Guarded whole-argument admission, shared with Projector. Byte accounting
896    /// is provisional until every visited child is eligible: a later excluded
897    /// child withholds the WHOLE input, regardless of tentative byte cost.
898    /// Structural visits are never rolled back. Arbitrary tool JSON is not a
899    /// content-block array; unclassified data/base64 fields remain eligible.
900    fn input(&mut self, value: &Value, depth: usize) -> io::Result<Option<usize>> {
901        self.node(depth)?;
902        if eligible_class(value).is_none() {
903            return Ok(None);
904        }
905        let mut bytes: usize = 8;
906        match value {
907            Value::String(text) => bytes = bytes.saturating_add(text.len()),
908            Value::Array(values) => {
909                for value in values {
910                    let Some(cost) = self.input(value, depth + 1)? else {
911                        return Ok(None);
912                    };
913                    bytes = bytes.saturating_add(cost);
914                }
915            }
916            Value::Object(values) => {
917                for (key, value) in values {
918                    let Some(cost) = self.input(value, depth + 1)? else {
919                        return Ok(None);
920                    };
921                    bytes = bytes.saturating_add(key.len()).saturating_add(cost);
922                }
923            }
924            _ => {}
925        }
926        Ok(Some(bytes))
927    }
928}
929
930fn projection_digest(
931    scope: &str,
932    count: usize,
933    messages: &[ArchivedMessage],
934) -> io::Result<String> {
935    let bytes = bounded_json(messages, MAX_SEGMENT_BYTES)?;
936    Ok(digest_parts(&[
937        scope.as_bytes(),
938        &(count as u64).to_be_bytes(),
939        &bytes,
940    ]))
941}
942fn digest_parts(parts: &[&[u8]]) -> String {
943    let mut hash = Sha256::new();
944    for part in parts {
945        hash.update((part.len() as u64).to_be_bytes());
946        hash.update(part);
947    }
948    format!("{:x}", hash.finalize())
949}
950fn bounded_json<T: Serialize + ?Sized>(value: &T, limit: usize) -> io::Result<Vec<u8>> {
951    struct Sink {
952        bytes: Vec<u8>,
953        limit: usize,
954    }
955    impl Write for Sink {
956        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
957            if bytes.len() > self.limit - self.bytes.len() {
958                return Err(invalid("archive serialized byte budget exceeded"));
959            }
960            self.bytes.extend_from_slice(bytes);
961            Ok(bytes.len())
962        }
963        fn flush(&mut self) -> io::Result<()> {
964            Ok(())
965        }
966    }
967    let mut sink = Sink {
968        bytes: Vec::new(),
969        limit,
970    };
971    serde_json::to_writer(&mut sink, value).map_err(json_error)?;
972    Ok(sink.bytes)
973}
974fn find_record(records: Vec<(usize, Record)>, id: &str) -> io::Result<Record> {
975    records
976        .into_iter()
977        .find(|(_, r)| r.id == id)
978        .map(|(_, r)| r)
979        .ok_or_else(not_found)
980}
981fn prefix(text: &str, bytes: usize) -> &str {
982    let mut end = bytes.min(text.len());
983    while !text.is_char_boundary(end) {
984        end -= 1;
985    }
986    &text[..end]
987}
988fn validate_key(key: &str) -> io::Result<()> {
989    if key.is_empty() || key.len() > 4096 {
990        return Err(invalid("archive scope/logical id must be 1..4096 bytes"));
991    }
992    Ok(())
993}
994
995fn validate_id(id: &str) -> io::Result<()> {
996    if id.len() != 32
997        || !id
998            .bytes()
999            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1000    {
1001        Err(invalid("invalid opaque archive id"))
1002    } else {
1003        Ok(())
1004    }
1005}
1006fn invalid(message: &'static str) -> io::Error {
1007    io::Error::new(io::ErrorKind::InvalidInput, message)
1008}
1009fn corrupt() -> io::Error {
1010    io::Error::new(io::ErrorKind::InvalidData, "invalid archive record")
1011}
1012fn not_found() -> io::Error {
1013    io::Error::new(
1014        io::ErrorKind::NotFound,
1015        "archive not available in this scope",
1016    )
1017}
1018fn json_error(_: serde_json::Error) -> io::Error {
1019    invalid("archive JSON serialization failed or exceeded budget")
1020}
1021
1022// ConfinedDir has no public directory fsync or bounded enumeration API. Keep a
1023// private directory fd using the same openat/O_NOFOLLOW policy and modes as
1024// private_fs, and use fixed numbered slots instead of an unbounded index scan.
1025#[cfg(unix)]
1026struct PrivateDir(std::fs::File);
1027#[cfg(unix)]
1028impl PrivateDir {
1029    fn new(base: &Path, scope: &str) -> io::Result<Self> {
1030        use std::os::unix::fs::OpenOptionsExt;
1031        if !base.is_absolute() || base == Path::new("/") {
1032            return Err(invalid("archive base must be an absolute non-root path"));
1033        }
1034        let mut dir = Self(
1035            std::fs::OpenOptions::new()
1036                .read(true)
1037                .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
1038                .open("/")?,
1039        );
1040        let components: Vec<_> = base.components().collect();
1041        for (index, component) in components.iter().enumerate() {
1042            match component {
1043                std::path::Component::RootDir => {}
1044                std::path::Component::Normal(name) => {
1045                    let name = name
1046                        .to_str()
1047                        .ok_or_else(|| invalid("archive path must be UTF-8"))?;
1048                    dir = dir.child(name, index == components.len() - 1)?;
1049                }
1050                _ => return Err(invalid("archive base contains traversal")),
1051            }
1052        }
1053        dir = dir.child("context-archives", true)?;
1054        dir.child(scope, true)
1055    }
1056    fn open_existing(base: &Path, scope: &str) -> io::Result<Self> {
1057        use std::os::unix::fs::OpenOptionsExt;
1058        if !base.is_absolute() || base == Path::new("/") {
1059            return Err(invalid("archive base must be an absolute non-root path"));
1060        }
1061        let mut dir = Self(
1062            std::fs::OpenOptions::new()
1063                .read(true)
1064                .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
1065                .open("/")?,
1066        );
1067        for component in base.components() {
1068            match component {
1069                std::path::Component::RootDir => {}
1070                std::path::Component::Normal(name) => {
1071                    let name = c_name(
1072                        name.to_str()
1073                            .ok_or_else(|| invalid("archive path must be UTF-8"))?,
1074                    )?;
1075                    dir = Self(dir.open(&name, libc::O_RDONLY | libc::O_DIRECTORY)?);
1076                }
1077                _ => return Err(invalid("archive base contains traversal")),
1078            }
1079        }
1080        for name in ["context-archives", scope] {
1081            dir = Self(dir.open(&c_name(name)?, libc::O_RDONLY | libc::O_DIRECTORY)?);
1082        }
1083        Ok(dir)
1084    }
1085
1086    fn fd(&self) -> libc::c_int {
1087        use std::os::fd::AsRawFd;
1088        self.0.as_raw_fd()
1089    }
1090    fn child(&self, name: &str, private: bool) -> io::Result<Self> {
1091        use crate::core::private_fs::DIR_MODE;
1092        use std::os::unix::fs::PermissionsExt;
1093        let name = c_name(name)?;
1094        // `mode_t` is u16 on macOS, u32 on Linux; DIR_MODE (0o700) fits both.
1095        let created =
1096            unsafe { libc::mkdirat(self.fd(), name.as_ptr(), DIR_MODE as libc::mode_t) } == 0;
1097        if !created {
1098            let error = io::Error::last_os_error();
1099            if error.raw_os_error() != Some(libc::EEXIST) {
1100                return Err(error);
1101            }
1102        }
1103        let file = self.open(&name, libc::O_RDONLY | libc::O_DIRECTORY)?;
1104        if private || created {
1105            file.set_permissions(std::fs::Permissions::from_mode(DIR_MODE))?;
1106        }
1107        // A previous mkdir may have succeeded but its parent fsync failed.
1108        // EEXIST on retry is not evidence that the directory entry is durable.
1109        self.sync()?;
1110        Ok(Self(file))
1111    }
1112    fn open(&self, name: &std::ffi::CStr, flags: i32) -> io::Result<std::fs::File> {
1113        use std::os::fd::FromRawFd;
1114        let fd = unsafe {
1115            libc::openat(
1116                self.fd(),
1117                name.as_ptr(),
1118                flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
1119                crate::core::private_fs::FILE_MODE,
1120            )
1121        };
1122        if fd < 0 {
1123            Err(io::Error::last_os_error())
1124        } else {
1125            Ok(unsafe { std::fs::File::from_raw_fd(fd) })
1126        }
1127    }
1128    fn check_readonly(file: &std::fs::File) -> io::Result<()> {
1129        use std::os::unix::fs::MetadataExt;
1130        let meta = file.metadata()?;
1131        if !meta.is_file() || meta.nlink() != 1 || meta.uid() != unsafe { libc::geteuid() } {
1132            return Err(io::Error::new(
1133                io::ErrorKind::PermissionDenied,
1134                "archive requires owned regular single-link files",
1135            ));
1136        }
1137        Ok(())
1138    }
1139    fn check(file: &std::fs::File) -> io::Result<()> {
1140        use std::os::unix::fs::PermissionsExt;
1141        Self::check_readonly(file)?;
1142        file.set_permissions(std::fs::Permissions::from_mode(
1143            crate::core::private_fs::FILE_MODE,
1144        ))
1145    }
1146    fn lock_existing(&self) -> io::Result<Option<std::fs::File>> {
1147        let file = match self.open(&c_name("lock")?, libc::O_RDONLY) {
1148            Ok(file) => file,
1149            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1150            Err(error) => return Err(error),
1151        };
1152        Self::check_readonly(&file)?;
1153        if !fs4::fs_std::FileExt::try_lock_shared(&file)? {
1154            return Err(io::Error::new(
1155                io::ErrorKind::WouldBlock,
1156                "archive busy; retry export",
1157            ));
1158        }
1159        Ok(Some(file))
1160    }
1161    fn lock(&self) -> io::Result<std::fs::File> {
1162        let file = self.open(&c_name("lock")?, libc::O_RDWR | libc::O_CREAT)?;
1163        Self::check(&file)?;
1164        if !fs4::fs_std::FileExt::try_lock_exclusive(&file)? {
1165            return Err(io::Error::new(
1166                io::ErrorKind::WouldBlock,
1167                "archive busy; retain source and retry",
1168            ));
1169        }
1170        Ok(file)
1171    }
1172    fn read_with(
1173        &self,
1174        slot: usize,
1175        remaining: usize,
1176        readonly: bool,
1177    ) -> io::Result<Option<Vec<u8>>> {
1178        let file = match self.open(&c_name(&format!("{slot:03}.json"))?, libc::O_RDONLY) {
1179            Ok(file) => file,
1180            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1181            Err(error) => return Err(error),
1182        };
1183        if readonly {
1184            Self::check_readonly(&file)?;
1185        } else {
1186            Self::check(&file)?;
1187        }
1188        let limit = remaining.min(MAX_SEGMENT_BYTES);
1189        if file.metadata()?.len() > limit as u64 {
1190            return Err(corrupt());
1191        }
1192        let mut bytes = Vec::new();
1193        file.take(limit as u64 + 1).read_to_end(&mut bytes)?;
1194        if bytes.len() > limit {
1195            return Err(corrupt());
1196        }
1197        Ok(Some(bytes))
1198    }
1199    fn publish(&self, slot: usize, bytes: &[u8]) -> io::Result<()> {
1200        let target = c_name(&format!("{slot:03}.json"))?;
1201        let tmp = c_name("pending.tmp")?;
1202        // Under the lock, an old temporary file is an unpublished failed write.
1203        // unlinkat never follows a link; existing content is never reused.
1204        if unsafe { libc::unlinkat(self.fd(), tmp.as_ptr(), 0) } != 0 {
1205            let error = io::Error::last_os_error();
1206            if error.kind() != io::ErrorKind::NotFound {
1207                return Err(error);
1208            }
1209        }
1210        let result = (|| {
1211            let mut file = self.open(&tmp, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
1212            file.write_all(bytes)?;
1213            file.sync_all()?;
1214            // Refuse any target that appeared as a symlink or hardlink.
1215            match self.open(&target, libc::O_RDONLY) {
1216                Ok(existing) => Self::check(&existing)?,
1217                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1218                Err(error) => return Err(error),
1219            }
1220            if unsafe { libc::renameat(self.fd(), tmp.as_ptr(), self.fd(), target.as_ptr()) } != 0 {
1221                return Err(io::Error::last_os_error());
1222            }
1223            self.sync()
1224        })();
1225        if result.is_err() {
1226            unsafe {
1227                libc::unlinkat(self.fd(), tmp.as_ptr(), 0);
1228            }
1229        }
1230        result
1231    }
1232    fn sync(&self) -> io::Result<()> {
1233        self.0.sync_all()
1234    }
1235}
1236#[cfg(unix)]
1237fn c_name(name: &str) -> io::Result<std::ffi::CString> {
1238    if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\\']) {
1239        return Err(invalid("invalid archive component"));
1240    }
1241    std::ffi::CString::new(name).map_err(|_| invalid("invalid archive component"))
1242}
1243
1244#[cfg(not(unix))]
1245struct PrivateDir;
1246#[cfg(not(unix))]
1247impl PrivateDir {
1248    fn unsupported<T>() -> io::Result<T> {
1249        Err(io::Error::new(
1250            io::ErrorKind::Unsupported,
1251            "private context archive requires Unix",
1252        ))
1253    }
1254    fn new(_: &Path, _: &str) -> io::Result<Self> {
1255        Self::unsupported()
1256    }
1257    fn lock(&self) -> io::Result<std::fs::File> {
1258        Self::unsupported()
1259    }
1260    fn open_existing(_: &Path, _: &str) -> io::Result<Self> {
1261        Self::unsupported()
1262    }
1263    fn lock_existing(&self) -> io::Result<Option<std::fs::File>> {
1264        Self::unsupported()
1265    }
1266    fn read_with(&self, _: usize, _: usize, _: bool) -> io::Result<Option<Vec<u8>>> {
1267        Self::unsupported()
1268    }
1269    fn publish(&self, _: usize, _: &[u8]) -> io::Result<()> {
1270        Self::unsupported()
1271    }
1272    fn sync(&self) -> io::Result<()> {
1273        Self::unsupported()
1274    }
1275}
1276
1277#[cfg(test)]
1278mod projection_tests {
1279    use super::*;
1280
1281    fn excluded_fixture(blob: &str) -> Vec<SharedMessage> {
1282        vec![
1283            Arc::new(json!({"role":"system","content":blob})),
1284            Arc::new(json!({"role":"developer","content":blob})),
1285            Arc::new(json!({"role":"user","_synaps_context":{"note":blob},"content":blob})),
1286            Arc::new(json!({"role":"user","retention":"local_only","content":blob})),
1287            Arc::new(json!({"role":"assistant","metadata":blob,"content":[
1288                {"type":"thinking","thinking":blob},
1289                {"type":"text","private":true,"text":blob},
1290                {"type":"image","source":{"data":blob}},
1291                {"type":"document","source":{"data":blob}},
1292                {"type":"text","text":" exact evidence αβ\n","metadata":blob},
1293                {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":blob}}
1294            ]})),
1295            Arc::new(json!({"role":"user","content":[
1296                {"type":"tool_result","tool_use_id":"r1","metadata":blob,"content":[
1297                    {"type":"image","source":{"data":blob}},
1298                    {"type":"text","sensitivity":"secret","text":blob},
1299                    {"type":"text","text":"exact tool evidence\n","metadata":blob}
1300                ]}
1301            ]})),
1302        ]
1303    }
1304
1305    #[test]
1306    fn excluded_aggregate_does_not_consume_eligible_budget_or_change_digest() {
1307        // Every blob fits by itself; collectively the raw input exceeds 16MiB.
1308        let messages = excluded_fixture(&"x".repeat(2 * 1024 * 1024));
1309        let source = messages.clone();
1310        for redactor in [None, Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>)] {
1311            let small =
1312                project_archive("excluded", &excluded_fixture("x"), "note", redactor.clone())
1313                    .unwrap();
1314            let large = project_archive("excluded", &messages, "note", redactor).unwrap();
1315            assert!(small == large);
1316            assert_eq!(large.source_message_count, 6);
1317            assert_eq!(large.messages[0].source_index, 4);
1318            assert_eq!(large.messages[0].block_indices, vec![4, 5]);
1319        }
1320        for (old, new) in source.iter().zip(&messages) {
1321            assert!(Arc::ptr_eq(old, new));
1322        }
1323    }
1324
1325    #[test]
1326    fn eligible_aggregate_refuses_before_any_redactor_call() {
1327        use std::sync::atomic::{AtomicUsize, Ordering};
1328        let calls = Arc::new(AtomicUsize::new(0));
1329        let count = calls.clone();
1330        let redactor: Arc<ArchiveRedactor> = Arc::new(move |_| {
1331            count.fetch_add(1, Ordering::SeqCst);
1332            "small".to_owned()
1333        });
1334        let piece = "x".repeat(MAX_INPUT_BYTES / 2);
1335        for messages in [
1336            vec![
1337                Arc::new(json!({"role":"user","content":piece})),
1338                Arc::new(json!({"role":"assistant","content":piece})),
1339            ],
1340            vec![Arc::new(json!({"role":"user","content":[{
1341                "type":"tool_result","tool_use_id":"r1","content":[
1342                    {"type":"text","text":piece}, {"type":"text","text":piece}
1343                ]
1344            }]}))],
1345        ] {
1346            let before = messages.clone();
1347            let error = project_archive("overflow", &messages, "", Some(redactor.clone()))
1348                .err()
1349                .unwrap();
1350            assert!(error.to_string().contains("input byte budget"));
1351            assert_eq!(calls.load(Ordering::SeqCst), 0);
1352            assert_eq!(before, messages);
1353        }
1354    }
1355
1356    #[test]
1357    fn large_tool_results_exact_with_redactor_opaque_without_it() {
1358        let text = "evidence αβ\n".repeat(250_000);
1359        let make = |text: &str| {
1360            vec![Arc::new(json!({"role":"user","content":[{
1361                "type":"tool_result","tool_use_id":"r1","content":text
1362            }]}))]
1363        };
1364        let p = project_archive("logs", &make(&text), "", Some(Arc::new(str::to_owned))).unwrap();
1365        assert_eq!(p.messages[0].message["content"][0]["content"], text);
1366        let huge = make(&"x".repeat(MAX_INPUT_BYTES + 1));
1367        let p = project_archive("logs", &huge, "", None).unwrap();
1368        assert_eq!(p.messages[0].message["content"][0]["content"], WITHHELD);
1369        assert!(project_archive("logs", &huge, "", Some(Arc::new(str::to_owned))).is_err());
1370    }
1371
1372    #[test]
1373    fn whole_argument_withholding_discards_tentative_bytes_not_structural_guards() {
1374        let huge = "x".repeat(MAX_INPUT_BYTES + 1);
1375        let make = |input: Value| {
1376            vec![Arc::new(json!({"role":"assistant","content":[{
1377                "type":"tool_use","id":"r1","name":"read","input":input
1378            }]}))]
1379        };
1380        // Both orders: the otherwise eligible field can precede the exclusion.
1381        for input in [
1382            json!({"a":huge,"z":{"sensitivity":"secret","body":huge}}),
1383            json!({"a":{"sensitivity":"secret","body":huge},"z":huge}),
1384        ] {
1385            let p =
1386                project_archive("input", &make(input), "", Some(Arc::new(str::to_owned))).unwrap();
1387            assert_eq!(
1388                p.messages[0].message["content"][0]["input"],
1389                json!({"_archive_withheld":true})
1390            );
1391        }
1392        // Arbitrary binary-looking tool arguments are NOT content blocks.
1393        let input = json!({"type":"image","data":"YWJj"});
1394        let p = project_archive(
1395            "input",
1396            &make(input.clone()),
1397            "",
1398            Some(Arc::new(str::to_owned)),
1399        )
1400        .unwrap();
1401        assert_eq!(p.messages[0].message["content"][0]["input"], input);
1402        let large = make(json!({"type":"image","data":huge}));
1403        assert!(project_archive("input", &large, "", Some(Arc::new(str::to_owned))).is_err());
1404        assert!(project_archive("input", &large, "", None).is_ok());
1405    }
1406
1407    #[test]
1408    fn visited_structure_is_bounded_but_excluded_descendants_are_opaque() {
1409        let mut deep = json!("leaf");
1410        for _ in 0..40 {
1411            deep = json!([deep]);
1412        }
1413        let make = |input: Value| {
1414            vec![Arc::new(json!({"role":"assistant","content":[{
1415                "type":"tool_use","id":"r1","name":"read","input":input
1416            }]}))]
1417        };
1418        let identity = Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>);
1419        assert!(project_archive("depth", &make(deep.clone()), "", identity.clone()).is_err());
1420        assert!(project_archive("depth", &make(deep.clone()), "", None).is_ok());
1421        assert!(project_archive(
1422            "depth",
1423            &make(json!({"private":true,"blob":deep})),
1424            "",
1425            identity.clone()
1426        )
1427        .is_ok());
1428        // A malformed content block isn't recursively interpreted as a block array.
1429        assert!(project_archive(
1430            "depth",
1431            &[Arc::new(json!({"role":"user","content":deep}))],
1432            "",
1433            identity.clone()
1434        )
1435        .is_ok());
1436        let wide = vec![Value::Null; 65536];
1437        assert!(project_archive("nodes", &make(json!(wide)), "", identity.clone()).is_err());
1438        let excluded = vec![Arc::new(json!({"role":"user","content":wide}))];
1439        assert!(project_archive("nodes", &excluded, "", identity).is_err());
1440    }
1441
1442    #[test]
1443    fn withheld_after_redaction_text_is_not_scanned_without_redactor() {
1444        let messages = vec![Arc::new(json!({"role":"user",
1445            "disclosure":"model_visible_after_redaction", "content":"x".repeat(MAX_INPUT_BYTES+1)
1446        }))];
1447        let p = project_archive("withheld", &messages, "", None).unwrap();
1448        assert_eq!(p.messages[0].message["content"], WITHHELD);
1449        assert!(project_archive("withheld", &messages, "", Some(Arc::new(str::to_owned))).is_err());
1450    }
1451
1452    #[test]
1453    fn json_escape_scratch_can_exceed_segment_limit_before_redaction() {
1454        // 3MiB source ->18MiB JSON, accepted by the old raw preflight and then
1455        // reduced by the trusted redactor. Do not introduce a new 16MiB scratch cap.
1456        let input = json!({"text":"\0".repeat(3 * 1024 * 1024)});
1457        let messages = vec![Arc::new(json!({"role":"assistant","content":[{
1458            "type":"tool_use","id":"r1","name":"read","input":input
1459        }]}))];
1460        let redactor: Arc<ArchiveRedactor> = Arc::new(|s| {
1461            if s.starts_with('{') {
1462                assert!(s.len() > MAX_SEGMENT_BYTES);
1463                "{\"text\":\"screened\"}".to_owned()
1464            } else {
1465                s.to_owned()
1466            }
1467        });
1468        let p = project_archive("escaping", &messages, "", Some(redactor)).unwrap();
1469        assert_eq!(
1470            p.messages[0].message["content"][0]["input"],
1471            json!({"text":"screened"})
1472        );
1473        assert!(project_archive("escaping", &messages, "", Some(Arc::new(str::to_owned))).is_err());
1474    }
1475
1476    fn compatibility_fixture() -> Vec<SharedMessage> {
1477        vec![
1478            Arc::new(json!({"role":"system","content":"omitted"})),
1479            Arc::new(json!({"role":"user","content":" exact αβ\n"})),
1480            Arc::new(json!({"role":"assistant","content":[
1481                {"type":"thinking","thinking":"private"},
1482                {"type":"text","text":"classified-value"},
1483                {"type":"tool_use","id":"r1","name":"read","input":{"path":"src/lib.rs","n":7,"binary":{"type":"image","data":"YWJj"}}},
1484                {"type":"tool_use","id":"c1","name":"context_checkpoint","input":{"note":"hidden"}},
1485                {"type":"tool_use","id":"x1","name":"read","input":{"nested":{"disclosure":"local_only","body":"omitted"}}}
1486            ]})),
1487            Arc::new(json!({"role":"user","content":[
1488                {"type":"tool_result","tool_use_id":"r1","is_error":false,"content":[
1489                    {"type":"image","data":"omitted"},
1490                    {"type":"text","text":"exact result\n"},
1491                    {"type":"text","text":"classified-value","disclosure":"model_visible_after_redaction"}
1492                ]}
1493            ]})),
1494            Arc::new(
1495                json!({"role":"user","disclosure":"model_visible_after_redaction","content":"classified-value"}),
1496            ),
1497            Arc::new(json!({"role":"user","_synaps_context":{},"content":"hidden"})),
1498        ]
1499    }
1500
1501    #[test]
1502    fn legacy_projection_golden() {
1503        // Captured against the legacy raw-input preflight before this fix.
1504        for (redactor, digest) in [
1505            (
1506                None,
1507                "aeb544a71629f741def049575eefb1a765117d65aa914bdcc1657fa677622031",
1508            ),
1509            (
1510                Some(
1511                    Arc::new(|s: &str| s.replace("classified-value", "[redacted]"))
1512                        as Arc<ArchiveRedactor>,
1513                ),
1514                "d70b321210ca3d2bc531b31a35cc02ee267eeb572e8a6dd88f1fb62725b082ec",
1515            ),
1516        ] {
1517            let p = project_archive(
1518                "compat-session",
1519                &compatibility_fixture(),
1520                "hidden note",
1521                redactor,
1522            )
1523            .unwrap();
1524            assert_eq!(p.digest, digest);
1525        }
1526    }
1527
1528    #[test]
1529    fn pure_projection_keeps_legacy_logical_digest_and_never_opens_paths() {
1530        let tmp = tempfile::tempdir().unwrap();
1531        let logical = tmp
1532            .path()
1533            .join("not-created/session")
1534            .to_str()
1535            .unwrap()
1536            .to_owned();
1537        let messages = vec![
1538            Arc::new(json!({"role":"system","content":"OMITTED"})),
1539            Arc::new(json!({"role":"assistant","content":[
1540                {"type":"thinking","thinking":"OMITTED"},
1541                {"type":"text","text":" exact αβ evidence\n"}
1542            ]})),
1543        ];
1544        let source = serde_json::to_vec(&messages).unwrap();
1545        let first = project_archive(&logical, &messages, "hidden note", None).unwrap();
1546        assert_eq!(first.logical_id, digest_parts(&[logical.as_bytes()]));
1547        assert_eq!(
1548            first.digest,
1549            projection_digest(&first.logical_id, 2, &first.messages).unwrap()
1550        );
1551        assert_eq!(first.messages[0].source_index, 1);
1552        assert_eq!(first.messages[0].block_indices, vec![1]);
1553        assert_eq!(first.source_message_count, 2);
1554        let mut changed = messages.clone();
1555        changed[0] = Arc::new(json!({"role":"developer","content":"DIFFERENT OMITTED"}));
1556        let second = project_archive(&logical, &changed, "changed note", None).unwrap();
1557        assert_eq!(first.digest, second.digest);
1558        assert_ne!(
1559            first.digest,
1560            project_archive("other-session", &messages, "hidden note", None)
1561                .unwrap()
1562                .digest
1563        );
1564        assert_eq!(serde_json::to_vec(&messages).unwrap(), source);
1565        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1566    }
1567
1568    #[test]
1569    fn pure_projection_shares_disclosure_redaction_and_input_limits() {
1570        let redactor: Arc<ArchiveRedactor> =
1571            Arc::new(|s| s.replace("classified-value", "[redacted]"));
1572        let messages = vec![
1573            Arc::new(json!({"role":"assistant","content":[
1574                {"type":"tool_use","id":"read_1","name":"read","input":{"path":"src/lib.rs"}},
1575                {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":"NOTE_ONLY"}}
1576            ]})),
1577            Arc::new(
1578                json!({"role":"user","content":[{"type":"tool_result","tool_use_id":"read_1","content":"classified-value"}]}),
1579            ),
1580            Arc::new(json!({"role":"user","sensitivity":"secret","content":"OMITTED"})),
1581            Arc::new(json!({"role":"user","_synaps_context":{},"content":"NOTE_ONLY"})),
1582        ];
1583        let projected =
1584            project_archive("session", &messages, "classified-value", Some(redactor)).unwrap();
1585        assert_eq!(projected.note, "[redacted]");
1586        let json = serde_json::to_string(&projected.messages).unwrap();
1587        assert!(!json.contains("classified-value"));
1588        assert!(!json.contains("NOTE_ONLY"));
1589        assert!(!json.contains("OMITTED"));
1590        assert!(json.contains("src/lib.rs"));
1591        assert_eq!(projected.messages.len(), 2);
1592        let withheld = project_archive("session", &messages, "password=bad", None).unwrap();
1593        assert_eq!(withheld.note, WITHHELD);
1594        assert!(!serde_json::to_string(&withheld.messages)
1595            .unwrap()
1596            .contains("src/lib.rs"));
1597        assert!(project_archive("", &[], "", None).is_err());
1598        assert!(project_archive(&"l".repeat(4097), &[], "", None).is_err());
1599        assert!(project_archive("s", &[], &"n".repeat(MAX_NOTE_BYTES + 1), None).is_err());
1600        assert!(
1601            project_archive("s", &vec![messages[0].clone(); MAX_MESSAGES + 1], "", None).is_err()
1602        );
1603    }
1604}
1605
1606#[cfg(all(test, unix))]
1607mod tests {
1608    use super::*;
1609    use std::os::unix::fs::{symlink, PermissionsExt};
1610
1611    fn msg(role: &str, content: Value) -> SharedMessage {
1612        Arc::new(json!({"role":role,"content":content}))
1613    }
1614    fn setup() -> (tempfile::TempDir, ArchiveStore) {
1615        let tmp = tempfile::tempdir().unwrap();
1616        let store =
1617            ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1618        (tmp, store)
1619    }
1620    fn directory(tmp: &tempfile::TempDir, store: &ArchiveStore) -> std::path::PathBuf {
1621        tmp.path().join("context-archives").join(&store.scope)
1622    }
1623
1624    #[test]
1625    fn export_preview_is_read_only_and_matches_projection_including_tombstones() {
1626        use std::os::unix::fs::MetadataExt;
1627        let tmp = tempfile::tempdir().unwrap();
1628        assert!(export_in(&tmp.path().join("missing"), "synthetic-project")
1629            .unwrap()
1630            .is_empty());
1631        assert!(export_in(tmp.path(), "synthetic-project")
1632            .unwrap()
1633            .is_empty());
1634        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1635        let store =
1636            ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1637        let dir = directory(&tmp, &store);
1638        // Even a newly opened empty store has no lock until its first operation.
1639        assert!(export_in(tmp.path(), "synthetic-project")
1640            .unwrap()
1641            .is_empty());
1642        assert!(!dir.join("lock").exists());
1643        let messages = vec![msg("user", json!("migration evidence"))];
1644        let reference = store.seal(&messages, "hidden note").unwrap();
1645        let projection =
1646            project_archive("synthetic-logical", &messages, "hidden note", None).unwrap();
1647        let tombstone = store
1648            .seal(&[msg("assistant", json!("remove me"))], "remove note")
1649            .unwrap();
1650        store.forget(&tombstone.id).unwrap();
1651        let mut paths = vec![
1652            tmp.path().to_path_buf(),
1653            tmp.path().join("context-archives"),
1654            dir.clone(),
1655        ];
1656        paths.extend(std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()));
1657        // Export must not silently repair permissions, unlike normal archive IO.
1658        for path in &paths {
1659            let mode = if path.is_dir() { 0o750 } else { 0o640 };
1660            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
1661        }
1662        let snapshot = || {
1663            paths
1664                .iter()
1665                .map(|p| {
1666                    let m = std::fs::metadata(p).unwrap();
1667                    (
1668                        m.mode(),
1669                        m.mtime(),
1670                        m.mtime_nsec(),
1671                        if m.is_file() {
1672                            std::fs::read(p).unwrap()
1673                        } else {
1674                            vec![]
1675                        },
1676                    )
1677                })
1678                .collect::<Vec<_>>()
1679        };
1680        let before = snapshot();
1681        let rows = export_in(tmp.path(), "synthetic-project").unwrap();
1682        assert_eq!(rows, store.export_records().unwrap());
1683        assert_eq!(before, snapshot());
1684        assert_eq!(
1685            rows[0],
1686            json!({"id":reference.id,"digest":projection.digest,"logical_id":projection.logical_id,
1687            "source_message_count":1,"messages":projection.messages,"note":"hidden note","tombstone":false})
1688        );
1689        assert_eq!(rows[1]["id"], tombstone.id);
1690        assert_eq!(rows[1]["tombstone"], true);
1691        assert_eq!(rows[1]["source_message_count"], 0);
1692        assert_eq!(rows[1]["messages"], json!([]));
1693        assert_eq!(rows[1]["note"], "");
1694        let lock = store.fs.lock().unwrap();
1695        assert_eq!(
1696            export_in(tmp.path(), "synthetic-project")
1697                .unwrap_err()
1698                .kind(),
1699            io::ErrorKind::WouldBlock
1700        );
1701        drop(lock);
1702        std::fs::write(dir.join("000.json"), b"corrupt").unwrap();
1703        assert!(export_in(tmp.path(), "synthetic-project").is_err());
1704    }
1705
1706    #[test]
1707    fn export_preview_refuses_links_and_never_creates_a_missing_lock() {
1708        let (tmp, store) = setup();
1709        let dir = directory(&tmp, &store);
1710        let r = store
1711            .seal(&[msg("user", json!("evidence"))], "note")
1712            .unwrap();
1713        let path = dir.join("000.json");
1714        let bytes = std::fs::read(&path).unwrap();
1715        std::fs::remove_file(dir.join("lock")).unwrap();
1716        assert!(export_in(tmp.path(), "synthetic-project").is_err());
1717        assert!(!dir.join("lock").exists());
1718        // The normal API may recreate its lock, but preview cannot.
1719        assert!(store.fetch(&r.id, 0, 1).is_ok());
1720        std::fs::remove_file(&path).unwrap();
1721        let outside = tmp.path().join("outside");
1722        std::fs::write(&outside, &bytes).unwrap();
1723        symlink(&outside, &path).unwrap();
1724        assert!(export_in(tmp.path(), "synthetic-project").is_err());
1725        std::fs::remove_file(&path).unwrap();
1726        std::fs::hard_link(&outside, &path).unwrap();
1727        assert!(export_in(tmp.path(), "synthetic-project").is_err());
1728        let alias = tmp.path().join("alias");
1729        symlink(tmp.path(), &alias).unwrap();
1730        assert!(export_in(&alias, "synthetic-project").is_err());
1731        assert_eq!(std::fs::read(&outside).unwrap(), bytes);
1732    }
1733
1734    #[test]
1735    fn attachments_remain_outside_eligible_memory_archive() {
1736        let (tmp, store) = setup();
1737        let messages = vec![msg(
1738            "user",
1739            json!([
1740                {"type":"text","text":"Compare the selected files"},
1741                {"type":"image","source":{"type":"base64","media_type":"image/png","data":"ATTACHMENT_IMAGE_SECRET"}},
1742                {"type":"document","title":"ATTACHMENT_FILENAME_SECRET","source":{"type":"base64","media_type":"application/pdf","data":"ATTACHMENT_PDF_SECRET"}},
1743                {"type":"document","source":{"type":"text","media_type":"text/plain","data":"ATTACHMENT_TEXT_SECRET"}}
1744            ]),
1745        )];
1746        let reference = store.seal(&messages, "safe checkpoint").unwrap();
1747        let fetched = store.fetch(&reference.id, 0, 10).unwrap();
1748        let serialized = serde_json::to_string(&fetched).unwrap();
1749        assert!(serialized.contains("Compare the selected files"));
1750        assert!(!serialized.contains("ATTACHMENT_"));
1751        let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1752        assert!(!raw.contains("ATTACHMENT_"));
1753        assert!(messages[0].to_string().contains("ATTACHMENT_TEXT_SECRET"));
1754    }
1755
1756    #[test]
1757    fn exact_text_source_indices_note_and_restart() {
1758        let (tmp, store) = setup();
1759        let messages = vec![
1760            msg("system", json!("FORBIDDEN SYSTEM")),
1761            msg("user", json!(" exact\n αβ  ")),
1762            msg(
1763                "assistant",
1764                json!([
1765                    {"type":"thinking","thinking":"FORBIDDEN REASONING"},
1766                    {"type":"text","text":"evidence✓", "cache_control":{"type":"ephemeral"}},
1767                    {"type":"image","data":"FORBIDDEN IMAGE"}
1768                ]),
1769            ),
1770            msg("developer", json!("FORBIDDEN DEVELOPER")),
1771        ];
1772        let reference = store.seal(&messages, "hidden routing note").unwrap();
1773        assert_eq!(reference.message_count, 2);
1774        assert_eq!(reference.source_message_count, 4);
1775        let reopened =
1776            ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1777        let fetched = reopened
1778            .fetch_with_provenance(&reference.id, 0, 128)
1779            .unwrap();
1780        assert_eq!(fetched[0].message, messages[1]);
1781        assert_eq!(fetched[0].source_index, 1);
1782        assert_eq!(fetched[1].source_index, 2);
1783        assert_eq!(fetched[1].block_indices, vec![1]);
1784        assert_eq!(
1785            reopened.fetch_note(&reference.id).unwrap(),
1786            "hidden routing note"
1787        );
1788        assert!(reopened.search("hidden routing", 10).unwrap().is_empty());
1789        assert_eq!(reopened.search("evidence", 10).unwrap()[0].id, reference.id);
1790        assert!(reopened
1791            .fetch(&reference.id, usize::MAX, 10)
1792            .unwrap()
1793            .is_empty());
1794        let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1795        assert!(!raw.contains("FORBIDDEN"));
1796        assert!(!raw.contains("cache_control"));
1797        assert_eq!(messages[0]["content"], "FORBIDDEN SYSTEM");
1798    }
1799
1800    #[test]
1801    fn duplicate_retry_ignores_note_but_not_logical_id() {
1802        let (tmp, store) = setup();
1803        let messages = vec![msg("user", json!("same eligible words"))];
1804        let first = store.seal(&messages, "first").unwrap();
1805        assert_eq!(store.seal(&messages, "second").unwrap(), first);
1806        assert_eq!(store.fetch_note(&first.id).unwrap(), "first");
1807        let other = ArchiveStore::new(tmp.path(), "synthetic-project", "other-logical").unwrap();
1808        let second = other.seal(&messages, "").unwrap();
1809        assert_ne!(first.id, second.id);
1810        assert_eq!(
1811            project_search(tmp.path(), "synthetic-project", "", 10)
1812                .unwrap()
1813                .len(),
1814            2
1815        );
1816        assert_eq!(
1817            project_fetch(tmp.path(), "synthetic-project", &first.id, 0, 10).unwrap(),
1818            messages
1819        );
1820        assert_eq!(other.fetch(&first.id, 0, 1).unwrap(), messages);
1821        store.forget(&first.id).unwrap();
1822        assert!(store.seal(&messages, "third").is_err());
1823        assert_eq!(other.fetch(&second.id, 0, 1).unwrap(), messages);
1824        assert_eq!(other.seal(&messages, "third").unwrap(), second);
1825    }
1826
1827    #[test]
1828    fn deletion_is_content_free_durable_and_cannot_resurrect() {
1829        let (tmp, store) = setup();
1830        let messages = vec![msg("user", json!("uniquely removable content"))];
1831        let reference = store.seal(&messages, "removable note").unwrap();
1832        store.forget(&reference.id).unwrap();
1833        store.forget(&reference.id).unwrap();
1834        let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1835        assert!(raw.contains("tombstone"));
1836        assert!(!raw.contains("removable"));
1837        assert!(!raw.contains("source_message_count"));
1838        let store =
1839            ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
1840        assert_eq!(
1841            store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
1842            io::ErrorKind::NotFound
1843        );
1844        assert!(store.fetch_note(&reference.id).is_err());
1845        assert!(store.search("", 10).unwrap().is_empty());
1846        assert_eq!(
1847            store.seal(&messages, "changed note").unwrap_err().kind(),
1848            io::ErrorKind::PermissionDenied
1849        );
1850    }
1851
1852    #[test]
1853    fn foreign_scope_copied_records_and_traversal_fail_closed() {
1854        let (tmp, store) = setup();
1855        let reference = store
1856            .seal(&[msg("user", json!("scope evidence"))], "")
1857            .unwrap();
1858        let foreign =
1859            ArchiveStore::new(tmp.path(), "foreign-project", "synthetic-logical").unwrap();
1860        assert!(foreign.fetch(&reference.id, 0, 1).is_err());
1861        for id in [
1862            "../000.json",
1863            "/tmp/file",
1864            "",
1865            "a/../b",
1866            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1867        ] {
1868            assert_eq!(
1869                store.fetch(id, 0, 1).unwrap_err().kind(),
1870                io::ErrorKind::InvalidInput
1871            );
1872            assert!(store.forget(id).is_err());
1873        }
1874        std::fs::copy(
1875            directory(&tmp, &store).join("000.json"),
1876            directory(&tmp, &foreign).join("000.json"),
1877        )
1878        .unwrap();
1879        assert_eq!(
1880            foreign.search("", 1).unwrap_err().kind(),
1881            io::ErrorKind::InvalidData
1882        );
1883        assert!(ArchiveStore::new(&tmp.path().join("../escape"), "p", "l").is_err());
1884    }
1885
1886    #[test]
1887    fn disclosure_and_raw_tool_data_are_withheld() {
1888        let (tmp, store) = setup();
1889        let mut messages = vec![
1890            msg(
1891                "assistant",
1892                json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"unclassified raw argument"}}]),
1893            ),
1894            msg(
1895                "user",
1896                json!([{"type":"tool_result","tool_use_id":"call_1","content":"unclassified raw output"}]),
1897            ),
1898            msg("user", json!("password=synthetic-credential")),
1899        ];
1900        for class in [
1901            "local_only",
1902            "persist_never_transmit",
1903            "never_persist",
1904            "model_visible_after_consent",
1905            "unknown",
1906        ] {
1907            messages.push(Arc::new(
1908                json!({"role":"user","content":"FORBIDDEN DISCLOSURE","disclosure":class}),
1909            ));
1910        }
1911        messages.push(Arc::new(
1912            json!({"role":"user","content":"FORBIDDEN SENSITIVITY","sensitivity":"secret"}),
1913        ));
1914        let reference = store.seal(&messages, "").unwrap();
1915        assert_eq!(reference.message_count, 3);
1916        let raw = std::fs::read_to_string(directory(&tmp, &store).join("000.json")).unwrap();
1917        for forbidden in ["unclassified raw", "synthetic-credential", "FORBIDDEN"] {
1918            assert!(!raw.contains(forbidden));
1919        }
1920        assert!(raw.contains("call_1"));
1921        assert!(raw.contains("_archive_withheld"));
1922    }
1923
1924    #[test]
1925    fn derived_context_envelope_is_not_searchable_source() {
1926        let (_tmp, store) = setup();
1927        let first = store
1928            .seal(
1929                &[msg("assistant", json!("original evidence"))],
1930                "NOTE_ONLY_SENTINEL",
1931            )
1932            .unwrap();
1933        let second=store.seal(&[
1934            Arc::new(json!({"role":"user","_synaps_context":{"schema":"synaps-context-window/1","archive":first.id},"content":"NOTE_ONLY_SENTINEL"})),
1935            Arc::new(json!({"role":"assistant","content":[{"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint","input":{"phase":"plan","note":"NOTE_ONLY_SENTINEL"}}]})),
1936            msg("assistant",json!("second-window evidence")),
1937        ],"next note").unwrap();
1938        assert!(store.search("NOTE_ONLY_SENTINEL", 10).unwrap().is_empty());
1939        let fetched = store.fetch_with_provenance(&second.id, 0, 10).unwrap();
1940        assert_eq!(fetched.len(), 2);
1941        assert_eq!(fetched[0].source_index, 1);
1942        assert_eq!(fetched[1].source_index, 2);
1943        assert!(!serde_json::to_string(&fetched)
1944            .unwrap()
1945            .contains("NOTE_ONLY_SENTINEL"));
1946    }
1947
1948    #[test]
1949    fn checkpoint_tool_note_stays_hidden_even_with_redactor() {
1950        let (_tmp, store) = setup();
1951        let store = store.with_redactor(Arc::new(str::to_owned));
1952        let reference = store
1953            .seal(
1954                &[msg(
1955                    "assistant",
1956                    json!([
1957                        {"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint",
1958                         "input":{"phase":"plan","note":"NOTE_ARGUMENT_SENTINEL"}}
1959                    ]),
1960                )],
1961                "NOTE_ARGUMENT_SENTINEL",
1962            )
1963            .unwrap();
1964        assert!(store
1965            .search("NOTE_ARGUMENT_SENTINEL", 10)
1966            .unwrap()
1967            .is_empty());
1968        assert!(
1969            !serde_json::to_string(&store.fetch(&reference.id, 0, 10).unwrap())
1970                .unwrap()
1971                .contains("NOTE_ARGUMENT_SENTINEL")
1972        );
1973        assert_eq!(
1974            store.fetch_note(&reference.id).unwrap(),
1975            "NOTE_ARGUMENT_SENTINEL"
1976        );
1977    }
1978
1979    #[test]
1980    fn trusted_redactor_retains_exact_eligible_tool_evidence() {
1981        let (_tmp, store) = setup();
1982        let store = store.with_redactor(Arc::new(|s| s.replace("classified-value", "[redacted]")));
1983        let messages = vec![
1984            msg(
1985                "assistant",
1986                json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"src/lib.rs","line":7}}]),
1987            ),
1988            msg(
1989                "user",
1990                json!([{"type":"tool_result","tool_use_id":"call_1","is_error":false,"content":"line 7\n  exact evidence\nclassified-value"}]),
1991            ),
1992        ];
1993        let reference = store.seal(&messages, "").unwrap();
1994        let fetched = store.fetch(&reference.id, 0, 10).unwrap();
1995        assert_eq!(fetched[0], messages[0]);
1996        assert_eq!(
1997            fetched[1]["content"][0]["content"],
1998            "line 7\n  exact evidence\n[redacted]"
1999        );
2000    }
2001
2002    #[test]
2003    fn oversized_input_output_limits_and_depth_leave_source_intact() {
2004        let (_tmp, store) = setup();
2005        let huge = vec![msg("user", json!("x".repeat(MAX_INPUT_BYTES + 1)))];
2006        assert!(store.seal(&huge, "").is_err());
2007        assert_eq!(
2008            huge[0]["content"].as_str().unwrap().len(),
2009            MAX_INPUT_BYTES + 1
2010        );
2011        let file_too_large = vec![msg("user", json!("\n".repeat(MAX_SEGMENT_BYTES / 2)))];
2012        assert!(store.seal(&file_too_large, "").is_err());
2013        assert!(store
2014            .seal(&vec![msg("user", json!("a")); MAX_MESSAGES + 1], "")
2015            .is_err());
2016        assert!(store.seal(&[], &"n".repeat(MAX_NOTE_BYTES + 1)).is_err());
2017        let mut deep = json!("value");
2018        for _ in 0..40 {
2019            deep = json!([deep]);
2020        }
2021        // Unknown content blocks are opaque; admitted tool JSON still has a
2022        // strict recursion limit before serialization or redaction.
2023        let deep = vec![msg(
2024            "assistant",
2025            json!([{
2026                "type":"tool_use", "id":"r1", "name":"read", "input":deep
2027            }]),
2028        )];
2029        let store = store.with_redactor(Arc::new(str::to_owned));
2030        assert!(store.seal(&deep, "").is_err());
2031        assert!(store.search(&"q".repeat(513), 1).is_err());
2032        assert!(store.search("", MAX_SEARCH_RESULTS + 1).is_err());
2033        assert!(store
2034            .fetch(&"a".repeat(32), 0, MAX_FETCH_MESSAGES + 1)
2035            .is_err());
2036        assert!(store.search("", 10).unwrap().is_empty());
2037    }
2038
2039    #[test]
2040    fn torn_final_fails_closed_stale_temporary_is_not_visible() {
2041        let (tmp, store) = setup();
2042        let dir = directory(&tmp, &store);
2043        std::fs::write(dir.join("pending.tmp"), b"unpublished partial").unwrap();
2044        assert!(store.search("", 1).unwrap().is_empty());
2045        let reference = store.seal(&[msg("user", json!("complete"))], "").unwrap();
2046        assert!(!dir.join("pending.tmp").exists());
2047        std::fs::write(dir.join("000.json"), b"{\"version\":1,").unwrap();
2048        assert_eq!(
2049            store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
2050            io::ErrorKind::InvalidData
2051        );
2052        assert!(store.search("", 1).is_err());
2053        assert!(store.seal(&[msg("user", json!("next"))], "").is_err());
2054        assert!(store.forget(&reference.id).is_err());
2055    }
2056
2057    #[test]
2058    fn private_modes_symlink_hardlink_fifo_and_lock_refusal() {
2059        let (tmp, store) = setup();
2060        let dir = directory(&tmp, &store);
2061        let reference = store.seal(&[msg("user", json!("private"))], "").unwrap();
2062        for path in [
2063            tmp.path().to_path_buf(),
2064            tmp.path().join("context-archives"),
2065            dir.clone(),
2066        ] {
2067            assert_eq!(
2068                std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
2069                0o700
2070            );
2071        }
2072        for name in ["000.json", "lock"] {
2073            assert_eq!(
2074                std::fs::metadata(dir.join(name))
2075                    .unwrap()
2076                    .permissions()
2077                    .mode()
2078                    & 0o777,
2079                0o600
2080            );
2081        }
2082        let lock = store.fs.lock().unwrap();
2083        assert_eq!(
2084            store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
2085            io::ErrorKind::WouldBlock
2086        );
2087        drop(lock);
2088        let victim = tmp.path().join("synthetic-victim");
2089        std::fs::write(&victim, "do not change").unwrap();
2090        std::fs::remove_file(dir.join("000.json")).unwrap();
2091        symlink(&victim, dir.join("000.json")).unwrap();
2092        assert!(store.search("", 1).is_err());
2093        assert!(store.seal(&[msg("user", json!("private"))], "").is_err());
2094        assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
2095        std::fs::remove_file(dir.join("000.json")).unwrap();
2096        std::fs::hard_link(&victim, dir.join("000.json")).unwrap();
2097        assert!(store.search("", 1).is_err());
2098        std::fs::remove_file(dir.join("000.json")).unwrap();
2099        let fifo = std::ffi::CString::new(dir.join("000.json").to_str().unwrap()).unwrap();
2100        assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
2101        assert!(store.search("", 1).is_err());
2102        let alias = tmp.path().join("alias");
2103        symlink(tmp.path().join("context-archives"), &alias).unwrap();
2104        assert!(ArchiveStore::new(&alias.join("nested"), "p", "l").is_err());
2105        std::fs::remove_file(dir.join("lock")).unwrap();
2106        symlink(&victim, dir.join("lock")).unwrap();
2107        assert!(store.fs.lock().is_err());
2108    }
2109
2110    #[test]
2111    fn multi_mib_context_roundtrips_without_truncation() {
2112        let (_tmp, store) = setup();
2113        let text = "eligible exact evidence\n".repeat(400_000);
2114        assert!(text.len() > 8 * 1024 * 1024);
2115        let messages = vec![msg("user", json!(text))];
2116        let reference = store.seal(&messages, "large source window").unwrap();
2117        assert_eq!(store.fetch(&reference.id, 0, 1).unwrap(), messages);
2118        assert_eq!(store.seal(&messages, "retry").unwrap(), reference);
2119    }
2120
2121    #[test]
2122    fn private_reasoning_disguised_as_assistant_text_is_omitted() {
2123        let (_tmp, store) = setup();
2124        let messages = vec![
2125            Arc::new(json!({"role":"assistant","channel":"analysis","content":"private analysis"})),
2126            Arc::new(
2127                json!({"role":"assistant","content_class":"private_reasoning","content":"private reasoning"}),
2128            ),
2129            Arc::new(json!({"role":"assistant","private":true,"content":"private text"})),
2130            Arc::new(json!({"role":"assistant","channel":"final","content":"public evidence"})),
2131        ];
2132        let reference = store.seal(&messages, "").unwrap();
2133        assert_eq!(reference.message_count, 1);
2134        let fetched = store.fetch_with_provenance(&reference.id, 0, 1).unwrap();
2135        assert_eq!(fetched[0].source_index, 3);
2136        assert_eq!(fetched[0].message["content"], "public evidence");
2137    }
2138
2139    #[test]
2140    fn after_redaction_requires_host_redactor_including_parent_class() {
2141        let (_tmp, store) = setup();
2142        let messages = vec![Arc::new(json!({
2143            "role":"assistant", "disclosure":"model_visible_after_redaction",
2144            "content":[{"type":"text", "text":"sensitive host classified material"}]
2145        }))];
2146        let reference = store.seal(&messages, "").unwrap();
2147        let fetched = store.fetch(&reference.id, 0, 1).unwrap();
2148        assert_eq!(fetched[0]["content"][0]["text"], WITHHELD);
2149    }
2150
2151    #[test]
2152    fn bounded_search_utf8_and_permanent_capacity() {
2153        let (_tmp, store) = setup();
2154        for index in 0..MAX_SEGMENTS {
2155            store
2156                .seal(
2157                    &[msg("user", json!(format!("{index} {}", "é".repeat(200))))],
2158                    "",
2159                )
2160                .unwrap();
2161        }
2162        let descriptors = store.search("", MAX_SEARCH_RESULTS).unwrap();
2163        assert_eq!(descriptors.len(), MAX_SEARCH_RESULTS);
2164        assert!(descriptors.iter().all(|d| d.snippet.len() <= 256));
2165        assert!(store.seal(&[msg("user", json!("overflow"))], "").is_err());
2166        store.forget(&descriptors[0].id).unwrap();
2167        assert!(store.seal(&[msg("user", json!("still full"))], "").is_err());
2168    }
2169}