Skip to main content

khive_runtime/
presentation.rs

1//! Verb response presentation modes and transformation.
2//!
3//! Transforms canonical handler output into caller-appropriate form after dispatch
4//! and before wire serialization. `Agent` mode abbreviates UUIDs/timestamps and drops
5//! empty fields; `Verbose` and `Human` pass through canonical JSON unchanged.
6//!
7//! This module also contains the `OutputFormat` axis (ADR-078) which governs how
8//! the resulting `serde_json::Value` is serialized or rendered to an output string.
9//! `PresentationMode` and `OutputFormat` compose independently.
10
11use std::collections::HashSet;
12
13use khive_types::VerbPresentationPolicy;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16
17/// Exact note-body locations selected by the request boundary for
18/// `get/list(parse_content=true)`. No policy marker is inferred from user JSON.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum NoteContentScope {
21    #[default]
22    None,
23    Record,
24    Items,
25    Notes,
26}
27
28impl NoteContentScope {
29    /// Keep note bodies outside a metadata-only transform, including nulls,
30    /// empty containers/strings, and fields that resemble IDs or timestamps.
31    /// The transform must preserve record order and the get/list envelope.
32    pub fn protect(self, mut value: Value, transform: impl FnOnce(Value) -> Value) -> Value {
33        if self == Self::None {
34            return transform(value);
35        }
36        fn take_content(record: &mut Value) -> Option<Value> {
37            record.as_object_mut()?.remove("content")
38        }
39        fn restore_content(record: &mut Value, content: Option<Value>) {
40            if let (Some(record), Some(content)) = (record.as_object_mut(), content) {
41                record.insert("content".to_string(), content);
42            }
43        }
44        if self == Self::Record {
45            let content = take_content(&mut value);
46            let mut value = transform(value);
47            restore_content(&mut value, content);
48            return value;
49        }
50        let key = if self == Self::Items {
51            "items"
52        } else {
53            "notes"
54        };
55        let contents: Vec<_> = value
56            .get_mut(key)
57            .and_then(Value::as_array_mut)
58            .into_iter()
59            .flatten()
60            .map(take_content)
61            .collect();
62        let mut value = transform(value);
63        if let Some(records) = value.get_mut(key).and_then(Value::as_array_mut) {
64            for (record, content) in records.iter_mut().zip(contents) {
65                restore_content(record, content);
66            }
67        }
68        value
69    }
70
71    fn stringify_table_content(self, value: &mut Value) {
72        fn stringify(record: &mut Value) {
73            if let Some(content) = record.get_mut("content") {
74                if content.is_object() || content.is_array() {
75                    *content = Value::String(content.to_string());
76                }
77            }
78        }
79        match self {
80            Self::None => {}
81            Self::Record => stringify(value),
82            Self::Items | Self::Notes => {
83                let key = if self == Self::Items {
84                    "items"
85                } else {
86                    "notes"
87                };
88                if let Some(records) = value.get_mut(key).and_then(Value::as_array_mut) {
89                    records.iter_mut().for_each(stringify);
90                }
91            }
92        }
93    }
94}
95
96// ── OutputFormat ─────────────────────────────────────────────────────────────
97
98/// Output serialization format for verb results (ADR-078).
99///
100/// Orthogonal to [`PresentationMode`]: `PresentationMode` controls field-level
101/// transforms (UUID shortening, timestamp compaction, empty-field dropping);
102/// `OutputFormat` controls how the resulting `serde_json::Value` is serialized
103/// or rendered to the wire string.
104///
105/// Default is [`OutputFormat::Json`] on every surface: compact and
106/// machine-walkable. Verbose JSON is lossless; Agent JSON additionally elides
107/// `namespace="local"` and duplicate `properties` entries (ADR-078 Amendment
108/// 3, Machine scope) but keeps `full_id` — it is the caller's chaining
109/// handle, not a redundant field.
110///
111/// Note: `Yaml` is a clean follow-up — implemented as a 3-variant enum
112/// (`Json`, `Auto`, `Table`) per ADR-078 §"yaml" which permits omission when
113/// the in-tree emitter would balloon.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
115#[serde(rename_all = "snake_case")]
116pub enum OutputFormat {
117    /// Compact JSON (`serde_json::to_string`). Default; lossless in Verbose
118    /// presentation, Machine-scope redundancy-reduced in Agent presentation
119    /// (`full_id` retained — see [`RedundancyScope`]).
120    #[default]
121    Json,
122    /// Shape-aware: markdown table for homogeneous record arrays (with
123    /// envelope siblings preserved as trailing `key: value` lines),
124    /// compact-JSON fallback for every other shape.
125    Auto,
126    /// Force the markdown-table renderer regardless of detected shape.
127    /// Ordinary results share Auto's dispatch. Opt-in parsed note bodies are
128    /// stringified for table display; Auto preserves their JSON values.
129    Table,
130}
131
132/// Cell truncation limit for markdown-table rendering (ADR-078 §3a).
133const CELL_TRUNCATE: usize = 120;
134
135/// Scalar payload fields hoisted from `properties` to the top level in the
136/// auto/table pre-pass, when no top-level sibling of that name exists.
137/// View-only (never applied to `json`): without the hoist, a table row for a
138/// scheduled event cannot say when it fires or whether it is pending —
139/// those fields exist only inside the nested `properties` bag.
140const PROPERTY_HOIST_FIELDS: &[&str] = &["trigger_at", "due", "status"];
141
142// ── Public render entry point ────────────────────────────────────────────────
143
144/// Render a successful verb result value to a wire string using the given format.
145///
146/// Called at the single serialization seam (ADR-078 §9) AFTER all `$prev` chain
147/// resolution and AFTER the [`PresentationMode`] transform.
148///
149/// Error envelopes (`ok=false`) are never passed here — the caller must handle
150/// them as compact JSON directly (ADR-078 §8.2).
151///
152/// Agent presentation applies the redundancy-reduction pre-pass (§7) for every
153/// format, including JSON — at Machine scope for `json` (keeps `full_id`) and
154/// View scope for `auto`/`table` (drops it too, ADR-078 Amendment 3). Auto/table
155/// also apply View scope for Human presentation; Verbose remains canonical and
156/// unreduced. The prepared value is then encoded as compact JSON or dispatched
157/// to the shape-aware renderer. Verbose also disables cell truncation in the
158/// table renderer (§3a).
159pub fn render_format(value: Value, format: OutputFormat, presentation: PresentationMode) -> String {
160    render_format_with_note_content(value, format, presentation, NoteContentScope::None)
161}
162
163/// Render an opt-in parsed-note response without treating its body as metadata
164/// or a top-level record table. Auto preserves parsed values; Table stringifies
165/// non-scalar content for display only.
166pub fn render_format_with_note_content(
167    value: Value,
168    format: OutputFormat,
169    presentation: PresentationMode,
170    content_scope: NoteContentScope,
171) -> String {
172    let value = prepare_format_value_with_note_content(value, format, presentation, content_scope);
173    match format {
174        OutputFormat::Json => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
175        OutputFormat::Auto if content_scope != NoteContentScope::None => {
176            serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
177        }
178        OutputFormat::Auto | OutputFormat::Table => {
179            render_auto(value, presentation != PresentationMode::Verbose)
180        }
181    }
182}
183
184/// Prepare a successful result for its requested format at the wire boundary.
185///
186/// JSON remains a JSON value in compounded response envelopes, so the MCP seam
187/// uses this helper directly instead of round-tripping through a serialized
188/// string. Keeping the policy here prevents the single-value and compounded
189/// paths from drifting.
190pub fn prepare_format_value(
191    value: Value,
192    format: OutputFormat,
193    presentation: PresentationMode,
194) -> Value {
195    let scope = match format {
196        OutputFormat::Json if presentation == PresentationMode::Agent => {
197            Some(RedundancyScope::Machine)
198        }
199        OutputFormat::Json => None,
200        OutputFormat::Auto | OutputFormat::Table if presentation != PresentationMode::Verbose => {
201            Some(RedundancyScope::View)
202        }
203        OutputFormat::Auto | OutputFormat::Table => None,
204    };
205    match scope {
206        Some(scope) => apply_redundancy_drop(value, scope),
207        None => value,
208    }
209}
210
211/// Apply format reductions to record metadata while preserving parsed bodies.
212pub fn prepare_format_value_with_note_content(
213    value: Value,
214    format: OutputFormat,
215    presentation: PresentationMode,
216    content_scope: NoteContentScope,
217) -> Value {
218    let mut value = content_scope.protect(value, |value| {
219        prepare_format_value(value, format, presentation)
220    });
221    if format == OutputFormat::Table {
222        content_scope.stringify_table_content(&mut value);
223    }
224    value
225}
226
227// ── Redundancy-reduction pre-pass (ADR-078 §7, Amendment 3) ────────────────
228
229/// Which redundancy-reduction rules apply (ADR-078 §7, Amendment 3).
230///
231/// `View` is the rendered-view scope (`auto`/`table`): the caller has opted
232/// into a display form that is never chained on programmatically, so
233/// `full_id` — the caller's stable chaining handle — is safe to drop along
234/// with the other reductions.
235///
236/// `Machine` is the machine-contract scope (Agent `json`): this is the
237/// default MCP response shape, the one a caller chains `$prev` and strict
238/// verb parameters (`memory.feedback(target_id=...)`, `gtd.assign
239/// (context_entity_id=...)`, etc.) from. It applies the `namespace` elision
240/// and the `properties` dedup, but never drops `full_id`, and the
241/// `properties` dedup itself exempts `full_id`/`ROUND_TRIP_FULL_UUID_FIELDS`
242/// keys — a caller reading a strict identifier out of `properties` must see
243/// the same canonical value there as at the top level. Dropping any of
244/// those would return a value the strict verbs that consume it reject.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum RedundancyScope {
247    View,
248    Machine,
249}
250
251/// Apply the redundancy-reduction pre-pass (ADR-078 §7, Amendment 3) to a
252/// value at the given [`RedundancyScope`].
253///
254/// Applies at most ONE pass over the value. This function is the canonical
255/// entry for the pre-pass; the per-record logic lives in `drop_record`.
256///
257/// Applied for Agent presentation in every format (`Machine` scope for
258/// `json`, `View` scope for non-Verbose `auto`/`table`). Callers are
259/// responsible for checking those conditions; this function applies
260/// unconditionally.
261pub fn apply_redundancy_drop(value: Value, scope: RedundancyScope) -> Value {
262    match value {
263        Value::Object(_) => drop_record(value, scope),
264        Value::Array(arr) => Value::Array(
265            arr.into_iter()
266                .map(|v| {
267                    if v.is_object() {
268                        drop_record(v, scope)
269                    } else {
270                        v
271                    }
272                })
273                .collect(),
274        ),
275        other => other,
276    }
277}
278
279/// Apply per-record redundancy rules (§7.1, §7.2, §7.3) to a single record object.
280fn drop_record(value: Value, scope: RedundancyScope) -> Value {
281    let Value::Object(mut map) = value else {
282        return value;
283    };
284
285    // `full_id` is a chaining handle. Dropping it is safe only in the View
286    // scope (a rendered auto/table view the caller opted into); the Machine
287    // scope (Agent JSON, the default machine contract) must keep it.
288    if scope == RedundancyScope::View {
289        map.remove("full_id");
290    }
291
292    // `"local"` is the common case, so only surface `namespace` when it isn't.
293    if map.get("namespace").and_then(Value::as_str) == Some("local") {
294        map.remove("namespace");
295    }
296
297    // Properties dedup: drop key-value pairs from `properties` that
298    // duplicate an identical top-level sibling. The scalar hoist for table
299    // columns lives in `hoist_table_scalars`, applied only on the table
300    // path: reshaping the compact-JSON fallback would move fields with no
301    // column to gain.
302    //
303    // Machine scope exempts `full_id`/`ROUND_TRIP_FULL_UUID_FIELDS`: a caller
304    // that reads the strict identifier out of `properties` (rather than the
305    // top-level convenience mirror) must keep getting the same canonical
306    // value there too. View scope is unaffected — those callers already
307    // opted into a display form, not the strict round-trip contract.
308    let props_val = map.remove("properties");
309    if let Some(Value::Object(props)) = props_val {
310        let mut new_props = Map::new();
311        for (k, v) in props {
312            let strict_round_trip = scope == RedundancyScope::Machine
313                && (k == "full_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&k.as_str()));
314            if !strict_round_trip && map.get(&k) == Some(&v) {
315                continue;
316            }
317            new_props.insert(k, v);
318        }
319        if !new_props.is_empty() {
320            map.insert("properties".to_string(), Value::Object(new_props));
321        }
322    } else if let Some(other) = props_val {
323        map.insert("properties".to_string(), other);
324    }
325
326    // A stream entry's record is opaque caller JSON, including arrays of
327    // objects whose fields happen to look like metadata.
328    let stream_entry = is_stream_entry(&map);
329    // Recurse into array values so nested record arrays are also reduced.
330    let out: Map<String, Value> = map
331        .into_iter()
332        .map(|(k, v)| {
333            if stream_entry && k == "record" {
334                return (k, v);
335            }
336            let v = match v {
337                Value::Array(arr) => Value::Array(
338                    arr.into_iter()
339                        .map(|item| {
340                            if item.is_object() {
341                                drop_record(item, scope)
342                            } else {
343                                item
344                            }
345                        })
346                        .collect(),
347                ),
348                other => other,
349            };
350            (k, v)
351        })
352        .collect();
353    Value::Object(out)
354}
355
356// ── Shape-aware rendering (`auto`) ──────────────────────────────────────────
357
358/// A record array located inside a value: the records, their ordered column
359/// keys, and — when the array sat under an object key — that key's name, so
360/// the caller can enumerate the remaining (sibling) keys.
361struct RecordArray {
362    key: Option<String>,
363    records: Vec<Value>,
364    columns: Vec<String>,
365}
366
367/// Render a value using shape-aware dispatch (ADR-078 §3, as amended).
368///
369/// Shape (a): homogeneous record array → markdown table, with any envelope
370/// siblings preserved as trailing `key: value` lines.
371/// Every other shape → compact JSON, lossless by construction. The former
372/// kv-block renderer for single records is removed: its truncation destroyed
373/// single-record payloads (e.g. a compose briefing's `markdown` field).
374fn render_auto(value: Value, truncate: bool) -> String {
375    match locate_record_array(&value) {
376        Some(found) => render_table_with_siblings(&value, &found, truncate),
377        None => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
378    }
379}
380
381/// Find the first homogeneous record array in `value`.
382///
383/// Checks:
384/// 1. `value` itself is an array of 2+ objects.
385/// 2. `value` is an object with a key whose value is an array of 2+ objects.
386fn locate_record_array(value: &Value) -> Option<RecordArray> {
387    let build = |key: Option<String>, arr: &[Value]| {
388        let records: Vec<Value> = arr.iter().cloned().map(hoist_table_scalars).collect();
389        let columns = collect_keys(&records);
390        RecordArray {
391            key,
392            records,
393            columns,
394        }
395    };
396    match value {
397        Value::Array(arr) if is_record_array(arr) => Some(build(None, arr)),
398        Value::Object(map) => map.iter().find_map(|(k, v)| match v {
399            Value::Array(arr) if is_record_array(arr) => Some(build(Some(k.clone()), arr)),
400            _ => None,
401        }),
402        _ => None,
403    }
404}
405
406/// Hoist `PROPERTY_HOIST_FIELDS` scalars out of a record's `properties` bag
407/// to the top level (never overwriting a top-level sibling), so the table
408/// renderer can surface them as columns — a scheduled event's
409/// `trigger_at`/`status` live only inside `properties`, and a nested cell
410/// renders as `{…}`. Table path only (ADR-078 Amendment 2): the compact-JSON
411/// fallback keeps its shape.
412fn hoist_table_scalars(record: Value) -> Value {
413    let Value::Object(mut map) = record else {
414        return record;
415    };
416    let mut props = match map.remove("properties") {
417        Some(Value::Object(props)) => props,
418        Some(other) => {
419            // Non-object `properties` is not a bag to hoist from — restore it.
420            map.insert("properties".to_string(), other);
421            return Value::Object(map);
422        }
423        None => return Value::Object(map),
424    };
425    for field in PROPERTY_HOIST_FIELDS {
426        if map.contains_key(*field) {
427            continue;
428        }
429        if props
430            .get(*field)
431            .is_some_and(|v| !v.is_object() && !v.is_array())
432        {
433            let v = props.remove(*field).expect("checked above");
434            map.insert((*field).to_string(), v);
435        }
436    }
437    if !props.is_empty() {
438        map.insert("properties".to_string(), Value::Object(props));
439    }
440    Value::Object(map)
441}
442
443/// An array of 2+ objects qualifies as a record array.
444fn is_record_array(arr: &[Value]) -> bool {
445    arr.len() >= 2 && arr.iter().all(Value::is_object)
446}
447
448/// Render the located record array as a table, then append one `key: value`
449/// line per remaining top-level key. Envelope fields (`has_more`, `offset`,
450/// counts) must survive rendering: dropping them made a truncated `query`
451/// page read as complete.
452fn render_table_with_siblings(value: &Value, found: &RecordArray, truncate: bool) -> String {
453    let mut out = render_table(&found.records, &found.columns, truncate);
454    let (Value::Object(map), Some(array_key)) = (value, &found.key) else {
455        return out;
456    };
457    for (k, v) in map {
458        if k == array_key {
459            continue;
460        }
461        let text = match v {
462            // A newline-bearing string renders as its JSON literal (one line,
463            // lossless) so a stored value cannot fabricate a sibling line or
464            // table row (ADR-078 Amendment 2 escaping contract).
465            Value::String(s) if !s.contains(['\n', '\r']) => s.clone(),
466            // Non-string scalars and nested values: compact JSON, untruncated
467            // — sibling fidelity is the point of this path.
468            other => serde_json::to_string(other).unwrap_or_default(),
469        };
470        out.push_str(&format!("{k}: {text}\n"));
471    }
472    out
473}
474
475/// Collect column names in first-seen order across all records.
476fn collect_keys(records: &[Value]) -> Vec<String> {
477    let mut seen = HashSet::new();
478    let mut keys = Vec::new();
479    for record in records {
480        if let Value::Object(map) = record {
481            for k in map.keys() {
482                if seen.insert(k.clone()) {
483                    keys.push(k.clone());
484                }
485            }
486        }
487    }
488    keys
489}
490
491// ── Markdown table renderer (ADR-078 §3a) ───────────────────────────────────
492
493/// Render a record array as a GitHub-Flavored Markdown table.
494fn render_table(records: &[Value], keys: &[String], truncate: bool) -> String {
495    let mut out = String::new();
496
497    out.push('|');
498    for k in keys {
499        out.push(' ');
500        out.push_str(k);
501        out.push_str(" |");
502    }
503    out.push('\n');
504
505    out.push('|');
506    for _ in keys {
507        out.push_str("---|");
508    }
509    out.push('\n');
510
511    for record in records {
512        out.push('|');
513        for k in keys {
514            let cell = record.get(k).unwrap_or(&Value::Null);
515            let text = cell_text(k, cell, truncate);
516            out.push(' ');
517            out.push_str(&text);
518            out.push_str(" |");
519        }
520        out.push('\n');
521    }
522
523    out
524}
525
526/// Column names exempt from cell truncation: identity and decision fields
527/// must arrive whole — a truncated id cannot be resolved and a truncated
528/// title cannot be selected on.
529fn truncation_exempt(key: &str) -> bool {
530    matches!(
531        key,
532        "id" | "kind"
533            | "status"
534            | "priority"
535            | "relation"
536            | "title"
537            | "name"
538            | "signature"
539            | "slug"
540            | "assignee"
541            | "from"
542            | "to"
543            | "due"
544    ) || key.ends_with("_id")
545        || key.ends_with("_at")
546        || key.starts_with("due")
547}
548
549/// Format a cell value: escape `|`, collapse newlines, truncate to ~120 chars
550/// unless `truncate` is off or the column is identity/decision-bearing.
551///
552/// Nested values are elided to a constant marker (`{…}` / `[…]`) rather than
553/// stringified and cut mid-JSON: truncated pseudo-JSON reads as data while
554/// silently missing fields. Full content is one `format=json` or `get` away.
555fn cell_text(key: &str, value: &Value, truncate: bool) -> String {
556    let raw = match value {
557        Value::Null => String::new(),
558        Value::Bool(b) => b.to_string(),
559        Value::Number(n) => n.to_string(),
560        Value::String(s) => s.clone(),
561        Value::Object(_) => return "{…}".to_string(),
562        Value::Array(arr) => {
563            if arr.iter().any(|v| v.is_object() || v.is_array()) {
564                return "[…]".to_string();
565            }
566            // Arrays of scalars (tags, ids) stay compact JSON.
567            serde_json::to_string(value).unwrap_or_default()
568        }
569    };
570
571    // Escape literal `|` and collapse embedded newlines to a space.
572    let escaped = raw.replace('|', "\\|").replace(['\n', '\r'], " ");
573
574    if !truncate || truncation_exempt(key) {
575        return escaped;
576    }
577
578    // Truncate to approximately CELL_TRUNCATE *characters* (char boundary,
579    // not byte index — slicing on a byte offset can panic on multi-byte chars).
580    let char_count = escaped.chars().count();
581    if char_count > CELL_TRUNCATE {
582        let truncated: String = escaped.chars().take(CELL_TRUNCATE).collect();
583        format!("{truncated}...")
584    } else {
585        escaped
586    }
587}
588
589/// Convert a microsecond epoch `i64` to an RFC 3339 / ISO-8601 string.
590///
591/// Entity and Note storage uses `i64` microseconds internally; this is the
592/// single conversion point before any field reaches the MCP boundary.
593///
594/// Format: `YYYY-MM-DDTHH:MM:SS.ffffffZ` (SecondsFormat::Micros, UTC `Z`).
595pub fn micros_to_iso(micros: i64) -> String {
596    chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros)
597        .unwrap_or_else(chrono::Utc::now)
598        .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
599}
600
601/// Parse an RFC 3339 timestamp (offset required) into microsecond epoch
602/// `i64` — the inverse of [`micros_to_iso`], and the single parse point for
603/// caller-supplied instants entering storage comparisons.
604///
605/// Leading/trailing whitespace is tolerated. Date-only and offset-less forms
606/// are rejected; callers own the verb-specific error context around the
607/// returned `ParseError`.
608pub fn rfc3339_to_utc_micros(raw: &str) -> Result<i64, chrono::ParseError> {
609    chrono::DateTime::parse_from_rfc3339(raw.trim()).map(|dt| dt.timestamp_micros())
610}
611
612/// How the response envelope is presented to the caller.
613#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
614#[serde(rename_all = "snake_case")]
615pub enum PresentationMode {
616    /// Token-efficient. Default for MCP callers (agents).
617    ///
618    /// Short UUIDs (8-char), compact timestamps (minute granularity or
619    /// relative), empty fields dropped, structural nulls preserved, score
620    /// fields truncated to 3 significant figures.
621    #[default]
622    Agent,
623    /// Full canonical shape. Default for `kkernel exec` and CI/scripted callers.
624    ///
625    /// No transformation — handler output passes through as-is.
626    Verbose,
627    /// Pretty-printed terminal output. Default for `khive` CLI.
628    ///
629    /// **At the MCP runtime level this is identical to `Verbose`** — the
630    /// canonical JSON is returned unchanged. Terminal formatting (relative
631    /// timestamps, glyph substitution, table layout) is applied by the CLI
632    /// layer (`khive-cli::format::pretty`), not the MCP response pipeline.
633    Human,
634}
635
636/// Lifecycle/operational `null` fields that are PRESERVED in Agent mode.
637///
638/// These fields carry state meaning (absent ≠ known-unknown) and must not be
639/// dropped. The channel-health fields distinguish a quarantine-only identity
640/// from a heartbeat row whose liveness facts were actually observed.
641const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
642    "completed_at",
643    "deleted_at",
644    "due_at",
645    "read_at",
646    "started_at",
647    "superseded_at",
648    "applied_at",
649    "withdrawn_at",
650    "reviewed_at",
651    "parent_id",
652    "superseded_by",
653    "replaced_by",
654    "poll_interval_secs",
655    "stalled",
656    "last_success_at",
657    "last_poll_attempt_at",
658    "last_failure_at",
659    "last_error",
660    "consecutive_failures",
661];
662
663/// Empty collection fields that define a stable response envelope and must
664/// survive Agent-mode compaction. Dropping these turns an empty page into a
665/// different response type and leaves callers unable to distinguish an empty
666/// result from a missing/unsupported field.
667const EMPTY_ARRAY_PRESERVE: &[&str] = &[
668    "items",
669    "entities",
670    "notes",
671    "edges",
672    "results",
673    "entries",
674    "neighbors",
675];
676
677fn is_stable_list_envelope(map: &Map<String, Value>) -> bool {
678    map.contains_key("requested_limit")
679        && map.contains_key("effective_limit")
680        && map.contains_key("limit_clamped")
681        && EMPTY_ARRAY_PRESERVE
682            .iter()
683            .any(|field| map.contains_key(*field))
684}
685
686/// Keyset cursor pages outside the ADR-023 envelope — `knowledge.list(after=…)`
687/// returns `{results, limit, order, next_after}` — are structural in the same
688/// way (ADR-045 Amendment 4): an empty `results` page and a terminal
689/// `next_after: null` are the walk's completion signals, so a caller that
690/// cannot see them cannot stop. A `results` array without a sibling
691/// `next_after` key is an ordinary response and gets the generic transform.
692fn is_keyset_cursor_envelope(map: &Map<String, Value>) -> bool {
693    map.contains_key("next_after")
694        && (map.get("results").is_some_and(Value::is_array)
695            || (map.get("head_seq").is_some_and(Value::is_number)
696                && map.get("entries").is_some_and(Value::is_array)))
697}
698
699fn is_stream_entry(map: &Map<String, Value>) -> bool {
700    map.get("seq").is_some_and(Value::is_number)
701        && map.contains_key("id")
702        && map.contains_key("created_at")
703        && map.contains_key("record")
704}
705
706/// Field names carrying caller-supplied payload timestamps that must never be
707/// compacted (relative-time or minute-truncated), regardless of nesting.
708///
709/// These encode domain semantics the caller needs to round-trip verbatim —
710/// e.g. `trigger_at` on a `schedule.remind`/`schedule.schedule` create
711/// response, returned as a top-level convenience field alongside `id` and
712/// `full_id`, not nested under `"properties"`. The `inside_properties` guard
713/// alone only protects fields nested under a literal `"properties"` key
714/// (as returned by `agenda`/`get`); it does not cover this top-level case,
715/// which `compact_timestamp` rewrote into either a relative string or a
716/// minute-truncated absolute form — either way discarding the seconds and
717/// offset the caller needs to round-trip the exact submitted value (#871).
718///
719/// `due` on `gtd.assign`/`gtd.tasks`/`gtd.next` responses is the same shape:
720/// a top-level convenience field mirroring `properties.due`, which
721/// `parse_due` already normalizes to full RFC 3339.
722const PAYLOAD_TIMESTAMP_FIELDS: &[&str] = &["trigger_at", "due"];
723
724/// UUID fields whose canonical value is itself a strict-verb input.
725///
726/// Shortening these would make a successful response fail when submitted back
727/// to the verb that produced or consumes it. `context_entity_id` and
728/// `thread_id` are explicit stable references rather than prefix searches;
729/// `outbound_ref` is the exact correlation key consumed by `comm.delivered`;
730/// `parent_id` is the explicit ancestry reference consumed by `propose`;
731/// `session_id` is an exact event-list filter; and `project_id` is the exact
732/// provenance anchor required by git issue and pull-request creation.
733const ROUND_TRIP_FULL_UUID_FIELDS: &[&str] = &[
734    "context_entity_id",
735    "thread_id",
736    "outbound_ref",
737    "parent_id",
738    "session_id",
739    "project_id",
740];
741
742/// Score field names that are truncated to 3 significant figures in Agent mode.
743const SCORE_FIELDS: &[&str] = &[
744    "score",
745    "rank_score",
746    "vector_similarity",
747    "keyword_score",
748    "salience",
749    "decay_factor",
750    "rrf_score",
751    "similarity",
752    "cross_encoder_score",
753    "graph_proximity_score",
754];
755
756/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
757const UUID_CANONICAL_LEN: usize = 36;
758
759/// Return true for fields whose whole-string UUID values may be shortened in
760/// Agent mode. Content-like fields are intentionally excluded even when their
761/// value happens to be UUID-shaped.
762///
763/// `full_id` and strict round-trip fields are explicitly excluded: their
764/// purpose is to give callers a stable chaining handle, so shortening them
765/// would produce a value that the corresponding strict verb rejects.
766fn should_shorten_uuid_field(key: &str) -> bool {
767    if key == "full_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&key) {
768        return false;
769    }
770    key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
771}
772
773/// Transform a successful verb result value according to the given
774/// [`PresentationMode`].
775///
776/// - `Verbose` / `Human`: returns `value` unchanged.
777/// - `Agent`: applies UUID shortening, timestamp compaction, empty-field
778///   dropping, structural-null preservation, and score truncation.
779///
780/// `now_unix_seconds` is sampled once per response and passed through so all
781/// relative datetime renderings within a response use the same instant.
782pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
783    present_with_policy(
784        value,
785        mode,
786        now_unix_seconds,
787        VerbPresentationPolicy::Standard,
788    )
789}
790
791/// Present a successful result using its trusted registered verb policy.
792/// Receipt policies preserve only their closed string paths, not descendants.
793pub fn present_with_policy(
794    value: Value,
795    mode: PresentationMode,
796    now_unix_seconds: i64,
797    policy: VerbPresentationPolicy,
798) -> Value {
799    if policy == VerbPresentationPolicy::AlwaysVerbose {
800        return value;
801    }
802    match mode {
803        PresentationMode::Verbose | PresentationMode::Human => value,
804        PresentationMode::Agent => {
805            let preserved_nulls: HashSet<&str> = LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
806            let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
807            let payload_timestamps: HashSet<&str> =
808                PAYLOAD_TIMESTAMP_FIELDS.iter().copied().collect();
809            transform_agent(
810                value,
811                &preserved_nulls,
812                &score_fields,
813                &payload_timestamps,
814                now_unix_seconds,
815                false,
816                match policy {
817                    VerbPresentationPolicy::StreamAppendReceipt => ReceiptContext::AppendRoot,
818                    VerbPresentationPolicy::StreamBatchReceipts => ReceiptContext::BatchRoot,
819                    _ => ReceiptContext::None,
820                },
821            )
822        }
823    }
824}
825
826#[derive(Clone, Copy, PartialEq, Eq)]
827enum ReceiptContext {
828    None,
829    AppendRoot,
830    BatchRoot,
831    BatchResults,
832    BatchMember,
833}
834
835/// Apply the Agent-mode transform to an arbitrary JSON value.
836///
837/// `inside_properties` is `true` when recursing inside a `"properties"` object.
838/// Caller-supplied payload timestamps (e.g. `trigger_at`) must not be compacted
839/// because they encode domain semantics the agent may need to round-trip.
840fn transform_agent(
841    value: Value,
842    preserved_nulls: &HashSet<&str>,
843    scores: &HashSet<&str>,
844    payload_timestamps: &HashSet<&str>,
845    now: i64,
846    inside_properties: bool,
847    receipt_context: ReceiptContext,
848) -> Value {
849    match value {
850        Value::Object(map) => {
851            let preserve_list_envelope =
852                is_stable_list_envelope(&map) || is_keyset_cursor_envelope(&map);
853            let stream_entry = is_stream_entry(&map);
854            let mut out = Map::new();
855            for (k, v) in map {
856                if stream_entry && k == "record" {
857                    out.insert(k, v);
858                    continue;
859                }
860                if v.is_string()
861                    && ((receipt_context == ReceiptContext::AppendRoot && k == "created_at")
862                        || (receipt_context == ReceiptContext::BatchMember
863                            && matches!(k.as_str(), "created_at" | "updated_at")))
864                {
865                    out.insert(k, v);
866                    continue;
867                }
868                let child_receipt_context = if receipt_context == ReceiptContext::BatchRoot
869                    && k == "results"
870                    && v.is_array()
871                {
872                    ReceiptContext::BatchResults
873                } else {
874                    ReceiptContext::None
875                };
876                // ADR-045 Amendment 3 scopes the empty-string carve-out to
877                // strings nested under an object-valued `properties`; a
878                // scalar or array `properties` value gets no carve-out.
879                let child_inside_properties =
880                    inside_properties || (k == "properties" && v.is_object());
881                let transformed = transform_field_agent(
882                    &k,
883                    v,
884                    preserved_nulls,
885                    scores,
886                    payload_timestamps,
887                    now,
888                    AgentFieldContext {
889                        inside_properties: child_inside_properties,
890                        preserve_list_envelope,
891                        receipt_context: child_receipt_context,
892                    },
893                );
894                match transformed {
895                    None => {} // drop
896                    Some(tv) => {
897                        out.insert(k, tv);
898                    }
899                }
900            }
901            Value::Object(out)
902        }
903        Value::Array(arr) => {
904            let items: Vec<Value> = arr
905                .into_iter()
906                .map(|v| {
907                    transform_agent(
908                        v,
909                        preserved_nulls,
910                        scores,
911                        payload_timestamps,
912                        now,
913                        inside_properties,
914                        if receipt_context == ReceiptContext::BatchResults {
915                            ReceiptContext::BatchMember
916                        } else {
917                            ReceiptContext::None
918                        },
919                    )
920                })
921                .collect();
922            Value::Array(items)
923        }
924        other => other,
925    }
926}
927
928/// Transform a single named field value under Agent mode.
929///
930/// Returns `None` if the field should be dropped.
931///
932/// `inside_properties` suppresses timestamp compaction for caller-submitted
933/// payload values nested under a literal `"properties"` key (e.g. `trigger_at`
934/// as returned by `agenda`/`get`). `payload_timestamps` suppresses compaction
935/// by field name regardless of nesting, covering top-level convenience fields
936/// such as the `trigger_at` returned directly in a `schedule.remind`/
937/// `schedule.schedule` create response (#871). Metadata timestamps at the top
938/// level (`created_at`, `updated_at`) are still compacted.
939#[derive(Clone, Copy)]
940struct AgentFieldContext {
941    inside_properties: bool,
942    preserve_list_envelope: bool,
943    receipt_context: ReceiptContext,
944}
945
946fn transform_field_agent(
947    key: &str,
948    value: Value,
949    preserved_nulls: &HashSet<&str>,
950    scores: &HashSet<&str>,
951    payload_timestamps: &HashSet<&str>,
952    now: i64,
953    context: AgentFieldContext,
954) -> Option<Value> {
955    match &value {
956        // Preserve lifecycle and stable-envelope nulls; drop other nulls.
957        Value::Null => {
958            if preserved_nulls.contains(key)
959                || (context.preserve_list_envelope && key == "next_after")
960            {
961                Some(value)
962            } else {
963                None
964            }
965        }
966        // Stable page-envelope arrays remain present even when empty.
967        Value::Array(a)
968            if context.preserve_list_envelope
969                && a.is_empty()
970                && EMPTY_ARRAY_PRESERVE.contains(&key) =>
971        {
972            Some(value)
973        }
974        // Preserve empty strings under a record's `properties` object: those
975        // keys exist only because a caller wrote them, so `""` there is data
976        // (set-to-empty vs absent, ADR-045 Amendment 3, issue #1995). Empty
977        // arrays and objects under `properties` are still dropped per the
978        // amendment's scope note.
979        Value::String(s) if s.is_empty() && context.inside_properties => Some(value),
980        // Drop other empty strings, arrays, objects.
981        Value::String(s) if s.is_empty() => None,
982        Value::Array(a) if a.is_empty() => None,
983        Value::Object(o) if o.is_empty() => None,
984        // Truncate score fields.
985        Value::Number(_) if scores.contains(key) => {
986            if let Some(f) = value.as_f64() {
987                Some(truncate_to_3_sig_figs(f))
988            } else {
989                Some(value)
990            }
991        }
992        // Shorten UUIDs only in fields whose names carry ID semantics.
993        Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
994            Some(Value::String(s[..8].to_string()))
995        }
996        // Compact ISO-8601 timestamps unless inside a caller-supplied payload
997        // object, or the field is a named payload timestamp at any nesting.
998        Value::String(s)
999            if !context.inside_properties
1000                && !payload_timestamps.contains(key)
1001                && looks_like_iso8601(s) =>
1002        {
1003            Some(Value::String(compact_timestamp(s, now)))
1004        }
1005        // Recurse into objects and arrays.
1006        Value::Object(_) | Value::Array(_) => Some(transform_agent(
1007            value,
1008            preserved_nulls,
1009            scores,
1010            payload_timestamps,
1011            now,
1012            context.inside_properties,
1013            context.receipt_context,
1014        )),
1015        // Everything else passes through.
1016        _ => Some(value),
1017    }
1018}
1019
1020/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
1021fn is_canonical_uuid(s: &str) -> bool {
1022    if s.len() != UUID_CANONICAL_LEN {
1023        return false;
1024    }
1025    let b = s.as_bytes();
1026    // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
1027    b[8] == b'-'
1028        && b[13] == b'-'
1029        && b[18] == b'-'
1030        && b[23] == b'-'
1031        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
1032        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
1033        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
1034        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
1035        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
1036}
1037
1038/// Returns `true` if `s` looks like an ISO-8601 datetime string.
1039///
1040/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
1041fn looks_like_iso8601(s: &str) -> bool {
1042    if s.len() < 16 {
1043        return false;
1044    }
1045    let b = s.as_bytes();
1046    b[4] == b'-'
1047        && b[7] == b'-'
1048        && b[10] == b'T'
1049        && b[13] == b':'
1050        && b[..4].iter().all(|c| c.is_ascii_digit())
1051        && b[5..7].iter().all(|c| c.is_ascii_digit())
1052        && b[8..10].iter().all(|c| c.is_ascii_digit())
1053        && b[11..13].iter().all(|c| c.is_ascii_digit())
1054}
1055
1056/// Compact an ISO-8601 timestamp for Agent mode.
1057///
1058/// - Within the last 24 hours: relative form (e.g. `"3m ago"`, `"2h ago"`).
1059/// - Older: minute-granularity absolute form `"YYYY-MM-DDTHH:MM"`.
1060fn compact_timestamp(s: &str, now: i64) -> String {
1061    // Parse Unix seconds from the timestamp if possible; fall back to truncation.
1062    if let Some(unix) = parse_iso8601_unix(s) {
1063        let diff = now - unix;
1064        if (0..86400).contains(&diff) {
1065            return relative_time(diff);
1066        }
1067    }
1068    // Minute granularity: take the first 16 chars.
1069    s.chars().take(16).collect()
1070}
1071
1072/// Attempt to parse an ISO-8601 datetime string to Unix seconds.
1073///
1074/// Only handles the subset produced by khive handlers:
1075/// `YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM|±HHMM]`. Returns `None` for anything
1076/// we can't parse (graceful degradation — the timestamp is still compacted
1077/// by truncation).
1078fn parse_iso8601_unix(s: &str) -> Option<i64> {
1079    // Minimum parseable: "YYYY-MM-DDTHH:MM:SS"
1080    if s.len() < 19 {
1081        return None;
1082    }
1083    let b = s.as_bytes();
1084    let year: i64 = parse_digits(&b[0..4])?;
1085    let month: i64 = parse_digits(&b[5..7])?;
1086    let day: i64 = parse_digits(&b[8..10])?;
1087    let hour: i64 = parse_digits(&b[11..13])?;
1088    let minute: i64 = parse_digits(&b[14..16])?;
1089    let second: i64 = parse_digits(&b[17..19])?;
1090
1091    // Simple Gregorian → local-wall-clock Unix seconds, then adjust for any
1092    // trailing timezone offset (see `parse_tz_offset_secs`) to get the
1093    // actual UTC instant.
1094    let days_since_epoch = days_from_civil(year, month, day);
1095    let local = days_since_epoch * 86400 + hour * 3600 + minute * 60 + second;
1096    let offset_secs = parse_tz_offset_secs(&s[19..])?;
1097    Some(local - offset_secs)
1098}
1099
1100/// Parse the tail of an ISO-8601 timestamp (everything from byte index 19
1101/// onward, i.e. after the whole-seconds field) into a UTC offset in seconds.
1102///
1103/// Handles, in order: optional fractional seconds (`.nnn`, skipped — this
1104/// parser only has whole-second precision), then one of:
1105/// - empty string or `"Z"` → offset 0
1106/// - `±HH:MM` or the compact `±HHMM` form → `sign * (hh*3600 + mm*60)`
1107///
1108/// Returns `None` for anything else (malformed tail).
1109fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
1110    let mut rest = tail;
1111    if let Some(after_dot) = rest.strip_prefix('.') {
1112        let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1113        if frac_len == 0 {
1114            return None;
1115        }
1116        rest = &after_dot[frac_len..];
1117    }
1118
1119    if rest.is_empty() || rest == "Z" {
1120        return Some(0);
1121    }
1122
1123    let sign: i64 = match rest.as_bytes().first()? {
1124        b'+' => 1,
1125        b'-' => -1,
1126        _ => return None,
1127    };
1128    let digits = &rest[1..];
1129    let (hh, mm) = match digits.len() {
1130        // "HH:MM"
1131        5 if digits.as_bytes()[2] == b':' => (
1132            parse_digits(&digits.as_bytes()[0..2])?,
1133            parse_digits(&digits.as_bytes()[3..5])?,
1134        ),
1135        // "HHMM"
1136        4 => (
1137            parse_digits(&digits.as_bytes()[0..2])?,
1138            parse_digits(&digits.as_bytes()[2..4])?,
1139        ),
1140        _ => return None,
1141    };
1142    if hh > 23 || mm > 59 {
1143        return None;
1144    }
1145    Some(sign * (hh * 3600 + mm * 60))
1146}
1147
1148fn parse_digits(b: &[u8]) -> Option<i64> {
1149    let s = std::str::from_utf8(b).ok()?;
1150    s.parse().ok()
1151}
1152
1153/// Gregorian date → days since 1970-01-01. Algorithm: Howard Hinnant's civil.
1154fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
1155    let y = if m <= 2 { y - 1 } else { y };
1156    let era = y.div_euclid(400);
1157    let yoe = y - era * 400;
1158    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
1159    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1160    era * 146097 + doe - 719468
1161}
1162
1163/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
1164fn relative_time(diff_secs: i64) -> String {
1165    if diff_secs < 60 {
1166        format!("{diff_secs}s ago")
1167    } else if diff_secs < 3600 {
1168        format!("{}m ago", diff_secs / 60)
1169    } else {
1170        format!("{}h ago", diff_secs / 3600)
1171    }
1172}
1173
1174/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
1175fn truncate_to_3_sig_figs(f: f64) -> Value {
1176    if f == 0.0 || !f.is_finite() {
1177        return Value::from(f);
1178    }
1179    let magnitude = f.abs().log10().floor() as i32;
1180    let factor = 10f64.powi(2 - magnitude);
1181    let rounded = (f * factor).round() / factor;
1182    // Re-serialize through serde_json to avoid floating-point noise.
1183    serde_json::Number::from_f64(rounded)
1184        .map(Value::Number)
1185        .unwrap_or(Value::from(rounded))
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191    use serde_json::json;
1192
1193    /// A fixed "now" for deterministic tests: 2026-05-23T16:18:00Z ≈ 1748016480.
1194    const NOW: i64 = 1_748_016_480;
1195
1196    #[test]
1197    fn rfc3339_to_utc_micros_round_trips_and_rejects_partial_forms() {
1198        let micros = 1_748_016_480_000_000_i64;
1199        assert_eq!(rfc3339_to_utc_micros(&micros_to_iso(micros)), Ok(micros));
1200        // Offset spellings resolve to the same instant; whitespace tolerated.
1201        // (NOW's epoch value is 2025-05-23T16:08:00Z despite its comment.)
1202        assert_eq!(
1203            rfc3339_to_utc_micros(" 2025-05-23T12:08:00-04:00 "),
1204            Ok(micros)
1205        );
1206        assert!(rfc3339_to_utc_micros("2026-05-23").is_err());
1207        assert!(rfc3339_to_utc_micros("2026-05-23T16:18:00").is_err());
1208    }
1209
1210    fn agent(v: Value) -> Value {
1211        present(v, PresentationMode::Agent, NOW)
1212    }
1213
1214    #[test]
1215    fn agent_preserves_empty_string_under_properties() {
1216        // ADR-045 Amendment 3 (issue #1995): a key under `properties` exists
1217        // only because a caller wrote it, so `""` there distinguishes
1218        // set-to-empty from absent and must survive Agent mode — at any
1219        // nesting depth under `properties`.
1220        let v = json!({
1221            "id": "a1b2c3d4",
1222            "summary": "",
1223            "properties": {"k": "", "nested": {"deep": ""}},
1224        });
1225        let out = agent(v);
1226        assert_eq!(out["properties"]["k"], json!(""));
1227        assert_eq!(out["properties"]["nested"]["deep"], json!(""));
1228        // Control: outside `properties` the empty-string drop still applies.
1229        assert!(out.get("summary").is_none());
1230    }
1231
1232    #[test]
1233    fn agent_drops_scalar_empty_properties_value() {
1234        // The carve-out applies only to strings nested under an object-valued
1235        // `properties`; a scalar `properties: ""` is the field's own value,
1236        // not a caller-written key under it, and drops like any empty string.
1237        let v = json!({
1238            "id": "a1b2c3d4",
1239            "properties": "",
1240        });
1241        let out = agent(v);
1242        assert!(out.get("properties").is_none());
1243    }
1244
1245    #[test]
1246    fn agent_still_drops_empty_containers_under_properties() {
1247        // The amendment's scope note keeps the container drop: empty arrays
1248        // and objects under `properties` are not carved out.
1249        let v = json!({
1250            "id": "a1b2c3d4",
1251            "properties": {"tags": [], "meta": {}, "kept": "x"},
1252        });
1253        let out = agent(v);
1254        assert!(out["properties"].get("tags").is_none());
1255        assert!(out["properties"].get("meta").is_none());
1256        assert_eq!(out["properties"]["kept"], json!("x"));
1257    }
1258
1259    #[test]
1260    fn verbose_passthrough() {
1261        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
1262        let out = present(v.clone(), PresentationMode::Verbose, NOW);
1263        assert_eq!(out, v);
1264    }
1265
1266    #[test]
1267    fn agent_shortens_uuid() {
1268        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
1269        let out = agent(v);
1270        assert_eq!(out["id"], json!("a1b2c3d4"));
1271    }
1272
1273    #[test]
1274    fn agent_drops_empty_string() {
1275        let v = json!({"title": "ok", "description": ""});
1276        let out = agent(v);
1277        assert!(out.get("description").is_none());
1278        assert_eq!(out["title"], json!("ok"));
1279    }
1280
1281    #[test]
1282    fn agent_drops_empty_array() {
1283        let v = json!({"tags": [], "title": "ok"});
1284        let out = agent(v);
1285        assert!(out.get("tags").is_none());
1286    }
1287
1288    #[test]
1289    fn agent_preserves_empty_list_page_arrays() {
1290        let v = json!({
1291            "items": [],
1292            "entities": [],
1293            "notes": [],
1294            "edges": [],
1295            "results": [],
1296            "entries": [],
1297            "neighbors": [],
1298            "next_after": null,
1299            "requested_limit": 10,
1300            "effective_limit": 10,
1301            "limit_clamped": false,
1302        });
1303        let out = agent(v);
1304        for key in EMPTY_ARRAY_PRESERVE {
1305            assert_eq!(out[*key], json!([]), "missing structural key {key}");
1306        }
1307        assert_eq!(out["next_after"], json!(null));
1308    }
1309
1310    #[test]
1311    fn agent_preserves_keyset_cursor_envelope_completion_signals() {
1312        // knowledge.list(after=…) terminal/empty page: both signals survive.
1313        let out = agent(json!({
1314            "results": [],
1315            "limit": 500,
1316            "order": "created_at ASC, id ASC",
1317            "next_after": null,
1318        }));
1319        assert_eq!(out["results"], json!([]));
1320        assert!(
1321            out.get("next_after").is_some_and(Value::is_null),
1322            "terminal cursor page must keep next_after:null: {out}"
1323        );
1324    }
1325
1326    #[test]
1327    fn agent_results_without_next_after_is_not_a_cursor_envelope() {
1328        let out = agent(json!({"results": [], "limit": 500, "title": "ordinary response"}));
1329        assert!(out.get("results").is_none());
1330    }
1331
1332    #[test]
1333    fn agent_still_drops_empty_arrays_outside_list_envelopes() {
1334        let out = agent(json!({"items": [], "entities": [], "title": "ordinary response"}));
1335        assert!(out.get("items").is_none());
1336        assert!(out.get("entities").is_none());
1337    }
1338
1339    #[test]
1340    fn agent_drops_empty_object() {
1341        let v = json!({"properties": {}, "title": "ok"});
1342        let out = agent(v);
1343        assert!(out.get("properties").is_none());
1344    }
1345
1346    #[test]
1347    fn agent_drops_non_lifecycle_null() {
1348        let v = json!({"result": null, "title": "ok"});
1349        let out = agent(v);
1350        assert!(out.get("result").is_none());
1351    }
1352
1353    #[test]
1354    fn agent_preserves_lifecycle_null() {
1355        let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
1356        let out = agent(v);
1357        assert_eq!(out["completed_at"], json!(null));
1358        assert_eq!(out["due_at"], json!(null));
1359    }
1360
1361    #[test]
1362    fn agent_preserves_relationship_null() {
1363        let v = json!({"parent_id": null, "superseded_by": null});
1364        let out = agent(v);
1365        assert_eq!(out["parent_id"], json!(null));
1366        assert_eq!(out["superseded_by"], json!(null));
1367    }
1368
1369    #[test]
1370    fn agent_preserves_unknown_channel_heartbeat_nulls() {
1371        let v = json!({
1372            "channels": [{
1373                "poll_interval_secs": null,
1374                "stalled": null,
1375                "last_success_at": null,
1376                "last_poll_attempt_at": null,
1377                "last_failure_at": null,
1378                "last_error": null,
1379                "consecutive_failures": null,
1380                "quarantined_count": 1,
1381            }]
1382        });
1383        let out = agent(v);
1384        let channel = out["channels"][0].as_object().expect("channel object");
1385        for field in [
1386            "poll_interval_secs",
1387            "stalled",
1388            "last_success_at",
1389            "last_poll_attempt_at",
1390            "last_failure_at",
1391            "last_error",
1392            "consecutive_failures",
1393        ] {
1394            assert_eq!(
1395                channel.get(field),
1396                Some(&Value::Null),
1397                "Agent presentation must preserve unknown heartbeat fact `{field}`"
1398            );
1399        }
1400    }
1401
1402    #[test]
1403    fn agent_truncates_score_field() {
1404        let v = json!({"score": 0.12345678});
1405        let out = agent(v);
1406        let s = out["score"].as_f64().unwrap();
1407        assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
1408    }
1409
1410    #[test]
1411    fn agent_presents_ranking_alias_and_nested_evidence() {
1412        let canonical = json!([
1413            {
1414                "rank_score": 0.0325224749,
1415                "score": 0.0325224749,
1416                "rank_score_kind": "rrf",
1417                "signals": {"vector_similarity": 0.842567, "keyword_score": 18.375}
1418            },
1419            {"rank_score": 0.0, "score": 0.0, "rank_score_kind": "keyword", "signals": {}}
1420        ]);
1421        for mode in [PresentationMode::Verbose, PresentationMode::Human] {
1422            assert_eq!(present(canonical.clone(), mode, NOW), canonical);
1423        }
1424        let shown = agent(canonical);
1425        assert_eq!(shown[0]["rank_score"], json!(0.0325));
1426        assert_eq!(shown[0]["score"], shown[0]["rank_score"]);
1427        assert_eq!(shown[0]["rank_score_kind"], "rrf");
1428        assert_eq!(shown[0]["signals"]["vector_similarity"], json!(0.843));
1429        assert_eq!(shown[0]["signals"]["keyword_score"], json!(18.4));
1430        assert_eq!(shown[1]["rank_score"], json!(0.0));
1431        assert_eq!(shown[1]["score"], shown[1]["rank_score"]);
1432        assert_eq!(shown[1]["rank_score_kind"], "keyword");
1433        assert!(shown[1].get("signals").is_none());
1434    }
1435
1436    #[test]
1437    fn agent_compacts_old_timestamp_to_minutes() {
1438        // Far past — not within 24h of NOW. Should be truncated to 16 chars.
1439        let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
1440        let out = agent(v);
1441        assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
1442    }
1443
1444    #[test]
1445    fn agent_compacts_recent_timestamp_to_relative() {
1446        // 3 minutes before NOW: diff = 180s.
1447        let ts_unix = NOW - 180;
1448        // Format as ISO-8601.
1449        let ts = unix_to_iso8601(ts_unix);
1450        let v = json!({"updated_at": ts});
1451        let out = agent(v);
1452        assert_eq!(out["updated_at"], json!("3m ago"));
1453    }
1454
1455    #[test]
1456    fn agent_does_not_compact_top_level_trigger_at_field() {
1457        // Regression for #871: `schedule.remind`'s create response returns
1458        // `trigger_at` as a top-level convenience field (sibling to `id`,
1459        // `full_id`), not nested under `"properties"`. The pre-existing
1460        // `inside_properties` guard alone did not protect it, so Agent-mode
1461        // compaction rewrote it: `at` here is far outside the 24h relative
1462        // window, so pre-fix it would have been minute-truncated to
1463        // "2026-07-11T19:00", discarding the seconds and the "-04:00"
1464        // offset the caller needs to round-trip the exact value verbatim.
1465        let at = "2026-07-11T19:00:00-04:00";
1466        let v = json!({
1467            "id": "a1b2c3d4",
1468            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1469            "event_type": "remind",
1470            "trigger_at": at,
1471            "repeat": null,
1472            "status": "pending",
1473        });
1474        let out = agent(v);
1475        assert_eq!(out["trigger_at"], json!(at));
1476    }
1477
1478    #[test]
1479    fn agent_does_not_compact_top_level_trigger_at_utc() {
1480        let at = "2026-07-11T23:00:00Z";
1481        let v = json!({"trigger_at": at});
1482        let out = agent(v);
1483        assert_eq!(out["trigger_at"], json!(at));
1484    }
1485
1486    #[test]
1487    fn agent_does_not_compact_top_level_trigger_at_offset_less() {
1488        let at = "2026-07-11T23:00:00";
1489        let v = json!({"trigger_at": at});
1490        let out = agent(v);
1491        assert_eq!(out["trigger_at"], json!(at));
1492    }
1493
1494    #[test]
1495    fn agent_still_compacts_other_top_level_timestamps_alongside_trigger_at() {
1496        // The `trigger_at` exemption is scoped to that field name only — a
1497        // sibling generic timestamp field must still be compacted, so the
1498        // fix does not blanket-disable Agent-mode compaction.
1499        let v = json!({
1500            "trigger_at": "2026-07-11T19:00:00-04:00",
1501            "created_at": "2020-01-01T10:30:45.123456Z",
1502        });
1503        let out = agent(v);
1504        assert_eq!(out["trigger_at"], json!("2026-07-11T19:00:00-04:00"));
1505        assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
1506    }
1507
1508    #[test]
1509    fn agent_does_not_compact_top_level_due() {
1510        // gtd.assign/gtd.tasks/gtd.next return the caller-supplied `due` as
1511        // a top-level convenience field mirroring `properties.due`; it must
1512        // round-trip verbatim through Agent-mode presentation, the same
1513        // guarantee already given to `trigger_at`.
1514        let due = "2026-08-01T09:30:15-04:00";
1515        let v = json!({"due": due});
1516        let out = agent(v);
1517        assert_eq!(out["due"], json!(due));
1518    }
1519
1520    #[test]
1521    fn agent_still_protects_nested_trigger_at_under_properties() {
1522        // Pre-existing protection (agenda/get responses nest trigger_at
1523        // under "properties") must remain intact alongside the new
1524        // top-level, field-name-based guard.
1525        let at = "2026-07-11T19:00:00-04:00";
1526        let v = json!({
1527            "id": "a1b2c3d4",
1528            "properties": {"trigger_at": at, "status": "pending"},
1529        });
1530        let out = agent(v);
1531        assert_eq!(out["properties"]["trigger_at"], json!(at));
1532    }
1533
1534    #[test]
1535    fn agent_recurses_into_nested_objects() {
1536        let v = json!({
1537            "items": [
1538                {
1539                    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1540                    "tags": [],
1541                    "score": 0.9999
1542                }
1543            ]
1544        });
1545        let out = agent(v);
1546        let item = &out["items"][0];
1547        assert_eq!(item["id"], json!("a1b2c3d4"));
1548        assert!(item.get("tags").is_none());
1549        let s = item["score"].as_f64().unwrap();
1550        assert!((s - 1.0).abs() < 1e-9);
1551    }
1552
1553    // full_id must never be shortened in Agent mode: it's the caller's
1554    // stable chaining handle.
1555    #[test]
1556    fn agent_preserves_full_id_as_36_chars() {
1557        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1558        let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
1559        let out = agent(v);
1560        // `id` is shortened to 8 chars
1561        assert_eq!(
1562            out["id"],
1563            json!("a1b2c3d4"),
1564            "id should be 8-char short form"
1565        );
1566        // `full_id` must remain the full 36-char UUID
1567        assert_eq!(
1568            out["full_id"].as_str().unwrap().len(),
1569            36,
1570            "full_id must be 36 chars in agent mode"
1571        );
1572        assert_eq!(
1573            out["full_id"],
1574            json!(uuid),
1575            "full_id must equal the original UUID"
1576        );
1577        // Verify the invariant: full_id starts with the short id prefix
1578        assert!(
1579            out["full_id"]
1580                .as_str()
1581                .unwrap()
1582                .starts_with(out["id"].as_str().unwrap()),
1583            "full_id must start with the short id prefix"
1584        );
1585    }
1586
1587    #[test]
1588    fn is_canonical_uuid_recognizes_valid() {
1589        assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1590        assert!(!is_canonical_uuid("a1b2c3d4"));
1591        assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
1592    }
1593
1594    #[test]
1595    fn looks_like_iso8601_recognizes_valid() {
1596        assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
1597        assert!(!looks_like_iso8601("not a timestamp"));
1598        assert!(!looks_like_iso8601("2026-05-23"));
1599    }
1600
1601    /// Format Unix seconds as ISO-8601 for test construction.
1602    fn unix_to_iso8601(unix: i64) -> String {
1603        let (y, mo, d, h, mi, s) = unix_to_civil(unix);
1604        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
1605    }
1606
1607    fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
1608        let s = unix % 86400;
1609        let days = unix / 86400;
1610        let h = s / 3600;
1611        let m = (s % 3600) / 60;
1612        let sec = s % 60;
1613        // Howard Hinnant civil_from_days
1614        let z = days + 719468;
1615        let era = z.div_euclid(146097);
1616        let doe = z - era * 146097;
1617        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1618        let y = yoe + era * 400;
1619        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1620        let mp = (5 * doy + 2) / 153;
1621        let d = doy - (153 * mp + 2) / 5 + 1;
1622        let mo = if mp < 10 { mp + 3 } else { mp - 9 };
1623        let y = if mo <= 2 { y + 1 } else { y };
1624        (y, mo, d, h, m, sec)
1625    }
1626
1627    #[test]
1628    fn agent_does_not_shorten_uuid_shaped_content_fields() {
1629        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1630        let out = agent(json!({
1631            "id": uuid,
1632            "full_id": uuid,
1633            "content": uuid,
1634            "description": uuid,
1635            "title": uuid,
1636            "query": uuid,
1637        }));
1638
1639        assert_eq!(out["id"], json!("a1b2c3d4"));
1640        assert_eq!(out["full_id"], json!(uuid));
1641        assert_eq!(out["content"], json!(uuid));
1642        assert_eq!(out["description"], json!(uuid));
1643        assert_eq!(out["title"], json!(uuid));
1644        assert_eq!(out["query"], json!(uuid));
1645    }
1646
1647    #[test]
1648    fn agent_shortens_suffix_id_fields() {
1649        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1650        let out = agent(json!({
1651            "note_id": uuid,
1652            "source_id": uuid,
1653            "target_id": uuid,
1654        }));
1655
1656        assert_eq!(out["note_id"], json!("a1b2c3d4"));
1657        assert_eq!(out["source_id"], json!("a1b2c3d4"));
1658        assert_eq!(out["target_id"], json!("a1b2c3d4"));
1659    }
1660
1661    #[test]
1662    fn agent_preserves_strict_round_trip_uuid_fields() {
1663        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1664        let out = agent(json!({
1665            "context_entity_id": uuid,
1666            "thread_id": uuid,
1667            "session_id": uuid,
1668            "project_id": uuid,
1669            "properties": {
1670                "context_entity_id": uuid,
1671                "thread_id": uuid,
1672                "outbound_ref": uuid,
1673                "parent_id": uuid,
1674                "session_id": uuid,
1675                "project_id": uuid,
1676            },
1677            "parent_id": uuid,
1678        }));
1679
1680        assert_eq!(out["context_entity_id"], json!(uuid));
1681        assert_eq!(out["thread_id"], json!(uuid));
1682        assert_eq!(out["session_id"], json!(uuid));
1683        assert_eq!(out["project_id"], json!(uuid));
1684        assert_eq!(out["properties"]["context_entity_id"], json!(uuid));
1685        assert_eq!(out["properties"]["thread_id"], json!(uuid));
1686        assert_eq!(out["properties"]["outbound_ref"], json!(uuid));
1687        assert_eq!(out["properties"]["parent_id"], json!(uuid));
1688        assert_eq!(out["properties"]["session_id"], json!(uuid));
1689        assert_eq!(out["properties"]["project_id"], json!(uuid));
1690        assert_eq!(out["parent_id"], json!(uuid));
1691    }
1692
1693    // ── ADR-078: OutputFormat tests ───────────────────────────────────────────
1694
1695    /// (a) verbose json preserves full shape (no field dropped, no transformation).
1696    #[test]
1697    fn format_verbose_json_preserves_full_shape() {
1698        let v = json!({
1699            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1700            "namespace": "local",
1701            "properties": {"k": "v"},
1702            "title": "test"
1703        });
1704        let rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Verbose);
1705        let parsed: Value = serde_json::from_str(&rendered).unwrap();
1706        // full_id must not be dropped in json mode.
1707        assert!(
1708            parsed.get("full_id").is_some(),
1709            "json mode must keep full_id"
1710        );
1711        // namespace must NOT be elided in json mode.
1712        assert_eq!(
1713            parsed.get("namespace").and_then(Value::as_str),
1714            Some("local")
1715        );
1716        // properties must NOT be deduped in json mode.
1717        assert!(parsed.get("properties").is_some());
1718    }
1719
1720    /// (a-agent-json) Agent JSON applies the Machine-scope redundancy
1721    /// reduction: `namespace`/duplicate-`properties` elision, but `full_id`
1722    /// is retained — it is the caller's chaining handle, not a view drop
1723    /// (ADR-078 Amendment 3, corrected).
1724    #[test]
1725    fn format_agent_json_drops_redundancy() {
1726        let v = json!({
1727            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1728            "namespace": "local",
1729            "title": "test",
1730            "status": "next",
1731            "properties": {"status": "next", "additive": true}
1732        });
1733        let rendered = render_format(v, OutputFormat::Json, PresentationMode::Agent);
1734        let parsed: Value = serde_json::from_str(&rendered).unwrap();
1735        assert!(
1736            parsed.get("full_id").is_some(),
1737            "Agent JSON must keep full_id: it is the machine contract's chaining handle"
1738        );
1739        assert!(parsed.get("namespace").is_none());
1740        assert_eq!(parsed["status"], json!("next"));
1741        assert!(parsed["properties"].get("status").is_none());
1742        assert_eq!(parsed["properties"]["additive"], json!(true));
1743    }
1744
1745    /// Agent JSON (Machine scope) keeps `full_id` while dropping
1746    /// `namespace="local"` and duplicate `properties`; Auto (View scope) on
1747    /// the same record still drops all three — the view scope is unchanged
1748    /// by Amendment 3's correction.
1749    #[test]
1750    fn agent_json_keeps_full_id_while_auto_still_drops_it() {
1751        let v = json!({
1752            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1753            "namespace": "local",
1754            "title": "test",
1755            "properties": {"title": "test", "additive": true}
1756        });
1757
1758        let json_rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
1759        let json_parsed: Value = serde_json::from_str(&json_rendered).unwrap();
1760        assert!(
1761            json_parsed.get("full_id").is_some(),
1762            "Agent JSON must keep full_id"
1763        );
1764        assert!(
1765            json_parsed.get("namespace").is_none(),
1766            "Agent JSON must elide namespace=\"local\""
1767        );
1768        assert!(
1769            json_parsed["properties"].get("title").is_none(),
1770            "Agent JSON must dedupe a duplicate properties entry"
1771        );
1772        assert_eq!(json_parsed["properties"]["additive"], json!(true));
1773
1774        let auto_rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1775        assert!(
1776            !auto_rendered.contains("full_id"),
1777            "Auto (view scope) must still drop full_id: {auto_rendered}"
1778        );
1779        assert!(
1780            !auto_rendered.contains("\"namespace\""),
1781            "Auto must still elide namespace=\"local\": {auto_rendered}"
1782        );
1783    }
1784
1785    /// (b1) homogeneous record array → markdown table with header + separator + rows.
1786    #[test]
1787    fn format_auto_homogeneous_array_renders_markdown_table() {
1788        let v = json!([
1789            {"id": "abc", "title": "First"},
1790            {"id": "def", "title": "Second"}
1791        ]);
1792        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1793        assert!(rendered.starts_with('|'), "must start with |");
1794        assert!(
1795            rendered.contains("| id |") || rendered.contains("| id"),
1796            "must have id column"
1797        );
1798        assert!(rendered.contains("title"), "must have title column");
1799        assert!(rendered.contains("|---|"), "must have separator row");
1800        assert!(rendered.contains("abc"), "must have first row data");
1801        assert!(rendered.contains("Second"), "must have second row data");
1802    }
1803
1804    /// (b2) single record → compact JSON, lossless (kv-block renderer removed).
1805    #[test]
1806    fn format_auto_single_record_renders_compact_json() {
1807        let v = json!({"id": "abc", "title": "Hello World"});
1808        let rendered = render_format(v.clone(), OutputFormat::Auto, PresentationMode::Agent);
1809        let parsed: Value = serde_json::from_str(&rendered).expect("must be valid JSON");
1810        assert_eq!(parsed, v, "single record must round-trip losslessly");
1811        assert!(
1812            !rendered.starts_with('|'),
1813            "single record must not be a markdown table"
1814        );
1815    }
1816
1817    /// (b2-lossless) a single record with a large payload field (a compose
1818    /// briefing's `markdown`) arrives whole — the former kv-block renderer
1819    /// truncated it.
1820    #[test]
1821    fn format_auto_single_record_large_payload_survives_whole() {
1822        let briefing = "line one\nline two\n".repeat(500); // ~9KB, newlines included
1823        let v = json!({"id": "abc", "markdown": briefing.clone()});
1824        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1825        let parsed: Value = serde_json::from_str(&rendered).expect("must be valid JSON");
1826        assert_eq!(
1827            parsed.get("markdown").and_then(Value::as_str),
1828            Some(briefing.as_str()),
1829            "large payload field must survive untruncated"
1830        );
1831    }
1832
1833    /// Envelope siblings survive table rendering: a `query`-style page whose
1834    /// `has_more` is dropped reads as complete — fail-open.
1835    #[test]
1836    fn format_auto_table_preserves_sibling_scalars() {
1837        let v = json!({
1838            "results": [
1839                {"id": "abc", "title": "First"},
1840                {"id": "def", "title": "Second"}
1841            ],
1842            "has_more": true,
1843            "offset": 20,
1844            "page_size": 2,
1845            "truncated": false
1846        });
1847        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1848        assert!(rendered.contains("| id"), "records must render as a table");
1849        assert!(
1850            rendered.contains("has_more: true"),
1851            "has_more sibling must survive: {rendered}"
1852        );
1853        assert!(
1854            rendered.contains("offset: 20"),
1855            "offset sibling must survive"
1856        );
1857        assert!(
1858            rendered.contains("page_size: 2"),
1859            "page_size sibling must survive"
1860        );
1861        assert!(
1862            rendered.contains("truncated: false"),
1863            "truncated sibling must survive"
1864        );
1865    }
1866
1867    /// Nested table cells render a constant elision marker, never
1868    /// truncated pseudo-JSON that reads as data while missing fields.
1869    #[test]
1870    fn format_auto_nested_cell_renders_elision_marker() {
1871        let big_nested: Value = json!({"k": "v".repeat(300), "other": {"deep": true}});
1872        let v = json!([
1873            {"id": "abc", "properties": big_nested},
1874            {"id": "def", "properties": {"x": 1}}
1875        ]);
1876        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1877        assert!(
1878            rendered.contains("{…}"),
1879            "nested object cell must render the elision marker: {rendered}"
1880        );
1881        assert!(
1882            !rendered.contains("..."),
1883            "no truncated pseudo-JSON in nested cells"
1884        );
1885    }
1886
1887    /// Arrays of scalars (tags) still render as compact JSON in cells.
1888    #[test]
1889    fn format_auto_scalar_array_cell_renders_compact_json() {
1890        let v = json!([
1891            {"id": "abc", "tags": ["lesson", "khive"]},
1892            {"id": "def", "tags": ["adr"]}
1893        ]);
1894        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1895        assert!(
1896            rendered.contains(r#"["lesson","khive"]"#),
1897            "scalar array cell must be compact JSON: {rendered}"
1898        );
1899    }
1900
1901    /// Identity/decision columns are exempt from truncation: a truncated
1902    /// title cannot be selected on, a truncated signature is unusable.
1903    #[test]
1904    fn format_auto_identity_columns_not_truncated() {
1905        let long_title = "T".repeat(300);
1906        let long_note = "N".repeat(300);
1907        let v = json!([
1908            {"id": "abc", "title": long_title.clone(), "note": long_note.clone()},
1909            {"id": "def", "title": "short", "note": "short"}
1910        ]);
1911        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1912        assert!(
1913            rendered.contains(&long_title),
1914            "title column must not be truncated"
1915        );
1916        assert!(
1917            !rendered.contains(&long_note),
1918            "non-exempt column must still truncate"
1919        );
1920    }
1921
1922    /// Verbose presentation disables cell truncation entirely (ADR-078 §3a).
1923    #[test]
1924    fn format_auto_verbose_disables_truncation() {
1925        let long_note = "N".repeat(300);
1926        let v = json!([
1927            {"id": "abc", "note": long_note.clone()},
1928            {"id": "def", "note": "short"}
1929        ]);
1930        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
1931        assert!(
1932            rendered.contains(&long_note),
1933            "verbose must render full cell content"
1934        );
1935    }
1936
1937    /// The pre-pass hoists named scalar payload fields (`trigger_at`, `due`,
1938    /// `status`) out of `properties` so they can surface as table columns —
1939    /// a scheduled event carries them only inside `properties`.
1940    #[test]
1941    fn table_path_hoists_payload_scalars_to_columns() {
1942        // The hoist is table-path only (ADR-078 Amendment 2): two scheduled
1943        // events whose trigger_at/status live inside `properties` must gain
1944        // top-level columns in the rendered table.
1945        let record = |n: u32| {
1946            json!({
1947                "id": format!("evt-{n}"),
1948                "properties": {
1949                    "trigger_at": "2026-09-01T14:00:00-04:00",
1950                    "status": "pending",
1951                    "dispatch_receipt": {"state": "succeeded"}
1952                }
1953            })
1954        };
1955        let out = render_format(
1956            json!([record(1), record(2)]),
1957            OutputFormat::Auto,
1958            PresentationMode::Agent,
1959        );
1960        let header = out.lines().next().expect("table header");
1961        assert!(
1962            header.contains("trigger_at") && header.contains("status"),
1963            "hoisted scalars must appear as table columns, got header: {header}"
1964        );
1965        assert!(
1966            out.contains("2026-09-01T14:00:00-04:00") && out.contains("pending"),
1967            "hoisted values must render in cells:\n{out}"
1968        );
1969    }
1970
1971    #[test]
1972    fn redundancy_drop_no_longer_hoists_outside_tables() {
1973        // Rule-separating control for the table-scoped hoist: the §7 pre-pass
1974        // alone must leave the properties bag in place, so a single record's
1975        // compact-JSON fallback keeps its shape.
1976        let v = json!({
1977            "id": "abc",
1978            "properties": {
1979                "trigger_at": "2026-09-01T14:00:00-04:00",
1980                "status": "pending"
1981            }
1982        });
1983        let reduced = apply_redundancy_drop(v, RedundancyScope::View);
1984        assert!(
1985            reduced.get("trigger_at").is_none() && reduced.get("status").is_none(),
1986            "the pre-pass must not hoist; the hoist is table-path only"
1987        );
1988        let props = reduced.get("properties").expect("properties must remain");
1989        assert_eq!(
1990            props.get("status").and_then(Value::as_str),
1991            Some("pending"),
1992            "fallback shape keeps payload fields inside properties"
1993        );
1994    }
1995
1996    #[test]
1997    fn sibling_string_with_newline_renders_as_json_literal() {
1998        // Escaping contract: a newline-bearing sibling string must not be able
1999        // to fabricate an additional `key: value` line.
2000        let v = json!({
2001            "items": [{"id": "a", "kind": "x"}, {"id": "b", "kind": "y"}],
2002            "note": "line one\nforged_key: forged_value",
2003            "has_more": true
2004        });
2005        let out = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2006        assert!(
2007            !out.contains("\nforged_key: forged_value"),
2008            "raw newline from a sibling string must not start a new line:\n{out}"
2009        );
2010        assert!(
2011            out.contains(r#"note: "line one\nforged_key: forged_value""#),
2012            "newline-bearing sibling renders as its JSON literal:\n{out}"
2013        );
2014        assert!(out.contains("has_more: true"), "siblings still preserved");
2015    }
2016
2017    #[test]
2018    fn carriage_return_in_cell_collapses_like_newline() {
2019        // Escaping contract pin: cells collapse \r exactly like \n, so a
2020        // \r- or \r\n-bearing value cannot smuggle a raw line break into
2021        // the table body and forge row structure.
2022        let v = json!([
2023            {"id": "a", "note": "before\rafter"},
2024            {"id": "b", "note": "one\r\ntwo"}
2025        ]);
2026        let out = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2027        assert!(
2028            !out.contains('\r'),
2029            "no raw carriage return may survive into rendered output:\n{out:?}"
2030        );
2031        assert!(
2032            out.contains("before after") && out.contains("one  two"),
2033            "\\r and \\r\\n collapse to spaces inside cells:\n{out}"
2034        );
2035    }
2036
2037    /// The hoist never overwrites an existing top-level sibling.
2038    #[test]
2039    fn redundancy_drop_hoist_does_not_overwrite_top_level() {
2040        let v = json!({
2041            "id": "abc",
2042            "status": "active",
2043            "properties": {"status": "pending"}
2044        });
2045        let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2046        assert_eq!(
2047            reduced.get("status").and_then(Value::as_str),
2048            Some("active"),
2049            "existing top-level status must win"
2050        );
2051        assert_eq!(
2052            reduced
2053                .get("properties")
2054                .and_then(|p| p.get("status"))
2055                .and_then(Value::as_str),
2056            Some("pending"),
2057            "conflicting properties value must stay where it was"
2058        );
2059    }
2060
2061    /// (b3) fallback: auto on heterogeneous/scalar value falls back to compact json.
2062    #[test]
2063    fn format_auto_scalar_fallback_compact_json() {
2064        let v = json!(42);
2065        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2066        assert_eq!(rendered, "42");
2067    }
2068
2069    /// (c) table format forces markdown table even when shape would normally be kv.
2070    #[test]
2071    fn format_table_forces_markdown_when_array() {
2072        let v = json!({
2073            "items": [
2074                {"name": "A", "score": 1},
2075                {"name": "B", "score": 2}
2076            ]
2077        });
2078        let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
2079        assert!(
2080            rendered.contains("|"),
2081            "table format must produce markdown table"
2082        );
2083        assert!(rendered.contains("name"), "must have name column");
2084        assert!(rendered.contains("score"), "must have score column");
2085    }
2086
2087    /// (c-fallback) table format falls back to compact json when no record array found.
2088    #[test]
2089    fn format_table_falls_back_to_json_when_no_array() {
2090        let v = json!({"single": "value"});
2091        let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
2092        // No record array → compact JSON fallback.
2093        let parsed: Value = serde_json::from_str(&rendered).unwrap();
2094        assert_eq!(parsed["single"], json!("value"));
2095    }
2096
2097    /// (d) redundancy-drop: auto/table skipped in Verbose mode (§7).
2098    #[test]
2099    fn format_auto_verbose_skips_redundancy_drop() {
2100        let v = json!({
2101            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
2102            "namespace": "local",
2103            "title": "test"
2104        });
2105        // In Verbose mode, redundancy drop must be skipped.
2106        // The value is a single object → compact JSON; full_id and namespace stay.
2107        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
2108        assert!(
2109            rendered.contains("full_id"),
2110            "verbose must preserve full_id"
2111        );
2112        assert!(
2113            rendered.contains("namespace"),
2114            "verbose must preserve namespace"
2115        );
2116    }
2117
2118    /// Indirect check that the redundancy pre-pass doesn't corrupt an error
2119    /// envelope's shape; the actual ok=false bypass is enforced by
2120    /// `render_result`, not by this pre-pass.
2121    #[test]
2122    fn redundancy_drop_does_not_corrupt_error_shape() {
2123        let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
2124        // apply_redundancy_drop is a pure value transform with no knowledge of
2125        // `ok`: bypassing it for error envelopes is the caller's job
2126        // (render_result in server.rs). This only checks the pre-pass itself
2127        // doesn't lose the error field.
2128        let reduced = apply_redundancy_drop(v.clone(), RedundancyScope::View);
2129        assert!(
2130            reduced.get("error").is_some(),
2131            "redundancy drop must preserve error field"
2132        );
2133        assert_eq!(
2134            reduced.get("ok").and_then(Value::as_bool),
2135            Some(false),
2136            "redundancy drop must preserve ok=false"
2137        );
2138    }
2139
2140    /// Properties dedup removes only keys that match a top-level sibling exactly.
2141    #[test]
2142    fn redundancy_drop_properties_dedup() {
2143        let v = json!({
2144            "id": "abc",
2145            "title": "Same",
2146            "properties": {
2147                "title": "Same",  // duplicate → removed
2148                "extra": "unique" // not at top level → kept
2149            }
2150        });
2151        let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2152        let props = reduced.get("properties").expect("properties must remain");
2153        assert!(props.get("extra").is_some(), "unique property must be kept");
2154        assert!(
2155            props.get("title").is_none(),
2156            "duplicate top-level property must be removed"
2157        );
2158    }
2159
2160    /// Cell truncation: text > 120 chars gets `...` appended.
2161    #[test]
2162    fn cell_text_truncates_long_values() {
2163        let long = "X".repeat(200);
2164        let v = json!([
2165            {"col": long.clone()},
2166            {"col": "short"}
2167        ]);
2168        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2169        // Cell must be truncated to ~120 chars + "..."
2170        assert!(
2171            rendered.contains("..."),
2172            "long cell must be truncated with ..."
2173        );
2174        assert!(
2175            !rendered.contains(&long),
2176            "full long string must not appear in table"
2177        );
2178    }
2179
2180    /// Cell truncation must not panic on multi-byte UTF-8 characters.
2181    ///
2182    /// A string of 119 ASCII bytes followed by a 3-byte CJK character and more
2183    /// text has `len() > 120` but byte index 120 falls inside the CJK char.
2184    /// The old byte-slice truncation would panic; char-boundary truncation is safe.
2185    #[test]
2186    fn cell_text_truncation_is_utf8_safe() {
2187        // 119 ASCII 'a' bytes, then CJK char U+4E2D (3 bytes each), then more text.
2188        // Total byte length: 119 + 3 * 10 + 5 > 120, but byte 120 is inside a CJK char.
2189        let prefix = "a".repeat(119);
2190        let suffix = "中".repeat(10); // each '中' is 3 bytes
2191        let long_multibyte = format!("{prefix}{suffix}trailing");
2192        let v = json!([
2193            {"col": long_multibyte.clone()},
2194            {"col": "ok"}
2195        ]);
2196        // Must not panic — this was the bug.
2197        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2198        assert!(
2199            rendered.contains("..."),
2200            "multibyte cell must be truncated with ..."
2201        );
2202        // The rendered string must be valid UTF-8 (no partial char slicing).
2203        assert!(
2204            std::str::from_utf8(rendered.as_bytes()).is_ok(),
2205            "rendered output must be valid UTF-8"
2206        );
2207    }
2208
2209    // --- parse_iso8601_unix / relative-time offset handling ---
2210
2211    #[test]
2212    fn parse_iso8601_unix_negative_offset_matches_equivalent_utc() {
2213        // "-04:00" is 4 hours behind UTC, so 11:55 local == 15:55Z.
2214        assert_eq!(
2215            parse_iso8601_unix("2026-07-09T11:55:00-04:00"),
2216            parse_iso8601_unix("2026-07-09T15:55:00Z")
2217        );
2218    }
2219
2220    #[test]
2221    fn parse_iso8601_unix_positive_offset_matches_equivalent_utc() {
2222        // "+04:00" is 4 hours ahead of UTC, so 20:15 local == 16:15Z.
2223        assert_eq!(
2224            parse_iso8601_unix("2026-05-23T20:15:00+04:00"),
2225            parse_iso8601_unix("2026-05-23T16:15:00Z")
2226        );
2227    }
2228
2229    #[test]
2230    fn parse_iso8601_unix_zero_offset_matches_z() {
2231        assert_eq!(
2232            parse_iso8601_unix("2026-07-09T15:55:00+00:00"),
2233            parse_iso8601_unix("2026-07-09T15:55:00Z")
2234        );
2235    }
2236
2237    #[test]
2238    fn parse_iso8601_unix_compact_offset_form_matches_colon_form() {
2239        assert_eq!(
2240            parse_iso8601_unix("2026-07-09T11:55:00-0400"),
2241            parse_iso8601_unix("2026-07-09T11:55:00-04:00")
2242        );
2243    }
2244
2245    #[test]
2246    fn parse_iso8601_unix_fractional_seconds_with_offset() {
2247        // Fractional seconds are dropped (whole-second precision only) but
2248        // must not prevent the trailing offset from being applied.
2249        assert_eq!(
2250            parse_iso8601_unix("2026-07-09T11:55:00.123-04:00"),
2251            parse_iso8601_unix("2026-07-09T15:55:00Z")
2252        );
2253    }
2254
2255    #[test]
2256    fn parse_iso8601_unix_fractional_seconds_with_z() {
2257        assert_eq!(
2258            parse_iso8601_unix("2026-07-09T15:55:00.999Z"),
2259            parse_iso8601_unix("2026-07-09T15:55:00Z")
2260        );
2261    }
2262
2263    #[test]
2264    fn parse_iso8601_unix_bare_form_unchanged() {
2265        // No trailing Z/offset at all: existing "no offset" behavior preserved.
2266        assert_eq!(
2267            parse_iso8601_unix("2026-07-09T15:55:00"),
2268            parse_iso8601_unix("2026-07-09T15:55:00Z")
2269        );
2270    }
2271
2272    #[test]
2273    fn parse_iso8601_unix_malformed_tail_returns_none() {
2274        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00X"), None);
2275        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+04"), None);
2276        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00."), None);
2277    }
2278
2279    #[test]
2280    fn parse_iso8601_unix_out_of_range_offset_returns_none() {
2281        // Hour out of range (>23), colon and compact forms.
2282        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+24:00"), None);
2283        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+2400"), None);
2284        // Minute out of range (>59), colon and compact forms.
2285        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+01:60"), None);
2286        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+0160"), None);
2287    }
2288
2289    #[test]
2290    fn parse_iso8601_unix_max_valid_offset_boundary_is_accepted() {
2291        // +23:59 / -23:59 are the largest valid offsets and must still parse.
2292        assert!(parse_iso8601_unix("2026-07-09T15:55:00+23:59").is_some());
2293        assert!(parse_iso8601_unix("2026-07-09T15:55:00-23:59").is_some());
2294        assert!(parse_iso8601_unix("2026-07-09T15:55:00+2359").is_some());
2295    }
2296
2297    #[test]
2298    fn compact_timestamp_offset_bearing_future_time_not_shown_as_ago() {
2299        // A wall-clock-identical-to-NOW timestamp carrying a "-02:00" offset
2300        // is actually 2h in the future; an offset-naive parser would misread
2301        // the wall-clock digits as UTC and report "0s ago".
2302        let out = compact_timestamp("2025-05-23T16:08:00-02:00", NOW);
2303        assert_ne!(out, "0s ago");
2304        assert_eq!(out, "2025-05-23T16:08");
2305    }
2306
2307    #[test]
2308    fn compact_timestamp_offset_bearing_past_time_renders_relative() {
2309        // "20:05+04:00" == "16:05Z", which is 3 minutes before NOW
2310        // (2025-05-23T16:08:00Z). Correct offset handling must produce
2311        // "3m ago"; the old offset-naive parser would compare wall-clock
2312        // 20:05 against NOW directly, landing outside the 24h window and
2313        // falling back to truncated absolute form instead.
2314        let out = compact_timestamp("2025-05-23T20:05:00+04:00", NOW);
2315        assert_eq!(out, "3m ago");
2316    }
2317}
2318
2319#[cfg(test)]
2320mod stream_presentation_tests {
2321    use super::*;
2322    use serde_json::json;
2323
2324    #[test]
2325    fn stream_agent_json_preserves_opaque_record_and_empty_page() {
2326        let record = json!([{"id": "aabbccdd-1234-4321-1234-abcdefabcdef", "score": 0.123456789, "created_at": "2026-09-08T00:00:00.123456Z", "empty": [], "null": null, "namespace": "local", "properties": {"namespace": "local"}}]);
2327        for payload in [record, Value::Null, json!([]), json!({}), json!("")] {
2328            let page = json!({"entries": [{"seq": 1, "id": "aabbccdd-1234-4321-1234-abcdefabcdef", "created_at": "2026-09-08T00:00:00.123456Z", "record": payload}], "head_seq": 1, "next_after": null});
2329            let presented = present(page, PresentationMode::Agent, 0);
2330            let out = prepare_format_value(presented, OutputFormat::Json, PresentationMode::Agent);
2331            assert_eq!(out["entries"][0].get("record"), Some(&payload));
2332            assert!(out.get("next_after").is_some_and(Value::is_null));
2333        }
2334        let empty = json!({"entries": [], "head_seq": 0, "next_after": null});
2335        assert_eq!(present(empty.clone(), PresentationMode::Agent, 0), empty);
2336    }
2337}
2338
2339#[cfg(test)]
2340mod issue_2537_standard_tests {
2341    use super::*;
2342    use serde_json::json;
2343
2344    #[test]
2345    fn issue_2537_standard_context_baseline_controls() {
2346        let timestamp = "2026-01-01T00:00:00.123456Z";
2347        let now = 1_767_225_780; // 2026-01-01T00:03:00Z, independently literal clock.
2348        let uuid = "aabbccdd-1234-4321-1234-abcdefabcdef";
2349        let payload = json!({"id":uuid,"created_at":timestamp,"empty":[],"null":null});
2350        let value = json!({"tool":"stream.batch","policy":"StreamBatchReceipts","id":uuid,
2351            "updated_at":timestamp,"score":0.123456,"empty":[],"null":null,
2352            "results":[{"id":uuid,"version":1,"updated_at":timestamp,"details":{"updated_at":timestamp}}],
2353            "properties":{"created_at":timestamp,"id":uuid,"empty":""},"trigger_at":timestamp,"due":timestamp,
2354            "entry":{"seq":1,"id":uuid,"created_at":timestamp,"record":payload},
2355            "cursor":{"head_seq":0,"entries":[],"next_after":null}});
2356        let shown = present(value.clone(), PresentationMode::Agent, now);
2357        assert_eq!(shown["updated_at"], "3m ago");
2358        assert_eq!(shown["results"][0]["updated_at"], "3m ago");
2359        assert_eq!(shown["results"][0]["details"]["updated_at"], "3m ago");
2360        assert_eq!(shown["id"], "aabbccdd");
2361        assert_eq!(shown["score"], json!(0.123));
2362        assert!(shown.get("empty").is_none());
2363        assert!(shown.get("null").is_none());
2364        assert_eq!(
2365            shown["properties"],
2366            json!({"created_at":timestamp,"id":"aabbccdd","empty":""})
2367        );
2368        assert_eq!(shown["trigger_at"], timestamp);
2369        assert_eq!(shown["due"], timestamp);
2370        assert_eq!(shown["entry"]["record"], payload);
2371        assert_eq!(shown["entry"]["created_at"], "3m ago");
2372        assert_eq!(shown["cursor"], value["cursor"]);
2373        for mode in [PresentationMode::Verbose, PresentationMode::Human] {
2374            assert_eq!(present(value.clone(), mode, now), value);
2375        }
2376        assert_eq!(
2377            present(
2378                json!({"properties":timestamp}),
2379                PresentationMode::Agent,
2380                now
2381            )["properties"],
2382            "3m ago"
2383        );
2384        assert_eq!(
2385            present(
2386                json!({"properties":[timestamp,{"created_at":timestamp}]}),
2387                PresentationMode::Agent,
2388                now
2389            )["properties"],
2390            json!([timestamp,{"created_at":"3m ago"}])
2391        );
2392    }
2393}
2394
2395#[cfg(test)]
2396mod issue_2537_receipt_policy_tests {
2397    use super::*;
2398    use serde_json::json;
2399
2400    const TIMESTAMP: &str = "2026-01-01T00:00:00.123456Z";
2401    const NOW: i64 = 1_767_225_780;
2402    const UUID: &str = "aabbccdd-1234-4321-1234-abcdefabcdef";
2403
2404    #[test]
2405    fn issue_2537_receipt_policies_preserve_only_the_named_fields() {
2406        let append = json!({
2407            "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2408            "score": 0.123456, "empty": [], "null": null,
2409            "details": {"created_at": TIMESTAMP},
2410            "ticker": {"last_tick_at": TIMESTAMP},
2411        });
2412        let batch = json!({
2413            "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2414            "results": [{
2415                "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2416                "details": {"created_at": TIMESTAMP, "updated_at": TIMESTAMP},
2417                "record": {"updated_at": TIMESTAMP},
2418                "score": 0.123456, "empty": [], "null": null,
2419            }],
2420            "properties": {"created_at": TIMESTAMP, "id": UUID, "empty": ""},
2421            "entry": {"seq": 1, "id": UUID, "created_at": TIMESTAMP,
2422                "record": {"id": UUID, "updated_at": TIMESTAMP, "empty": [], "null": null}},
2423            "trigger_at": TIMESTAMP, "due": TIMESTAMP,
2424            "ticker": {"last_tick_at": TIMESTAMP},
2425        });
2426        for (value, policy) in [
2427            (append, VerbPresentationPolicy::StreamAppendReceipt),
2428            (batch, VerbPresentationPolicy::StreamBatchReceipts),
2429        ] {
2430            let mut expected = present(value.clone(), PresentationMode::Agent, NOW);
2431            assert_eq!(expected["created_at"], "3m ago");
2432            assert_eq!(expected["updated_at"], "3m ago");
2433            assert_eq!(expected["id"], "aabbccdd");
2434            assert_eq!(expected["ticker"]["last_tick_at"], "3m ago");
2435            if policy == VerbPresentationPolicy::StreamAppendReceipt {
2436                expected["created_at"] = json!(TIMESTAMP);
2437            } else {
2438                expected["results"][0]["created_at"] = json!(TIMESTAMP);
2439                expected["results"][0]["updated_at"] = json!(TIMESTAMP);
2440            }
2441            assert_eq!(
2442                present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2443                expected
2444            );
2445            for mode in [PresentationMode::Verbose, PresentationMode::Human] {
2446                assert_eq!(present_with_policy(value.clone(), mode, NOW, policy), value);
2447            }
2448            assert_eq!(
2449                present_with_policy(
2450                    value.clone(),
2451                    PresentationMode::Agent,
2452                    NOW,
2453                    VerbPresentationPolicy::AlwaysVerbose
2454                ),
2455                value
2456            );
2457        }
2458    }
2459
2460    #[test]
2461    fn issue_2537_receipt_paths_do_not_match_other_containers_or_descendants() {
2462        let row = json!({"created_at": TIMESTAMP, "updated_at": TIMESTAMP});
2463        for (policy, cases) in [
2464            (
2465                VerbPresentationPolicy::StreamAppendReceipt,
2466                vec![
2467                    json!([row.clone()]),
2468                    json!({"details": row.clone()}),
2469                    json!({"results": [row.clone()]}),
2470                    json!({"created_at": {"created_at": TIMESTAMP}}),
2471                ],
2472            ),
2473            (
2474                VerbPresentationPolicy::StreamBatchReceipts,
2475                vec![
2476                    json!([{"results": [row.clone()]}]),
2477                    json!({"results": row.clone()}),
2478                    json!({"results": [[row.clone()]]}),
2479                    json!({"details": {"results": [row.clone()]}}),
2480                    json!({"results": [{"details": row.clone(), "record": row.clone(), "results": [row.clone()]}]}),
2481                    json!({"results": [{"created_at": {"updated_at": TIMESTAMP}, "updated_at": null}]}),
2482                ],
2483            ),
2484        ] {
2485            for value in cases {
2486                assert_eq!(
2487                    present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2488                    present(value.clone(), PresentationMode::Agent, NOW),
2489                    "container must not acquire receipt protection: {value}"
2490                );
2491            }
2492        }
2493    }
2494
2495    #[test]
2496    fn issue_2537_receipt_strings_are_preserved_without_parsing() {
2497        for text in ["", "not a timestamp", "2026-01-01T05:30:00.123456+05:30"] {
2498            let append = json!({"created_at": text});
2499            assert_eq!(
2500                present_with_policy(
2501                    append.clone(),
2502                    PresentationMode::Agent,
2503                    NOW,
2504                    VerbPresentationPolicy::StreamAppendReceipt
2505                ),
2506                append
2507            );
2508            let batch = json!({"results": [{"created_at": text, "updated_at": text}]});
2509            assert_eq!(
2510                present_with_policy(
2511                    batch.clone(),
2512                    PresentationMode::Agent,
2513                    NOW,
2514                    VerbPresentationPolicy::StreamBatchReceipts
2515                ),
2516                batch
2517            );
2518        }
2519        for non_string in [Value::Null, json!([]), json!({}), json!(42), json!(true)] {
2520            for (value, policy) in [
2521                (
2522                    json!({"created_at": non_string}),
2523                    VerbPresentationPolicy::StreamAppendReceipt,
2524                ),
2525                (
2526                    json!({"results": [{"created_at": non_string, "updated_at": non_string}]}),
2527                    VerbPresentationPolicy::StreamBatchReceipts,
2528                ),
2529            ] {
2530                assert_eq!(
2531                    present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2532                    present(value, PresentationMode::Agent, NOW)
2533                );
2534            }
2535        }
2536    }
2537}
2538
2539#[cfg(test)]
2540mod parsed_note_content_tests {
2541    use super::*;
2542    use serde_json::json;
2543
2544    #[test]
2545    fn issue2757_content_is_opaque_to_metadata_and_format_reductions() {
2546        let payload = json!([
2547            {"id":"11111111-1111-4111-8111-111111111111", "full_id":"keep", "namespace":"local",
2548             "created_at":"2026-09-15T12:34:56.123456Z", "score":0.123456789,
2549             "nil":null, "empty":"", "array":[], "object":{},
2550             "properties":{"id":"11111111-1111-4111-8111-111111111111"}},
2551            {"status":"pending"}
2552        ]);
2553        for (scope, response, pointer) in [
2554            (
2555                NoteContentScope::Record,
2556                json!({"id":"22222222-2222-4222-8222-222222222222", "content":payload}),
2557                "/content",
2558            ),
2559            (
2560                NoteContentScope::Items,
2561                json!({"items":[{"id":"22222222-2222-4222-8222-222222222222", "content":payload}], "requested_limit":1, "effective_limit":1, "limit_clamped":false}),
2562                "/items/0/content",
2563            ),
2564            (
2565                NoteContentScope::Notes,
2566                json!({"notes":[{"id":"22222222-2222-4222-8222-222222222222", "content":payload}], "next_after":null}),
2567                "/notes/0/content",
2568            ),
2569        ] {
2570            let presented =
2571                scope.protect(response, |value| present(value, PresentationMode::Agent, 0));
2572            for format in [OutputFormat::Json, OutputFormat::Auto, OutputFormat::Table] {
2573                let rendered = render_format_with_note_content(
2574                    presented.clone(),
2575                    format,
2576                    PresentationMode::Agent,
2577                    scope,
2578                );
2579                let actual: Value = serde_json::from_str(&rendered)
2580                    .expect("single note stays a JSON record, never a table of its body");
2581                let expected = if format == OutputFormat::Table {
2582                    Value::String(payload.to_string())
2583                } else {
2584                    payload.clone()
2585                };
2586                assert_eq!(actual.pointer(pointer), Some(&expected));
2587            }
2588        }
2589    }
2590}