Skip to main content

khive_runtime/
presentation.rs

1//! Verb response presentation modes and transformation.
2//!
3//! Transforms canonical handler output into caller-appropriate form after dispatch
4//! and before wire serialization. `Agent` mode abbreviates UUIDs and drops
5//! empty fields; `Verbose` and `Human` pass through canonical JSON unchanged.
6//!
7//! This module also contains the `OutputFormat` axis (ADR-078) which governs how
8//! the resulting `serde_json::Value` is serialized or rendered to an output string.
9//! `PresentationMode` and `OutputFormat` compose independently.
10
11use std::{cmp::Ordering, collections::HashSet};
12
13use khive_types::VerbPresentationPolicy;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16
17/// Exact note-body locations selected by the request boundary for
18/// `get/list(parse_content=true)`. No policy marker is inferred from user JSON.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum NoteContentScope {
21    #[default]
22    None,
23    Record,
24    Items,
25    Notes,
26}
27
28impl NoteContentScope {
29    /// Keep note bodies outside a metadata-only transform, including nulls,
30    /// empty containers/strings, and fields that resemble IDs or timestamps.
31    /// The transform must preserve record order and the get/list envelope.
32    pub fn protect(self, mut value: Value, transform: impl FnOnce(Value) -> Value) -> Value {
33        if self == Self::None {
34            return transform(value);
35        }
36        fn take_content(record: &mut Value) -> Option<Value> {
37            record.as_object_mut()?.remove("content")
38        }
39        fn restore_content(record: &mut Value, content: Option<Value>) {
40            if let (Some(record), Some(content)) = (record.as_object_mut(), content) {
41                record.insert("content".to_string(), content);
42            }
43        }
44        if self == Self::Record {
45            let content = take_content(&mut value);
46            let mut value = transform(value);
47            restore_content(&mut value, content);
48            return value;
49        }
50        let key = if self == Self::Items {
51            "items"
52        } else {
53            "notes"
54        };
55        let contents: Vec<_> = value
56            .get_mut(key)
57            .and_then(Value::as_array_mut)
58            .into_iter()
59            .flatten()
60            .map(take_content)
61            .collect();
62        let mut value = transform(value);
63        if let Some(records) = value.get_mut(key).and_then(Value::as_array_mut) {
64            for (record, content) in records.iter_mut().zip(contents) {
65                restore_content(record, content);
66            }
67        }
68        value
69    }
70
71    fn stringify_table_content(self, value: &mut Value) {
72        fn stringify(record: &mut Value) {
73            if let Some(content) = record.get_mut("content") {
74                if content.is_object() || content.is_array() {
75                    *content = Value::String(content.to_string());
76                }
77            }
78        }
79        match self {
80            Self::None => {}
81            Self::Record => stringify(value),
82            Self::Items | Self::Notes => {
83                let key = if self == Self::Items {
84                    "items"
85                } else {
86                    "notes"
87                };
88                if let Some(records) = value.get_mut(key).and_then(Value::as_array_mut) {
89                    records.iter_mut().for_each(stringify);
90                }
91            }
92        }
93    }
94}
95
96// ── OutputFormat ─────────────────────────────────────────────────────────────
97
98/// Output serialization format for verb results (ADR-078).
99///
100/// Orthogonal to [`PresentationMode`]: `PresentationMode` controls field-level
101/// transforms (UUID shortening, exact UTC timestamps, empty-field dropping);
102/// `OutputFormat` controls how the resulting `serde_json::Value` is serialized
103/// or rendered to the wire string.
104///
105/// Default is [`OutputFormat::Json`] on every surface: compact and
106/// machine-walkable. Verbose JSON is lossless; Agent JSON additionally elides
107/// `namespace="local"` and duplicate `properties` entries (ADR-078 Amendment
108/// 3, Machine scope) but keeps `full_id` — it is the caller's chaining
109/// handle, not a redundant field.
110///
111/// Note: `Yaml` is a clean follow-up — implemented as a 3-variant enum
112/// (`Json`, `Auto`, `Table`) per ADR-078 §"yaml" which permits omission when
113/// the in-tree emitter would balloon.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
115#[serde(rename_all = "snake_case")]
116pub enum OutputFormat {
117    /// Compact JSON (`serde_json::to_string`). Default; lossless in Verbose
118    /// presentation, Machine-scope redundancy-reduced in Agent presentation
119    /// (`full_id` retained — see [`RedundancyScope`]).
120    #[default]
121    Json,
122    /// Shape-aware: markdown table for homogeneous record arrays (with
123    /// envelope siblings preserved as trailing `key: value` lines),
124    /// compact-JSON fallback for every other shape.
125    Auto,
126    /// Force the markdown-table renderer regardless of detected shape.
127    /// Ordinary results share Auto's dispatch. Opt-in parsed note bodies are
128    /// stringified for table display; Auto preserves their JSON values.
129    Table,
130}
131
132/// Cell truncation limit for markdown-table rendering (ADR-078 §3a).
133const CELL_TRUNCATE: usize = 120;
134
135/// Scalar payload fields hoisted from `properties` to the top level in the
136/// auto/table pre-pass, when no top-level sibling of that name exists.
137/// View-only (never applied to `json`): without the hoist, a table row for a
138/// scheduled event cannot say when it fires or whether it is pending —
139/// those fields exist only inside the nested `properties` bag.
140const PROPERTY_HOIST_FIELDS: &[&str] = &["trigger_at", "due", "status"];
141
142// ── Public render entry point ────────────────────────────────────────────────
143
144/// Render a successful verb result value to a wire string using the given format.
145///
146/// Called at the single serialization seam (ADR-078 §9) AFTER all `$prev` chain
147/// resolution and AFTER the [`PresentationMode`] transform.
148///
149/// Error envelopes (`ok=false`) are never passed here — the caller must handle
150/// them as compact JSON directly (ADR-078 §8.2).
151///
152/// Agent presentation applies the redundancy-reduction pre-pass (§7) for every
153/// format, including JSON — at Machine scope for `json` (keeps `full_id`) and
154/// View scope for `auto`/`table` (drops it too, ADR-078 Amendment 3). Auto/table
155/// also apply View scope for Human presentation; Verbose remains canonical and
156/// unreduced. The prepared value is then encoded as compact JSON or dispatched
157/// to the shape-aware renderer. Verbose also disables cell truncation in the
158/// table renderer (§3a).
159pub fn render_format(value: Value, format: OutputFormat, presentation: PresentationMode) -> String {
160    render_format_with_note_content(value, format, presentation, NoteContentScope::None)
161}
162
163/// Render an opt-in parsed-note response without treating its body as metadata
164/// or a top-level record table. Auto preserves parsed values; Table stringifies
165/// non-scalar content for display only.
166pub fn render_format_with_note_content(
167    value: Value,
168    format: OutputFormat,
169    presentation: PresentationMode,
170    content_scope: NoteContentScope,
171) -> String {
172    let value = prepare_format_value_with_note_content(value, format, presentation, content_scope);
173    match format {
174        OutputFormat::Json => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
175        OutputFormat::Auto if content_scope != NoteContentScope::None => {
176            serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
177        }
178        OutputFormat::Auto | OutputFormat::Table => {
179            render_auto(value, presentation != PresentationMode::Verbose)
180        }
181    }
182}
183
184/// Prepare a successful result for its requested format at the wire boundary.
185///
186/// JSON remains a JSON value in compounded response envelopes, so the MCP seam
187/// uses this helper directly instead of round-tripping through a serialized
188/// string. Keeping the policy here prevents the single-value and compounded
189/// paths from drifting.
190pub fn prepare_format_value(
191    value: Value,
192    format: OutputFormat,
193    presentation: PresentationMode,
194) -> Value {
195    let scope = match format {
196        OutputFormat::Json if presentation == PresentationMode::Agent => {
197            Some(RedundancyScope::Machine)
198        }
199        OutputFormat::Json => None,
200        OutputFormat::Auto | OutputFormat::Table if presentation != PresentationMode::Verbose => {
201            Some(RedundancyScope::View)
202        }
203        OutputFormat::Auto | OutputFormat::Table => None,
204    };
205    match scope {
206        Some(scope) => apply_redundancy_drop(value, scope),
207        None => value,
208    }
209}
210
211/// Apply format reductions to record metadata while preserving parsed bodies.
212pub fn prepare_format_value_with_note_content(
213    value: Value,
214    format: OutputFormat,
215    presentation: PresentationMode,
216    content_scope: NoteContentScope,
217) -> Value {
218    let mut value = content_scope.protect(value, |value| {
219        prepare_format_value(value, format, presentation)
220    });
221    if format == OutputFormat::Table {
222        content_scope.stringify_table_content(&mut value);
223    }
224    value
225}
226
227// ── Redundancy-reduction pre-pass (ADR-078 §7, Amendment 3) ────────────────
228
229/// Which redundancy-reduction rules apply (ADR-078 §7, Amendment 3).
230///
231/// `View` is the rendered-view scope (`auto`/`table`): the caller has opted
232/// into a display form that is never chained on programmatically, so
233/// `full_id` — the caller's stable chaining handle — is safe to drop along
234/// with the other reductions.
235///
236/// `Machine` is the machine-contract scope (Agent `json`): this is the
237/// default MCP response shape, the one a caller chains `$prev` and strict
238/// verb parameters (`memory.feedback(target_id=...)`, `gtd.assign
239/// (context_entity_id=...)`, etc.) from. It applies the `namespace` elision
240/// and the `properties` dedup, but never drops `full_id`, and the
241/// `properties` dedup itself exempts `full_id`/`ROUND_TRIP_FULL_UUID_FIELDS`
242/// keys — a caller reading a strict identifier out of `properties` must see
243/// the same canonical value there as at the top level. Dropping any of
244/// those would return a value the strict verbs that consume it reject.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum RedundancyScope {
247    View,
248    Machine,
249}
250
251/// Apply the redundancy-reduction pre-pass (ADR-078 §7, Amendment 3) to a
252/// value at the given [`RedundancyScope`].
253///
254/// Applies at most ONE pass over the value. This function is the canonical
255/// entry for the pre-pass; the per-record logic lives in `drop_record`.
256///
257/// Applied for Agent presentation in every format (`Machine` scope for
258/// `json`, `View` scope for non-Verbose `auto`/`table`). Callers are
259/// responsible for checking those conditions; this function applies
260/// unconditionally.
261pub fn apply_redundancy_drop(value: Value, scope: RedundancyScope) -> Value {
262    match value {
263        Value::Object(_) => drop_record(value, scope),
264        Value::Array(arr) => Value::Array(
265            arr.into_iter()
266                .map(|v| {
267                    if v.is_object() {
268                        drop_record(v, scope)
269                    } else {
270                        v
271                    }
272                })
273                .collect(),
274        ),
275        other => other,
276    }
277}
278
279/// Apply per-record redundancy rules (§7.1, §7.2, §7.3) to a single record object.
280fn drop_record(value: Value, scope: RedundancyScope) -> Value {
281    let Value::Object(mut map) = value else {
282        return value;
283    };
284
285    // `full_id` is a chaining handle. Dropping it is safe only in the View
286    // scope (a rendered auto/table view the caller opted into); the Machine
287    // scope (Agent JSON, the default machine contract) must keep it.
288    if scope == RedundancyScope::View {
289        map.remove("full_id");
290    }
291
292    // `"local"` is the common case, so only surface `namespace` when it isn't.
293    if map.get("namespace").and_then(Value::as_str) == Some("local") {
294        map.remove("namespace");
295    }
296
297    // Properties dedup: drop key-value pairs from `properties` that
298    // duplicate an identical top-level sibling. The scalar hoist for table
299    // columns lives in `hoist_table_scalars`, applied only on the table
300    // path: reshaping the compact-JSON fallback would move fields with no
301    // column to gain.
302    //
303    // Machine scope exempts `full_id`/`ROUND_TRIP_FULL_UUID_FIELDS`: a caller
304    // that reads the strict identifier out of `properties` (rather than the
305    // top-level convenience mirror) must keep getting the same canonical
306    // value there too. View scope is unaffected — those callers already
307    // opted into a display form, not the strict round-trip contract.
308    let props_val = map.remove("properties");
309    if let Some(Value::Object(props)) = props_val {
310        let mut new_props = Map::new();
311        for (k, v) in props {
312            let strict_round_trip = scope == RedundancyScope::Machine
313                && (k == "full_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&k.as_str()));
314            if !strict_round_trip && map.get(&k) == Some(&v) {
315                continue;
316            }
317            new_props.insert(k, v);
318        }
319        if !new_props.is_empty() {
320            map.insert("properties".to_string(), Value::Object(new_props));
321        }
322    } else if let Some(other) = props_val {
323        map.insert("properties".to_string(), other);
324    }
325
326    // A stream entry's record is opaque caller JSON, including arrays of
327    // objects whose fields happen to look like metadata.
328    let stream_entry = is_stream_entry(&map);
329    // Recurse into array values so nested record arrays are also reduced.
330    let out: Map<String, Value> = map
331        .into_iter()
332        .map(|(k, v)| {
333            if stream_entry && k == "record" {
334                return (k, v);
335            }
336            let v = match v {
337                Value::Array(arr) => Value::Array(
338                    arr.into_iter()
339                        .map(|item| {
340                            if item.is_object() {
341                                drop_record(item, scope)
342                            } else {
343                                item
344                            }
345                        })
346                        .collect(),
347                ),
348                other => other,
349            };
350            (k, v)
351        })
352        .collect();
353    Value::Object(out)
354}
355
356// ── Shape-aware rendering (`auto`) ──────────────────────────────────────────
357
358/// A record array located inside a value: the records, their ordered column
359/// keys, and — when the array sat under an object key — that key's name, so
360/// the caller can enumerate the remaining (sibling) keys.
361struct RecordArray {
362    key: Option<String>,
363    records: Vec<Value>,
364    columns: Vec<String>,
365}
366
367/// Render a value using shape-aware dispatch (ADR-078 §3, as amended).
368///
369/// Shape (a): homogeneous record array → markdown table, with any envelope
370/// siblings preserved as trailing `key: value` lines.
371/// Every other shape → compact JSON, lossless by construction. The former
372/// kv-block renderer for single records is removed: its truncation destroyed
373/// single-record payloads (e.g. a compose briefing's `markdown` field).
374fn render_auto(value: Value, truncate: bool) -> String {
375    match locate_record_array(&value) {
376        Some(found) => render_table_with_siblings(&value, &found, truncate),
377        None => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
378    }
379}
380
381/// Find the first homogeneous record array in `value`.
382///
383/// Checks:
384/// 1. `value` itself is an array of 2+ objects.
385/// 2. `value` is an object with a key whose value is an array of 2+ objects.
386fn locate_record_array(value: &Value) -> Option<RecordArray> {
387    let build = |key: Option<String>, arr: &[Value]| {
388        let records: Vec<Value> = arr.iter().cloned().map(hoist_table_scalars).collect();
389        let columns = collect_keys(&records);
390        RecordArray {
391            key,
392            records,
393            columns,
394        }
395    };
396    match value {
397        Value::Array(arr) if is_record_array(arr) => Some(build(None, arr)),
398        Value::Object(map) => map.iter().find_map(|(k, v)| match v {
399            Value::Array(arr) if is_record_array(arr) => Some(build(Some(k.clone()), arr)),
400            _ => None,
401        }),
402        _ => None,
403    }
404}
405
406/// Hoist `PROPERTY_HOIST_FIELDS` scalars out of a record's `properties` bag
407/// to the top level (never overwriting a top-level sibling), so the table
408/// renderer can surface them as columns — a scheduled event's
409/// `trigger_at`/`status` live only inside `properties`, and a nested cell
410/// renders as `{…}`. Table path only (ADR-078 Amendment 2): the compact-JSON
411/// fallback keeps its shape.
412fn hoist_table_scalars(record: Value) -> Value {
413    let Value::Object(mut map) = record else {
414        return record;
415    };
416    let mut props = match map.remove("properties") {
417        Some(Value::Object(props)) => props,
418        Some(other) => {
419            // Non-object `properties` is not a bag to hoist from — restore it.
420            map.insert("properties".to_string(), other);
421            return Value::Object(map);
422        }
423        None => return Value::Object(map),
424    };
425    for field in PROPERTY_HOIST_FIELDS {
426        if map.contains_key(*field) {
427            continue;
428        }
429        if props
430            .get(*field)
431            .is_some_and(|v| !v.is_object() && !v.is_array())
432        {
433            let v = props.remove(*field).expect("checked above");
434            map.insert((*field).to_string(), v);
435        }
436    }
437    if !props.is_empty() {
438        map.insert("properties".to_string(), Value::Object(props));
439    }
440    Value::Object(map)
441}
442
443/// An array of 2+ objects qualifies as a record array.
444fn is_record_array(arr: &[Value]) -> bool {
445    arr.len() >= 2 && arr.iter().all(Value::is_object)
446}
447
448/// Render the located record array as a table, then append one `key: value`
449/// line per remaining top-level key. Envelope fields (`has_more`, `offset`,
450/// counts) must survive rendering: dropping them made a truncated `query`
451/// page read as complete.
452fn render_table_with_siblings(value: &Value, found: &RecordArray, truncate: bool) -> String {
453    let mut out = render_table(&found.records, &found.columns, truncate);
454    let (Value::Object(map), Some(array_key)) = (value, &found.key) else {
455        return out;
456    };
457    for (k, v) in map {
458        if k == array_key {
459            continue;
460        }
461        let text = match v {
462            // A newline-bearing string renders as its JSON literal (one line,
463            // lossless) so a stored value cannot fabricate a sibling line or
464            // table row (ADR-078 Amendment 2 escaping contract).
465            Value::String(s) if !s.contains(['\n', '\r']) => s.clone(),
466            // Non-string scalars and nested values: compact JSON, untruncated
467            // — sibling fidelity is the point of this path.
468            other => serde_json::to_string(other).unwrap_or_default(),
469        };
470        out.push_str(&format!("{k}: {text}\n"));
471    }
472    out
473}
474
475/// Collect column names in first-seen order across all records.
476fn collect_keys(records: &[Value]) -> Vec<String> {
477    let mut seen = HashSet::new();
478    let mut keys = Vec::new();
479    for record in records {
480        if let Value::Object(map) = record {
481            for k in map.keys() {
482                if seen.insert(k.clone()) {
483                    keys.push(k.clone());
484                }
485            }
486        }
487    }
488    keys
489}
490
491// ── Markdown table renderer (ADR-078 §3a) ───────────────────────────────────
492
493/// Render a record array as a GitHub-Flavored Markdown table.
494fn render_table(records: &[Value], keys: &[String], truncate: bool) -> String {
495    let mut out = String::new();
496
497    out.push('|');
498    for k in keys {
499        out.push(' ');
500        out.push_str(k);
501        out.push_str(" |");
502    }
503    out.push('\n');
504
505    out.push('|');
506    for _ in keys {
507        out.push_str("---|");
508    }
509    out.push('\n');
510
511    for record in records {
512        out.push('|');
513        for k in keys {
514            let cell = record.get(k).unwrap_or(&Value::Null);
515            let text = cell_text(k, cell, truncate);
516            out.push(' ');
517            out.push_str(&text);
518            out.push_str(" |");
519        }
520        out.push('\n');
521    }
522
523    out
524}
525
526/// Column names exempt from cell truncation: identity and decision fields
527/// must arrive whole — a truncated id cannot be resolved and a truncated
528/// title cannot be selected on.
529fn truncation_exempt(key: &str) -> bool {
530    matches!(
531        key,
532        "id" | "kind"
533            | "status"
534            | "priority"
535            | "relation"
536            | "title"
537            | "name"
538            | "signature"
539            | "slug"
540            | "assignee"
541            | "from"
542            | "to"
543            | "due"
544    ) || key.ends_with("_id")
545        || key.ends_with("_at")
546        || key.starts_with("due")
547}
548
549/// Format a cell value: escape `|`, collapse newlines, truncate to ~120 chars
550/// unless `truncate` is off or the column is identity/decision-bearing.
551///
552/// Nested values are elided to a constant marker (`{…}` / `[…]`) rather than
553/// stringified and cut mid-JSON: truncated pseudo-JSON reads as data while
554/// silently missing fields. Full content is one `format=json` or `get` away.
555fn cell_text(key: &str, value: &Value, truncate: bool) -> String {
556    let raw = match value {
557        Value::Null => String::new(),
558        Value::Bool(b) => b.to_string(),
559        Value::Number(n) => n.to_string(),
560        Value::String(s) => s.clone(),
561        Value::Object(_) => return "{…}".to_string(),
562        Value::Array(arr) => {
563            if arr.iter().any(|v| v.is_object() || v.is_array()) {
564                return "[…]".to_string();
565            }
566            // Arrays of scalars (tags, ids) stay compact JSON.
567            serde_json::to_string(value).unwrap_or_default()
568        }
569    };
570
571    // Escape literal `|` and collapse embedded newlines to a space.
572    let escaped = raw.replace('|', "\\|").replace(['\n', '\r'], " ");
573
574    if !truncate || truncation_exempt(key) {
575        return escaped;
576    }
577
578    // Truncate to approximately CELL_TRUNCATE *characters* (char boundary,
579    // not byte index — slicing on a byte offset can panic on multi-byte chars).
580    let char_count = escaped.chars().count();
581    if char_count > CELL_TRUNCATE {
582        let truncated: String = escaped.chars().take(CELL_TRUNCATE).collect();
583        format!("{truncated}...")
584    } else {
585        escaped
586    }
587}
588
589/// Format an instant in UTC using RFC3339 whole seconds and a `Z` suffix.
590///
591/// Fractional seconds are omitted. Chrono's supported years and signed
592/// expanded-year rendering are inherited; no additional range validation is done.
593/// Parsing and error policy remain with callers.
594pub fn format_utc_rfc3339_seconds<Tz: chrono::TimeZone>(value: &chrono::DateTime<Tz>) -> String {
595    value
596        .with_timezone(&chrono::Utc)
597        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
598}
599
600/// Convert a microsecond epoch `i64` to an exact UTC ISO-8601 string.
601///
602/// Output always carries six fractional digits and a `Z` suffix. Years
603/// 0000–9999 use the RFC 3339 spelling; other years use Chrono's signed
604/// expanded-year convention. The full `i64` microsecond range is supported.
605pub fn micros_to_iso(micros: i64) -> String {
606    if let Some(dt) = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros) {
607        return dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
608    }
609
610    // Gregorian leap positions repeat every 400 years. Euclidean reduction
611    // keeps the remainder in 1970–2369, including for negative timestamps.
612    const CYCLE_MICROS: i64 = 146_097 * 86_400 * 1_000_000;
613    let cycles = micros.div_euclid(CYCLE_MICROS);
614    let reduced =
615        chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros.rem_euclid(CYCLE_MICROS))
616            .expect("400-year remainder is within Chrono's range");
617    let year = i64::from(chrono::Datelike::year(&reduced)) + cycles * 400;
618    let rendered = reduced.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
619    format!("{year:+05}{}", &rendered[4..])
620}
621
622/// Parse an RFC 3339 timestamp (offset required) into microsecond epoch
623/// `i64` — the inverse of [`micros_to_iso`] within the RFC 3339 four-digit
624/// year range. Expanded ISO years are not accepted. This is the single parse
625/// point for caller-supplied instants entering storage comparisons.
626///
627/// Leading/trailing whitespace is tolerated. Date-only and offset-less forms
628/// are rejected; callers own the verb-specific error context around the
629/// returned `ParseError`.
630pub fn rfc3339_to_utc_micros(raw: &str) -> Result<i64, chrono::ParseError> {
631    chrono::DateTime::parse_from_rfc3339(raw.trim()).map(|dt| dt.timestamp_micros())
632}
633
634/// How the response envelope is presented to the caller.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
636#[serde(rename_all = "snake_case")]
637pub enum PresentationMode {
638    /// Token-efficient. Default for MCP callers (agents).
639    ///
640    /// Short UUIDs (8-char), exact UTC timestamps with relative labels on
641    /// list rows, empty fields dropped, structural nulls preserved, and
642    /// score fields truncated to 3 significant figures.
643    #[default]
644    Agent,
645    /// Full canonical shape. Default for `kkernel exec` and CI/scripted callers.
646    ///
647    /// No transformation — handler output passes through as-is.
648    Verbose,
649    /// Pretty-printed terminal output. Default for `khive` CLI.
650    ///
651    /// **At the MCP runtime level this is identical to `Verbose`** — the
652    /// canonical JSON is returned unchanged. Terminal formatting (relative
653    /// timestamps, glyph substitution, table layout) is applied by the CLI
654    /// layer (`khive-cli::format::pretty`), not the MCP response pipeline.
655    Human,
656}
657
658/// Lifecycle/operational `null` fields that are PRESERVED in Agent mode.
659///
660/// These fields carry state meaning (absent ≠ known-unknown) and must not be
661/// dropped. The channel-health fields distinguish a quarantine-only identity
662/// from a heartbeat row whose liveness facts were actually observed.
663const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
664    "completed_at",
665    "deleted_at",
666    "due_at",
667    "read_at",
668    "started_at",
669    "superseded_at",
670    "applied_at",
671    "withdrawn_at",
672    "reviewed_at",
673    "parent_id",
674    "superseded_by",
675    "replaced_by",
676    "poll_interval_secs",
677    "stalled",
678    "last_success_at",
679    "last_poll_attempt_at",
680    "last_failure_at",
681    "last_error",
682    "consecutive_failures",
683];
684
685/// Empty collection fields that define a stable response envelope and must
686/// survive Agent-mode compaction. Dropping these turns an empty page into a
687/// different response type and leaves callers unable to distinguish an empty
688/// result from a missing/unsupported field.
689const EMPTY_ARRAY_PRESERVE: &[&str] = &[
690    "items",
691    "entities",
692    "notes",
693    "edges",
694    "results",
695    "entries",
696    "neighbors",
697];
698
699fn is_stable_list_envelope(map: &Map<String, Value>) -> bool {
700    map.contains_key("requested_limit")
701        && map.contains_key("effective_limit")
702        && map.contains_key("limit_clamped")
703        && EMPTY_ARRAY_PRESERVE
704            .iter()
705            .any(|field| map.contains_key(*field))
706}
707
708/// Keyset cursor pages outside the ADR-023 envelope — `knowledge.list(after=…)`
709/// returns `{results, limit, order, next_after}` — are structural in the same
710/// way (ADR-045 Amendment 4): an empty `results` page and a terminal
711/// `next_after: null` are the walk's completion signals, so a caller that
712/// cannot see them cannot stop. A `results` array without a sibling
713/// `next_after` key is an ordinary response and gets the generic transform.
714fn is_keyset_cursor_envelope(map: &Map<String, Value>) -> bool {
715    map.contains_key("next_after")
716        && (map.get("results").is_some_and(Value::is_array)
717            || (map.get("head_seq").is_some_and(Value::is_number)
718                && map.get("entries").is_some_and(Value::is_array)))
719}
720
721fn is_stream_entry(map: &Map<String, Value>) -> bool {
722    map.get("seq").is_some_and(Value::is_number)
723        && map.contains_key("id")
724        && map.contains_key("created_at")
725        && map.contains_key("record")
726}
727
728/// Field names carrying caller-supplied payload timestamps that must never be
729/// transformed, regardless of nesting.
730///
731/// These encode domain semantics the caller needs to round-trip verbatim —
732/// e.g. `trigger_at` on a `schedule.remind`/`schedule.schedule` create
733/// response, returned as a top-level convenience field alongside `id` and
734/// `full_id`, not nested under `"properties"`. The `inside_properties` guard
735/// alone only protects fields nested under a literal `"properties"` key
736/// (as returned by `agenda`/`get`); it does not cover this top-level case,
737/// which older Agent presentation rewrote into a relative or minute-truncated
738/// form, discarding the seconds and offset needed for round trips (#871).
739///
740/// `due` on `gtd.assign`/`gtd.tasks`/`gtd.next` responses is the same shape:
741/// a top-level convenience field mirroring `properties.due`, which
742/// `parse_due` already normalizes to full RFC 3339.
743const PAYLOAD_TIMESTAMP_FIELDS: &[&str] = &["trigger_at", "due"];
744
745/// UUID fields whose canonical value is itself a strict-verb input.
746///
747/// Shortening these would make a successful response fail when submitted back
748/// to the verb that produced or consumes it. `context_entity_id` and
749/// `thread_id` are explicit stable references rather than prefix searches;
750/// `outbound_ref` is the exact correlation key consumed by `comm.delivered`;
751/// `parent_id` is the explicit ancestry reference consumed by `propose`;
752/// `session_id` is an exact event-list filter; and `project_id` is the exact
753/// provenance anchor required by git issue and pull-request creation.
754const ROUND_TRIP_FULL_UUID_FIELDS: &[&str] = &[
755    "context_entity_id",
756    "thread_id",
757    "outbound_ref",
758    "parent_id",
759    "session_id",
760    "project_id",
761];
762
763/// Score field names that are truncated to 3 significant figures in Agent mode.
764const SCORE_FIELDS: &[&str] = &[
765    "score",
766    "rank_score",
767    "vector_similarity",
768    "keyword_score",
769    "salience",
770    "decay_factor",
771    "rrf_score",
772    "similarity",
773    "cross_encoder_score",
774    "graph_proximity_score",
775];
776
777/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
778const UUID_CANONICAL_LEN: usize = 36;
779
780/// Return true for fields whose whole-string UUID values may be shortened in
781/// Agent mode. Content-like fields are intentionally excluded even when their
782/// value happens to be UUID-shaped.
783///
784/// `full_id` and strict round-trip fields are explicitly excluded: their
785/// purpose is to give callers a stable chaining handle, so shortening them
786/// would produce a value that the corresponding strict verb rejects.
787fn should_shorten_uuid_field(key: &str) -> bool {
788    if key == "full_id" || key == "bridge_instance_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&key)
789    {
790        return false;
791    }
792    key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
793}
794
795/// Transform a successful verb result value according to the given
796/// [`PresentationMode`].
797///
798/// - `Verbose` / `Human`: returns `value` unchanged.
799/// - `Agent`: applies UUID shortening, exact UTC timestamp rendering, empty-field
800///   dropping, structural-null preservation, and score truncation.
801///
802/// `now_unix_seconds` is a whole-second clock for callers without a finer
803/// sample. All relative list-row labels within a response use this instant.
804pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
805    present_with_policy(
806        value,
807        mode,
808        now_unix_seconds,
809        VerbPresentationPolicy::Standard,
810    )
811}
812
813/// Present a successful result using its trusted registered verb policy.
814/// Receipt policies preserve only their closed string paths, not descendants.
815pub fn present_with_policy(
816    value: Value,
817    mode: PresentationMode,
818    now_unix_seconds: i64,
819    policy: VerbPresentationPolicy,
820) -> Value {
821    present_with_policy_at(value, mode, now_unix_seconds.into(), policy)
822}
823
824/// A response-wide clock sample. Fractional precision distinguishes a newly
825/// stored row from a truly future row within the same Unix second.
826#[derive(Clone, Copy)]
827pub struct PresentationNow {
828    seconds: i64,
829    nanoseconds: u32,
830}
831
832impl From<i64> for PresentationNow {
833    fn from(seconds: i64) -> Self {
834        Self {
835            seconds,
836            nanoseconds: 0,
837        }
838    }
839}
840
841impl From<i32> for PresentationNow {
842    fn from(seconds: i32) -> Self {
843        i64::from(seconds).into()
844    }
845}
846
847impl From<chrono::DateTime<chrono::Utc>> for PresentationNow {
848    fn from(now: chrono::DateTime<chrono::Utc>) -> Self {
849        Self {
850            seconds: now.timestamp(),
851            nanoseconds: now.timestamp_subsec_nanos(),
852        }
853    }
854}
855
856/// Present with the response's single sampled instant, including its fraction.
857pub fn present_with_policy_at(
858    value: Value,
859    mode: PresentationMode,
860    now: PresentationNow,
861    policy: VerbPresentationPolicy,
862) -> Value {
863    if policy == VerbPresentationPolicy::AlwaysVerbose {
864        return value;
865    }
866    match mode {
867        PresentationMode::Verbose | PresentationMode::Human => value,
868        PresentationMode::Agent => {
869            // These root paths are selected by the registered agenda handler,
870            // never by a payload marker or a nested lookalike. The cursor's
871            // timestamp spelling and full UUID jointly define its seek position.
872            let mut value = value;
873            let agenda_next = (policy == VerbPresentationPolicy::AgendaContinuation)
874                .then(|| value.as_object_mut().and_then(|map| map.remove("next")))
875                .flatten();
876            let agenda_empty = policy == VerbPresentationPolicy::AgendaContinuation
877                && value
878                    .get("events")
879                    .and_then(Value::as_array)
880                    .is_some_and(Vec::is_empty);
881            let config = AgentTransformConfig {
882                preserved_nulls: LIFECYCLE_NULL_PRESERVE.iter().copied().collect(),
883                scores: SCORE_FIELDS.iter().copied().collect(),
884                payload_timestamps: PAYLOAD_TIMESTAMP_FIELDS.iter().copied().collect(),
885                now,
886            };
887            let mut value = transform_agent(
888                value,
889                &config,
890                AgentTreeContext {
891                    inside_properties: false,
892                    object_properties: false,
893                    array_member: false,
894                    receipt_context: match policy {
895                        VerbPresentationPolicy::StreamAppendReceipt => ReceiptContext::AppendRoot,
896                        VerbPresentationPolicy::StreamBatchReceipts => ReceiptContext::BatchRoot,
897                        _ => ReceiptContext::None,
898                    },
899                },
900            );
901            if let Some(map) = value.as_object_mut() {
902                if let Some(next) = agenda_next {
903                    map.insert("next".into(), next);
904                }
905                if agenda_empty {
906                    map.insert("events".into(), Value::Array(Vec::new()));
907                }
908            }
909            value
910        }
911    }
912}
913
914#[derive(Clone, Copy, PartialEq, Eq)]
915enum ReceiptContext {
916    None,
917    AppendRoot,
918    BatchRoot,
919    BatchResults,
920    BatchMember,
921}
922
923struct AgentTransformConfig {
924    preserved_nulls: HashSet<&'static str>,
925    scores: HashSet<&'static str>,
926    payload_timestamps: HashSet<&'static str>,
927    now: PresentationNow,
928}
929
930#[derive(Clone, Copy)]
931struct AgentTreeContext {
932    inside_properties: bool,
933    object_properties: bool,
934    array_member: bool,
935    receipt_context: ReceiptContext,
936}
937
938/// Apply the Agent-mode transform to an arbitrary JSON value.
939///
940/// `inside_properties` is `true` when recursing inside a `"properties"` value.
941/// Caller-supplied payload timestamps (e.g. `trigger_at`) must not be normalized
942/// because they encode domain semantics the agent may need to round-trip.
943fn transform_agent(
944    value: Value,
945    config: &AgentTransformConfig,
946    context: AgentTreeContext,
947) -> Value {
948    match value {
949        Value::Object(map) => {
950            let preserve_list_envelope =
951                is_stable_list_envelope(&map) || is_keyset_cursor_envelope(&map);
952            let stream_entry = is_stream_entry(&map);
953            let relative_created_at = (context.array_member
954                && !context.inside_properties
955                && !map.contains_key("created_at_relative")
956                && !matches!(
957                    context.receipt_context,
958                    ReceiptContext::AppendRoot | ReceiptContext::BatchMember
959                ))
960            .then(|| map.get("created_at").and_then(Value::as_str))
961            .flatten()
962            .and_then(|raw| {
963                let (_, created_at) = exact_timestamp(raw)?;
964                let age = config.now.seconds.checked_sub(created_at)?;
965                // Floor the full fractional age. The lexicographic comparison
966                // also handles canonical fractions longer than nanoseconds.
967                age.checked_sub(i64::from(
968                    timestamp_fraction_cmp(raw, config.now.nanoseconds) == Ordering::Greater,
969                ))
970            })
971            .filter(|difference| *difference >= 0)
972            .map(relative_time);
973            let mut out = Map::new();
974            for (k, v) in map {
975                if stream_entry && k == "record" {
976                    out.insert(k, v);
977                    continue;
978                }
979                if v.is_string()
980                    && ((context.receipt_context == ReceiptContext::AppendRoot
981                        && k == "created_at")
982                        || (context.receipt_context == ReceiptContext::BatchMember
983                            && matches!(k.as_str(), "created_at" | "updated_at")))
984                {
985                    out.insert(k, v);
986                    continue;
987                }
988                let child_receipt_context = if context.receipt_context == ReceiptContext::BatchRoot
989                    && k == "results"
990                    && v.is_array()
991                {
992                    ReceiptContext::BatchResults
993                } else {
994                    ReceiptContext::None
995                };
996                // ADR-045 Amendment 3 scopes the empty-string carve-out to
997                // strings nested under an object-valued `properties`; a
998                // scalar or array `properties` value gets no carve-out.
999                let child_inside_properties = context.inside_properties || k == "properties";
1000                let child_object_properties =
1001                    context.object_properties || (k == "properties" && v.is_object());
1002                let transformed = transform_field_agent(
1003                    &k,
1004                    v,
1005                    config,
1006                    AgentFieldContext {
1007                        inside_properties: child_inside_properties,
1008                        object_properties: child_object_properties,
1009                        preserve_list_envelope,
1010                        receipt_context: child_receipt_context,
1011                    },
1012                );
1013                match transformed {
1014                    None => {} // drop
1015                    Some(tv) => {
1016                        out.insert(k, tv);
1017                    }
1018                }
1019            }
1020            if let Some(relative) = relative_created_at {
1021                out.insert("created_at_relative".to_string(), Value::String(relative));
1022            }
1023            Value::Object(out)
1024        }
1025        Value::Array(arr) => {
1026            let items: Vec<Value> = arr
1027                .into_iter()
1028                .map(|v| {
1029                    transform_agent(
1030                        v,
1031                        config,
1032                        AgentTreeContext {
1033                            inside_properties: context.inside_properties,
1034                            object_properties: context.object_properties,
1035                            array_member: true,
1036                            receipt_context: if context.receipt_context
1037                                == ReceiptContext::BatchResults
1038                            {
1039                                ReceiptContext::BatchMember
1040                            } else {
1041                                ReceiptContext::None
1042                            },
1043                        },
1044                    )
1045                })
1046                .collect();
1047            Value::Array(items)
1048        }
1049        other => other,
1050    }
1051}
1052
1053/// Transform a single named field value under Agent mode.
1054///
1055/// Returns `None` if the field should be dropped.
1056///
1057/// `inside_properties` suppresses timestamp normalization for caller-submitted
1058/// payload values nested under a literal `"properties"` key (e.g. `trigger_at`
1059/// as returned by `agenda`/`get`). `payload_timestamps` suppresses normalization
1060/// by field name regardless of nesting, covering top-level convenience fields
1061/// such as the `trigger_at` returned directly in a `schedule.remind`/
1062/// `schedule.schedule` create response (#871). Metadata timestamps at the top
1063/// level (`created_at`, `updated_at`) still render as exact UTC instants.
1064#[derive(Clone, Copy)]
1065struct AgentFieldContext {
1066    inside_properties: bool,
1067    object_properties: bool,
1068    preserve_list_envelope: bool,
1069    receipt_context: ReceiptContext,
1070}
1071
1072fn transform_field_agent(
1073    key: &str,
1074    value: Value,
1075    config: &AgentTransformConfig,
1076    context: AgentFieldContext,
1077) -> Option<Value> {
1078    match &value {
1079        // Preserve lifecycle and stable-envelope nulls; drop other nulls.
1080        Value::Null => {
1081            if config.preserved_nulls.contains(key)
1082                || (context.preserve_list_envelope && key == "next_after")
1083            {
1084                Some(value)
1085            } else {
1086                None
1087            }
1088        }
1089        // Stable page-envelope arrays remain present even when empty.
1090        Value::Array(a)
1091            if context.preserve_list_envelope
1092                && a.is_empty()
1093                && EMPTY_ARRAY_PRESERVE.contains(&key) =>
1094        {
1095            Some(value)
1096        }
1097        // Preserve empty strings under a record's `properties` object: those
1098        // keys exist only because a caller wrote them, so `""` there is data
1099        // (set-to-empty vs absent, ADR-045 Amendment 3, issue #1995). Empty
1100        // arrays and objects under `properties` are still dropped per the
1101        // amendment's scope note.
1102        Value::String(s) if s.is_empty() && context.object_properties => Some(value),
1103        // Drop other empty strings, arrays, objects.
1104        Value::String(s) if s.is_empty() => None,
1105        Value::Array(a) if a.is_empty() => None,
1106        Value::Object(o) if o.is_empty() => None,
1107        // Truncate score fields.
1108        Value::Number(_) if config.scores.contains(key) => {
1109            if let Some(f) = value.as_f64() {
1110                Some(truncate_to_3_sig_figs(f))
1111            } else {
1112                Some(value)
1113            }
1114        }
1115        // Shorten UUIDs only in fields whose names carry ID semantics.
1116        Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
1117            Some(Value::String(s[..8].to_string()))
1118        }
1119        // Render whole, offset-bearing timestamps in exact UTC form unless
1120        // this is caller-supplied or otherwise protected payload data.
1121        Value::String(s)
1122            if !context.inside_properties
1123                && !config.payload_timestamps.contains(key)
1124                && looks_like_iso8601(s) =>
1125        {
1126            Some(Value::String(
1127                exact_timestamp(s).map_or_else(|| s.clone(), |(rendered, _)| rendered),
1128            ))
1129        }
1130        // Recurse into objects and arrays.
1131        Value::Object(_) | Value::Array(_) => Some(transform_agent(
1132            value,
1133            config,
1134            AgentTreeContext {
1135                inside_properties: context.inside_properties,
1136                object_properties: context.object_properties,
1137                array_member: false,
1138                receipt_context: context.receipt_context,
1139            },
1140        )),
1141        // Everything else passes through.
1142        _ => Some(value),
1143    }
1144}
1145
1146/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
1147fn is_canonical_uuid(s: &str) -> bool {
1148    if s.len() != UUID_CANONICAL_LEN {
1149        return false;
1150    }
1151    let b = s.as_bytes();
1152    // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
1153    b[8] == b'-'
1154        && b[13] == b'-'
1155        && b[18] == b'-'
1156        && b[23] == b'-'
1157        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
1158        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
1159        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
1160        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
1161        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
1162}
1163
1164/// Returns `true` if `s` looks like an ISO-8601 datetime string.
1165///
1166/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
1167fn looks_like_iso8601(s: &str) -> bool {
1168    if s.len() < 16 {
1169        return false;
1170    }
1171    let b = s.as_bytes();
1172    b[4] == b'-'
1173        && b[7] == b'-'
1174        && b[10] == b'T'
1175        && b[13] == b':'
1176        && b[..4].iter().all(|c| c.is_ascii_digit())
1177        && b[5..7].iter().all(|c| c.is_ascii_digit())
1178        && b[8..10].iter().all(|c| c.is_ascii_digit())
1179        && b[11..13].iter().all(|c| c.is_ascii_digit())
1180}
1181
1182/// Render a whole timestamp in UTC while retaining its original fraction digits.
1183fn exact_timestamp(s: &str) -> Option<(String, i64)> {
1184    let unix = parse_iso8601_unix(s)?;
1185    let tail = s.get(19..)?;
1186    let fraction = if let Some(after_dot) = tail.strip_prefix('.') {
1187        let digits = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1188        tail.get(..digits + 1)?
1189    } else {
1190        ""
1191    };
1192    let utc = chrono::DateTime::<chrono::Utc>::from_timestamp(unix, 0)?;
1193    Some((
1194        format!("{}{fraction}Z", utc.format("%Y-%m-%dT%H:%M:%S")),
1195        unix,
1196    ))
1197}
1198
1199/// Compare the timestamp's decimal fraction with a sampled nanosecond value.
1200/// Digits beyond nanoseconds matter when the leading nine digits tie.
1201fn timestamp_fraction_cmp(timestamp: &str, now_nanoseconds: u32) -> Ordering {
1202    let fraction = if timestamp.as_bytes().get(19) == Some(&b'.') {
1203        timestamp.as_bytes()[20..]
1204            .split(|byte| !byte.is_ascii_digit())
1205            .next()
1206            .unwrap_or_default()
1207    } else {
1208        &[]
1209    };
1210    let now_fraction = format!("{now_nanoseconds:09}");
1211    for index in 0..fraction.len().max(9) {
1212        let created_digit = fraction.get(index).copied().unwrap_or(b'0');
1213        let now_digit = now_fraction.as_bytes().get(index).copied().unwrap_or(b'0');
1214        match created_digit.cmp(&now_digit) {
1215            Ordering::Equal => {}
1216            difference => return difference,
1217        }
1218    }
1219    Ordering::Equal
1220}
1221
1222/// Parse a whole ISO-8601 datetime with an explicit offset to Unix seconds.
1223///
1224/// Accepts `YYYY-MM-DDTHH:MM:SS[.frac](Z|±HH:MM|±HHMM)`. An offset-less,
1225/// invalid or trailing-text value stays byte-exact in Agent presentation.
1226fn parse_iso8601_unix(s: &str) -> Option<i64> {
1227    if s.len() < 20 {
1228        return None;
1229    }
1230    let b = s.as_bytes();
1231    if b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
1232        return None;
1233    }
1234    let year: i64 = parse_digits(&b[0..4])?;
1235    let month: i64 = parse_digits(&b[5..7])?;
1236    let day: i64 = parse_digits(&b[8..10])?;
1237    let hour: i64 = parse_digits(&b[11..13])?;
1238    let minute: i64 = parse_digits(&b[14..16])?;
1239    let second: i64 = parse_digits(&b[17..19])?;
1240
1241    let local = chrono::NaiveDate::from_ymd_opt(
1242        year.try_into().ok()?,
1243        month.try_into().ok()?,
1244        day.try_into().ok()?,
1245    )?
1246    .and_hms_opt(
1247        hour.try_into().ok()?,
1248        minute.try_into().ok()?,
1249        second.try_into().ok()?,
1250    )?
1251    .and_utc()
1252    .timestamp();
1253    let offset_secs = parse_tz_offset_secs(s.get(19..)?)?;
1254    local.checked_sub(offset_secs)
1255}
1256
1257/// Parse the tail of an ISO-8601 timestamp (everything from byte index 19
1258/// onward, i.e. after the whole-seconds field) into a UTC offset in seconds.
1259///
1260/// Handles, in order: optional fractional seconds (`.nnn`, skipped — this
1261/// parser only has whole-second precision), then one of:
1262/// - `"Z"` → offset 0
1263/// - `±HH:MM` or the compact `±HHMM` form → `sign * (hh*3600 + mm*60)`
1264///
1265/// Returns `None` for anything else (malformed tail).
1266fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
1267    let mut rest = tail;
1268    if let Some(after_dot) = rest.strip_prefix('.') {
1269        let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1270        if frac_len == 0 {
1271            return None;
1272        }
1273        rest = &after_dot[frac_len..];
1274    }
1275
1276    if rest == "Z" {
1277        return Some(0);
1278    }
1279
1280    let sign: i64 = match rest.as_bytes().first()? {
1281        b'+' => 1,
1282        b'-' => -1,
1283        _ => return None,
1284    };
1285    let digits = &rest[1..];
1286    let (hh, mm) = match digits.len() {
1287        // "HH:MM"
1288        5 if digits.as_bytes()[2] == b':' => (
1289            parse_digits(&digits.as_bytes()[0..2])?,
1290            parse_digits(&digits.as_bytes()[3..5])?,
1291        ),
1292        // "HHMM"
1293        4 => (
1294            parse_digits(&digits.as_bytes()[0..2])?,
1295            parse_digits(&digits.as_bytes()[2..4])?,
1296        ),
1297        _ => return None,
1298    };
1299    if hh > 23 || mm > 59 {
1300        return None;
1301    }
1302    Some(sign * (hh * 3600 + mm * 60))
1303}
1304
1305fn parse_digits(b: &[u8]) -> Option<i64> {
1306    if !b.iter().all(u8::is_ascii_digit) {
1307        return None;
1308    }
1309    let s = std::str::from_utf8(b).ok()?;
1310    s.parse().ok()
1311}
1312
1313/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
1314fn relative_time(diff_secs: i64) -> String {
1315    if diff_secs < 60 {
1316        format!("{diff_secs}s ago")
1317    } else if diff_secs < 3600 {
1318        format!("{}m ago", diff_secs / 60)
1319    } else if diff_secs < 86400 {
1320        format!("{}h ago", diff_secs / 3600)
1321    } else {
1322        format!("{}d ago", diff_secs / 86400)
1323    }
1324}
1325
1326/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
1327fn truncate_to_3_sig_figs(f: f64) -> Value {
1328    if f == 0.0 || !f.is_finite() {
1329        return Value::from(f);
1330    }
1331    let magnitude = f.abs().log10().floor() as i32;
1332    let factor = 10f64.powi(2 - magnitude);
1333    let rounded = (f * factor).round() / factor;
1334    let rounded = if rounded.is_finite() {
1335        rounded
1336    } else {
1337        // Decimal formatting handles subnormals without an overflowing scale.
1338        // If rounding would exceed f64's range, retain the finite input.
1339        format!("{f:.2e}")
1340            .parse::<f64>()
1341            .ok()
1342            .filter(|value| value.is_finite())
1343            .unwrap_or(f)
1344    };
1345    // Re-serialize through serde_json to avoid floating-point noise.
1346    serde_json::Number::from_f64(rounded)
1347        .map(Value::Number)
1348        .unwrap_or(Value::from(f))
1349}
1350
1351#[cfg(test)]
1352#[path = "presentation_tests.rs"]
1353mod tests;
1354
1355#[cfg(test)]
1356#[path = "presentation_stream_presentation_tests.rs"]
1357mod stream_presentation_tests;
1358
1359#[cfg(test)]
1360#[path = "presentation_issue_2537_standard_tests.rs"]
1361mod issue_2537_standard_tests;
1362
1363#[cfg(test)]
1364#[path = "presentation_issue_2537_receipt_policy_tests.rs"]
1365mod issue_2537_receipt_policy_tests;
1366
1367#[cfg(test)]
1368#[path = "presentation_parsed_note_content_tests.rs"]
1369mod parsed_note_content_tests;
1370
1371#[cfg(test)]
1372#[path = "presentation_adr045_amendment9_tests.rs"]
1373mod adr045_amendment9_tests;