rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

services_hardening_evidence_policy.rs (15540B)


      1 #![forbid(unsafe_code)]
      2 
      3 use serde_json::Value;
      4 use sha2::{Digest, Sha256};
      5 use std::collections::BTreeSet;
      6 
      7 const POLICY: &str = include_str!("../contracts/services_hardening/evidence_policy.v1.json");
      8 const CONSUMER_ROOT: &str = include_str!("../.radroots-consumer-root");
      9 
     10 fn policy() -> Value {
     11     serde_json::from_str(POLICY).expect("evidence policy decision must be valid JSON")
     12 }
     13 
     14 fn sha256_hex(bytes: &[u8]) -> String {
     15     Sha256::digest(bytes)
     16         .iter()
     17         .map(|byte| format!("{byte:02x}"))
     18         .collect()
     19 }
     20 
     21 fn decision_sections_digest(value: &Value) -> String {
     22     let sections = serde_json::json!({
     23         "completion": value["completion"],
     24         "configuration": value["configuration"],
     25         "configuration_fixture": value["configuration_fixture"],
     26         "cursor": value["cursor"],
     27         "fixed_vector": value["fixed_vector"],
     28         "policy_digest": value["policy_digest"],
     29         "publication_relationship": value["publication_relationship"],
     30         "required_optional_semantics": value["required_optional_semantics"],
     31         "selector": value["selector"],
     32         "source_kinds": value["source_kinds"],
     33         "configuration_vectors": value["configuration_vectors"],
     34         "completion_vectors": value["completion_vectors"],
     35         "cursor_vectors": value["cursor_vectors"],
     36         "coverage_vectors": value["coverage_vectors"]
     37     });
     38     sha256_hex(&serde_json::to_vec(&sections).expect("serialize decision sections"))
     39 }
     40 
     41 fn stable_id(value: &str) -> bool {
     42     !value.is_empty()
     43         && value.len() <= 64
     44         && value.as_bytes()[0].is_ascii_lowercase()
     45         && value
     46             .bytes()
     47             .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_-".contains(&byte))
     48 }
     49 
     50 fn mutate_config(base: &Value, mutation: &Value) -> Value {
     51     let mut config = base.clone();
     52     let object = config.as_object_mut().unwrap();
     53     match mutation["op"].as_str().unwrap() {
     54         "none" => {}
     55         "append_source" => object["sources"]
     56             .as_array_mut()
     57             .unwrap()
     58             .push(mutation["source"].clone()),
     59         "set_source_field" => {
     60             object["sources"][0][mutation["field"].as_str().unwrap()] = mutation["value"].clone();
     61         }
     62         "set_source_fields" => {
     63             for (field, value) in mutation["fields"].as_object().unwrap() {
     64                 object["sources"][0][field] = value.clone();
     65             }
     66         }
     67         "remove_top_field" => {
     68             object.remove(mutation["field"].as_str().unwrap());
     69         }
     70         "set_top_field" => {
     71             object.insert(
     72                 mutation["field"].as_str().unwrap().to_owned(),
     73                 mutation["value"].clone(),
     74             );
     75         }
     76         "add_top_field" => {
     77             object.insert(
     78                 mutation["field"].as_str().unwrap().to_owned(),
     79                 mutation["value"].clone(),
     80             );
     81         }
     82         "repeat_sources" => {
     83             let template = object["sources"][0].clone();
     84             let count = mutation["count"].as_u64().unwrap();
     85             let sources = (0..count)
     86                 .map(|index| {
     87                     let mut source = template.clone();
     88                     source["source_id"] = format!("trade-{index}").into();
     89                     source["relay_id"] = format!("relay-{index}").into();
     90                     source
     91                 })
     92                 .collect();
     93             object.insert("sources".to_owned(), Value::Array(sources));
     94         }
     95         "remove_source_field" => {
     96             object["sources"][0]
     97                 .as_object_mut()
     98                 .unwrap()
     99                 .remove(mutation["field"].as_str().unwrap());
    100         }
    101         "add_source_field" => {
    102             object["sources"][0].as_object_mut().unwrap().insert(
    103                 mutation["field"].as_str().unwrap().to_owned(),
    104                 mutation["value"].clone(),
    105             );
    106         }
    107         operation => panic!("unknown configuration mutation {operation}"),
    108     }
    109     config
    110 }
    111 
    112 fn validate_config(config: &Value) -> Result<(), &'static str> {
    113     let Some(object) = config.as_object() else {
    114         return Err("invalid_config_type");
    115     };
    116     let required_top = BTreeSet::from(["contract", "contract_version", "policy_id", "sources"]);
    117     let actual_top = object.keys().map(String::as_str).collect::<BTreeSet<_>>();
    118     if !required_top.is_subset(&actual_top) {
    119         return Err("missing_required_field");
    120     }
    121     if actual_top != required_top {
    122         return Err("unknown_field");
    123     }
    124     if config["contract"] != "radroots.rhi.evidence-policy" || config["contract_version"] != 1 {
    125         return Err("invalid_contract_identity");
    126     }
    127     if !config["policy_id"].as_str().is_some_and(stable_id) {
    128         return Err("invalid_policy_id");
    129     }
    130     let Some(sources) = config["sources"].as_array() else {
    131         return Err("invalid_sources_type");
    132     };
    133     if sources.is_empty() || sources.len() > 16 {
    134         return Err("source_count_out_of_range");
    135     }
    136     let required_source = BTreeSet::from([
    137         "deadline_ms",
    138         "kind",
    139         "lookback_seconds",
    140         "overlap_seconds",
    141         "relay_id",
    142         "required",
    143         "selector",
    144         "source_id",
    145     ]);
    146     for source in sources {
    147         let Some(source) = source.as_object() else {
    148             return Err("invalid_source_type");
    149         };
    150         let actual = source.keys().map(String::as_str).collect::<BTreeSet<_>>();
    151         if !required_source.is_subset(&actual) {
    152             return Err("missing_required_field");
    153         }
    154         if actual != required_source {
    155             return Err("unknown_field");
    156         }
    157         if source["kind"] != "nostr_relay" {
    158             return Err("unsupported_source_kind");
    159         }
    160         if source["selector"] != "trade_mutation_lineage_v1" {
    161             return Err("unsupported_selector");
    162         }
    163         if !source["source_id"].as_str().is_some_and(stable_id)
    164             || !source["relay_id"].as_str().is_some_and(stable_id)
    165         {
    166             return Err("invalid_source_id");
    167         }
    168         if !source["required"].is_boolean() {
    169             return Err("invalid_required_type");
    170         }
    171         let deadline = source["deadline_ms"]
    172             .as_u64()
    173             .ok_or("invalid_deadline_type")?;
    174         if !(100..=30_000).contains(&deadline) {
    175             return Err("deadline_out_of_range");
    176         }
    177         let lookback = source["lookback_seconds"]
    178             .as_u64()
    179             .ok_or("invalid_lookback_type")?;
    180         if !(60..=2_678_400).contains(&lookback) {
    181             return Err("lookback_out_of_range");
    182         }
    183         let overlap = source["overlap_seconds"]
    184             .as_u64()
    185             .ok_or("invalid_overlap_type")?;
    186         if !(1..=86_400).contains(&overlap) {
    187             return Err("overlap_out_of_range");
    188         }
    189         if overlap > lookback {
    190             return Err("overlap_exceeds_lookback");
    191         }
    192     }
    193     let source_ids = sources
    194         .iter()
    195         .map(|source| source["source_id"].as_str().unwrap())
    196         .collect::<BTreeSet<_>>();
    197     if source_ids.len() != sources.len() {
    198         return Err("duplicate_source_id");
    199     }
    200     let bindings = sources
    201         .iter()
    202         .map(|source| {
    203             format!(
    204                 "{}|{}|{}",
    205                 source["kind"].as_str().unwrap(),
    206                 source["relay_id"].as_str().unwrap(),
    207                 source["selector"].as_str().unwrap()
    208             )
    209         })
    210         .collect::<BTreeSet<_>>();
    211     if bindings.len() != sources.len() {
    212         return Err("duplicate_source_binding");
    213     }
    214     if !sources.iter().any(|source| source["required"] == true) {
    215         return Err("no_required_source");
    216     }
    217     Ok(())
    218 }
    219 
    220 fn completion(input: &Value) -> &'static str {
    221     if input["unsupported"] == true {
    222         "unsupported"
    223     } else if input["resource_limit"] == true {
    224         "incomplete_resource_limit"
    225     } else if input["disconnect"] == true || input["relay_error"] == true {
    226         "incomplete_unavailable"
    227     } else if let Some(eose_at) = input["eose_at_ms"].as_u64() {
    228         if eose_at < input["deadline_ms"].as_u64().unwrap() {
    229             "complete"
    230         } else {
    231             "incomplete_timeout"
    232         }
    233     } else if input["deadline_elapsed_without_eose"] == true {
    234         "incomplete_timeout"
    235     } else {
    236         "incomplete_unknown"
    237     }
    238 }
    239 
    240 fn coverage(vector: &Value) -> (&'static str, Vec<&'static str>) {
    241     let sources = vector["sources"].as_array().unwrap();
    242     if sources
    243         .iter()
    244         .any(|source| source["required"] == true && source["completion"] == "unsupported")
    245     {
    246         return ("Unsupported", vec!["Indeterminate"]);
    247     }
    248     let all_required_complete = sources
    249         .iter()
    250         .filter(|source| source["required"] == true)
    251         .all(|source| source["completion"] == "complete");
    252     if all_required_complete && vector["scope_prerequisites_satisfied"] == true {
    253         return ("ScopeSatisfied", vec!["Valid", "Invalid", "Indeterminate"]);
    254     }
    255     if sources.iter().any(|source| {
    256         source["completion"] == "complete" || source["admitted_events"].as_u64().unwrap() > 0
    257     }) {
    258         ("Partial", vec!["Indeterminate"])
    259     } else {
    260         ("Missing", vec!["Indeterminate"])
    261     }
    262 }
    263 
    264 #[test]
    265 fn policy_identity_source_and_selector_are_exact() {
    266     assert_eq!(CONSUMER_ROOT, "rhi\n");
    267     let value = policy();
    268     assert_eq!(value["schema"], "radroots.rhi.evidence-policy-decision.v1");
    269     assert_eq!(value["contract_version"], 1);
    270     assert_eq!(value["decision_state"], "reserved_preimplementation");
    271     assert_eq!(
    272         value["source_kinds"]["qualified_v1"],
    273         serde_json::json!(["nostr_relay"])
    274     );
    275     assert_eq!(
    276         value["selector"],
    277         serde_json::json!({
    278             "id": "trade_mutation_lineage_v1",
    279             "trade_event_contract": {
    280                 "repository": "https://github.com/radrootslabs/lib",
    281                 "path": "contracts/architecture/decisions/services_hardening_events.v1.json",
    282                 "schema": "radroots.services-hardening.event-decisions.v1",
    283                 "contract_family": "radroots.trade.mutation-index.v1"
    284             },
    285             "event_kinds": [3470, 3471, 3472, 3473, 3474],
    286             "event_kind_meanings": ["proposal", "decision", "revision_proposal", "revision_decision", "cancellation"],
    287             "dynamic_filter": { "tag": "#d", "value": "exact_job_trade_id" },
    288             "required_structural_tags": ["contract", "d", "x:mutation", "p:buyer", "p:seller"],
    289             "nonproposal_additional_tags": ["x:root", "x:parent"],
    290             "author_policy": "validate_against_typed_trade_party_and_mutation_contract",
    291             "filter_extra_tags": false,
    292             "maximum_events_per_result": 4096,
    293             "maximum_result_bytes": 8_388_608
    294         })
    295     );
    296 }
    297 
    298 #[test]
    299 fn cursor_completion_and_coverage_fail_closed() {
    300     let value = policy();
    301     assert_eq!(
    302         value["cursor"]["tuple"],
    303         serde_json::json!(["created_at_unix_seconds", "event_id_lowercase_hex"])
    304     );
    305     assert_eq!(value["cursor"]["equal_timestamp_safe"], true);
    306     assert_eq!(
    307         value["cursor"]["rejected_event"],
    308         "cannot_advance_cursor_or_completion_or_dirty_generation"
    309     );
    310     assert_eq!(
    311         value["completion"]["success_evidence"],
    312         "nostr_eose_received_before_source_deadline"
    313     );
    314     assert_eq!(
    315         value["completion"]["response_without_eose"],
    316         "incomplete_unknown"
    317     );
    318     assert_eq!(
    319         value["required_optional_semantics"]["coverage_precedence"],
    320         serde_json::json!(["Unsupported", "ScopeSatisfied", "Partial", "Missing"])
    321     );
    322     assert_eq!(
    323         value["required_optional_semantics"]["optional_incomplete"],
    324         "record_safe_source_result_without_blocking_scope_satisfied"
    325     );
    326     let base = &value["configuration_fixture"];
    327     for vector in value["configuration_vectors"].as_array().unwrap() {
    328         let candidate = mutate_config(base, &vector["mutation"]);
    329         let actual = validate_config(&candidate)
    330             .map(|()| "valid")
    331             .unwrap_or_else(|error| error);
    332         assert_eq!(
    333             actual,
    334             vector["expected"].as_str().unwrap(),
    335             "configuration vector {}",
    336             vector["case"]
    337         );
    338     }
    339     for vector in value["completion_vectors"].as_array().unwrap() {
    340         assert_eq!(
    341             completion(&vector["input"]),
    342             vector["expected"].as_str().unwrap(),
    343             "completion vector {}",
    344             vector["case"]
    345         );
    346     }
    347     for vector in value["cursor_vectors"].as_array().unwrap() {
    348         let current = &vector["current"];
    349         let candidate = &vector["candidate"];
    350         let next = if candidate["admitted"] == true
    351             && vector["source_complete"] == true
    352             && vector["generation_fence"] == true
    353             && (
    354                 candidate["created_at_unix_seconds"].as_u64(),
    355                 candidate["event_id_lowercase_hex"].as_str(),
    356             ) > (
    357                 current["created_at_unix_seconds"].as_u64(),
    358                 current["event_id_lowercase_hex"].as_str(),
    359             ) {
    360             candidate
    361         } else {
    362             current
    363         };
    364         let projected = serde_json::json!({
    365             "created_at_unix_seconds": next["created_at_unix_seconds"],
    366             "event_id_lowercase_hex": next["event_id_lowercase_hex"]
    367         });
    368         assert_eq!(
    369             projected, vector["expected"],
    370             "cursor vector {}",
    371             vector["case"]
    372         );
    373     }
    374     for vector in value["coverage_vectors"].as_array().unwrap() {
    375         let (actual_coverage, actual_outcomes) = coverage(vector);
    376         assert_eq!(actual_coverage, vector["expected_coverage"]);
    377         assert_eq!(
    378             actual_outcomes,
    379             vector["allowed_outcomes"]
    380                 .as_array()
    381                 .unwrap()
    382                 .iter()
    383                 .map(|outcome| outcome.as_str().unwrap())
    384                 .collect::<Vec<_>>()
    385         );
    386     }
    387 }
    388 
    389 #[test]
    390 fn policy_digest_vector_and_publication_independence_are_exact() {
    391     let value = policy();
    392     let vector = &value["fixed_vector"]["normalized_policy"];
    393     let canonical = serde_json::to_vec(vector).expect("canonical vector JSON");
    394     assert_eq!(
    395         canonical,
    396         value["fixed_vector"]["canonical_policy_utf8"]
    397             .as_str()
    398             .unwrap()
    399             .as_bytes()
    400     );
    401     let mut preimage = b"radroots:rhi-evidence-policy:v1\0".to_vec();
    402     preimage.extend_from_slice(&canonical);
    403     assert_eq!(
    404         preimage,
    405         value["fixed_vector"]["preimage_utf8"]
    406             .as_str()
    407             .unwrap()
    408             .as_bytes()
    409     );
    410     assert_eq!(
    411         sha256_hex(&preimage),
    412         value["fixed_vector"]["policy_digest"].as_str().unwrap()
    413     );
    414     assert_eq!(
    415         value["publication_relationship"],
    416         serde_json::json!({
    417             "relationship": "independent",
    418             "evidence_sources_are_publication_targets": false,
    419             "publication_targets_affect_coverage_or_outcome": false,
    420             "publication_failure_changes_report_or_attestation_content": false,
    421             "required_publication_failure": "publication_workflow_and_readiness_only",
    422             "disabled_publication": "no_target_or_network_work"
    423         })
    424     );
    425     assert_eq!(
    426         decision_sections_digest(&value),
    427         "242642269d9272d2a1bae50bae5a02a1329cab1a2dfb636d363aa362ce475011"
    428     );
    429 }