Skip to main content

harn_kernel/
type_contract.rs

1//! Runtime type-contract matching shared by native and portable execution.
2//!
3//! The parser owns `TypeExpr`; this module owns how those expressions match
4//! runtime values. Runtimes project their value representation through the
5//! small `TypeContractValue` interface instead of maintaining another type
6//! dispatch table.
7
8use harn_parser::builtin_signatures::{BuiltinSignature, Ty};
9use harn_parser::TypeExpr;
10
11use crate::DataValue;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RuntimeTypeKind {
15    Int,
16    Float,
17    Decimal,
18    String,
19    Bytes,
20    Bool,
21    Nil,
22    List,
23    Dict,
24    Closure,
25    Duration,
26    Enum,
27    Struct,
28    TaskHandle,
29    Channel,
30    Atomic,
31    Rng,
32    SyncPermit,
33    Resource,
34    ResourceGuard,
35    McpClient,
36    VerdictReceipt,
37    Set,
38    Generator,
39    Stream,
40    Range,
41    Iter,
42    Pair,
43    Harness,
44}
45
46pub trait TypeContractValue: Sized {
47    fn runtime_type_kind(&self) -> RuntimeTypeKind;
48    fn list_items(&self) -> Option<&[Self]> {
49        None
50    }
51    fn record_field(&self, _name: &str) -> Option<&Self> {
52        None
53    }
54    /// Apply `predicate` to every value in a dictionary-like value.
55    ///
56    /// Returning `None` distinguishes a value that is not enumerable from an
57    /// empty dictionary. This keeps homogeneous `dict<K, V>` checks exact
58    /// without forcing either runtime to allocate a projection vector.
59    fn record_values_match(&self, _predicate: &mut dyn FnMut(&Self) -> bool) -> Option<bool> {
60        None
61    }
62    fn string_literal(&self) -> Option<&str> {
63        None
64    }
65    fn int_literal(&self) -> Option<i64> {
66        None
67    }
68    fn nominal_type_name(&self) -> Option<&str> {
69        None
70    }
71}
72
73fn is_nominal(name: &str, nominal_type_names: &[String]) -> bool {
74    nominal_type_names.iter().any(|ty| ty == name)
75}
76
77pub fn matches_type<V: TypeContractValue>(
78    value: &V,
79    expected: &TypeExpr,
80    type_params: &[String],
81    nominal_type_names: &[String],
82) -> bool {
83    use RuntimeTypeKind as Kind;
84    match expected {
85        TypeExpr::Named(name) => match name.as_str() {
86            _ if type_params.iter().any(|param| param == name) => true,
87            "any" | "unknown" => true,
88            "int" => value.runtime_type_kind() == Kind::Int,
89            "float" | "number" => matches!(value.runtime_type_kind(), Kind::Float | Kind::Int),
90            "decimal" => value.runtime_type_kind() == Kind::Decimal,
91            "string" => value.runtime_type_kind() == Kind::String,
92            "bool" => value.runtime_type_kind() == Kind::Bool,
93            "nil" => value.runtime_type_kind() == Kind::Nil,
94            "list" => value.runtime_type_kind() == Kind::List,
95            "dict" | "record" => value.runtime_type_kind() == Kind::Dict,
96            "bytes" => value.runtime_type_kind() == Kind::Bytes,
97            "duration" => value.runtime_type_kind() == Kind::Duration,
98            "set" => value.runtime_type_kind() == Kind::Set,
99            "range" => value.runtime_type_kind() == Kind::Range,
100            "iter" => value.runtime_type_kind() == Kind::Iter,
101            "generator" | "Generator" => value.runtime_type_kind() == Kind::Generator,
102            "stream" | "Stream" => value.runtime_type_kind() == Kind::Stream,
103            "channel" => value.runtime_type_kind() == Kind::Channel,
104            "task_handle" => value.runtime_type_kind() == Kind::TaskHandle,
105            "atomic" => value.runtime_type_kind() == Kind::Atomic,
106            "rng" => value.runtime_type_kind() == Kind::Rng,
107            "sync_permit" => value.runtime_type_kind() == Kind::SyncPermit,
108            "resource" => value.runtime_type_kind() == Kind::Resource,
109            "resource_guard" => value.runtime_type_kind() == Kind::ResourceGuard,
110            "mcp_client" => value.runtime_type_kind() == Kind::McpClient,
111            "verdict_receipt" => value.runtime_type_kind() == Kind::VerdictReceipt,
112            "pair" => value.runtime_type_kind() == Kind::Pair,
113            "enum" => value.runtime_type_kind() == Kind::Enum,
114            "struct" => value.runtime_type_kind() == Kind::Struct,
115            "closure" => value.runtime_type_kind() == Kind::Closure,
116            _ if !nominal_type_names.iter().any(|ty| ty == name) => true,
117            _ => value
118                .nominal_type_name()
119                .is_some_and(|actual| actual == name),
120        },
121        TypeExpr::Union(members) => members
122            .iter()
123            .any(|member| matches_type(value, member, type_params, nominal_type_names)),
124        TypeExpr::Intersection(members) => members
125            .iter()
126            .all(|member| matches_type(value, member, type_params, nominal_type_names)),
127        TypeExpr::List(inner) => value.list_items().is_some_and(|items| {
128            items
129                .iter()
130                .all(|item| matches_type(item, inner, type_params, nominal_type_names))
131        }),
132        TypeExpr::Tuple(elements) => value.list_items().is_some_and(|items| {
133            items.len() == elements.len()
134                && items.iter().zip(elements).all(|(item, element)| {
135                    matches_type(item, element, type_params, nominal_type_names)
136                })
137        }),
138        TypeExpr::DictType(_, value_type) => {
139            value.runtime_type_kind() == Kind::Dict
140                && record_values_match(value, value_type, type_params, nominal_type_names)
141        }
142        TypeExpr::Iter(_) | TypeExpr::Generator(_) | TypeExpr::Stream(_) => matches!(
143            value.runtime_type_kind(),
144            Kind::List | Kind::Generator | Kind::Stream
145        ),
146        TypeExpr::Shape(fields) | TypeExpr::OpenShape { fields, .. } => {
147            matches!(value.runtime_type_kind(), Kind::Dict | Kind::Struct)
148                && fields
149                    .iter()
150                    .all(|field| match value.record_field(&field.name) {
151                        Some(field_value)
152                            if field.optional && field_value.runtime_type_kind() == Kind::Nil =>
153                        {
154                            true
155                        }
156                        Some(field_value) => matches_type(
157                            field_value,
158                            &field.type_expr,
159                            type_params,
160                            nominal_type_names,
161                        ),
162                        None => field.optional,
163                    })
164        }
165        TypeExpr::Applied { name, args } => match (name.as_str(), args.as_slice()) {
166            ("list" | "List", [inner]) => value.list_items().is_some_and(|items| {
167                items
168                    .iter()
169                    .all(|item| matches_type(item, inner, type_params, nominal_type_names))
170            }),
171            ("dict" | "Dict", [_, value_type]) => {
172                value.runtime_type_kind() == Kind::Dict
173                    && record_values_match(value, value_type, type_params, nominal_type_names)
174            }
175            // `Option<T>` is the nullable spelling *unless* the program declares
176            // its own nominal `Option`. A user-defined `enum Option<T>` produces
177            // an `EnumVariant`, which is neither nil nor a `T`, so the built-in
178            // reading rejects every value the user could construct.
179            ("Option", [inner]) if !is_nominal("Option", nominal_type_names) => {
180                value.runtime_type_kind() == Kind::Nil
181                    || matches_type(value, inner, type_params, nominal_type_names)
182            }
183            // An applied nominal type constrains identity but not its arguments:
184            // the VM does not monomorphize, so `Box<int>` and `Box<string>` are
185            // the same runtime shape. This mirrors the `Named` arm.
186            (name, _) if is_nominal(name, nominal_type_names) => value
187                .nominal_type_name()
188                .is_some_and(|actual| actual == name),
189            _ => true,
190        },
191        TypeExpr::FnType { .. } => value.runtime_type_kind() == Kind::Closure,
192        TypeExpr::Never => false,
193        TypeExpr::LitString(expected) => value.string_literal() == Some(expected),
194        TypeExpr::LitInt(expected) => value.int_literal() == Some(*expected),
195        TypeExpr::Owned(inner) => matches_type(value, inner, type_params, nominal_type_names),
196    }
197}
198
199/// Match a value against the canonical const-friendly type used by builtin
200/// and capability manifests.
201///
202/// Conversion is owned by `harn-parser`; runtimes therefore do not maintain a
203/// second interpretation of `Ty` alongside the source-language `TypeExpr`.
204pub fn matches_manifest_type<V: TypeContractValue>(value: &V, expected: &Ty) -> bool {
205    let expected = harn_parser::builtin_signatures::ty_to_type_expr(expected);
206    matches_type(value, &expected, &[], &[])
207}
208
209/// Match a runtime value against the closed schema subset emitted by the
210/// canonical compiler for typed default parameters.
211///
212/// This is intentionally narrower than Harn's user-facing schema language:
213/// it owns only the compiler-generated `type`, `properties`, `required`,
214/// `items`, `additional_properties`, `union`, `all_of`, `enum`, and `const`
215/// vocabulary. Both native and portable runtimes can project values through
216/// [`TypeContractValue`] without growing a second source-type dispatcher.
217pub fn matches_compiler_schema<V: TypeContractValue>(value: &V, schema: &DataValue) -> bool {
218    matches_compiler_schema_inner(value, schema, 0)
219}
220
221fn matches_compiler_schema_inner<V: TypeContractValue>(
222    value: &V,
223    schema: &DataValue,
224    depth: usize,
225) -> bool {
226    const MAX_SCHEMA_DEPTH: usize = 256;
227    if depth >= MAX_SCHEMA_DEPTH {
228        return false;
229    }
230    let DataValue::Record(fields) = schema else {
231        return false;
232    };
233
234    if let Some(DataValue::List(branches)) = fields.get("union") {
235        return branches
236            .iter()
237            .any(|branch| matches_compiler_schema_inner(value, branch, depth + 1));
238    }
239    if let Some(DataValue::List(branches)) = fields.get("all_of") {
240        return branches
241            .iter()
242            .all(|branch| matches_compiler_schema_inner(value, branch, depth + 1));
243    }
244    if let Some(DataValue::String(expected)) = fields.get("type") {
245        use RuntimeTypeKind as Kind;
246        let matches = match expected.as_str() {
247            "int" => value.runtime_type_kind() == Kind::Int,
248            "float" => matches!(value.runtime_type_kind(), Kind::Int | Kind::Float),
249            "string" => value.runtime_type_kind() == Kind::String,
250            "bool" => value.runtime_type_kind() == Kind::Bool,
251            "nil" => value.runtime_type_kind() == Kind::Nil,
252            "list" => value.runtime_type_kind() == Kind::List,
253            "dict" => value.runtime_type_kind() == Kind::Dict,
254            "bytes" => value.runtime_type_kind() == Kind::Bytes,
255            "closure" => value.runtime_type_kind() == Kind::Closure,
256            // Sets cannot cross the portable data boundary.
257            "set" => value.runtime_type_kind() == Kind::Set,
258            _ => false,
259        };
260        if !matches {
261            return false;
262        }
263    }
264    if let Some(DataValue::List(allowed)) = fields.get("enum") {
265        if !allowed
266            .iter()
267            .any(|candidate| literal_matches(value, candidate))
268        {
269            return false;
270        }
271    }
272    if let Some(expected) = fields.get("const") {
273        if !literal_matches(value, expected) {
274            return false;
275        }
276    }
277    if let Some(DataValue::List(required)) = fields.get("required") {
278        if !required.iter().all(|name| match name {
279            DataValue::String(name) => value.record_field(name).is_some(),
280            _ => false,
281        }) {
282            return false;
283        }
284    }
285    if let Some(DataValue::Record(properties)) = fields.get("properties") {
286        for (name, child_schema) in properties {
287            if let Some(child) = value.record_field(name) {
288                if !matches_compiler_schema_inner(child, child_schema, depth + 1) {
289                    return false;
290                }
291            }
292        }
293    }
294    if let Some(additional) = fields.get("additional_properties") {
295        if value.record_values_match(&mut |child| {
296            matches_compiler_schema_inner(child, additional, depth + 1)
297        }) != Some(true)
298        {
299            return false;
300        }
301    }
302    true
303}
304
305fn literal_matches<V: TypeContractValue>(value: &V, expected: &DataValue) -> bool {
306    match expected {
307        DataValue::String(expected) => value.string_literal() == Some(expected),
308        DataValue::Int(expected) => value.int_literal() == Some(*expected),
309        DataValue::Nil => value.runtime_type_kind() == RuntimeTypeKind::Nil,
310        _ => false,
311    }
312}
313
314/// Return whether a canonical manifest type can cross the portable
315/// [`DataValue`] boundary without losing information or type precision.
316///
317/// The capability registry describes both JSON-shaped host calls and native
318/// runtime objects such as channels, streams, closures, schemas, and generic
319/// results. Portable execution must reject the latter structurally instead of
320/// collapsing them to `any` and pretending the contract is enforceable.
321pub fn manifest_type_is_portable(expected: &Ty) -> bool {
322    match expected {
323        Ty::Any | Ty::LitInt(_) | Ty::LitString(_) => true,
324        Ty::Named(name) => matches!(
325            *name,
326            "any"
327                | "unknown"
328                | "nil"
329                | "bool"
330                | "int"
331                | "float"
332                | "number"
333                | "string"
334                | "bytes"
335                | "list"
336                | "dict"
337                | "record"
338        ),
339        Ty::Optional(inner) => manifest_type_is_portable(inner),
340        Ty::Apply("list" | "List", [inner]) | Ty::Apply("Option", [inner]) => {
341            manifest_type_is_portable(inner)
342        }
343        Ty::Apply("dict" | "Dict", [key, value]) => {
344            manifest_dict_key_is_portable(key) && manifest_type_is_portable(value)
345        }
346        Ty::Union(members) => !members.is_empty() && members.iter().all(manifest_type_is_portable),
347        Ty::Shape(fields) => fields
348            .iter()
349            .all(|field| manifest_type_is_portable(&field.ty)),
350        // An open record is portable when its named fields are. The row tails
351        // stand for keys the manifest does not describe, so they are only
352        // portable if they are themselves portable container types.
353        Ty::OpenShape(fields, rests) => {
354            fields
355                .iter()
356                .all(|field| manifest_type_is_portable(&field.ty))
357                && rests.iter().all(manifest_type_is_portable)
358        }
359        Ty::Generic(_) | Ty::Apply(_, _) | Ty::Fn(_, _) | Ty::SchemaOf(_) | Ty::Never => false,
360    }
361}
362
363fn manifest_dict_key_is_portable(expected: &Ty) -> bool {
364    match expected {
365        Ty::Any | Ty::Named("any" | "unknown" | "string") | Ty::LitString(_) => true,
366        Ty::Union(members) => {
367            !members.is_empty() && members.iter().all(manifest_dict_key_is_portable)
368        }
369        _ => false,
370    }
371}
372
373/// Return whether every parameter and successful return value in a canonical
374/// capability signature is representable by the portable value contract.
375pub fn manifest_signature_is_portable(signature: &BuiltinSignature) -> bool {
376    signature
377        .params
378        .iter()
379        .all(|parameter| manifest_type_is_portable(&parameter.ty))
380        && manifest_type_is_portable(&signature.returns)
381}
382
383fn record_values_match<V: TypeContractValue>(
384    value: &V,
385    value_type: &TypeExpr,
386    type_params: &[String],
387    nominal_type_names: &[String],
388) -> bool {
389    value
390        .record_values_match(&mut |field| {
391            matches_type(field, value_type, type_params, nominal_type_names)
392        })
393        .unwrap_or(false)
394}
395
396impl TypeContractValue for DataValue {
397    fn runtime_type_kind(&self) -> RuntimeTypeKind {
398        match self {
399            Self::Nil => RuntimeTypeKind::Nil,
400            Self::Bool(_) => RuntimeTypeKind::Bool,
401            Self::Int(_) => RuntimeTypeKind::Int,
402            Self::Float(_) => RuntimeTypeKind::Float,
403            Self::String(_) => RuntimeTypeKind::String,
404            Self::Bytes(_) => RuntimeTypeKind::Bytes,
405            Self::List(_) => RuntimeTypeKind::List,
406            Self::Record(_) => RuntimeTypeKind::Dict,
407        }
408    }
409
410    fn list_items(&self) -> Option<&[Self]> {
411        match self {
412            Self::List(items) => Some(items),
413            _ => None,
414        }
415    }
416
417    fn record_field(&self, name: &str) -> Option<&Self> {
418        match self {
419            Self::Record(fields) => fields.get(name),
420            _ => None,
421        }
422    }
423
424    fn record_values_match(&self, predicate: &mut dyn FnMut(&Self) -> bool) -> Option<bool> {
425        match self {
426            Self::Record(fields) => Some(fields.values().all(predicate)),
427            _ => None,
428        }
429    }
430
431    fn string_literal(&self) -> Option<&str> {
432        match self {
433            Self::String(value) => Some(value),
434            _ => None,
435        }
436    }
437
438    fn int_literal(&self) -> Option<i64> {
439        match self {
440            Self::Int(value) => Some(*value),
441            _ => None,
442        }
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use harn_parser::builtin_signatures::{ShapeFieldDescriptor, Ty};
449
450    use super::*;
451
452    const STRING: Ty = Ty::Named("string");
453    const STRING_LIST_ARGS: &[Ty] = &[STRING];
454    const RESULT_ARGS: &[Ty] = &[STRING, Ty::Named("dict")];
455    const INT_KEYED_DICT_ARGS: &[Ty] = &[Ty::Named("int"), STRING];
456    const RECORD_FIELDS: &[ShapeFieldDescriptor] = &[
457        ShapeFieldDescriptor::new("name", STRING),
458        ShapeFieldDescriptor::optional("note", STRING),
459    ];
460
461    #[test]
462    fn manifest_types_use_the_source_type_contract() {
463        let strings = DataValue::List(vec![DataValue::String("kernel".into())]);
464        assert!(matches_manifest_type(
465            &strings,
466            &Ty::Apply("list", STRING_LIST_ARGS)
467        ));
468        assert!(!matches_manifest_type(
469            &DataValue::List(vec![DataValue::Int(1)]),
470            &Ty::Apply("list", STRING_LIST_ARGS)
471        ));
472
473        let record = DataValue::Record(std::collections::BTreeMap::from([(
474            "name".to_string(),
475            DataValue::String("portable".into()),
476        )]));
477        assert!(matches_manifest_type(&record, &Ty::Shape(RECORD_FIELDS)));
478    }
479
480    #[test]
481    fn portable_manifest_types_are_exactly_data_value_types() {
482        assert!(manifest_type_is_portable(&Ty::Apply(
483            "list",
484            STRING_LIST_ARGS
485        )));
486        assert!(manifest_type_is_portable(&Ty::Shape(RECORD_FIELDS)));
487        assert!(!manifest_type_is_portable(&Ty::Apply(
488            "Result",
489            RESULT_ARGS
490        )));
491        assert!(!manifest_type_is_portable(&Ty::Named("channel")));
492        assert!(!manifest_type_is_portable(&Ty::Fn(&[], &STRING)));
493        assert!(!manifest_type_is_portable(&Ty::Generic("T")));
494        assert!(!manifest_type_is_portable(&Ty::Apply(
495            "dict",
496            INT_KEYED_DICT_ARGS
497        )));
498    }
499
500    /// A stand-in for a runtime that has nominal values (enum variants and
501    /// struct instances). `DataValue` deliberately has no such variant, so the
502    /// nominal arms of the matcher need a value type that reports one.
503    struct Nominal(&'static str);
504
505    impl TypeContractValue for Nominal {
506        fn runtime_type_kind(&self) -> RuntimeTypeKind {
507            RuntimeTypeKind::Enum
508        }
509
510        fn nominal_type_name(&self) -> Option<&str> {
511            Some(self.0)
512        }
513    }
514
515    /// A program that declares its own `enum Option<T>` means *that* type, not
516    /// the built-in nullable spelling. Before this was distinguished, the
517    /// built-in reading rejected every value the user could construct, because
518    /// an `Option.Some(1)` is an enum variant rather than nil or an int.
519    #[test]
520    fn a_user_declared_option_is_matched_nominally() {
521        let applied = TypeExpr::Applied {
522            name: "Option".to_string(),
523            args: vec![TypeExpr::Named("int".to_string())],
524        };
525        let user_declared = vec!["Option".to_string()];
526
527        assert!(
528            matches_type(&Nominal("Option"), &applied, &[], &user_declared),
529            "a user-declared Option enum must satisfy its own type"
530        );
531        assert!(
532            !matches_type(&Nominal("Result"), &applied, &[], &user_declared),
533            "and must still reject a different nominal type"
534        );
535
536        // With no such declaration in scope, `Option<int>` keeps its built-in
537        // nullable reading.
538        assert!(matches_type(&DataValue::Nil, &applied, &[], &[]));
539        assert!(matches_type(&DataValue::Int(1), &applied, &[], &[]));
540        assert!(!matches_type(
541            &DataValue::String("no".into()),
542            &applied,
543            &[],
544            &[]
545        ));
546    }
547}