Skip to main content

agent_engine/runtime/trace/
controls.rs

1//! Explicit trace controls (Task 12, spec §5.1 raw-capture clause + §6.1
2//! surfaces): `/trace next`, `/trace next content`, `/trace status`.
3//!
4//! ## One-shot semantics
5//!
6//! Arming (`/trace next`) enables tracing for **exactly the next logical
7//! outgoing provider request** — even when the `telemetry` config level is
8//! Off — and then auto-disarms. The armed ephemeral context carries a
9//! one-shot request gate consumed inside `RequestTracer::begin`: the first
10//! request wins, all of its retry attempts emit, and subsequent tool-loop
11//! requests through the same context are disabled. It never silently
12//! enables indefinite persistence: when telemetry is Off the one-shot
13//! record rides an ephemeral writer whose handle the runtime retains (in
14//! its one-shot state, off the request path) until it is replaced by a
15//! re-arm or drained by the `shutdown_observability*` exit epilogue — so
16//! the armed record flushes even when the session exits immediately after
17//! the request. The existing Basic/Full config rule is unchanged — arming
18//! on top of an already-enabled session only adds the (optional) content
19//! capture.
20//!
21//! ## Content capture (explicit, ephemeral, redacted)
22//!
23//! Persisted traces contain no content, so `synaps trace export
24//! --include-content` needs an explicit capture: `/trace next content` arms
25//! a **one-request, bounded, redacted** capture of the request body.
26//! Security decisions, deliberately conservative:
27//!
28//! - capture is redacted **at capture time** (structural media scrub plus
29//!   recursive credential-key / secret-pattern scrub, see
30//!   `trace::export::redact_value`) — raw media never touches disk;
31//! - plain text remains explicitly authorized content: a text document
32//!   lowered to ordinary OpenAI `text`/`input_text` has lost its structural
33//!   provenance and cannot be distinguished from a typed prompt here. Its
34//!   text may be captured under this opt-in (subject to the secret scrub);
35//! - the bundle is written with the Phase 1 private-fs helpers (`0600`
36//!   file, `0700` parent, symlink-refusing, atomic) under
37//!   `<synaps base dir>/trace/capture/`;
38//! - the bundle carries its own schema tag (`synaps-trace-content-capture/1`)
39//!   so it can never masquerade as a `synaps-request-trace/1` record;
40//! - capture is bounded ([`CAPTURE_MAX_BYTES`]) and expires
41//!   ([`CAPTURE_TTL`]): `trace export --include-content` consumes (deletes)
42//!   it, and an expired bundle is refused and deleted instead of exported;
43//!
44//!   **Expiry semantics (documented guarantee):** expiry is *logical* —
45//!   an expired bundle can never be exported (the export path checks the
46//!   embedded `expires_unix_ms` and deletes stale bundles). Physical
47//!   deletion is a bounded, fail-soft sweep run at Runtime/session
48//!   startup, in both the sync and async shutdown epilogues, and on every
49//!   trace interaction (every new capture, `/trace status`, every
50//!   `synaps trace export` invocation). A bundle left behind by a killed
51//!   process may therefore sit (private `0600` file, already redacted) on
52//!   disk past its TTL until the next session startup/shutdown or trace
53//!   interaction — but it is unreadable through any supported export path
54//!   from the moment it expires;
55//! - only the request **body** is captured — headers, cookies, and
56//!   credentials never enter the capture path by construction (they are
57//!   attached by the HTTP client after the body is built).
58//!
59//! The capture file is how the arm crosses the process boundary to a later
60//! `synaps trace export` invocation without weakening privacy: the on-disk
61//! artifact is already redacted, private, bounded, and short-lived.
62
63use super::types::TraceId;
64use serde::{Deserialize, Serialize};
65use std::path::{Path, PathBuf};
66use std::sync::atomic::{AtomicBool, Ordering};
67use std::sync::Mutex;
68use std::time::{Duration, SystemTime, UNIX_EPOCH};
69
70/// Schema tag for the ephemeral content-capture bundle. Distinct from the
71/// request-trace schema by design.
72pub const CONTENT_CAPTURE_SCHEMA: &str = "synaps-trace-content-capture/1";
73
74/// Upper bound on captured (post-redaction) body bytes.
75pub const CAPTURE_MAX_BYTES: usize = 1024 * 1024;
76
77/// Retention window for a capture bundle.
78pub const CAPTURE_TTL: Duration = Duration::from_secs(15 * 60);
79
80/// Default capture directory: `<synaps base dir>/trace/capture/`.
81pub fn default_capture_dir() -> PathBuf {
82    agent_core::core::config::base_dir()
83        .join("trace")
84        .join("capture")
85}
86
87/// Current arm state.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum TraceArm {
90    Off,
91    /// Trace the next request (metadata only), then auto-disarm.
92    NextMetadata,
93    /// Trace the next request AND write a redacted content-capture bundle.
94    NextWithContent,
95}
96
97/// Session trace controls: one-shot arm consumed by the next request.
98#[derive(Debug, Default)]
99pub struct TraceControls {
100    state: Mutex<Option<ArmKind>>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104enum ArmKind {
105    Metadata,
106    Content,
107}
108
109impl TraceControls {
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    /// Arm for exactly the next request. Re-arming replaces the pending arm.
115    pub fn arm_next(&self, with_content: bool) {
116        *self.state.lock().expect("trace controls poisoned") = Some(if with_content {
117            ArmKind::Content
118        } else {
119            ArmKind::Metadata
120        });
121    }
122
123    pub fn disarm(&self) {
124        *self.state.lock().expect("trace controls poisoned") = None;
125    }
126
127    /// Non-consuming view for `/trace status`.
128    pub fn peek(&self) -> TraceArm {
129        match *self.state.lock().expect("trace controls poisoned") {
130            None => TraceArm::Off,
131            Some(ArmKind::Metadata) => TraceArm::NextMetadata,
132            Some(ArmKind::Content) => TraceArm::NextWithContent,
133        }
134    }
135
136    /// Consume the pending arm (auto-disable). Returns `Some(with_content)`
137    /// exactly once per arming.
138    pub fn consume(&self) -> Option<bool> {
139        self.state
140            .lock()
141            .expect("trace controls poisoned")
142            .take()
143            .map(|kind| kind == ArmKind::Content)
144    }
145}
146
147// --- Ephemeral content capture ---
148
149/// On-disk shape of the capture bundle. Clearly labeled non-request-trace
150/// schema; `redacted` is a hard marker checked again on export.
151#[derive(Debug, Serialize, Deserialize)]
152pub struct ContentCaptureBundle {
153    /// Always [`CONTENT_CAPTURE_SCHEMA`].
154    pub schema: String,
155    pub request_id: TraceId,
156    pub created_unix_ms: u64,
157    pub expires_unix_ms: u64,
158    /// Always true: the body was recursively redacted before writing.
159    pub redacted: bool,
160    /// True when the raw body exceeded [`CAPTURE_MAX_BYTES`] and was
161    /// therefore NOT captured (fail closed, never truncated mid-secret).
162    pub over_budget: bool,
163    /// Redacted request body (request content fields only — never headers
164    /// or credentials). `None` iff `over_budget`.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub body: Option<serde_json::Value>,
167}
168
169pub(crate) fn unix_ms_now() -> u64 {
170    SystemTime::now()
171        .duration_since(UNIX_EPOCH)
172        .map(|d| d.as_millis() as u64)
173        .unwrap_or(0)
174}
175
176/// One-request content capture arm, attached to a `TraceContext` when the
177/// user ran `/trace next content`. Fires at most once.
178#[derive(Debug)]
179pub struct ContentCapture {
180    dir: PathBuf,
181    consumed: AtomicBool,
182}
183
184impl ContentCapture {
185    pub fn new(dir: PathBuf) -> Self {
186        Self {
187            dir,
188            consumed: AtomicBool::new(false),
189        }
190    }
191
192    /// Capture one request body: parse, redact recursively, bound, write a
193    /// private bundle. Never fails the request — any error is logged as
194    /// metadata and the capture is simply lost. Fires at most once.
195    pub fn capture(&self, request_id: &TraceId, body_bytes: &[u8]) {
196        if self.consumed.swap(true, Ordering::SeqCst) {
197            return;
198        }
199        // Opportunistic retention sweep (see module docs): any bundle from
200        // an earlier session that outlived its TTL is removed before a new
201        // one is written — expiry is enforced logically at export, deleted
202        // physically on the next trace interaction.
203        let _ = super::export::sweep_expired_captures(&self.dir);
204        let over_budget = body_bytes.len() > CAPTURE_MAX_BYTES;
205        let body = if over_budget {
206            None
207        } else {
208            match serde_json::from_slice::<serde_json::Value>(body_bytes) {
209                Ok(mut value) => {
210                    scrub_media_content(&mut value);
211                    super::export::redact_value(&mut value);
212                    Some(value)
213                }
214                Err(_) => {
215                    tracing::warn!(
216                        byte_len = body_bytes.len(),
217                        "trace content capture skipped: body is not JSON"
218                    );
219                    return;
220                }
221            }
222        };
223        let now = unix_ms_now();
224        let bundle = ContentCaptureBundle {
225            schema: CONTENT_CAPTURE_SCHEMA.to_string(),
226            request_id: request_id.clone(),
227            created_unix_ms: now,
228            expires_unix_ms: now + CAPTURE_TTL.as_millis() as u64,
229            redacted: true,
230            over_budget,
231            body,
232        };
233        if let Err(err) = write_bundle(&self.dir, &bundle) {
234            // Metadata-only: path/IO reason, never body content.
235            tracing::warn!(reason = %err, "trace content capture write failed");
236        }
237    }
238
239    /// Consume the arm on a provider path that cannot capture a request
240    /// body (no exact serialized body exists in this process). Returns
241    /// `true` exactly once so the caller can surface the failure; never
242    /// writes anything.
243    pub fn mark_unsupported(&self) -> bool {
244        !self.consumed.swap(true, Ordering::SeqCst)
245    }
246}
247
248/// Scrub recognizable media structures BEFORE the opt-in bundle is written.
249/// Recurse through all containers (including tool_result), not arbitrary text:
250/// ordinary prompt/tool text remains explicitly authorized capture content.
251/// Replacing whole typed blocks also withholds extracted document text and any
252/// alternate payload fields, without trying to recognize base64 by its bytes.
253fn scrub_media_content(value: &mut serde_json::Value) {
254    use serde_json::Value;
255
256    fn image_url_is_data(value: &Value) -> bool {
257        value
258            .as_str()
259            .or_else(|| value["url"].as_str())
260            .is_some_and(|url| {
261                url.trim_start()
262                    .get(..5)
263                    .is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
264            })
265    }
266
267    let descriptor = match value["type"].as_str() {
268        Some("image" | "input_image") => Some("[redacted image]"),
269        Some("document" | "file" | "input_file") => Some("[redacted document]"),
270        Some("image_url") if image_url_is_data(&value["image_url"]) => Some("[redacted image]"),
271        _ => None,
272    };
273    if let Some(descriptor) = descriptor {
274        *value = Value::String(descriptor.to_string());
275        return;
276    }
277
278    match value {
279        Value::Array(items) => {
280            for item in items {
281                scrub_media_content(item);
282            }
283        }
284        Value::Object(object) => {
285            for (key, child) in object {
286                match key.as_str() {
287                    // Gemini parts have no type discriminator; also accept
288                    // the snake_case protobuf spelling.
289                    "inlineData" | "inline_data" => {
290                        *child = Value::String("[redacted media]".to_string());
291                    }
292                    // Handles nested OpenAI file objects without a type tag.
293                    "file_data" => {
294                        *child = Value::String("[redacted document]".to_string());
295                    }
296                    "image_url" if image_url_is_data(child) => {
297                        *child = Value::String("[redacted image]".to_string());
298                    }
299                    _ => scrub_media_content(child),
300                }
301            }
302        }
303        _ => {}
304    }
305}
306
307/// Path of the capture bundle for a request ID.
308pub fn capture_path(dir: &Path, request_id: &TraceId) -> PathBuf {
309    // TraceId alphabet includes '/' and ':'; flatten to a safe file name.
310    let name: String = request_id
311        .as_str()
312        .chars()
313        .map(|c| if c == '/' || c == ':' { '_' } else { c })
314        .collect();
315    dir.join(format!("capture-{name}.json"))
316}
317
318fn write_bundle(dir: &Path, bundle: &ContentCaptureBundle) -> Result<(), String> {
319    agent_core::core::private_fs::ensure_private_dir(dir).map_err(|e| e.to_string())?;
320    let path = capture_path(dir, &bundle.request_id);
321    let data = serde_json::to_vec_pretty(bundle).map_err(|e| e.to_string())?;
322    agent_core::core::private_fs::write_atomic_private(&path, &data).map_err(|e| e.to_string())
323}
324
325// --- `/trace status` report ---
326
327/// Metadata-only status for `/trace status`: mode, persistence path,
328/// counters. Never secrets, never content.
329#[derive(Debug, Clone)]
330pub struct TraceStatusReport {
331    /// Session persistence via the telemetry Basic/Full rule.
332    pub persistent_enabled: bool,
333    pub arm: TraceArm,
334    /// Where trace records are persisted when enabled.
335    pub trace_path: Option<PathBuf>,
336    pub writer_stats: Option<crate::runtime::telemetry::WriterStats>,
337    pub degraded_records: u64,
338}
339
340impl TraceStatusReport {
341    pub fn render(&self) -> String {
342        use std::fmt::Write as _;
343        let mut out = String::new();
344        let mode = match (self.persistent_enabled, self.arm) {
345            (true, TraceArm::NextWithContent) => "enabled (next request adds content capture)",
346            (true, _) => "enabled (telemetry basic/full)",
347            (false, TraceArm::NextMetadata) => "armed for next request (metadata only)",
348            (false, TraceArm::NextWithContent) => {
349                "armed for next request (metadata + redacted content capture)"
350            }
351            (false, TraceArm::Off) => "disabled",
352        };
353        let _ = writeln!(out, "trace: {mode}");
354        match &self.trace_path {
355            Some(path) => {
356                let _ = writeln!(out, "  persistence: {}", path.display());
357            }
358            None => {
359                let _ = writeln!(out, "  persistence: (unresolved)");
360            }
361        }
362        match self.writer_stats {
363            Some(stats) => {
364                let _ = writeln!(
365                    out,
366                    "  writer: {} enqueued, {} written, {} dropped; {} degraded records",
367                    stats.enqueued, stats.written, stats.dropped, self.degraded_records,
368                );
369            }
370            None => {
371                let _ = writeln!(
372                    out,
373                    "  writer: not running; {} degraded records",
374                    self.degraded_records
375                );
376            }
377        }
378        out.trim_end().to_string()
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn arm_is_consumed_exactly_once() {
388        let controls = TraceControls::new();
389        assert_eq!(controls.peek(), TraceArm::Off);
390        assert!(controls.consume().is_none());
391
392        controls.arm_next(false);
393        assert_eq!(controls.peek(), TraceArm::NextMetadata);
394        assert_eq!(controls.consume(), Some(false));
395        assert_eq!(controls.peek(), TraceArm::Off);
396        assert!(controls.consume().is_none(), "second consume must be empty");
397
398        controls.arm_next(true);
399        assert_eq!(controls.peek(), TraceArm::NextWithContent);
400        assert_eq!(controls.consume(), Some(true));
401        assert!(controls.consume().is_none());
402    }
403
404    #[test]
405    fn content_capture_fires_once_writes_private_redacted_bundle() {
406        let dir = tempfile::tempdir().expect("tempdir");
407        let capture = ContentCapture::new(dir.path().join("cap"));
408        let id = TraceId::new("req-1-0").unwrap();
409        let secret = "sk-SENTINEL1234567890abcd";
410        let body = serde_json::json!({
411            "model": "m",
412            "messages": [{"role": "user", "content": format!("my key is {secret}")}],
413            "api_key": "topsecret-value"
414        });
415        capture.capture(&id, serde_json::to_vec(&body).unwrap().as_slice());
416
417        let path = capture_path(&dir.path().join("cap"), &id);
418        let data = std::fs::read_to_string(&path).expect("bundle exists");
419        assert!(!data.contains(secret), "raw sentinel secret persisted");
420        assert!(!data.contains("topsecret-value"));
421        assert!(data.contains(CONTENT_CAPTURE_SCHEMA));
422        let bundle: ContentCaptureBundle = serde_json::from_str(&data).unwrap();
423        assert!(bundle.redacted);
424        assert!(!bundle.over_budget);
425
426        #[cfg(unix)]
427        {
428            use std::os::unix::fs::PermissionsExt as _;
429            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
430            assert_eq!(mode, 0o600);
431        }
432
433        // Second capture on the same arm is a no-op.
434        std::fs::remove_file(&path).unwrap();
435        capture.capture(&id, b"{}");
436        assert!(!path.exists(), "capture fired twice");
437    }
438
439    #[test]
440    fn content_capture_scrubs_canonical_and_wire_media_before_writing() {
441        use serde_json::json;
442        let dir = tempfile::tempdir().unwrap();
443        let id = TraceId::new("req-media").unwrap();
444        let media = json!([
445            {"type": "image", "source": {"type": "base64", "media_type": "image/png",
446             "data": "CANONICAL_IMAGE_SENTINEL"}},
447            {"type": "document", "title": "report.pdf",
448             "source": {"type": "base64", "data": "CANONICAL_PDF_SENTINEL"}},
449            {"type": "document", "title": "notes.txt",
450             "source": {"type": "text", "data": "CANONICAL_TEXT_SENTINEL"},
451             "text": "EXTRACTED_DOCUMENT_SENTINEL"},
452            {"type": "image_url", "image_url": {"url": "data:image/png;base64,CHAT_IMAGE_SENTINEL"}},
453            {"image_url": "data:image/png;base64,BARE_IMAGE_SENTINEL"},
454            {"type": "file", "file": {"filename": "report.pdf",
455             "file_data": "data:application/pdf;base64,CHAT_FILE_SENTINEL"}},
456            {"file": {"file_data": "UNTAGGED_FILE_SENTINEL"}},
457            {"type": "input_image", "image_url": "data:image/png;base64,RESPONSES_IMAGE_SENTINEL"},
458            {"type": "input_file", "filename": "report.pdf", "file_data": "RESPONSES_FILE_SENTINEL"},
459            {"inlineData": {"mimeType": "image/png", "data": "GEMINI_SENTINEL"}},
460            {"inline_data": {"mime_type": "application/pdf", "data": "PROTOBUF_SENTINEL"}},
461            {"image_url": {"url": " DATA:image/png;base64,CASE_INSENSITIVE_SENTINEL"}}
462        ]);
463        let body = json!({"messages": [{"role": "user", "content": [
464            {"type": "text", "text": "Keep the actual prompt"},
465            {"type": "tool_result", "tool_use_id": "t1", "content": [
466                {"type": "text", "text": "Keep authorized tool text"},
467                {"type": "tool_result", "tool_use_id": "nested", "content": media}
468            ]}
469        ]}], "input": media, "model": "m"});
470        let original = body.clone();
471        let capture = ContentCapture::new(dir.path().join("cap"));
472        capture.capture(&id, &serde_json::to_vec(&body).unwrap());
473        // Inspect the persisted bytes, not only an in-memory scrub result:
474        // no raw media is allowed to land in the private bundle either.
475        let data = std::fs::read_to_string(capture_path(&dir.path().join("cap"), &id)).unwrap();
476        assert!(!data.contains("SENTINEL"), "media payload persisted");
477        let bundle: ContentCaptureBundle = serde_json::from_str(&data).unwrap();
478        assert!(bundle.redacted);
479        assert!(!bundle.over_budget);
480        let captured = bundle.body.unwrap();
481        assert_eq!(captured["model"], "m");
482        let content = &captured["messages"][0]["content"];
483        assert_eq!(content[0]["text"], "Keep the actual prompt");
484        assert_eq!(content[1]["type"], "tool_result");
485        assert_eq!(
486            content[1]["content"][0]["text"],
487            "Keep authorized tool text"
488        );
489        assert_eq!(
490            body, original,
491            "capture must not change the outgoing request"
492        );
493    }
494
495    #[test]
496    fn content_capture_preserves_authorized_non_media_including_lowered_text_documents() {
497        use serde_json::json;
498        let dir = tempfile::tempdir().unwrap();
499        let id = TraceId::new("req-authorized-text").unwrap();
500        // OpenAI text-document lowering has no provenance left at this seam.
501        // Do not guess based on filename markers or scrub all input_text/text.
502        // Such file text remains part of explicitly authorized content capture.
503        let body = json!({
504            "input": [{"role": "user", "content": [
505                {"type": "input_text", "text": "[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
506                {"type": "input_text", "text": "AUTHORIZED_FILE_TEXT"},
507                {"type": "input_text", "text": "ordinary prompt"}
508            ]}],
509            "messages": [{"role": "user", "content": [
510                {"type": "text", "text": "[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
511                {"type": "text", "text": "AUTHORIZED_CHAT_FILE_TEXT"},
512                {"type": "image_url", "image_url": {"url": "https://example.org/image.png"}},
513                {"type": "tool_result", "tool_use_id": "t1", "content": "authorized tool text"}
514            ]}],
515            "data": "ordinary non-media data",
516            "tools": [{"name": "example", "input_schema": {"type": "object"}}]
517        });
518        ContentCapture::new(dir.path().to_path_buf())
519            .capture(&id, &serde_json::to_vec(&body).unwrap());
520        let data = std::fs::read_to_string(capture_path(dir.path(), &id)).unwrap();
521        let bundle: ContentCaptureBundle = serde_json::from_str(&data).unwrap();
522        assert_eq!(bundle.body, Some(body));
523    }
524
525    #[test]
526    fn over_budget_body_is_refused_not_truncated() {
527        let dir = tempfile::tempdir().expect("tempdir");
528        let capture = ContentCapture::new(dir.path().to_path_buf());
529        let id = TraceId::new("req-big").unwrap();
530        let big = serde_json::json!({"content": "x".repeat(CAPTURE_MAX_BYTES + 10)});
531        capture.capture(&id, serde_json::to_vec(&big).unwrap().as_slice());
532        let path = capture_path(dir.path(), &id);
533        let bundle: ContentCaptureBundle =
534            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
535        assert!(bundle.over_budget);
536        assert!(bundle.body.is_none());
537    }
538
539    #[test]
540    fn new_capture_sweeps_a_previous_expired_bundle() {
541        let dir = tempfile::tempdir().expect("tempdir");
542        let cap_dir = dir.path().join("cap");
543        agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
544
545        // Capture A: expired long ago (as if left behind by a dead process).
546        let stale_id = TraceId::new("req-stale").unwrap();
547        let stale = ContentCaptureBundle {
548            schema: CONTENT_CAPTURE_SCHEMA.to_string(),
549            request_id: stale_id.clone(),
550            created_unix_ms: 1_000,
551            expires_unix_ms: 2_000,
552            redacted: true,
553            over_budget: false,
554            body: Some(serde_json::json!({"old": true})),
555        };
556        let stale_path = capture_path(&cap_dir, &stale_id);
557        std::fs::write(&stale_path, serde_json::to_vec(&stale).unwrap()).unwrap();
558
559        // Capture B removes A on its way in (opportunistic sweep).
560        let capture = ContentCapture::new(cap_dir.clone());
561        let fresh_id = TraceId::new("req-fresh").unwrap();
562        capture.capture(&fresh_id, b"{\"x\":1}");
563
564        assert!(
565            !stale_path.exists(),
566            "expired capture A must be swept when capture B is written"
567        );
568        assert!(capture_path(&cap_dir, &fresh_id).exists());
569    }
570
571    #[test]
572    fn mark_unsupported_consumes_the_arm_exactly_once_without_artifact() {
573        let dir = tempfile::tempdir().expect("tempdir");
574        let cap_dir = dir.path().join("cap");
575        let capture = ContentCapture::new(cap_dir.clone());
576        assert!(capture.mark_unsupported(), "first consume reports true");
577        assert!(!capture.mark_unsupported(), "second consume is a no-op");
578        // A later capture attempt on the consumed arm writes nothing.
579        let id = TraceId::new("req-after").unwrap();
580        capture.capture(&id, b"{}");
581        assert!(!capture_path(&cap_dir, &id).exists());
582    }
583
584    #[test]
585    fn status_render_mentions_mode_and_no_secrets() {
586        let report = TraceStatusReport {
587            persistent_enabled: false,
588            arm: TraceArm::NextMetadata,
589            trace_path: Some(PathBuf::from("/tmp/request-trace.jsonl")),
590            writer_stats: None,
591            degraded_records: 2,
592        };
593        let text = report.render();
594        assert!(text.contains("armed for next request (metadata only)"));
595        assert!(text.contains("request-trace.jsonl"));
596        assert!(text.contains("2 degraded"));
597    }
598}