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 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::{cmp::Ordering, 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, exact UTC timestamps, 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/// Format an instant in UTC using RFC3339 whole seconds and a `Z` suffix.
590///
591/// Fractional seconds are omitted. Chrono's supported years and signed
592/// expanded-year rendering are inherited; no additional range validation is done.
593/// Parsing and error policy remain with callers.
594pub fn format_utc_rfc3339_seconds<Tz: chrono::TimeZone>(value: &chrono::DateTime<Tz>) -> String {
595    value
596        .with_timezone(&chrono::Utc)
597        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
598}
599
600/// Convert a microsecond epoch `i64` to an exact UTC ISO-8601 string.
601///
602/// Output always carries six fractional digits and a `Z` suffix. Years
603/// 0000–9999 use the RFC 3339 spelling; other years use Chrono's signed
604/// expanded-year convention. The full `i64` microsecond range is supported.
605pub fn micros_to_iso(micros: i64) -> String {
606    if let Some(dt) = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros) {
607        return dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
608    }
609
610    // Gregorian leap positions repeat every 400 years. Euclidean reduction
611    // keeps the remainder in 1970–2369, including for negative timestamps.
612    const CYCLE_MICROS: i64 = 146_097 * 86_400 * 1_000_000;
613    let cycles = micros.div_euclid(CYCLE_MICROS);
614    let reduced =
615        chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros.rem_euclid(CYCLE_MICROS))
616            .expect("400-year remainder is within Chrono's range");
617    let year = i64::from(chrono::Datelike::year(&reduced)) + cycles * 400;
618    let rendered = reduced.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
619    format!("{year:+05}{}", &rendered[4..])
620}
621
622/// Parse an RFC 3339 timestamp (offset required) into microsecond epoch
623/// `i64` — the inverse of [`micros_to_iso`] within the RFC 3339 four-digit
624/// year range. Expanded ISO years are not accepted. This is the single parse
625/// point for caller-supplied instants entering storage comparisons.
626///
627/// Leading/trailing whitespace is tolerated. Date-only and offset-less forms
628/// are rejected; callers own the verb-specific error context around the
629/// returned `ParseError`.
630pub fn rfc3339_to_utc_micros(raw: &str) -> Result<i64, chrono::ParseError> {
631    chrono::DateTime::parse_from_rfc3339(raw.trim()).map(|dt| dt.timestamp_micros())
632}
633
634/// How the response envelope is presented to the caller.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
636#[serde(rename_all = "snake_case")]
637pub enum PresentationMode {
638    /// Token-efficient. Default for MCP callers (agents).
639    ///
640    /// Short UUIDs (8-char), exact UTC timestamps with relative labels on
641    /// list rows, empty fields dropped, structural nulls preserved, and
642    /// score fields truncated to 3 significant figures.
643    #[default]
644    Agent,
645    /// Full canonical shape. Default for `kkernel exec` and CI/scripted callers.
646    ///
647    /// No transformation — handler output passes through as-is.
648    Verbose,
649    /// Pretty-printed terminal output. Default for `khive` CLI.
650    ///
651    /// **At the MCP runtime level this is identical to `Verbose`** — the
652    /// canonical JSON is returned unchanged. Terminal formatting (relative
653    /// timestamps, glyph substitution, table layout) is applied by the CLI
654    /// layer (`khive-cli::format::pretty`), not the MCP response pipeline.
655    Human,
656}
657
658/// Lifecycle/operational `null` fields that are PRESERVED in Agent mode.
659///
660/// These fields carry state meaning (absent ≠ known-unknown) and must not be
661/// dropped. The channel-health fields distinguish a quarantine-only identity
662/// from a heartbeat row whose liveness facts were actually observed.
663const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
664    "completed_at",
665    "deleted_at",
666    "due_at",
667    "read_at",
668    "started_at",
669    "superseded_at",
670    "applied_at",
671    "withdrawn_at",
672    "reviewed_at",
673    "parent_id",
674    "superseded_by",
675    "replaced_by",
676    "poll_interval_secs",
677    "stalled",
678    "last_success_at",
679    "last_poll_attempt_at",
680    "last_failure_at",
681    "last_error",
682    "consecutive_failures",
683];
684
685/// Empty collection fields that define a stable response envelope and must
686/// survive Agent-mode compaction. Dropping these turns an empty page into a
687/// different response type and leaves callers unable to distinguish an empty
688/// result from a missing/unsupported field.
689const EMPTY_ARRAY_PRESERVE: &[&str] = &[
690    "items",
691    "entities",
692    "notes",
693    "edges",
694    "results",
695    "entries",
696    "neighbors",
697];
698
699fn is_stable_list_envelope(map: &Map<String, Value>) -> bool {
700    map.contains_key("requested_limit")
701        && map.contains_key("effective_limit")
702        && map.contains_key("limit_clamped")
703        && EMPTY_ARRAY_PRESERVE
704            .iter()
705            .any(|field| map.contains_key(*field))
706}
707
708/// Keyset cursor pages outside the ADR-023 envelope — `knowledge.list(after=…)`
709/// returns `{results, limit, order, next_after}` — are structural in the same
710/// way (ADR-045 Amendment 4): an empty `results` page and a terminal
711/// `next_after: null` are the walk's completion signals, so a caller that
712/// cannot see them cannot stop. A `results` array without a sibling
713/// `next_after` key is an ordinary response and gets the generic transform.
714fn is_keyset_cursor_envelope(map: &Map<String, Value>) -> bool {
715    map.contains_key("next_after")
716        && (map.get("results").is_some_and(Value::is_array)
717            || (map.get("head_seq").is_some_and(Value::is_number)
718                && map.get("entries").is_some_and(Value::is_array)))
719}
720
721fn is_stream_entry(map: &Map<String, Value>) -> bool {
722    map.get("seq").is_some_and(Value::is_number)
723        && map.contains_key("id")
724        && map.contains_key("created_at")
725        && map.contains_key("record")
726}
727
728/// Field names carrying caller-supplied payload timestamps that must never be
729/// transformed, regardless of nesting.
730///
731/// These encode domain semantics the caller needs to round-trip verbatim —
732/// e.g. `trigger_at` on a `schedule.remind`/`schedule.schedule` create
733/// response, returned as a top-level convenience field alongside `id` and
734/// `full_id`, not nested under `"properties"`. The `inside_properties` guard
735/// alone only protects fields nested under a literal `"properties"` key
736/// (as returned by `agenda`/`get`); it does not cover this top-level case,
737/// which older Agent presentation rewrote into a relative or minute-truncated
738/// form, discarding the seconds and offset needed for round trips (#871).
739///
740/// `due` on `gtd.assign`/`gtd.tasks`/`gtd.next` responses is the same shape:
741/// a top-level convenience field mirroring `properties.due`, which
742/// `parse_due` already normalizes to full RFC 3339.
743const PAYLOAD_TIMESTAMP_FIELDS: &[&str] = &["trigger_at", "due"];
744
745/// UUID fields whose canonical value is itself a strict-verb input.
746///
747/// Shortening these would make a successful response fail when submitted back
748/// to the verb that produced or consumes it. `context_entity_id` and
749/// `thread_id` are explicit stable references rather than prefix searches;
750/// `outbound_ref` is the exact correlation key consumed by `comm.delivered`;
751/// `parent_id` is the explicit ancestry reference consumed by `propose`;
752/// `session_id` is an exact event-list filter; and `project_id` is the exact
753/// provenance anchor required by git issue and pull-request creation.
754const ROUND_TRIP_FULL_UUID_FIELDS: &[&str] = &[
755    "context_entity_id",
756    "thread_id",
757    "outbound_ref",
758    "parent_id",
759    "session_id",
760    "project_id",
761];
762
763/// Score field names that are truncated to 3 significant figures in Agent mode.
764const SCORE_FIELDS: &[&str] = &[
765    "score",
766    "rank_score",
767    "vector_similarity",
768    "keyword_score",
769    "salience",
770    "decay_factor",
771    "rrf_score",
772    "similarity",
773    "cross_encoder_score",
774    "graph_proximity_score",
775];
776
777/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
778const UUID_CANONICAL_LEN: usize = 36;
779
780/// Return true for fields whose whole-string UUID values may be shortened in
781/// Agent mode. Content-like fields are intentionally excluded even when their
782/// value happens to be UUID-shaped.
783///
784/// `full_id` and strict round-trip fields are explicitly excluded: their
785/// purpose is to give callers a stable chaining handle, so shortening them
786/// would produce a value that the corresponding strict verb rejects.
787fn should_shorten_uuid_field(key: &str) -> bool {
788    if key == "full_id" || key == "bridge_instance_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&key)
789    {
790        return false;
791    }
792    key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
793}
794
795/// Transform a successful verb result value according to the given
796/// [`PresentationMode`].
797///
798/// - `Verbose` / `Human`: returns `value` unchanged.
799/// - `Agent`: applies UUID shortening, exact UTC timestamp rendering, empty-field
800///   dropping, structural-null preservation, and score truncation.
801///
802/// `now_unix_seconds` is a whole-second clock for callers without a finer
803/// sample. All relative list-row labels within a response use this instant.
804pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
805    present_with_policy(
806        value,
807        mode,
808        now_unix_seconds,
809        VerbPresentationPolicy::Standard,
810    )
811}
812
813/// Present a successful result using its trusted registered verb policy.
814/// Receipt policies preserve only their closed string paths, not descendants.
815pub fn present_with_policy(
816    value: Value,
817    mode: PresentationMode,
818    now_unix_seconds: i64,
819    policy: VerbPresentationPolicy,
820) -> Value {
821    present_with_policy_at(value, mode, now_unix_seconds.into(), policy)
822}
823
824/// A response-wide clock sample. Fractional precision distinguishes a newly
825/// stored row from a truly future row within the same Unix second.
826#[derive(Clone, Copy)]
827pub struct PresentationNow {
828    seconds: i64,
829    nanoseconds: u32,
830}
831
832impl From<i64> for PresentationNow {
833    fn from(seconds: i64) -> Self {
834        Self {
835            seconds,
836            nanoseconds: 0,
837        }
838    }
839}
840
841impl From<i32> for PresentationNow {
842    fn from(seconds: i32) -> Self {
843        i64::from(seconds).into()
844    }
845}
846
847impl From<chrono::DateTime<chrono::Utc>> for PresentationNow {
848    fn from(now: chrono::DateTime<chrono::Utc>) -> Self {
849        Self {
850            seconds: now.timestamp(),
851            nanoseconds: now.timestamp_subsec_nanos(),
852        }
853    }
854}
855
856/// Present with the response's single sampled instant, including its fraction.
857pub fn present_with_policy_at(
858    value: Value,
859    mode: PresentationMode,
860    now: PresentationNow,
861    policy: VerbPresentationPolicy,
862) -> Value {
863    if policy == VerbPresentationPolicy::AlwaysVerbose {
864        return value;
865    }
866    match mode {
867        PresentationMode::Verbose | PresentationMode::Human => value,
868        PresentationMode::Agent => {
869            // These root paths are selected by the registered agenda handler,
870            // never by a payload marker or a nested lookalike. The cursor's
871            // timestamp spelling and full UUID jointly define its seek position.
872            let mut value = value;
873            let agenda_next = (policy == VerbPresentationPolicy::AgendaContinuation)
874                .then(|| value.as_object_mut().and_then(|map| map.remove("next")))
875                .flatten();
876            let agenda_empty = policy == VerbPresentationPolicy::AgendaContinuation
877                && value
878                    .get("events")
879                    .and_then(Value::as_array)
880                    .is_some_and(Vec::is_empty);
881            let config = AgentTransformConfig {
882                preserved_nulls: LIFECYCLE_NULL_PRESERVE.iter().copied().collect(),
883                scores: SCORE_FIELDS.iter().copied().collect(),
884                payload_timestamps: PAYLOAD_TIMESTAMP_FIELDS.iter().copied().collect(),
885                now,
886            };
887            let mut value = transform_agent(
888                value,
889                &config,
890                AgentTreeContext {
891                    inside_properties: false,
892                    object_properties: false,
893                    array_member: false,
894                    receipt_context: match policy {
895                        VerbPresentationPolicy::StreamAppendReceipt => ReceiptContext::AppendRoot,
896                        VerbPresentationPolicy::StreamBatchReceipts => ReceiptContext::BatchRoot,
897                        _ => ReceiptContext::None,
898                    },
899                },
900            );
901            if let Some(map) = value.as_object_mut() {
902                if let Some(next) = agenda_next {
903                    map.insert("next".into(), next);
904                }
905                if agenda_empty {
906                    map.insert("events".into(), Value::Array(Vec::new()));
907                }
908            }
909            value
910        }
911    }
912}
913
914#[derive(Clone, Copy, PartialEq, Eq)]
915enum ReceiptContext {
916    None,
917    AppendRoot,
918    BatchRoot,
919    BatchResults,
920    BatchMember,
921}
922
923struct AgentTransformConfig {
924    preserved_nulls: HashSet<&'static str>,
925    scores: HashSet<&'static str>,
926    payload_timestamps: HashSet<&'static str>,
927    now: PresentationNow,
928}
929
930#[derive(Clone, Copy)]
931struct AgentTreeContext {
932    inside_properties: bool,
933    object_properties: bool,
934    array_member: bool,
935    receipt_context: ReceiptContext,
936}
937
938/// Apply the Agent-mode transform to an arbitrary JSON value.
939///
940/// `inside_properties` is `true` when recursing inside a `"properties"` value.
941/// Caller-supplied payload timestamps (e.g. `trigger_at`) must not be normalized
942/// because they encode domain semantics the agent may need to round-trip.
943fn transform_agent(
944    value: Value,
945    config: &AgentTransformConfig,
946    context: AgentTreeContext,
947) -> Value {
948    match value {
949        Value::Object(map) => {
950            let preserve_list_envelope =
951                is_stable_list_envelope(&map) || is_keyset_cursor_envelope(&map);
952            let stream_entry = is_stream_entry(&map);
953            let relative_created_at = (context.array_member
954                && !context.inside_properties
955                && !map.contains_key("created_at_relative")
956                && !matches!(
957                    context.receipt_context,
958                    ReceiptContext::AppendRoot | ReceiptContext::BatchMember
959                ))
960            .then(|| map.get("created_at").and_then(Value::as_str))
961            .flatten()
962            .and_then(|raw| {
963                let (_, created_at) = exact_timestamp(raw)?;
964                let age = config.now.seconds.checked_sub(created_at)?;
965                // Floor the full fractional age. The lexicographic comparison
966                // also handles canonical fractions longer than nanoseconds.
967                age.checked_sub(i64::from(
968                    timestamp_fraction_cmp(raw, config.now.nanoseconds) == Ordering::Greater,
969                ))
970            })
971            .filter(|difference| *difference >= 0)
972            .map(relative_time);
973            let mut out = Map::new();
974            for (k, v) in map {
975                if stream_entry && k == "record" {
976                    out.insert(k, v);
977                    continue;
978                }
979                if v.is_string()
980                    && ((context.receipt_context == ReceiptContext::AppendRoot
981                        && k == "created_at")
982                        || (context.receipt_context == ReceiptContext::BatchMember
983                            && matches!(k.as_str(), "created_at" | "updated_at")))
984                {
985                    out.insert(k, v);
986                    continue;
987                }
988                let child_receipt_context = if context.receipt_context == ReceiptContext::BatchRoot
989                    && k == "results"
990                    && v.is_array()
991                {
992                    ReceiptContext::BatchResults
993                } else {
994                    ReceiptContext::None
995                };
996                // ADR-045 Amendment 3 scopes the empty-string carve-out to
997                // strings nested under an object-valued `properties`; a
998                // scalar or array `properties` value gets no carve-out.
999                let child_inside_properties = context.inside_properties || k == "properties";
1000                let child_object_properties =
1001                    context.object_properties || (k == "properties" && v.is_object());
1002                let transformed = transform_field_agent(
1003                    &k,
1004                    v,
1005                    config,
1006                    AgentFieldContext {
1007                        inside_properties: child_inside_properties,
1008                        object_properties: child_object_properties,
1009                        preserve_list_envelope,
1010                        receipt_context: child_receipt_context,
1011                    },
1012                );
1013                match transformed {
1014                    None => {} // drop
1015                    Some(tv) => {
1016                        out.insert(k, tv);
1017                    }
1018                }
1019            }
1020            if let Some(relative) = relative_created_at {
1021                out.insert("created_at_relative".to_string(), Value::String(relative));
1022            }
1023            Value::Object(out)
1024        }
1025        Value::Array(arr) => {
1026            let items: Vec<Value> = arr
1027                .into_iter()
1028                .map(|v| {
1029                    transform_agent(
1030                        v,
1031                        config,
1032                        AgentTreeContext {
1033                            inside_properties: context.inside_properties,
1034                            object_properties: context.object_properties,
1035                            array_member: true,
1036                            receipt_context: if context.receipt_context
1037                                == ReceiptContext::BatchResults
1038                            {
1039                                ReceiptContext::BatchMember
1040                            } else {
1041                                ReceiptContext::None
1042                            },
1043                        },
1044                    )
1045                })
1046                .collect();
1047            Value::Array(items)
1048        }
1049        other => other,
1050    }
1051}
1052
1053/// Transform a single named field value under Agent mode.
1054///
1055/// Returns `None` if the field should be dropped.
1056///
1057/// `inside_properties` suppresses timestamp normalization for caller-submitted
1058/// payload values nested under a literal `"properties"` key (e.g. `trigger_at`
1059/// as returned by `agenda`/`get`). `payload_timestamps` suppresses normalization
1060/// by field name regardless of nesting, covering top-level convenience fields
1061/// such as the `trigger_at` returned directly in a `schedule.remind`/
1062/// `schedule.schedule` create response (#871). Metadata timestamps at the top
1063/// level (`created_at`, `updated_at`) still render as exact UTC instants.
1064#[derive(Clone, Copy)]
1065struct AgentFieldContext {
1066    inside_properties: bool,
1067    object_properties: bool,
1068    preserve_list_envelope: bool,
1069    receipt_context: ReceiptContext,
1070}
1071
1072fn transform_field_agent(
1073    key: &str,
1074    value: Value,
1075    config: &AgentTransformConfig,
1076    context: AgentFieldContext,
1077) -> Option<Value> {
1078    match &value {
1079        // Preserve lifecycle and stable-envelope nulls; drop other nulls.
1080        Value::Null => {
1081            if config.preserved_nulls.contains(key)
1082                || (context.preserve_list_envelope && key == "next_after")
1083            {
1084                Some(value)
1085            } else {
1086                None
1087            }
1088        }
1089        // Stable page-envelope arrays remain present even when empty.
1090        Value::Array(a)
1091            if context.preserve_list_envelope
1092                && a.is_empty()
1093                && EMPTY_ARRAY_PRESERVE.contains(&key) =>
1094        {
1095            Some(value)
1096        }
1097        // Preserve empty strings under a record's `properties` object: those
1098        // keys exist only because a caller wrote them, so `""` there is data
1099        // (set-to-empty vs absent, ADR-045 Amendment 3, issue #1995). Empty
1100        // arrays and objects under `properties` are still dropped per the
1101        // amendment's scope note.
1102        Value::String(s) if s.is_empty() && context.object_properties => Some(value),
1103        // Drop other empty strings, arrays, objects.
1104        Value::String(s) if s.is_empty() => None,
1105        Value::Array(a) if a.is_empty() => None,
1106        Value::Object(o) if o.is_empty() => None,
1107        // Truncate score fields.
1108        Value::Number(_) if config.scores.contains(key) => {
1109            if let Some(f) = value.as_f64() {
1110                Some(truncate_to_3_sig_figs(f))
1111            } else {
1112                Some(value)
1113            }
1114        }
1115        // Shorten UUIDs only in fields whose names carry ID semantics.
1116        Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
1117            Some(Value::String(s[..8].to_string()))
1118        }
1119        // Render whole, offset-bearing timestamps in exact UTC form unless
1120        // this is caller-supplied or otherwise protected payload data.
1121        Value::String(s)
1122            if !context.inside_properties
1123                && !config.payload_timestamps.contains(key)
1124                && looks_like_iso8601(s) =>
1125        {
1126            Some(Value::String(
1127                exact_timestamp(s).map_or_else(|| s.clone(), |(rendered, _)| rendered),
1128            ))
1129        }
1130        // Recurse into objects and arrays.
1131        Value::Object(_) | Value::Array(_) => Some(transform_agent(
1132            value,
1133            config,
1134            AgentTreeContext {
1135                inside_properties: context.inside_properties,
1136                object_properties: context.object_properties,
1137                array_member: false,
1138                receipt_context: context.receipt_context,
1139            },
1140        )),
1141        // Everything else passes through.
1142        _ => Some(value),
1143    }
1144}
1145
1146/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
1147fn is_canonical_uuid(s: &str) -> bool {
1148    if s.len() != UUID_CANONICAL_LEN {
1149        return false;
1150    }
1151    let b = s.as_bytes();
1152    // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
1153    b[8] == b'-'
1154        && b[13] == b'-'
1155        && b[18] == b'-'
1156        && b[23] == b'-'
1157        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
1158        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
1159        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
1160        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
1161        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
1162}
1163
1164/// Returns `true` if `s` looks like an ISO-8601 datetime string.
1165///
1166/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
1167fn looks_like_iso8601(s: &str) -> bool {
1168    if s.len() < 16 {
1169        return false;
1170    }
1171    let b = s.as_bytes();
1172    b[4] == b'-'
1173        && b[7] == b'-'
1174        && b[10] == b'T'
1175        && b[13] == b':'
1176        && b[..4].iter().all(|c| c.is_ascii_digit())
1177        && b[5..7].iter().all(|c| c.is_ascii_digit())
1178        && b[8..10].iter().all(|c| c.is_ascii_digit())
1179        && b[11..13].iter().all(|c| c.is_ascii_digit())
1180}
1181
1182/// Render a whole timestamp in UTC while retaining its original fraction digits.
1183fn exact_timestamp(s: &str) -> Option<(String, i64)> {
1184    let unix = parse_iso8601_unix(s)?;
1185    let tail = s.get(19..)?;
1186    let fraction = if let Some(after_dot) = tail.strip_prefix('.') {
1187        let digits = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1188        tail.get(..digits + 1)?
1189    } else {
1190        ""
1191    };
1192    let utc = chrono::DateTime::<chrono::Utc>::from_timestamp(unix, 0)?;
1193    Some((
1194        format!("{}{fraction}Z", utc.format("%Y-%m-%dT%H:%M:%S")),
1195        unix,
1196    ))
1197}
1198
1199/// Compare the timestamp's decimal fraction with a sampled nanosecond value.
1200/// Digits beyond nanoseconds matter when the leading nine digits tie.
1201fn timestamp_fraction_cmp(timestamp: &str, now_nanoseconds: u32) -> Ordering {
1202    let fraction = if timestamp.as_bytes().get(19) == Some(&b'.') {
1203        timestamp.as_bytes()[20..]
1204            .split(|byte| !byte.is_ascii_digit())
1205            .next()
1206            .unwrap_or_default()
1207    } else {
1208        &[]
1209    };
1210    let now_fraction = format!("{now_nanoseconds:09}");
1211    for index in 0..fraction.len().max(9) {
1212        let created_digit = fraction.get(index).copied().unwrap_or(b'0');
1213        let now_digit = now_fraction.as_bytes().get(index).copied().unwrap_or(b'0');
1214        match created_digit.cmp(&now_digit) {
1215            Ordering::Equal => {}
1216            difference => return difference,
1217        }
1218    }
1219    Ordering::Equal
1220}
1221
1222/// Parse a whole ISO-8601 datetime with an explicit offset to Unix seconds.
1223///
1224/// Accepts `YYYY-MM-DDTHH:MM:SS[.frac](Z|±HH:MM|±HHMM)`. An offset-less,
1225/// invalid or trailing-text value stays byte-exact in Agent presentation.
1226fn parse_iso8601_unix(s: &str) -> Option<i64> {
1227    if s.len() < 20 {
1228        return None;
1229    }
1230    let b = s.as_bytes();
1231    if b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
1232        return None;
1233    }
1234    let year: i64 = parse_digits(&b[0..4])?;
1235    let month: i64 = parse_digits(&b[5..7])?;
1236    let day: i64 = parse_digits(&b[8..10])?;
1237    let hour: i64 = parse_digits(&b[11..13])?;
1238    let minute: i64 = parse_digits(&b[14..16])?;
1239    let second: i64 = parse_digits(&b[17..19])?;
1240
1241    let local = chrono::NaiveDate::from_ymd_opt(
1242        year.try_into().ok()?,
1243        month.try_into().ok()?,
1244        day.try_into().ok()?,
1245    )?
1246    .and_hms_opt(
1247        hour.try_into().ok()?,
1248        minute.try_into().ok()?,
1249        second.try_into().ok()?,
1250    )?
1251    .and_utc()
1252    .timestamp();
1253    let offset_secs = parse_tz_offset_secs(s.get(19..)?)?;
1254    local.checked_sub(offset_secs)
1255}
1256
1257/// Parse the tail of an ISO-8601 timestamp (everything from byte index 19
1258/// onward, i.e. after the whole-seconds field) into a UTC offset in seconds.
1259///
1260/// Handles, in order: optional fractional seconds (`.nnn`, skipped — this
1261/// parser only has whole-second precision), then one of:
1262/// - `"Z"` → offset 0
1263/// - `±HH:MM` or the compact `±HHMM` form → `sign * (hh*3600 + mm*60)`
1264///
1265/// Returns `None` for anything else (malformed tail).
1266fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
1267    let mut rest = tail;
1268    if let Some(after_dot) = rest.strip_prefix('.') {
1269        let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1270        if frac_len == 0 {
1271            return None;
1272        }
1273        rest = &after_dot[frac_len..];
1274    }
1275
1276    if rest == "Z" {
1277        return Some(0);
1278    }
1279
1280    let sign: i64 = match rest.as_bytes().first()? {
1281        b'+' => 1,
1282        b'-' => -1,
1283        _ => return None,
1284    };
1285    let digits = &rest[1..];
1286    let (hh, mm) = match digits.len() {
1287        // "HH:MM"
1288        5 if digits.as_bytes()[2] == b':' => (
1289            parse_digits(&digits.as_bytes()[0..2])?,
1290            parse_digits(&digits.as_bytes()[3..5])?,
1291        ),
1292        // "HHMM"
1293        4 => (
1294            parse_digits(&digits.as_bytes()[0..2])?,
1295            parse_digits(&digits.as_bytes()[2..4])?,
1296        ),
1297        _ => return None,
1298    };
1299    if hh > 23 || mm > 59 {
1300        return None;
1301    }
1302    Some(sign * (hh * 3600 + mm * 60))
1303}
1304
1305fn parse_digits(b: &[u8]) -> Option<i64> {
1306    let s = std::str::from_utf8(b).ok()?;
1307    s.parse().ok()
1308}
1309
1310/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
1311fn relative_time(diff_secs: i64) -> String {
1312    if diff_secs < 60 {
1313        format!("{diff_secs}s ago")
1314    } else if diff_secs < 3600 {
1315        format!("{}m ago", diff_secs / 60)
1316    } else if diff_secs < 86400 {
1317        format!("{}h ago", diff_secs / 3600)
1318    } else {
1319        format!("{}d ago", diff_secs / 86400)
1320    }
1321}
1322
1323/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
1324fn truncate_to_3_sig_figs(f: f64) -> Value {
1325    if f == 0.0 || !f.is_finite() {
1326        return Value::from(f);
1327    }
1328    let magnitude = f.abs().log10().floor() as i32;
1329    let factor = 10f64.powi(2 - magnitude);
1330    let rounded = (f * factor).round() / factor;
1331    // Re-serialize through serde_json to avoid floating-point noise.
1332    serde_json::Number::from_f64(rounded)
1333        .map(Value::Number)
1334        .unwrap_or(Value::from(rounded))
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::*;
1340    use serde_json::json;
1341
1342    /// A fixed "now" for deterministic tests: 2025-05-23T16:08:00Z.
1343    const NOW: i64 = 1_748_016_480;
1344
1345    #[test]
1346    fn utc_rfc3339_seconds_normalizes_offsets_and_omits_fractions() {
1347        for (raw, expected) in [
1348            (
1349                "2026-01-01T00:15:30.987654321+01:00",
1350                "2025-12-31T23:15:30Z",
1351            ),
1352            ("1969-12-31T23:59:59.999999999Z", "1969-12-31T23:59:59Z"),
1353        ] {
1354            let instant = chrono::DateTime::parse_from_rfc3339(raw).unwrap();
1355            assert_eq!(format_utc_rfc3339_seconds(&instant), expected, "{raw}");
1356        }
1357    }
1358
1359    #[test]
1360    fn micros_to_iso_preserves_chrono_range_spelling() {
1361        for (micros, expected) in [
1362            (0, "1970-01-01T00:00:00.000000Z"),
1363            (-1, "1969-12-31T23:59:59.999999Z"),
1364            (1_748_016_480_123_456, "2025-05-23T16:08:00.123456Z"),
1365            (-62_198_755_200_000_000, "-0001-01-01T00:00:00.000000Z"),
1366            (253_402_300_800_000_000, "+10000-01-01T00:00:00.000000Z"),
1367            (-8_334_601_228_800_000_000, "-262143-01-01T00:00:00.000000Z"),
1368            (8_210_266_876_799_999_999, "+262142-12-31T23:59:59.999999Z"),
1369        ] {
1370            assert!(
1371                chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros).is_some(),
1372                "{micros}"
1373            );
1374            assert_eq!(micros_to_iso(micros), expected, "{micros}");
1375        }
1376    }
1377
1378    #[test]
1379    fn micros_to_iso_formats_outside_chrono_range_exactly() {
1380        for (micros, expected) in [
1381            (i64::MIN, "-290308-12-21T19:59:05.224192Z"),
1382            (i64::MAX, "+294247-01-10T04:00:54.775807Z"),
1383            (-8_334_601_228_800_000_001, "-262144-12-31T23:59:59.999999Z"),
1384            (8_210_266_876_800_000_000, "+262143-01-01T00:00:00.000000Z"),
1385            (-8_898_108_636_303_876_544, "-280000-02-29T12:34:56.123456Z"),
1386            (8_776_940_198_400_000_001, "+280100-03-01T00:00:00.000001Z"),
1387        ] {
1388            assert!(
1389                chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros).is_none(),
1390                "{micros}"
1391            );
1392            assert_eq!(micros_to_iso(micros), expected, "{micros}");
1393        }
1394    }
1395
1396    #[test]
1397    fn expanded_micros_timestamps_pass_through_agent_without_relative_labels() {
1398        for (micros, expected) in [
1399            (i64::MIN, "-290308-12-21T19:59:05.224192Z"),
1400            (i64::MAX, "+294247-01-10T04:00:54.775807Z"),
1401        ] {
1402            let shown = present(
1403                json!({"items": [{"created_at": micros_to_iso(micros)}]}),
1404                PresentationMode::Agent,
1405                NOW,
1406            );
1407            assert_eq!(shown, json!({"items": [{"created_at": expected}]}));
1408            assert!(rfc3339_to_utc_micros(expected).is_err());
1409        }
1410    }
1411
1412    #[test]
1413    fn rfc3339_to_utc_micros_round_trips_and_rejects_partial_forms() {
1414        let micros = 1_748_016_480_000_000_i64;
1415        assert_eq!(rfc3339_to_utc_micros(&micros_to_iso(micros)), Ok(micros));
1416        // Offset spellings resolve to the same instant; whitespace tolerated.
1417        // The offset spelling names the same instant as NOW.
1418        assert_eq!(
1419            rfc3339_to_utc_micros(" 2025-05-23T12:08:00-04:00 "),
1420            Ok(micros)
1421        );
1422        assert!(rfc3339_to_utc_micros("2026-05-23").is_err());
1423        assert!(rfc3339_to_utc_micros("2026-05-23T16:18:00").is_err());
1424    }
1425
1426    fn agent(v: Value) -> Value {
1427        present(v, PresentationMode::Agent, NOW)
1428    }
1429
1430    #[test]
1431    fn agent_preserves_empty_string_under_properties() {
1432        // ADR-045 Amendment 3 (issue #1995): a key under `properties` exists
1433        // only because a caller wrote it, so `""` there distinguishes
1434        // set-to-empty from absent and must survive Agent mode — at any
1435        // nesting depth under `properties`.
1436        let v = json!({
1437            "id": "a1b2c3d4",
1438            "summary": "",
1439            "properties": {"k": "", "nested": {"deep": ""}},
1440        });
1441        let out = agent(v);
1442        assert_eq!(out["properties"]["k"], json!(""));
1443        assert_eq!(out["properties"]["nested"]["deep"], json!(""));
1444        // Control: outside `properties` the empty-string drop still applies.
1445        assert!(out.get("summary").is_none());
1446    }
1447
1448    #[test]
1449    fn agent_drops_scalar_empty_properties_value() {
1450        // The carve-out applies only to strings nested under an object-valued
1451        // `properties`; a scalar `properties: ""` is the field's own value,
1452        // not a caller-written key under it, and drops like any empty string.
1453        let v = json!({
1454            "id": "a1b2c3d4",
1455            "properties": "",
1456        });
1457        let out = agent(v);
1458        assert!(out.get("properties").is_none());
1459    }
1460
1461    #[test]
1462    fn agent_still_drops_empty_containers_under_properties() {
1463        // The amendment's scope note keeps the container drop: empty arrays
1464        // and objects under `properties` are not carved out.
1465        let v = json!({
1466            "id": "a1b2c3d4",
1467            "properties": {"tags": [], "meta": {}, "kept": "x"},
1468        });
1469        let out = agent(v);
1470        assert!(out["properties"].get("tags").is_none());
1471        assert!(out["properties"].get("meta").is_none());
1472        assert_eq!(out["properties"]["kept"], json!("x"));
1473    }
1474
1475    #[test]
1476    fn verbose_passthrough() {
1477        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
1478        let out = present(v.clone(), PresentationMode::Verbose, NOW);
1479        assert_eq!(out, v);
1480    }
1481
1482    #[test]
1483    fn agent_shortens_uuid() {
1484        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
1485        let out = agent(v);
1486        assert_eq!(out["id"], json!("a1b2c3d4"));
1487    }
1488
1489    #[test]
1490    fn agent_drops_empty_string() {
1491        let v = json!({"title": "ok", "description": ""});
1492        let out = agent(v);
1493        assert!(out.get("description").is_none());
1494        assert_eq!(out["title"], json!("ok"));
1495    }
1496
1497    #[test]
1498    fn agent_drops_empty_array() {
1499        let v = json!({"tags": [], "title": "ok"});
1500        let out = agent(v);
1501        assert!(out.get("tags").is_none());
1502    }
1503
1504    #[test]
1505    fn agent_preserves_empty_list_page_arrays() {
1506        let v = json!({
1507            "items": [],
1508            "entities": [],
1509            "notes": [],
1510            "edges": [],
1511            "results": [],
1512            "entries": [],
1513            "neighbors": [],
1514            "next_after": null,
1515            "requested_limit": 10,
1516            "effective_limit": 10,
1517            "limit_clamped": false,
1518        });
1519        let out = agent(v);
1520        for key in EMPTY_ARRAY_PRESERVE {
1521            assert_eq!(out[*key], json!([]), "missing structural key {key}");
1522        }
1523        assert_eq!(out["next_after"], json!(null));
1524    }
1525
1526    #[test]
1527    fn agent_preserves_keyset_cursor_envelope_completion_signals() {
1528        // knowledge.list(after=…) terminal/empty page: both signals survive.
1529        let out = agent(json!({
1530            "results": [],
1531            "limit": 500,
1532            "order": "created_at ASC, id ASC",
1533            "next_after": null,
1534        }));
1535        assert_eq!(out["results"], json!([]));
1536        assert!(
1537            out.get("next_after").is_some_and(Value::is_null),
1538            "terminal cursor page must keep next_after:null: {out}"
1539        );
1540    }
1541
1542    #[test]
1543    fn agent_results_without_next_after_is_not_a_cursor_envelope() {
1544        let out = agent(json!({"results": [], "limit": 500, "title": "ordinary response"}));
1545        assert!(out.get("results").is_none());
1546    }
1547
1548    #[test]
1549    fn agent_still_drops_empty_arrays_outside_list_envelopes() {
1550        let out = agent(json!({"items": [], "entities": [], "title": "ordinary response"}));
1551        assert!(out.get("items").is_none());
1552        assert!(out.get("entities").is_none());
1553    }
1554
1555    #[test]
1556    fn agent_drops_empty_object() {
1557        let v = json!({"properties": {}, "title": "ok"});
1558        let out = agent(v);
1559        assert!(out.get("properties").is_none());
1560    }
1561
1562    #[test]
1563    fn agent_drops_non_lifecycle_null() {
1564        let v = json!({"result": null, "title": "ok"});
1565        let out = agent(v);
1566        assert!(out.get("result").is_none());
1567    }
1568
1569    #[test]
1570    fn agent_preserves_lifecycle_null() {
1571        let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
1572        let out = agent(v);
1573        assert_eq!(out["completed_at"], json!(null));
1574        assert_eq!(out["due_at"], json!(null));
1575    }
1576
1577    #[test]
1578    fn agent_preserves_relationship_null() {
1579        let v = json!({"parent_id": null, "superseded_by": null});
1580        let out = agent(v);
1581        assert_eq!(out["parent_id"], json!(null));
1582        assert_eq!(out["superseded_by"], json!(null));
1583    }
1584
1585    #[test]
1586    fn agent_preserves_unknown_channel_heartbeat_nulls() {
1587        let v = json!({
1588            "channels": [{
1589                "poll_interval_secs": null,
1590                "stalled": null,
1591                "last_success_at": null,
1592                "last_poll_attempt_at": null,
1593                "last_failure_at": null,
1594                "last_error": null,
1595                "consecutive_failures": null,
1596                "quarantined_count": 1,
1597            }]
1598        });
1599        let out = agent(v);
1600        let channel = out["channels"][0].as_object().expect("channel object");
1601        for field in [
1602            "poll_interval_secs",
1603            "stalled",
1604            "last_success_at",
1605            "last_poll_attempt_at",
1606            "last_failure_at",
1607            "last_error",
1608            "consecutive_failures",
1609        ] {
1610            assert_eq!(
1611                channel.get(field),
1612                Some(&Value::Null),
1613                "Agent presentation must preserve unknown heartbeat fact `{field}`"
1614            );
1615        }
1616    }
1617
1618    #[test]
1619    fn agent_truncates_score_field() {
1620        let v = json!({"score": 0.12345678});
1621        let out = agent(v);
1622        let s = out["score"].as_f64().unwrap();
1623        assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
1624    }
1625
1626    #[test]
1627    fn agent_presents_ranking_alias_and_nested_evidence() {
1628        let canonical = json!([
1629            {
1630                "rank_score": 0.0325224749,
1631                "score": 0.0325224749,
1632                "rank_score_kind": "rrf",
1633                "signals": {"vector_similarity": 0.842567, "keyword_score": 18.375}
1634            },
1635            {"rank_score": 0.0, "score": 0.0, "rank_score_kind": "keyword", "signals": {}}
1636        ]);
1637        for mode in [PresentationMode::Verbose, PresentationMode::Human] {
1638            assert_eq!(present(canonical.clone(), mode, NOW), canonical);
1639        }
1640        let shown = agent(canonical);
1641        assert_eq!(shown[0]["rank_score"], json!(0.0325));
1642        assert_eq!(shown[0]["score"], shown[0]["rank_score"]);
1643        assert_eq!(shown[0]["rank_score_kind"], "rrf");
1644        assert_eq!(shown[0]["signals"]["vector_similarity"], json!(0.843));
1645        assert_eq!(shown[0]["signals"]["keyword_score"], json!(18.4));
1646        assert_eq!(shown[1]["rank_score"], json!(0.0));
1647        assert_eq!(shown[1]["score"], shown[1]["rank_score"]);
1648        assert_eq!(shown[1]["rank_score_kind"], "keyword");
1649        assert!(shown[1].get("signals").is_none());
1650    }
1651
1652    #[test]
1653    fn agent_renders_old_timestamp_exactly() {
1654        let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
1655        let out = agent(v);
1656        assert_eq!(out["created_at"], json!("2020-01-01T10:30:45.123456Z"));
1657        assert!(out.get("created_at_relative").is_none());
1658    }
1659
1660    #[test]
1661    fn agent_renders_recent_timestamp_exactly() {
1662        let ts_unix = NOW - 180;
1663        let ts = unix_to_iso8601(ts_unix);
1664        let v = json!({"updated_at": ts.clone()});
1665        let out = agent(v);
1666        assert_eq!(out["updated_at"], json!(ts));
1667    }
1668
1669    #[test]
1670    fn agent_does_not_compact_top_level_trigger_at_field() {
1671        // Regression for #871: `schedule.remind`'s create response returns
1672        // `trigger_at` as a top-level convenience field (sibling to `id`,
1673        // `full_id`), not nested under `"properties"`. The pre-existing
1674        // `inside_properties` guard alone did not protect it, so Agent-mode
1675        // compaction rewrote it: `at` here is far outside the 24h relative
1676        // window, so pre-fix it would have been minute-truncated to
1677        // "2026-07-11T19:00", discarding the seconds and the "-04:00"
1678        // offset the caller needs to round-trip the exact value verbatim.
1679        let at = "2026-07-11T19:00:00-04:00";
1680        let v = json!({
1681            "id": "a1b2c3d4",
1682            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1683            "event_type": "remind",
1684            "trigger_at": at,
1685            "repeat": null,
1686            "status": "pending",
1687        });
1688        let out = agent(v);
1689        assert_eq!(out["trigger_at"], json!(at));
1690    }
1691
1692    #[test]
1693    fn agent_does_not_compact_top_level_trigger_at_utc() {
1694        let at = "2026-07-11T23:00:00Z";
1695        let v = json!({"trigger_at": at});
1696        let out = agent(v);
1697        assert_eq!(out["trigger_at"], json!(at));
1698    }
1699
1700    #[test]
1701    fn agent_does_not_compact_top_level_trigger_at_offset_less() {
1702        let at = "2026-07-11T23:00:00";
1703        let v = json!({"trigger_at": at});
1704        let out = agent(v);
1705        assert_eq!(out["trigger_at"], json!(at));
1706    }
1707
1708    #[test]
1709    fn agent_still_renders_other_top_level_timestamps_alongside_trigger_at() {
1710        // The `trigger_at` exemption is scoped to that field name only — a
1711        // sibling generic timestamp field still converts to exact UTC.
1712        let v = json!({
1713            "trigger_at": "2026-07-11T19:00:00-04:00",
1714            "created_at": "2020-01-01T10:30:45.123456Z",
1715        });
1716        let out = agent(v);
1717        assert_eq!(out["trigger_at"], json!("2026-07-11T19:00:00-04:00"));
1718        assert_eq!(out["created_at"], json!("2020-01-01T10:30:45.123456Z"));
1719    }
1720
1721    #[test]
1722    fn agent_does_not_compact_top_level_due() {
1723        // gtd.assign/gtd.tasks/gtd.next return the caller-supplied `due` as
1724        // a top-level convenience field mirroring `properties.due`; it must
1725        // round-trip verbatim through Agent-mode presentation, the same
1726        // guarantee already given to `trigger_at`.
1727        let due = "2026-08-01T09:30:15-04:00";
1728        let v = json!({"due": due});
1729        let out = agent(v);
1730        assert_eq!(out["due"], json!(due));
1731    }
1732
1733    #[test]
1734    fn agent_still_protects_nested_trigger_at_under_properties() {
1735        // Pre-existing protection (agenda/get responses nest trigger_at
1736        // under "properties") must remain intact alongside the new
1737        // top-level, field-name-based guard.
1738        let at = "2026-07-11T19:00:00-04:00";
1739        let v = json!({
1740            "id": "a1b2c3d4",
1741            "properties": {"trigger_at": at, "status": "pending"},
1742        });
1743        let out = agent(v);
1744        assert_eq!(out["properties"]["trigger_at"], json!(at));
1745    }
1746
1747    #[test]
1748    fn agent_recurses_into_nested_objects() {
1749        let v = json!({
1750            "items": [
1751                {
1752                    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1753                    "tags": [],
1754                    "score": 0.9999
1755                }
1756            ]
1757        });
1758        let out = agent(v);
1759        let item = &out["items"][0];
1760        assert_eq!(item["id"], json!("a1b2c3d4"));
1761        assert!(item.get("tags").is_none());
1762        let s = item["score"].as_f64().unwrap();
1763        assert!((s - 1.0).abs() < 1e-9);
1764    }
1765
1766    // full_id must never be shortened in Agent mode: it's the caller's
1767    // stable chaining handle.
1768    #[test]
1769    fn agent_preserves_full_id_as_36_chars() {
1770        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1771        let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
1772        let out = agent(v);
1773        // `id` is shortened to 8 chars
1774        assert_eq!(
1775            out["id"],
1776            json!("a1b2c3d4"),
1777            "id should be 8-char short form"
1778        );
1779        // `full_id` must remain the full 36-char UUID
1780        assert_eq!(
1781            out["full_id"].as_str().unwrap().len(),
1782            36,
1783            "full_id must be 36 chars in agent mode"
1784        );
1785        assert_eq!(
1786            out["full_id"],
1787            json!(uuid),
1788            "full_id must equal the original UUID"
1789        );
1790        // Verify the invariant: full_id starts with the short id prefix
1791        assert!(
1792            out["full_id"]
1793                .as_str()
1794                .unwrap()
1795                .starts_with(out["id"].as_str().unwrap()),
1796            "full_id must start with the short id prefix"
1797        );
1798    }
1799
1800    #[test]
1801    fn is_canonical_uuid_recognizes_valid() {
1802        assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1803        assert!(!is_canonical_uuid("a1b2c3d4"));
1804        assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
1805    }
1806
1807    #[test]
1808    fn looks_like_iso8601_recognizes_valid() {
1809        assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
1810        assert!(!looks_like_iso8601("not a timestamp"));
1811        assert!(!looks_like_iso8601("2026-05-23"));
1812    }
1813
1814    /// Format Unix seconds as ISO-8601 for test construction.
1815    fn unix_to_iso8601(unix: i64) -> String {
1816        let (y, mo, d, h, mi, s) = unix_to_civil(unix);
1817        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
1818    }
1819
1820    fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
1821        let s = unix % 86400;
1822        let days = unix / 86400;
1823        let h = s / 3600;
1824        let m = (s % 3600) / 60;
1825        let sec = s % 60;
1826        // Howard Hinnant civil_from_days
1827        let z = days + 719468;
1828        let era = z.div_euclid(146097);
1829        let doe = z - era * 146097;
1830        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1831        let y = yoe + era * 400;
1832        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1833        let mp = (5 * doy + 2) / 153;
1834        let d = doy - (153 * mp + 2) / 5 + 1;
1835        let mo = if mp < 10 { mp + 3 } else { mp - 9 };
1836        let y = if mo <= 2 { y + 1 } else { y };
1837        (y, mo, d, h, m, sec)
1838    }
1839
1840    #[test]
1841    fn agent_does_not_shorten_uuid_shaped_content_fields() {
1842        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1843        let out = agent(json!({
1844            "id": uuid,
1845            "full_id": uuid,
1846            "content": uuid,
1847            "description": uuid,
1848            "title": uuid,
1849            "query": uuid,
1850        }));
1851
1852        assert_eq!(out["id"], json!("a1b2c3d4"));
1853        assert_eq!(out["full_id"], json!(uuid));
1854        assert_eq!(out["content"], json!(uuid));
1855        assert_eq!(out["description"], json!(uuid));
1856        assert_eq!(out["title"], json!(uuid));
1857        assert_eq!(out["query"], json!(uuid));
1858    }
1859
1860    #[test]
1861    fn agent_shortens_suffix_id_fields() {
1862        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1863        let out = agent(json!({
1864            "note_id": uuid,
1865            "source_id": uuid,
1866            "target_id": uuid,
1867        }));
1868
1869        assert_eq!(out["note_id"], json!("a1b2c3d4"));
1870        assert_eq!(out["source_id"], json!("a1b2c3d4"));
1871        assert_eq!(out["target_id"], json!("a1b2c3d4"));
1872    }
1873
1874    #[test]
1875    fn agent_preserves_strict_round_trip_uuid_fields() {
1876        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1877        let out = agent(json!({
1878            "context_entity_id": uuid,
1879            "thread_id": uuid,
1880            "session_id": uuid,
1881            "project_id": uuid,
1882            "properties": {
1883                "context_entity_id": uuid,
1884                "thread_id": uuid,
1885                "outbound_ref": uuid,
1886                "parent_id": uuid,
1887                "session_id": uuid,
1888                "project_id": uuid,
1889            },
1890            "parent_id": uuid,
1891        }));
1892
1893        assert_eq!(out["context_entity_id"], json!(uuid));
1894        assert_eq!(out["thread_id"], json!(uuid));
1895        assert_eq!(out["session_id"], json!(uuid));
1896        assert_eq!(out["project_id"], json!(uuid));
1897        assert_eq!(out["properties"]["context_entity_id"], json!(uuid));
1898        assert_eq!(out["properties"]["thread_id"], json!(uuid));
1899        assert_eq!(out["properties"]["outbound_ref"], json!(uuid));
1900        assert_eq!(out["properties"]["parent_id"], json!(uuid));
1901        assert_eq!(out["properties"]["session_id"], json!(uuid));
1902        assert_eq!(out["properties"]["project_id"], json!(uuid));
1903        assert_eq!(out["parent_id"], json!(uuid));
1904    }
1905
1906    // ── ADR-078: OutputFormat tests ───────────────────────────────────────────
1907
1908    /// (a) verbose json preserves full shape (no field dropped, no transformation).
1909    #[test]
1910    fn format_verbose_json_preserves_full_shape() {
1911        let v = json!({
1912            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1913            "namespace": "local",
1914            "properties": {"k": "v"},
1915            "title": "test"
1916        });
1917        let rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Verbose);
1918        let parsed: Value = serde_json::from_str(&rendered).unwrap();
1919        // full_id must not be dropped in json mode.
1920        assert!(
1921            parsed.get("full_id").is_some(),
1922            "json mode must keep full_id"
1923        );
1924        // namespace must NOT be elided in json mode.
1925        assert_eq!(
1926            parsed.get("namespace").and_then(Value::as_str),
1927            Some("local")
1928        );
1929        // properties must NOT be deduped in json mode.
1930        assert!(parsed.get("properties").is_some());
1931    }
1932
1933    /// (a-agent-json) Agent JSON applies the Machine-scope redundancy
1934    /// reduction: `namespace`/duplicate-`properties` elision, but `full_id`
1935    /// is retained — it is the caller's chaining handle, not a view drop
1936    /// (ADR-078 Amendment 3, corrected).
1937    #[test]
1938    fn format_agent_json_drops_redundancy() {
1939        let v = json!({
1940            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1941            "namespace": "local",
1942            "title": "test",
1943            "status": "next",
1944            "properties": {"status": "next", "additive": true}
1945        });
1946        let rendered = render_format(v, OutputFormat::Json, PresentationMode::Agent);
1947        let parsed: Value = serde_json::from_str(&rendered).unwrap();
1948        assert!(
1949            parsed.get("full_id").is_some(),
1950            "Agent JSON must keep full_id: it is the machine contract's chaining handle"
1951        );
1952        assert!(parsed.get("namespace").is_none());
1953        assert_eq!(parsed["status"], json!("next"));
1954        assert!(parsed["properties"].get("status").is_none());
1955        assert_eq!(parsed["properties"]["additive"], json!(true));
1956    }
1957
1958    /// Agent JSON (Machine scope) keeps `full_id` while dropping
1959    /// `namespace="local"` and duplicate `properties`; Auto (View scope) on
1960    /// the same record still drops all three — the view scope is unchanged
1961    /// by Amendment 3's correction.
1962    #[test]
1963    fn agent_json_keeps_full_id_while_auto_still_drops_it() {
1964        let v = json!({
1965            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1966            "namespace": "local",
1967            "title": "test",
1968            "properties": {"title": "test", "additive": true}
1969        });
1970
1971        let json_rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
1972        let json_parsed: Value = serde_json::from_str(&json_rendered).unwrap();
1973        assert!(
1974            json_parsed.get("full_id").is_some(),
1975            "Agent JSON must keep full_id"
1976        );
1977        assert!(
1978            json_parsed.get("namespace").is_none(),
1979            "Agent JSON must elide namespace=\"local\""
1980        );
1981        assert!(
1982            json_parsed["properties"].get("title").is_none(),
1983            "Agent JSON must dedupe a duplicate properties entry"
1984        );
1985        assert_eq!(json_parsed["properties"]["additive"], json!(true));
1986
1987        let auto_rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1988        assert!(
1989            !auto_rendered.contains("full_id"),
1990            "Auto (view scope) must still drop full_id: {auto_rendered}"
1991        );
1992        assert!(
1993            !auto_rendered.contains("\"namespace\""),
1994            "Auto must still elide namespace=\"local\": {auto_rendered}"
1995        );
1996    }
1997
1998    /// (b1) homogeneous record array → markdown table with header + separator + rows.
1999    #[test]
2000    fn format_auto_homogeneous_array_renders_markdown_table() {
2001        let v = json!([
2002            {"id": "abc", "title": "First"},
2003            {"id": "def", "title": "Second"}
2004        ]);
2005        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2006        assert!(rendered.starts_with('|'), "must start with |");
2007        assert!(
2008            rendered.contains("| id |") || rendered.contains("| id"),
2009            "must have id column"
2010        );
2011        assert!(rendered.contains("title"), "must have title column");
2012        assert!(rendered.contains("|---|"), "must have separator row");
2013        assert!(rendered.contains("abc"), "must have first row data");
2014        assert!(rendered.contains("Second"), "must have second row data");
2015    }
2016
2017    /// (b2) single record → compact JSON, lossless (kv-block renderer removed).
2018    #[test]
2019    fn format_auto_single_record_renders_compact_json() {
2020        let v = json!({"id": "abc", "title": "Hello World"});
2021        let rendered = render_format(v.clone(), OutputFormat::Auto, PresentationMode::Agent);
2022        let parsed: Value = serde_json::from_str(&rendered).expect("must be valid JSON");
2023        assert_eq!(parsed, v, "single record must round-trip losslessly");
2024        assert!(
2025            !rendered.starts_with('|'),
2026            "single record must not be a markdown table"
2027        );
2028    }
2029
2030    /// (b2-lossless) a single record with a large payload field (a compose
2031    /// briefing's `markdown`) arrives whole — the former kv-block renderer
2032    /// truncated it.
2033    #[test]
2034    fn format_auto_single_record_large_payload_survives_whole() {
2035        let briefing = "line one\nline two\n".repeat(500); // ~9KB, newlines included
2036        let v = json!({"id": "abc", "markdown": briefing.clone()});
2037        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2038        let parsed: Value = serde_json::from_str(&rendered).expect("must be valid JSON");
2039        assert_eq!(
2040            parsed.get("markdown").and_then(Value::as_str),
2041            Some(briefing.as_str()),
2042            "large payload field must survive untruncated"
2043        );
2044    }
2045
2046    /// Envelope siblings survive table rendering: a `query`-style page whose
2047    /// `has_more` is dropped reads as complete — fail-open.
2048    #[test]
2049    fn format_auto_table_preserves_sibling_scalars() {
2050        let v = json!({
2051            "results": [
2052                {"id": "abc", "title": "First"},
2053                {"id": "def", "title": "Second"}
2054            ],
2055            "has_more": true,
2056            "offset": 20,
2057            "page_size": 2,
2058            "truncated": false
2059        });
2060        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2061        assert!(rendered.contains("| id"), "records must render as a table");
2062        assert!(
2063            rendered.contains("has_more: true"),
2064            "has_more sibling must survive: {rendered}"
2065        );
2066        assert!(
2067            rendered.contains("offset: 20"),
2068            "offset sibling must survive"
2069        );
2070        assert!(
2071            rendered.contains("page_size: 2"),
2072            "page_size sibling must survive"
2073        );
2074        assert!(
2075            rendered.contains("truncated: false"),
2076            "truncated sibling must survive"
2077        );
2078    }
2079
2080    /// Nested table cells render a constant elision marker, never
2081    /// truncated pseudo-JSON that reads as data while missing fields.
2082    #[test]
2083    fn format_auto_nested_cell_renders_elision_marker() {
2084        let big_nested: Value = json!({"k": "v".repeat(300), "other": {"deep": true}});
2085        let v = json!([
2086            {"id": "abc", "properties": big_nested},
2087            {"id": "def", "properties": {"x": 1}}
2088        ]);
2089        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2090        assert!(
2091            rendered.contains("{…}"),
2092            "nested object cell must render the elision marker: {rendered}"
2093        );
2094        assert!(
2095            !rendered.contains("..."),
2096            "no truncated pseudo-JSON in nested cells"
2097        );
2098    }
2099
2100    /// Arrays of scalars (tags) still render as compact JSON in cells.
2101    #[test]
2102    fn format_auto_scalar_array_cell_renders_compact_json() {
2103        let v = json!([
2104            {"id": "abc", "tags": ["lesson", "khive"]},
2105            {"id": "def", "tags": ["adr"]}
2106        ]);
2107        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2108        assert!(
2109            rendered.contains(r#"["lesson","khive"]"#),
2110            "scalar array cell must be compact JSON: {rendered}"
2111        );
2112    }
2113
2114    /// Identity/decision columns are exempt from truncation: a truncated
2115    /// title cannot be selected on, a truncated signature is unusable.
2116    #[test]
2117    fn format_auto_identity_columns_not_truncated() {
2118        let long_title = "T".repeat(300);
2119        let long_note = "N".repeat(300);
2120        let v = json!([
2121            {"id": "abc", "title": long_title.clone(), "note": long_note.clone()},
2122            {"id": "def", "title": "short", "note": "short"}
2123        ]);
2124        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2125        assert!(
2126            rendered.contains(&long_title),
2127            "title column must not be truncated"
2128        );
2129        assert!(
2130            !rendered.contains(&long_note),
2131            "non-exempt column must still truncate"
2132        );
2133    }
2134
2135    /// Verbose presentation disables cell truncation entirely (ADR-078 §3a).
2136    #[test]
2137    fn format_auto_verbose_disables_truncation() {
2138        let long_note = "N".repeat(300);
2139        let v = json!([
2140            {"id": "abc", "note": long_note.clone()},
2141            {"id": "def", "note": "short"}
2142        ]);
2143        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
2144        assert!(
2145            rendered.contains(&long_note),
2146            "verbose must render full cell content"
2147        );
2148    }
2149
2150    /// The pre-pass hoists named scalar payload fields (`trigger_at`, `due`,
2151    /// `status`) out of `properties` so they can surface as table columns —
2152    /// a scheduled event carries them only inside `properties`.
2153    #[test]
2154    fn table_path_hoists_payload_scalars_to_columns() {
2155        // The hoist is table-path only (ADR-078 Amendment 2): two scheduled
2156        // events whose trigger_at/status live inside `properties` must gain
2157        // top-level columns in the rendered table.
2158        let record = |n: u32| {
2159            json!({
2160                "id": format!("evt-{n}"),
2161                "properties": {
2162                    "trigger_at": "2026-09-01T14:00:00-04:00",
2163                    "status": "pending",
2164                    "dispatch_receipt": {"state": "succeeded"}
2165                }
2166            })
2167        };
2168        let out = render_format(
2169            json!([record(1), record(2)]),
2170            OutputFormat::Auto,
2171            PresentationMode::Agent,
2172        );
2173        let header = out.lines().next().expect("table header");
2174        assert!(
2175            header.contains("trigger_at") && header.contains("status"),
2176            "hoisted scalars must appear as table columns, got header: {header}"
2177        );
2178        assert!(
2179            out.contains("2026-09-01T14:00:00-04:00") && out.contains("pending"),
2180            "hoisted values must render in cells:\n{out}"
2181        );
2182    }
2183
2184    #[test]
2185    fn redundancy_drop_no_longer_hoists_outside_tables() {
2186        // Rule-separating control for the table-scoped hoist: the §7 pre-pass
2187        // alone must leave the properties bag in place, so a single record's
2188        // compact-JSON fallback keeps its shape.
2189        let v = json!({
2190            "id": "abc",
2191            "properties": {
2192                "trigger_at": "2026-09-01T14:00:00-04:00",
2193                "status": "pending"
2194            }
2195        });
2196        let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2197        assert!(
2198            reduced.get("trigger_at").is_none() && reduced.get("status").is_none(),
2199            "the pre-pass must not hoist; the hoist is table-path only"
2200        );
2201        let props = reduced.get("properties").expect("properties must remain");
2202        assert_eq!(
2203            props.get("status").and_then(Value::as_str),
2204            Some("pending"),
2205            "fallback shape keeps payload fields inside properties"
2206        );
2207    }
2208
2209    #[test]
2210    fn sibling_string_with_newline_renders_as_json_literal() {
2211        // Escaping contract: a newline-bearing sibling string must not be able
2212        // to fabricate an additional `key: value` line.
2213        let v = json!({
2214            "items": [{"id": "a", "kind": "x"}, {"id": "b", "kind": "y"}],
2215            "note": "line one\nforged_key: forged_value",
2216            "has_more": true
2217        });
2218        let out = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2219        assert!(
2220            !out.contains("\nforged_key: forged_value"),
2221            "raw newline from a sibling string must not start a new line:\n{out}"
2222        );
2223        assert!(
2224            out.contains(r#"note: "line one\nforged_key: forged_value""#),
2225            "newline-bearing sibling renders as its JSON literal:\n{out}"
2226        );
2227        assert!(out.contains("has_more: true"), "siblings still preserved");
2228    }
2229
2230    #[test]
2231    fn carriage_return_in_cell_collapses_like_newline() {
2232        // Escaping contract pin: cells collapse \r exactly like \n, so a
2233        // \r- or \r\n-bearing value cannot smuggle a raw line break into
2234        // the table body and forge row structure.
2235        let v = json!([
2236            {"id": "a", "note": "before\rafter"},
2237            {"id": "b", "note": "one\r\ntwo"}
2238        ]);
2239        let out = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2240        assert!(
2241            !out.contains('\r'),
2242            "no raw carriage return may survive into rendered output:\n{out:?}"
2243        );
2244        assert!(
2245            out.contains("before after") && out.contains("one  two"),
2246            "\\r and \\r\\n collapse to spaces inside cells:\n{out}"
2247        );
2248    }
2249
2250    /// The hoist never overwrites an existing top-level sibling.
2251    #[test]
2252    fn redundancy_drop_hoist_does_not_overwrite_top_level() {
2253        let v = json!({
2254            "id": "abc",
2255            "status": "active",
2256            "properties": {"status": "pending"}
2257        });
2258        let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2259        assert_eq!(
2260            reduced.get("status").and_then(Value::as_str),
2261            Some("active"),
2262            "existing top-level status must win"
2263        );
2264        assert_eq!(
2265            reduced
2266                .get("properties")
2267                .and_then(|p| p.get("status"))
2268                .and_then(Value::as_str),
2269            Some("pending"),
2270            "conflicting properties value must stay where it was"
2271        );
2272    }
2273
2274    /// (b3) fallback: auto on heterogeneous/scalar value falls back to compact json.
2275    #[test]
2276    fn format_auto_scalar_fallback_compact_json() {
2277        let v = json!(42);
2278        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2279        assert_eq!(rendered, "42");
2280    }
2281
2282    /// (c) table format forces markdown table even when shape would normally be kv.
2283    #[test]
2284    fn format_table_forces_markdown_when_array() {
2285        let v = json!({
2286            "items": [
2287                {"name": "A", "score": 1},
2288                {"name": "B", "score": 2}
2289            ]
2290        });
2291        let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
2292        assert!(
2293            rendered.contains("|"),
2294            "table format must produce markdown table"
2295        );
2296        assert!(rendered.contains("name"), "must have name column");
2297        assert!(rendered.contains("score"), "must have score column");
2298    }
2299
2300    /// (c-fallback) table format falls back to compact json when no record array found.
2301    #[test]
2302    fn format_table_falls_back_to_json_when_no_array() {
2303        let v = json!({"single": "value"});
2304        let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
2305        // No record array → compact JSON fallback.
2306        let parsed: Value = serde_json::from_str(&rendered).unwrap();
2307        assert_eq!(parsed["single"], json!("value"));
2308    }
2309
2310    /// (d) redundancy-drop: auto/table skipped in Verbose mode (§7).
2311    #[test]
2312    fn format_auto_verbose_skips_redundancy_drop() {
2313        let v = json!({
2314            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
2315            "namespace": "local",
2316            "title": "test"
2317        });
2318        // In Verbose mode, redundancy drop must be skipped.
2319        // The value is a single object → compact JSON; full_id and namespace stay.
2320        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
2321        assert!(
2322            rendered.contains("full_id"),
2323            "verbose must preserve full_id"
2324        );
2325        assert!(
2326            rendered.contains("namespace"),
2327            "verbose must preserve namespace"
2328        );
2329    }
2330
2331    /// Indirect check that the redundancy pre-pass doesn't corrupt an error
2332    /// envelope's shape; the actual ok=false bypass is enforced by
2333    /// `render_result`, not by this pre-pass.
2334    #[test]
2335    fn redundancy_drop_does_not_corrupt_error_shape() {
2336        let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
2337        // apply_redundancy_drop is a pure value transform with no knowledge of
2338        // `ok`: bypassing it for error envelopes is the caller's job
2339        // (render_result in server.rs). This only checks the pre-pass itself
2340        // doesn't lose the error field.
2341        let reduced = apply_redundancy_drop(v.clone(), RedundancyScope::View);
2342        assert!(
2343            reduced.get("error").is_some(),
2344            "redundancy drop must preserve error field"
2345        );
2346        assert_eq!(
2347            reduced.get("ok").and_then(Value::as_bool),
2348            Some(false),
2349            "redundancy drop must preserve ok=false"
2350        );
2351    }
2352
2353    /// Properties dedup removes only keys that match a top-level sibling exactly.
2354    #[test]
2355    fn redundancy_drop_properties_dedup() {
2356        let v = json!({
2357            "id": "abc",
2358            "title": "Same",
2359            "properties": {
2360                "title": "Same",  // duplicate → removed
2361                "extra": "unique" // not at top level → kept
2362            }
2363        });
2364        let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2365        let props = reduced.get("properties").expect("properties must remain");
2366        assert!(props.get("extra").is_some(), "unique property must be kept");
2367        assert!(
2368            props.get("title").is_none(),
2369            "duplicate top-level property must be removed"
2370        );
2371    }
2372
2373    /// Cell truncation: text > 120 chars gets `...` appended.
2374    #[test]
2375    fn cell_text_truncates_long_values() {
2376        let long = "X".repeat(200);
2377        let v = json!([
2378            {"col": long.clone()},
2379            {"col": "short"}
2380        ]);
2381        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2382        // Cell must be truncated to ~120 chars + "..."
2383        assert!(
2384            rendered.contains("..."),
2385            "long cell must be truncated with ..."
2386        );
2387        assert!(
2388            !rendered.contains(&long),
2389            "full long string must not appear in table"
2390        );
2391    }
2392
2393    /// Cell truncation must not panic on multi-byte UTF-8 characters.
2394    ///
2395    /// A string of 119 ASCII bytes followed by a 3-byte CJK character and more
2396    /// text has `len() > 120` but byte index 120 falls inside the CJK char.
2397    /// The old byte-slice truncation would panic; char-boundary truncation is safe.
2398    #[test]
2399    fn cell_text_truncation_is_utf8_safe() {
2400        // 119 ASCII 'a' bytes, then CJK char U+4E2D (3 bytes each), then more text.
2401        // Total byte length: 119 + 3 * 10 + 5 > 120, but byte 120 is inside a CJK char.
2402        let prefix = "a".repeat(119);
2403        let suffix = "中".repeat(10); // each '中' is 3 bytes
2404        let long_multibyte = format!("{prefix}{suffix}trailing");
2405        let v = json!([
2406            {"col": long_multibyte.clone()},
2407            {"col": "ok"}
2408        ]);
2409        // Must not panic — this was the bug.
2410        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2411        assert!(
2412            rendered.contains("..."),
2413            "multibyte cell must be truncated with ..."
2414        );
2415        // The rendered string must be valid UTF-8 (no partial char slicing).
2416        assert!(
2417            std::str::from_utf8(rendered.as_bytes()).is_ok(),
2418            "rendered output must be valid UTF-8"
2419        );
2420    }
2421
2422    // --- parse_iso8601_unix / relative-time offset handling ---
2423
2424    #[test]
2425    fn parse_iso8601_unix_negative_offset_matches_equivalent_utc() {
2426        // "-04:00" is 4 hours behind UTC, so 11:55 local == 15:55Z.
2427        assert_eq!(
2428            parse_iso8601_unix("2026-07-09T11:55:00-04:00"),
2429            parse_iso8601_unix("2026-07-09T15:55:00Z")
2430        );
2431    }
2432
2433    #[test]
2434    fn parse_iso8601_unix_positive_offset_matches_equivalent_utc() {
2435        // "+04:00" is 4 hours ahead of UTC, so 20:15 local == 16:15Z.
2436        assert_eq!(
2437            parse_iso8601_unix("2026-05-23T20:15:00+04:00"),
2438            parse_iso8601_unix("2026-05-23T16:15:00Z")
2439        );
2440    }
2441
2442    #[test]
2443    fn parse_iso8601_unix_zero_offset_matches_z() {
2444        assert_eq!(
2445            parse_iso8601_unix("2026-07-09T15:55:00+00:00"),
2446            parse_iso8601_unix("2026-07-09T15:55:00Z")
2447        );
2448    }
2449
2450    #[test]
2451    fn parse_iso8601_unix_compact_offset_form_matches_colon_form() {
2452        assert_eq!(
2453            parse_iso8601_unix("2026-07-09T11:55:00-0400"),
2454            parse_iso8601_unix("2026-07-09T11:55:00-04:00")
2455        );
2456    }
2457
2458    #[test]
2459    fn parse_iso8601_unix_fractional_seconds_with_offset() {
2460        // Fractional seconds are dropped (whole-second precision only) but
2461        // must not prevent the trailing offset from being applied.
2462        assert_eq!(
2463            parse_iso8601_unix("2026-07-09T11:55:00.123-04:00"),
2464            parse_iso8601_unix("2026-07-09T15:55:00Z")
2465        );
2466    }
2467
2468    #[test]
2469    fn parse_iso8601_unix_fractional_seconds_with_z() {
2470        assert_eq!(
2471            parse_iso8601_unix("2026-07-09T15:55:00.999Z"),
2472            parse_iso8601_unix("2026-07-09T15:55:00Z")
2473        );
2474    }
2475
2476    #[test]
2477    fn parse_iso8601_unix_rejects_bare_form() {
2478        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00"), None);
2479    }
2480
2481    #[test]
2482    fn parse_iso8601_unix_malformed_tail_returns_none() {
2483        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00X"), None);
2484        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+04"), None);
2485        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00."), None);
2486    }
2487
2488    #[test]
2489    fn parse_iso8601_unix_out_of_range_offset_returns_none() {
2490        // Hour out of range (>23), colon and compact forms.
2491        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+24:00"), None);
2492        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+2400"), None);
2493        // Minute out of range (>59), colon and compact forms.
2494        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+01:60"), None);
2495        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+0160"), None);
2496    }
2497
2498    #[test]
2499    fn parse_iso8601_unix_max_valid_offset_boundary_is_accepted() {
2500        // +23:59 / -23:59 are the largest valid offsets and must still parse.
2501        assert!(parse_iso8601_unix("2026-07-09T15:55:00+23:59").is_some());
2502        assert!(parse_iso8601_unix("2026-07-09T15:55:00-23:59").is_some());
2503        assert!(parse_iso8601_unix("2026-07-09T15:55:00+2359").is_some());
2504    }
2505
2506    #[test]
2507    fn exact_timestamp_converts_future_offset_without_relative_form() {
2508        let out = exact_timestamp("2025-05-23T16:08:00-02:00").unwrap();
2509        assert_eq!(out.0, "2025-05-23T18:08:00Z");
2510        assert!(out.1 > NOW);
2511    }
2512
2513    #[test]
2514    fn exact_timestamp_converts_past_offset_and_retains_fraction() {
2515        let out = exact_timestamp("2025-05-23T20:05:00.123+04:00").unwrap();
2516        assert_eq!(out.0, "2025-05-23T16:05:00.123Z");
2517        assert_eq!(out.1, NOW - 180);
2518    }
2519}
2520
2521#[cfg(test)]
2522mod stream_presentation_tests {
2523    use super::*;
2524    use serde_json::json;
2525
2526    #[test]
2527    fn stream_agent_json_preserves_opaque_record_and_empty_page() {
2528        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"}}]);
2529        for payload in [record, Value::Null, json!([]), json!({}), json!("")] {
2530            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});
2531            let presented = present(page, PresentationMode::Agent, 0);
2532            let out = prepare_format_value(presented, OutputFormat::Json, PresentationMode::Agent);
2533            assert_eq!(out["entries"][0].get("record"), Some(&payload));
2534            assert!(out.get("next_after").is_some_and(Value::is_null));
2535        }
2536        let empty = json!({"entries": [], "head_seq": 0, "next_after": null});
2537        assert_eq!(present(empty.clone(), PresentationMode::Agent, 0), empty);
2538    }
2539}
2540
2541#[cfg(test)]
2542mod issue_2537_standard_tests {
2543    use super::*;
2544    use serde_json::json;
2545
2546    #[test]
2547    fn issue_2537_standard_context_baseline_controls() {
2548        let timestamp = "2026-01-01T00:00:00.123456Z";
2549        let now = 1_767_225_780; // 2026-01-01T00:03:00Z, independently literal clock.
2550        let uuid = "aabbccdd-1234-4321-1234-abcdefabcdef";
2551        let payload = json!({"id":uuid,"created_at":timestamp,"empty":[],"null":null});
2552        let value = json!({"tool":"stream.batch","policy":"StreamBatchReceipts","id":uuid,
2553            "updated_at":timestamp,"score":0.123456,"empty":[],"null":null,
2554            "results":[{"id":uuid,"version":1,"updated_at":timestamp,"details":{"updated_at":timestamp}}],
2555            "properties":{"created_at":timestamp,"id":uuid,"empty":""},"trigger_at":timestamp,"due":timestamp,
2556            "entry":{"seq":1,"id":uuid,"created_at":timestamp,"record":payload},
2557            "cursor":{"head_seq":0,"entries":[],"next_after":null}});
2558        let shown = present(value.clone(), PresentationMode::Agent, now);
2559        assert_eq!(shown["updated_at"], timestamp);
2560        assert_eq!(shown["results"][0]["updated_at"], timestamp);
2561        assert_eq!(shown["results"][0]["details"]["updated_at"], timestamp);
2562        assert_eq!(shown["id"], "aabbccdd");
2563        assert_eq!(shown["score"], json!(0.123));
2564        assert!(shown.get("empty").is_none());
2565        assert!(shown.get("null").is_none());
2566        assert_eq!(
2567            shown["properties"],
2568            json!({"created_at":timestamp,"id":"aabbccdd","empty":""})
2569        );
2570        assert_eq!(shown["trigger_at"], timestamp);
2571        assert_eq!(shown["due"], timestamp);
2572        assert_eq!(shown["entry"]["record"], payload);
2573        assert_eq!(shown["entry"]["created_at"], timestamp);
2574        assert_eq!(shown["cursor"], value["cursor"]);
2575        for mode in [PresentationMode::Verbose, PresentationMode::Human] {
2576            assert_eq!(present(value.clone(), mode, now), value);
2577        }
2578        assert_eq!(
2579            present(
2580                json!({"properties":timestamp}),
2581                PresentationMode::Agent,
2582                now
2583            )["properties"],
2584            timestamp
2585        );
2586        assert_eq!(
2587            present(
2588                json!({"properties":[timestamp,{"created_at":timestamp}]}),
2589                PresentationMode::Agent,
2590                now
2591            )["properties"],
2592            json!([timestamp,{"created_at":timestamp}])
2593        );
2594    }
2595}
2596
2597#[cfg(test)]
2598mod issue_2537_receipt_policy_tests {
2599    use super::*;
2600    use serde_json::json;
2601
2602    const TIMESTAMP: &str = "2026-01-01T00:00:00.123456Z";
2603    const NOW: i64 = 1_767_225_780;
2604    const UUID: &str = "aabbccdd-1234-4321-1234-abcdefabcdef";
2605
2606    #[test]
2607    fn issue_2537_receipt_policies_preserve_only_the_named_fields() {
2608        let append = json!({
2609            "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2610            "score": 0.123456, "empty": [], "null": null,
2611            "details": {"created_at": TIMESTAMP},
2612            "ticker": {"last_tick_at": TIMESTAMP},
2613        });
2614        let batch = json!({
2615            "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2616            "results": [{
2617                "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2618                "details": {"created_at": TIMESTAMP, "updated_at": TIMESTAMP},
2619                "record": {"updated_at": TIMESTAMP},
2620                "score": 0.123456, "empty": [], "null": null,
2621            }],
2622            "properties": {"created_at": TIMESTAMP, "id": UUID, "empty": ""},
2623            "entry": {"seq": 1, "id": UUID, "created_at": TIMESTAMP,
2624                "record": {"id": UUID, "updated_at": TIMESTAMP, "empty": [], "null": null}},
2625            "trigger_at": TIMESTAMP, "due": TIMESTAMP,
2626            "ticker": {"last_tick_at": TIMESTAMP},
2627        });
2628        for (value, policy) in [
2629            (append, VerbPresentationPolicy::StreamAppendReceipt),
2630            (batch, VerbPresentationPolicy::StreamBatchReceipts),
2631        ] {
2632            let mut expected = present(value.clone(), PresentationMode::Agent, NOW);
2633            assert_eq!(expected["created_at"], TIMESTAMP);
2634            assert_eq!(expected["updated_at"], TIMESTAMP);
2635            assert_eq!(expected["id"], "aabbccdd");
2636            assert_eq!(expected["ticker"]["last_tick_at"], TIMESTAMP);
2637            if policy == VerbPresentationPolicy::StreamAppendReceipt {
2638                expected["created_at"] = json!(TIMESTAMP);
2639            } else {
2640                expected["results"][0]["created_at"] = json!(TIMESTAMP);
2641                expected["results"][0]["updated_at"] = json!(TIMESTAMP);
2642                expected["results"][0]
2643                    .as_object_mut()
2644                    .unwrap()
2645                    .remove("created_at_relative");
2646            }
2647            assert_eq!(
2648                present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2649                expected
2650            );
2651            for mode in [PresentationMode::Verbose, PresentationMode::Human] {
2652                assert_eq!(present_with_policy(value.clone(), mode, NOW, policy), value);
2653            }
2654            assert_eq!(
2655                present_with_policy(
2656                    value.clone(),
2657                    PresentationMode::Agent,
2658                    NOW,
2659                    VerbPresentationPolicy::AlwaysVerbose
2660                ),
2661                value
2662            );
2663        }
2664    }
2665
2666    #[test]
2667    fn issue_2537_receipt_paths_do_not_match_other_containers_or_descendants() {
2668        let row = json!({"created_at": TIMESTAMP, "updated_at": TIMESTAMP});
2669        for (policy, cases) in [
2670            (
2671                VerbPresentationPolicy::StreamAppendReceipt,
2672                vec![
2673                    json!([row.clone()]),
2674                    json!({"details": row.clone()}),
2675                    json!({"results": [row.clone()]}),
2676                    json!({"created_at": {"created_at": TIMESTAMP}}),
2677                ],
2678            ),
2679            (
2680                VerbPresentationPolicy::StreamBatchReceipts,
2681                vec![
2682                    json!([{"results": [row.clone()]}]),
2683                    json!({"results": row.clone()}),
2684                    json!({"results": [[row.clone()]]}),
2685                    json!({"details": {"results": [row.clone()]}}),
2686                    json!({"results": [{"details": row.clone(), "record": row.clone(), "results": [row.clone()]}]}),
2687                    json!({"results": [{"created_at": {"updated_at": TIMESTAMP}, "updated_at": null}]}),
2688                ],
2689            ),
2690        ] {
2691            for value in cases {
2692                assert_eq!(
2693                    present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2694                    present(value.clone(), PresentationMode::Agent, NOW),
2695                    "container must not acquire receipt protection: {value}"
2696                );
2697            }
2698        }
2699    }
2700
2701    #[test]
2702    fn issue_2537_receipt_strings_are_preserved_without_parsing() {
2703        for text in ["", "not a timestamp", "2026-01-01T05:30:00.123456+05:30"] {
2704            let append = json!({"created_at": text});
2705            assert_eq!(
2706                present_with_policy(
2707                    append.clone(),
2708                    PresentationMode::Agent,
2709                    NOW,
2710                    VerbPresentationPolicy::StreamAppendReceipt
2711                ),
2712                append
2713            );
2714            let batch = json!({"results": [{"created_at": text, "updated_at": text}]});
2715            assert_eq!(
2716                present_with_policy(
2717                    batch.clone(),
2718                    PresentationMode::Agent,
2719                    NOW,
2720                    VerbPresentationPolicy::StreamBatchReceipts
2721                ),
2722                batch
2723            );
2724        }
2725        for non_string in [Value::Null, json!([]), json!({}), json!(42), json!(true)] {
2726            for (value, policy) in [
2727                (
2728                    json!({"created_at": non_string}),
2729                    VerbPresentationPolicy::StreamAppendReceipt,
2730                ),
2731                (
2732                    json!({"results": [{"created_at": non_string, "updated_at": non_string}]}),
2733                    VerbPresentationPolicy::StreamBatchReceipts,
2734                ),
2735            ] {
2736                assert_eq!(
2737                    present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2738                    present(value, PresentationMode::Agent, NOW)
2739                );
2740            }
2741        }
2742    }
2743}
2744
2745#[cfg(test)]
2746mod parsed_note_content_tests {
2747    use super::*;
2748    use serde_json::json;
2749
2750    #[test]
2751    fn issue2757_content_is_opaque_to_metadata_and_format_reductions() {
2752        let payload = json!([
2753            {"id":"11111111-1111-4111-8111-111111111111", "full_id":"keep", "namespace":"local",
2754             "created_at":"2026-09-15T12:34:56.123456Z", "score":0.123456789,
2755             "nil":null, "empty":"", "array":[], "object":{},
2756             "properties":{"id":"11111111-1111-4111-8111-111111111111"}},
2757            {"status":"pending"}
2758        ]);
2759        for (scope, response, pointer) in [
2760            (
2761                NoteContentScope::Record,
2762                json!({"id":"22222222-2222-4222-8222-222222222222", "content":payload}),
2763                "/content",
2764            ),
2765            (
2766                NoteContentScope::Items,
2767                json!({"items":[{"id":"22222222-2222-4222-8222-222222222222", "content":payload}], "requested_limit":1, "effective_limit":1, "limit_clamped":false}),
2768                "/items/0/content",
2769            ),
2770            (
2771                NoteContentScope::Notes,
2772                json!({"notes":[{"id":"22222222-2222-4222-8222-222222222222", "content":payload}], "next_after":null}),
2773                "/notes/0/content",
2774            ),
2775        ] {
2776            let presented =
2777                scope.protect(response, |value| present(value, PresentationMode::Agent, 0));
2778            for format in [OutputFormat::Json, OutputFormat::Auto, OutputFormat::Table] {
2779                let rendered = render_format_with_note_content(
2780                    presented.clone(),
2781                    format,
2782                    PresentationMode::Agent,
2783                    scope,
2784                );
2785                let actual: Value = serde_json::from_str(&rendered)
2786                    .expect("single note stays a JSON record, never a table of its body");
2787                let expected = if format == OutputFormat::Table {
2788                    Value::String(payload.to_string())
2789                } else {
2790                    payload.clone()
2791                };
2792                assert_eq!(actual.pointer(pointer), Some(&expected));
2793            }
2794        }
2795    }
2796}
2797
2798#[cfg(test)]
2799mod adr045_amendment9_tests {
2800    use super::*;
2801    use serde_json::json;
2802
2803    const NOW: i64 = 1_748_016_480; // 2025-05-23T16:08:00Z
2804
2805    #[test]
2806    fn agent_exact_utc_preserves_fraction_digits_and_instant() {
2807        for (utc_clock, plus_clock, minus_clock) in [
2808            (
2809                "2025-05-23T16:05:00",
2810                "2025-05-23T20:05:00",
2811                "2025-05-23T12:05:00",
2812            ),
2813            (
2814                "2020-01-01T10:30:45",
2815                "2020-01-01T14:30:45",
2816                "2020-01-01T06:30:45",
2817            ),
2818        ] {
2819            for fraction in ["", ".123", ".123456"] {
2820                let expected = format!("{utc_clock}{fraction}Z");
2821                for (clock, offset) in [
2822                    (utc_clock, "Z"),
2823                    (utc_clock, "+00:00"),
2824                    (plus_clock, "+0400"),
2825                    (minus_clock, "-04:00"),
2826                ] {
2827                    let input = format!("{clock}{fraction}{offset}");
2828                    let shown = present(
2829                        json!({"updated_at": input.clone()}),
2830                        PresentationMode::Agent,
2831                        NOW,
2832                    );
2833                    assert_eq!(shown["updated_at"], expected, "{input}");
2834                    assert_eq!(
2835                        parse_iso8601_unix(&input),
2836                        parse_iso8601_unix(&expected),
2837                        "{input}"
2838                    );
2839                }
2840            }
2841        }
2842    }
2843
2844    #[test]
2845    fn agent_preserves_timestamp_prefix_body() {
2846        let body = "2025-05-23T16:05:00Z followed by ordinary message text";
2847        let shown = present(json!({"content": body}), PresentationMode::Agent, NOW);
2848        assert_eq!(shown["content"], body);
2849    }
2850
2851    #[test]
2852    fn agent_preserves_offsetless_timestamp() {
2853        let body = "2025-05-23T16:05:00";
2854        let shown = present(json!({"content": body}), PresentationMode::Agent, NOW);
2855        assert_eq!(shown["content"], body);
2856    }
2857
2858    #[test]
2859    fn agent_preserves_malformed_offset() {
2860        let body = "2025-05-23T16:05:00+25:00";
2861        let shown = present(json!({"content": body}), PresentationMode::Agent, NOW);
2862        assert_eq!(shown["content"], body);
2863    }
2864
2865    #[test]
2866    fn agent_list_rows_pair_exact_and_relative_time_in_each_band() {
2867        let bands = [
2868            (42, "42s ago"),
2869            (180, "3m ago"),
2870            (3 * 3600, "3h ago"),
2871            (2 * 86400, "2d ago"),
2872        ];
2873        let rows: Vec<Value> = bands
2874            .into_iter()
2875            .map(|(age, _)| {
2876                let created = chrono::DateTime::<chrono::Utc>::from_timestamp(NOW - age, 0)
2877                    .unwrap()
2878                    .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2879                json!({"created_at": created, "updated_at": created})
2880            })
2881            .collect();
2882        let shown = present(json!({"items": rows.clone()}), PresentationMode::Agent, NOW);
2883        for (index, (_, relative)) in bands.into_iter().enumerate() {
2884            assert_eq!(
2885                shown["items"][index]["created_at"],
2886                rows[index]["created_at"]
2887            );
2888            assert_eq!(
2889                shown["items"][index]["updated_at"],
2890                rows[index]["updated_at"]
2891            );
2892            assert_eq!(shown["items"][index]["created_at_relative"], relative);
2893            assert!(shown["items"][index].get("updated_at_relative").is_none());
2894        }
2895    }
2896
2897    #[test]
2898    fn agent_relative_sibling_obeys_array_properties_existing_and_future_bounds() {
2899        let recent = "2025-05-23T16:05:00+00:00";
2900        let future = "2025-05-23T16:08:10Z";
2901        let shown = present(
2902            json!({
2903                "created_at": recent,
2904                "items": [
2905                    {"created_at": future},
2906                    {"created_at": recent, "created_at_relative": "canonical"},
2907                    {"created_at": "2025-05-23T16:05:00"},
2908                    {"updated_at": recent},
2909                    {"created_at": "2025-05-23T16:08:00.000001Z"},
2910                    {"created_at": "2025-05-23T16:07:59.999999Z"},
2911                    {"created_at": "2025-05-23T16:08:00.000000Z"},
2912                    {"properties": [{"created_at": recent, "empty": ""}]}
2913                ],
2914                "properties": {"nested": [{"created_at": recent}]}
2915            }),
2916            PresentationMode::Agent,
2917            NOW,
2918        );
2919        assert_eq!(shown["created_at"], "2025-05-23T16:05:00Z");
2920        assert!(shown.get("created_at_relative").is_none());
2921        assert_eq!(shown["items"][0]["created_at"], future);
2922        assert!(shown["items"][0].get("created_at_relative").is_none());
2923        assert_eq!(shown["items"][1]["created_at_relative"], "canonical");
2924        assert_eq!(shown["items"][2]["created_at"], "2025-05-23T16:05:00");
2925        assert!(shown["items"][2].get("created_at_relative").is_none());
2926        assert!(shown["items"][3].get("created_at_relative").is_none());
2927        assert!(shown["items"][4].get("created_at_relative").is_none());
2928        assert_eq!(shown["items"][5]["created_at_relative"], "0s ago");
2929        assert_eq!(shown["items"][6]["created_at_relative"], "0s ago");
2930        assert_eq!(shown["items"][7]["properties"][0]["created_at"], recent);
2931        assert!(shown["items"][7]["properties"][0]
2932            .get("created_at_relative")
2933            .is_none());
2934        assert!(shown["items"][7]["properties"][0].get("empty").is_none());
2935        assert_eq!(shown["properties"]["nested"][0]["created_at"], recent);
2936        assert!(shown["properties"]["nested"][0]
2937            .get("created_at_relative")
2938            .is_none());
2939    }
2940
2941    #[test]
2942    fn agent_relative_sibling_uses_sampled_fraction_within_same_second() {
2943        let sampled = chrono::DateTime::parse_from_rfc3339("2025-05-23T16:08:00.500000000Z")
2944            .unwrap()
2945            .with_timezone(&chrono::Utc);
2946        let shown = present_with_policy_at(
2947            json!({"items": [
2948                {"created_at": "2025-05-23T16:08:00.499999999Z"},
2949                {"created_at": "2025-05-23T16:08:00.500000000Z"},
2950                {"created_at": "2025-05-23T16:08:00.5000000000Z"},
2951                {"created_at": "2025-05-23T16:08:00.5000000001Z"},
2952                {"created_at": "2025-05-23T16:08:00.500000001Z"},
2953                {"created_at": "2025-05-23T16:07:59.500000001Z"},
2954                {"created_at": "2025-05-23T16:07:59.500000000Z"}
2955            ]}),
2956            PresentationMode::Agent,
2957            sampled.into(),
2958            VerbPresentationPolicy::Standard,
2959        );
2960        for index in [0, 1, 2, 5] {
2961            assert_eq!(shown["items"][index]["created_at_relative"], "0s ago");
2962        }
2963        for index in [3, 4] {
2964            assert!(shown["items"][index].get("created_at_relative").is_none());
2965        }
2966        assert_eq!(shown["items"][6]["created_at_relative"], "1s ago");
2967    }
2968}