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