ubl_wasm/
lib.rs

1//! UBL WASM — Chip execution engine for WebAssembly
2//!
3//! CONSTITUTION: Decoder ≠ Canon
4//! - serde_json: decoder-only (from_str::<T> into TYPED structs)
5//! - json_atomic: canonizer (ONLY source of canonical bytes / stable hashing)
6//! - NEVER serde_json::Value, NEVER json!, NEVER Map as semantic model.
7
8use wasm_bindgen::prelude::*;
9use serde::{Deserialize, Serialize};
10use json_atomic::canonize;
11use std::collections::BTreeMap;
12
13// ============================================================================
14// TYPED STRUCTS — the only semantic truth
15// ============================================================================
16
17#[derive(Debug, Clone, Deserialize, Serialize)]
18pub struct ChipInput {
19    pub did: String,
20    pub cid: String,
21    pub logic: LogicExpr,
22}
23
24// LogicExpr supports typical JSON policy AST forms:
25// - {">=":[{"$ref":"user.age"}, 18]}
26// - {"and":[ {...}, {...} ]}
27// - {"or":[ {...}, {...} ]}
28// - {"not": {...}}
29// - true / false
30#[derive(Debug, Clone, Deserialize, Serialize)]
31#[serde(untagged)]
32pub enum LogicExpr {
33    Bool(bool),
34    OpMap(BTreeMap<String, serde_json::Value>), // decoder-only intermediate; immediately retyped in parse_op()
35}
36
37#[derive(Debug, Clone)]
38pub enum Op {
39    Gte([Operand; 2]),
40    Lte([Operand; 2]),
41    Gt([Operand; 2]),
42    Lt([Operand; 2]),
43    Eq([Operand; 2]),
44    And(Vec<LogicExprStrict>),
45    Or(Vec<LogicExprStrict>),
46    Not(Box<LogicExprStrict>),
47}
48
49#[derive(Debug, Clone)]
50pub enum LogicExprStrict {
51    Bool(bool),
52    Op(Op),
53}
54
55#[derive(Debug, Clone, Deserialize, Serialize)]
56#[serde(untagged)]
57pub enum Operand {
58    LiteralNumber(f64),
59    LiteralBool(bool),
60    LiteralString(String),
61    Ref(RefOperand),
62}
63
64#[derive(Debug, Clone, Deserialize, Serialize)]
65pub struct RefOperand {
66    #[serde(rename = "$ref")]
67    pub path: String,
68}
69
70// Context is a typed tree (no serde_json::Value)
71#[derive(Debug, Clone, Deserialize, Serialize)]
72pub struct Context {
73    #[serde(flatten)]
74    pub fields: BTreeMap<String, ContextValue>,
75}
76
77#[derive(Debug, Clone, Deserialize, Serialize)]
78#[serde(untagged)]
79pub enum ContextValue {
80    Null,
81    Bool(bool),
82    Number(f64),
83    String(String),
84    Array(Vec<ContextValue>),
85    Object(BTreeMap<String, ContextValue>),
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct DecisionOutput {
90    pub reason: String,
91    pub result: bool,
92}
93
94#[derive(Debug, Clone, Serialize)]
95pub struct ChipResult {
96    pub chip_cid: String,
97    pub decision_cid: String,
98    pub did: String,
99    pub reason: String,
100    pub result: bool,
101}
102
103// ============================================================================
104// WASM EXPORTS
105// ============================================================================
106
107#[wasm_bindgen]
108pub fn blake3_hex(data: &[u8]) -> String {
109    blake3::hash(data).to_hex().to_string()
110}
111
112#[wasm_bindgen]
113pub fn canonicalize_context(ctx_json: &str) -> Result<String, JsValue> {
114    // decoder-only parse into typed Context
115    let ctx: Context = serde_json::from_str(ctx_json)
116        .map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
117
118    // canonize uses json_atomic only
119    let bytes = canonize(&ctx.fields)
120        .map_err(|e| JsValue::from_str(&format!("Canonize error: {}", e)))?;
121
122    String::from_utf8(bytes)
123        .map_err(|e| JsValue::from_str(&format!("UTF-8 error: {}", e)))
124}
125
126#[wasm_bindgen]
127pub fn decision_cid(
128    did: &str,
129    chip_cid: &str,
130    ctx_json: &str,
131    result: bool,
132    reason: &str,
133) -> Result<String, JsValue> {
134    let ctx: Context = serde_json::from_str(ctx_json)
135        .map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
136
137    let ctx_canon = canonize(&ctx.fields)
138        .map_err(|e| JsValue::from_str(&format!("Canonize error: {}", e)))?;
139
140    let out = DecisionOutput {
141        reason: reason.to_string(),
142        result,
143    };
144
145    let out_canon = canonize(&out)
146        .map_err(|e| JsValue::from_str(&format!("Canonize error: {}", e)))?;
147
148    // IMPORTANT: keep the preimage format stable forever (constitution string)
149    let mut preimage = Vec::new();
150    preimage.extend_from_slice(b"UBL_DECISION_V1\0");
151    preimage.extend_from_slice(did.as_bytes());
152    preimage.push(0);
153    preimage.extend_from_slice(chip_cid.as_bytes());
154    preimage.push(0);
155    preimage.extend_from_slice(&ctx_canon);
156    preimage.push(0);
157    preimage.extend_from_slice(&out_canon);
158
159    Ok(blake3_hex(&preimage))
160}
161
162#[wasm_bindgen]
163pub fn evaluate_chip(chip_json: &str, ctx_json: &str) -> Result<String, JsValue> {
164    // chip decode (typed)
165    let chip: ChipInput = serde_json::from_str(chip_json)
166        .map_err(|e| JsValue::from_str(&format!("Parse chip error: {}", e)))?;
167
168    // ctx decode (typed)
169    let ctx: Context = serde_json::from_str(ctx_json)
170        .map_err(|e| JsValue::from_str(&format!("Parse ctx error: {}", e)))?;
171
172    // normalize logic into strict typed expression
173    let strict = to_strict(&chip.logic)
174        .map_err(|e| JsValue::from_str(&format!("Logic parse error: {}", e)))?;
175
176    let (result, reason) = eval_logic(&strict, &ctx);
177
178    let cid = decision_cid(&chip.did, &chip.cid, ctx_json, result, &reason)?;
179
180    let out = ChipResult {
181        chip_cid: chip.cid.clone(),
182        decision_cid: cid,
183        did: chip.did.clone(),
184        reason,
185        result,
186    };
187
188    let bytes = canonize(&out)
189        .map_err(|e| JsValue::from_str(&format!("Canonize error: {}", e)))?;
190
191    String::from_utf8(bytes)
192        .map_err(|e| JsValue::from_str(&format!("UTF-8 error: {}", e)))
193}
194
195// ============================================================================
196// LOGIC NORMALIZATION (serde_json only as decoder-only intermediate)
197// ============================================================================
198
199fn to_strict(expr: &LogicExpr) -> Result<LogicExprStrict, String> {
200    match expr {
201        LogicExpr::Bool(b) => Ok(LogicExprStrict::Bool(*b)),
202        LogicExpr::OpMap(map) => {
203            if map.len() != 1 {
204                return Err("Logic op map must have exactly 1 key".to_string());
205            }
206            let (k, v) = map.iter().next().unwrap();
207            let op = parse_op(k, v)?;
208            Ok(LogicExprStrict::Op(op))
209        }
210    }
211}
212
213fn parse_op(op: &str, v: &serde_json::Value) -> Result<Op, String> {
214    match op {
215        ">=" => Ok(Op::Gte(parse_two_operands(v)?)),
216        "<=" => Ok(Op::Lte(parse_two_operands(v)?)),
217        ">"  => Ok(Op::Gt(parse_two_operands(v)?)),
218        "<"  => Ok(Op::Lt(parse_two_operands(v)?)),
219        "==" => Ok(Op::Eq(parse_two_operands(v)?)),
220        "and" => {
221            let arr = v.as_array().ok_or("and expects array")?;
222            let mut items = Vec::new();
223            for item in arr {
224                let e: LogicExpr = serde_json::from_value(item.clone()).map_err(|e| e.to_string())?;
225                items.push(to_strict(&e)?);
226            }
227            Ok(Op::And(items))
228        }
229        "or" => {
230            let arr = v.as_array().ok_or("or expects array")?;
231            let mut items = Vec::new();
232            for item in arr {
233                let e: LogicExpr = serde_json::from_value(item.clone()).map_err(|e| e.to_string())?;
234                items.push(to_strict(&e)?);
235            }
236            Ok(Op::Or(items))
237        }
238        "not" => {
239            let e: LogicExpr = serde_json::from_value(v.clone()).map_err(|e| e.to_string())?;
240            Ok(Op::Not(Box::new(to_strict(&e)?)))
241        }
242        _ => Err(format!("Unknown operator: {}", op)),
243    }
244}
245
246fn parse_two_operands(v: &serde_json::Value) -> Result<[Operand; 2], String> {
247    let arr = v.as_array().ok_or("comparison expects array")?;
248    if arr.len() != 2 {
249        return Err("comparison expects exactly 2 operands".to_string());
250    }
251    let a: Operand = serde_json::from_value(arr[0].clone()).map_err(|e| e.to_string())?;
252    let b: Operand = serde_json::from_value(arr[1].clone()).map_err(|e| e.to_string())?;
253    Ok([a, b])
254}
255
256// ============================================================================
257// LOGIC EVALUATION (pure, no I/O)
258// ============================================================================
259
260fn eval_logic(expr: &LogicExprStrict, ctx: &Context) -> (bool, String) {
261    match expr {
262        LogicExprStrict::Bool(b) => (*b, format!("{}", b)),
263        LogicExprStrict::Op(op) => match op {
264            Op::Gte([a,b]) => eval_cmp(a,b,ctx,">=", |x,y| x>=y),
265            Op::Lte([a,b]) => eval_cmp(a,b,ctx,"<=", |x,y| x<=y),
266            Op::Gt([a,b])  => eval_cmp(a,b,ctx,">",  |x,y| x>y),
267            Op::Lt([a,b])  => eval_cmp(a,b,ctx,"<",  |x,y| x<y),
268            Op::Eq([a,b])  => eval_eq(a,b,ctx),
269            Op::And(items) => {
270                let mut reasons = Vec::new();
271                for it in items {
272                    let (r, rs) = eval_logic(it, ctx);
273                    reasons.push(rs);
274                    if !r {
275                        return (false, format!("and(false): {}", reasons.join(" ∧ ")));
276                    }
277                }
278                (true, format!("and(true): {}", reasons.join(" ∧ ")))
279            }
280            Op::Or(items) => {
281                let mut reasons = Vec::new();
282                for it in items {
283                    let (r, rs) = eval_logic(it, ctx);
284                    reasons.push(rs);
285                    if r {
286                        return (true, format!("or(true): {}", reasons.join(" ∨ ")));
287                    }
288                }
289                (false, format!("or(false): {}", reasons.join(" ∨ ")))
290            }
291            Op::Not(inner) => {
292                let (r, rs) = eval_logic(inner, ctx);
293                (!r, format!("not({})", rs))
294            }
295        }
296    }
297}
298
299fn eval_cmp<F>(a: &Operand, b: &Operand, ctx: &Context, op: &str, f: F) -> (bool, String)
300where
301    F: Fn(f64, f64) -> bool,
302{
303    let la = resolve_number(a, ctx);
304    let lb = resolve_number(b, ctx);
305    match (la, lb) {
306        (Some(x), Some(y)) => {
307            let r = f(x, y);
308            (r, format!("{} {} {} = {}", x, op, y, r))
309        }
310        _ => (false, format!("invalid {} operands", op)),
311    }
312}
313
314fn eval_eq(a: &Operand, b: &Operand, ctx: &Context) -> (bool, String) {
315    let ra = resolve_any(a, ctx);
316    let rb = resolve_any(b, ctx);
317    let r = ra == rb;
318    (r, format!("{:?} == {:?} = {}", ra, rb, r))
319}
320
321#[derive(Debug, Clone, PartialEq)]
322enum AnyVal {
323    Null,
324    Bool(bool),
325    Num(f64),
326    Str(String),
327}
328
329fn resolve_any(op: &Operand, ctx: &Context) -> AnyVal {
330    match op {
331        Operand::LiteralNumber(n) => AnyVal::Num(*n),
332        Operand::LiteralBool(b) => AnyVal::Bool(*b),
333        Operand::LiteralString(s) => AnyVal::Str(s.clone()),
334        Operand::Ref(r) => {
335            match get_ctx_value(ctx, &r.path) {
336                Some(ContextValue::Null) => AnyVal::Null,
337                Some(ContextValue::Bool(b)) => AnyVal::Bool(*b),
338                Some(ContextValue::Number(n)) => AnyVal::Num(*n),
339                Some(ContextValue::String(s)) => AnyVal::Str(s.clone()),
340                _ => AnyVal::Null,
341            }
342        }
343    }
344}
345
346fn resolve_number(op: &Operand, ctx: &Context) -> Option<f64> {
347    match op {
348        Operand::LiteralNumber(n) => Some(*n),
349        Operand::Ref(r) => match get_ctx_value(ctx, &r.path) {
350            Some(ContextValue::Number(n)) => Some(*n),
351            _ => None,
352        },
353        _ => None,
354    }
355}
356
357fn get_ctx_value<'a>(ctx: &'a Context, path: &str) -> Option<&'a ContextValue> {
358    let parts: Vec<&str> = path.split('.').collect();
359    let mut cur = &ctx.fields;
360
361    for (i, part) in parts.iter().enumerate() {
362        let v = cur.get(*part)?;
363        if i == parts.len() - 1 {
364            return Some(v);
365        }
366        match v {
367            ContextValue::Object(obj) => cur = obj,
368            _ => return None,
369        }
370    }
371    None
372}