evolving 0.1.1

git for decisions — an immutable, content-addressed ledger of human-authored decisions that resurfaces when a bound check goes red
Documentation
//! Canonical-JSON (RFC 8785 / JCS) + content-addressed identity.
//! For our string-only payload, serde_json's default output (sorted BTreeMap
//! keys, compact separators, raw non-ASCII, no `/`-escape) IS JCS — verified by
//! the golden vectors. Liveness arrays are sorted+deduped here so set-valued
//! fields are order-insensitive; grounds[] keeps authored order.
use crate::tick::{Check, Tick};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};

fn sorted_set(v: &[String]) -> Vec<String> {
    let mut s: Vec<String> = v.to_vec();
    s.sort(); // byte order == codepoint == UTF-16 order for the ASCII tokens we use
    s.dedup();
    s
}

fn check_value(c: &Check) -> Value {
    match c {
        Check::Person { reference } => json!({ "by": "person", "ref": reference }),
        Check::Test {
            reference,
            verified_at_sha,
            counter_test,
            liveness,
        } => {
            // Built as a manual Map (not json!) so an absent counter_test OMITS the key entirely —
            // mirroring the grounds[].check omit-on-None below. omit-on-None keeps both the
            // Some-carrying goldens AND every harvested id stable; a "tidy to always-emit" would
            // silently move every harvested id.
            let mut o = Map::new();
            o.insert("by".into(), Value::String("test".into()));
            o.insert("ref".into(), Value::String(reference.clone()));
            o.insert(
                "verified_at_sha".into(),
                Value::String(verified_at_sha.clone()),
            );
            if let Some(ct) = counter_test {
                o.insert("counter_test".into(), Value::String(ct.clone()));
            }
            o.insert(
                "liveness".into(),
                json!({
                    "platforms":    sorted_set(&liveness.platforms),
                    "triggered_by": sorted_set(&liveness.triggered_by),
                    "surfaces":     sorted_set(&liveness.surfaces),
                }),
            );
            Value::Object(o)
        }
    }
}

/// The Value containing ONLY the hashed fields (decision, observe, grounds, parent_id).
pub fn hashed_value(t: &Tick) -> Value {
    let grounds: Vec<Value> = t
        .grounds
        .iter()
        .map(|g| {
            // Built as a manual Map (not json!) so an absent check OMITS the key
            // entirely — never serializes as null (design §4.8).
            let mut o = Map::new();
            o.insert("claim".into(), Value::String(g.claim.clone()));
            o.insert("supports".into(), Value::String(g.supports.clone()));
            if let Some(c) = &g.check {
                o.insert("check".into(), check_value(c));
            }
            Value::Object(o)
        })
        .collect();
    json!({
        "decision": t.decision,
        "observe": t.observe,
        "grounds": grounds,
        "parent_id": t.parent_id,
    })
}

/// Canonical bytes for our **string-only** hashed `Value`. serde_json's compact
/// output over a sorted-key `Value` equals RFC-8785/JCS *only* because the payload
/// contains no numbers/bools/nulls (JCS number canonicalization is not applied).
/// Do not reuse on a number-bearing `Value` (see module header).
pub fn canonical_json(v: &Value) -> String {
    serde_json::to_string(v).expect("Value is serializable")
}

/// id = first 12 hex of SHA-256 over the canonical-JSON of the hashed fields.
pub fn compute_id(t: &Tick) -> String {
    let canon = canonical_json(&hashed_value(t));
    let full = hex::encode(Sha256::digest(canon.as_bytes()));
    full[..12].to_string()
}