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