polyc-crypto 2026.7.0

Provenance signatures (commonware-cryptography ed25519) for polychrome tool calls.
Documentation
//! Canonical JSON form for approval and mandate args binding.
//!
//! Every gate that compares a tool call's `args_json` against a human-approved
//! (or mandate-bound) copy must compare by **value**, not raw bytes: providers
//! re-emit calls with non-deterministic key order, so a byte compare rejects a
//! legitimately approved call. This module holds the ONE canonicalizer those
//! binding domains share — the agent loop's approval matching and the payment
//! proxy's approval/mandate binding both call [`canon_args`], so the two
//! canonical forms can never drift (a duplicated copy of this exact helper once
//! shipped a silent no-op under `serde_json`'s `preserve_order` feature).

use serde_json::Value;

/// Canonicalizes a tool call's `args_json` for binding comparisons: parses and
/// re-serializes with every object's keys sorted, so the byte form depends only
/// on the (key, value) pairs, not key order.
///
/// Keys are sorted EXPLICITLY rather than by relying on `serde_json::Value`'s
/// map type: a linking binary may enable the `preserve_order` feature
/// (transitively, e.g. via `alloy`), under which a `Value::to_string()`
/// round-trip keeps INSERTION order and is a silent no-op. Sorting by hand is
/// correct under either feature setting. Malformed JSON (or a non-object) is
/// returned trimmed and otherwise unchanged, so it matches itself
/// byte-for-byte.
///
/// MATCHING normalization only: a human-signed approval still binds the exact
/// key/value set; stored/signed/echoed args stay raw.
#[must_use]
pub fn canon_args(args_json: &str) -> String {
    serde_json::from_str::<Value>(args_json).map_or_else(
        |_| args_json.trim().to_owned(),
        |v| sort_json_keys(&v).to_string(),
    )
}

/// Recursively rebuilds a JSON value with every object's keys in sorted order.
/// Order-independent regardless of `serde_json`'s `preserve_order` feature.
fn sort_json_keys(v: &Value) -> Value {
    match v {
        Value::Object(map) => {
            let mut entries: Vec<(&String, &Value)> = map.iter().collect();
            entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
            Value::Object(
                entries
                    .into_iter()
                    .map(|(k, val)| (k.clone(), sort_json_keys(val)))
                    .collect(),
            )
        }
        Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
        other => other.clone(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn canon_args_normalizes_key_order() {
        // Same key/value set, different order → identical canonical bytes.
        assert_eq!(
            canon_args(r#"{"template":"x","name":"y"}"#),
            canon_args(r#"{"name":"y","template":"x"}"#)
        );
        // Exact sorted output, including NESTED objects — proves keys are
        // actually reordered (not merely that serde's default map happens to
        // sort). This is the assertion that fails under `preserve_order` if
        // `canon_args` leans on `Value::to_string()` instead of sorting
        // explicitly.
        assert_eq!(
            canon_args(r#"{"b":1,"a":{"d":2,"c":3}}"#),
            r#"{"a":{"c":3,"d":2},"b":1}"#
        );
        // Different VALUES still differ (the binding is not weakened).
        assert_ne!(canon_args(r#"{"name":"y"}"#), canon_args(r#"{"name":"z"}"#));
        // Malformed JSON → returned trimmed, matches itself.
        assert_eq!(canon_args("  not json  "), "not json");
    }

    #[test]
    fn canon_args_sorts_keys_inside_arrays() {
        assert_eq!(
            canon_args(r#"{"list":[{"z":1,"a":2}]}"#),
            r#"{"list":[{"a":2,"z":1}]}"#
        );
    }
}