Skip to main content

kanonak_codec/
lib.rs

1//! kanonak-codec — the generic, ontology-independent codec runtime (Rust port).
2//!
3//! Given a `CodecSchema` (the per-package metadata a generated SDK embeds) and a
4//! set of typed nodes, it builds the canonical input model and content-addresses
5//! it via `kanonak-canonical` (the same content-form the Python/TypeScript
6//! references and the `kanonak hash` CLI produce). It also (de)serializes the
7//! normalized-JSON wire form. Self-contained: carriers come from the schema's
8//! datatype URIs, and the resolved foundation URIs are embedded by the generator,
9//! so hashing needs no runtime ontology resolution.
10//!
11//! A node is a plain JSON object (`serde_json::Map<String, serde_json::Value>`) —
12//! the `$`-envelope plus alias-collapsed local-name fields. A generated typed
13//! model serializes to one. Note: `serde_json::Value` (the node field model) is
14//! distinct from `kanonak_canonical::Value` (the canonical-input value enum).
15
16use kanonak_canonical::{
17    canonical_form as canonical_form_pkg, canonical_hash as canonical_hash_pkg, carrier_of,
18    is_readable_by, parse_coordinate, versionless_key, CanonError, CoordinateVersion, Package,
19    Statement, Subject, Value,
20};
21use serde_json::{Map, Value as Json};
22
23mod typed;
24pub use typed::{to_node, KanonakNode, KanonakResource, Ref};
25
26/// The reserved `$`-envelope keys, which never become statements/predicates.
27/// `$name` (0.2.0) carries an embedded value's authored dict-key — hash-relevant.
28/// `$types` (0.4.0, runtime#10) carries a multi-typed node's FULL type set.
29const ENVELOPE_KEYS: [&str; 7] = [
30    "$type",
31    "$types",
32    "$id",
33    "$name",
34    "$contentHash",
35    "$version",
36    "$extra",
37];
38
39/// A node is a JSON object.
40pub type Node = Map<String, Json>;
41
42/// Errors raised by the codec runtime. Fails loudly — no fallbacks.
43#[derive(Debug)]
44pub enum CodecError {
45    /// A node, schema, or package context was malformed.
46    Malformed(String),
47    /// The underlying canonical library rejected a lexical/value.
48    Canon(CanonError),
49}
50
51impl std::fmt::Display for CodecError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            CodecError::Malformed(m) => write!(f, "{}", m),
55            CodecError::Canon(e) => write!(f, "{}", e.0),
56        }
57    }
58}
59
60impl std::error::Error for CodecError {}
61
62impl From<CanonError> for CodecError {
63    fn from(e: CanonError) -> Self {
64        CodecError::Canon(e)
65    }
66}
67
68fn err<T>(msg: impl Into<String>) -> Result<T, CodecError> {
69    Err(CodecError::Malformed(msg.into()))
70}
71
72/// The raw lexical token of a scalar — the input the canonical form normalizes.
73/// bool -> "true"/"false"; string -> the string; number -> its plain decimal
74/// string (serde_json's `Number::to_string()` gives "5" / "1.5", never
75/// scientific notation). The canonical crate re-normalizes from there.
76fn lexical(value: &Json) -> String {
77    match value {
78        Json::Bool(b) => {
79            if *b {
80                "true".to_string()
81            } else {
82                "false".to_string()
83            }
84        }
85        Json::String(s) => s.clone(),
86        Json::Number(n) => n.to_string(),
87        other => other.to_string(),
88    }
89}
90
91/// The schema's `classes` map. A class entry may carry an optional
92/// `ancestors` list (0.6.1, runtime#28): every superclass, transitively, by
93/// durable VERSIONED URI — what lets [`type_matches`] accept a subclass node
94/// for a superclass check. Additive and canonicalization-INERT: a schema
95/// without it is still valid, and absent, a type match is by the class itself
96/// only — stricter, never looser.
97fn classes_of(schema: &Json) -> Result<&Map<String, Json>, CodecError> {
98    schema
99        .get("classes")
100        .and_then(|c| c.as_object())
101        .ok_or_else(|| CodecError::Malformed("schema is missing 'classes'".into()))
102}
103
104/// A coordinate's versionless key and version, or `None` when the string is
105/// not a STRICTLY well-formed coordinate or carries no version (never errors).
106fn versioned_coordinate(uri: &str) -> Option<(String, CoordinateVersion)> {
107    let c = parse_coordinate(uri).ok()?;
108    let version = c.version?;
109    Some((format!("{}/{}/{}", c.publisher, c.package, c.name), version))
110}
111
112/// The versionless identity of a coordinate (versioned or not), or `None`
113/// when the string is not one.
114fn identity_of(uri: &str) -> Option<String> {
115    versionless_key(uri).ok()
116}
117
118fn version_order(v: CoordinateVersion) -> (u64, u64, u64) {
119    (v.major, v.minor, v.patch)
120}
121
122fn format_version(v: CoordinateVersion) -> String {
123    format!("{}.{}.{}", v.major, v.minor, v.patch)
124}
125
126/// THE class lookup (runtime#28). A node is typed with the version of the
127/// class its producer's import resolved to; a codec generated from a later
128/// COMPATIBLE version of the same package must still read it. So: the exact
129/// versioned key first, then the class with the same publisher, package and
130/// name whose version can read the written one — `is_readable_by` from
131/// `kanonak-canonical`, the protocol's single compatibility rule, pinned in
132/// every port. Returns the class's schema key and entry. When nothing
133/// matches, the error says why, ending in a bracketed kind the conformance
134/// vectors pin: `[unknown-type]`, `[newer-version]` (the data may use terms
135/// this schema lacks), `[other-major]`, or `[other-minor-line]` (below 1.0.0
136/// the minor is the incompatible line).
137fn class_for<'a>(
138    classes: &'a Map<String, Json>,
139    type_uri: &str,
140) -> Result<(&'a str, &'a Json), String> {
141    if let Some((key, cls)) = classes.get_key_value(type_uri) {
142        return Ok((key.as_str(), cls));
143    }
144    let unknown = || format!("no schema for type {} [unknown-type]", type_uri);
145    let (key, written) = match versioned_coordinate(type_uri) {
146        Some(found) => found,
147        None => return Err(unknown()),
148    };
149
150    let mut readable: Option<(&'a str, &'a Json, CoordinateVersion)> = None;
151    let mut nearest: Option<CoordinateVersion> = None;
152    for (uri, cls) in classes.iter() {
153        let version = match versioned_coordinate(uri) {
154            Some((k, v)) if k == key => v,
155            _ => continue,
156        };
157        if is_readable_by(written, version) {
158            if readable.map_or(true, |(_, _, best)| {
159                version_order(version) > version_order(best)
160            }) {
161                readable = Some((uri.as_str(), cls, version));
162            }
163        } else if nearest.map_or(true, |n| version_order(version) > version_order(n)) {
164            nearest = Some(version);
165        }
166    }
167    if let Some((uri, cls, _)) = readable {
168        return Ok((uri, cls));
169    }
170    match nearest {
171        Some(r) => Err(incompatible_version(type_uri, written, r)),
172        None => Err(unknown()),
173    }
174}
175
176fn incompatible_version(type_uri: &str, w: CoordinateVersion, r: CoordinateVersion) -> String {
177    let (ws, rs) = (format_version(w), format_version(r));
178    if w.major != r.major {
179        return format!(
180            "{} is written at {}, a different major version than this codec's schema ({}) \
181             [other-major]",
182            type_uri, ws, rs
183        );
184    }
185    if w.major == 0 && w.minor != r.minor {
186        return format!(
187            "{} is written at {}; below 1.0.0 a different minor is a different version line \
188             than this codec's schema ({}) [other-minor-line]",
189            type_uri, ws, rs
190        );
191    }
192    format!(
193        "{} is written at {}, newer than this codec's schema ({}); upgrade the codec to read it \
194         [newer-version]",
195        type_uri, ws, rs
196    )
197}
198
199/// The class to HASH a node or embedded value with: the exact versioned class
200/// only. A content hash is computed over the producer's predicate and type
201/// URIs, versions included, so a node written at an earlier compatible
202/// version cannot be re-hashed with a later schema — its predicates would
203/// carry the later version and the hash would differ. When only a compatible
204/// class exists, say so (`[hash-needs-exact-version]`) rather than produce a
205/// different hash or the bare "no schema" error.
206fn hash_class_for<'a>(
207    schema: &'a Json,
208    type_uri: &str,
209    what: &str,
210) -> Result<&'a Json, CodecError> {
211    let classes = classes_of(schema)?;
212    if let Some(cls) = classes.get(type_uri) {
213        return Ok(cls);
214    }
215    match class_for(classes, type_uri) {
216        Ok((found, _)) => err(format!(
217            "Cannot hash {} {}: this codec's schema has it at {}, and a content hash is \
218             computed with the producer's schema version — hashing needs the exact class \
219             [hash-needs-exact-version]",
220            what, type_uri, found
221        )),
222        Err(_) => err(format!(
223            "no schema for {} {} [unknown-type]",
224            what, type_uri
225        )),
226    }
227}
228
229/// Whether a node is an instance of the class `class_uri` — what a generated
230/// type guard asks (runtime#28). Each of the node's types (`$types`, else its
231/// `$type`) is resolved through the same compatible lookup [`deserialize`]
232/// uses, so a node written at an earlier compatible version satisfies the
233/// later version's guard; it matches when that class IS `class_uri` or has it
234/// among its `ancestors`. Classes compare by versionless identity. A type this
235/// schema cannot read (unknown, newer, another major) never matches. Errors
236/// when `class_uri` is not a class coordinate or the schema is malformed.
237/// For a typed instance, [`KanonakNode::type_matches`] asks the same question
238/// of its envelope.
239pub fn type_matches(node: &Node, class_uri: &str, schema: &Json) -> Result<bool, CodecError> {
240    let members: Vec<&str> = match node.get("$types") {
241        None | Some(Json::Null) => node
242            .get("$type")
243            .and_then(|t| t.as_str())
244            .filter(|t| !t.is_empty())
245            .into_iter()
246            .collect(),
247        Some(Json::Array(items)) => items
248            .iter()
249            .map(|t| t.as_str())
250            .collect::<Option<Vec<&str>>>()
251            .ok_or_else(|| {
252                CodecError::Malformed("type_matches: $types must be a list of type URIs".into())
253            })?,
254        Some(_) => return err("type_matches: $types must be a list of type URIs"),
255    };
256    type_matches_members(&members, class_uri, schema)
257}
258
259/// The one type-match walk, over a node's type members (`$types`, else
260/// `$type`), shared by the dictionary and typed surfaces.
261pub(crate) fn type_matches_members(
262    members: &[&str],
263    class_uri: &str,
264    schema: &Json,
265) -> Result<bool, CodecError> {
266    let target = identity_of(class_uri).ok_or_else(|| {
267        CodecError::Malformed(format!(
268            "type_matches: '{}' is not a class coordinate",
269            class_uri
270        ))
271    })?;
272    let classes = classes_of(schema)?;
273
274    for &member in members {
275        let (key, cls) = match class_for(classes, member) {
276            Ok(found) => found,
277            Err(_) => continue,
278        };
279        let type_uri = cls
280            .get("typeUri")
281            .and_then(|t| t.as_str())
282            .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'typeUri'", key)))?;
283        if identity_of(type_uri).as_deref() == Some(target.as_str()) {
284            return Ok(true);
285        }
286        match cls.get("ancestors") {
287            None | Some(Json::Null) => {}
288            Some(Json::Array(ancestors)) => {
289                for ancestor in ancestors {
290                    let ancestor = ancestor.as_str().ok_or_else(|| {
291                        CodecError::Malformed(format!(
292                            "class {}: 'ancestors' must be a list of class URIs",
293                            key
294                        ))
295                    })?;
296                    if identity_of(ancestor).as_deref() == Some(target.as_str()) {
297                        return Ok(true);
298                    }
299                }
300            }
301            Some(_) => {
302                return err(format!(
303                    "class {}: 'ancestors' must be a list of class URIs",
304                    key
305                ))
306            }
307        }
308    }
309    Ok(false)
310}
311
312/// An enumeration member found by [`enum_member`].
313#[derive(Debug, Clone, PartialEq)]
314pub struct EnumMemberMatch {
315    /// The enumeration class's durable URI (its key in the schema's `enums`).
316    pub enum_type: String,
317    /// The member's durable URI as this schema keys it.
318    pub uri: String,
319    /// The member's schema entry (`{"label": ...}`).
320    pub member: Json,
321}
322
323/// The enumeration member a `{"$ref": …}` names (runtime#28): the exact
324/// versioned key first, then the same member at a version that can read the
325/// written one — so a member referenced at an earlier compatible version of
326/// the package resolves against a later schema. `None` when this schema has
327/// no such member, which, as for `enums` itself, means "not mine", never
328/// "invalid".
329pub fn enum_member(schema: &Json, reference: &str) -> Option<EnumMemberMatch> {
330    let enums = schema.get("enums").and_then(|e| e.as_object())?;
331    for (enum_type, e) in enums.iter() {
332        if let Some(member) = e.get("members").and_then(|m| m.get(reference)) {
333            return Some(EnumMemberMatch {
334                enum_type: enum_type.clone(),
335                uri: reference.to_string(),
336                member: member.clone(),
337            });
338        }
339    }
340    let (key, written) = versioned_coordinate(reference)?;
341    let mut best: Option<(&str, &str, &Json, CoordinateVersion)> = None;
342    for (enum_type, e) in enums.iter() {
343        let members = match e.get("members").and_then(|m| m.as_object()) {
344            Some(members) => members,
345            None => continue,
346        };
347        for (uri, member) in members.iter() {
348            let version = match versioned_coordinate(uri) {
349                Some((k, v)) if k == key => v,
350                _ => continue,
351            };
352            if !is_readable_by(written, version) {
353                continue;
354            }
355            if best.map_or(true, |(_, _, _, b)| {
356                version_order(version) > version_order(b)
357            }) {
358                best = Some((enum_type.as_str(), uri.as_str(), member, version));
359            }
360        }
361    }
362    best.map(|(enum_type, uri, member, _)| EnumMemberMatch {
363        enum_type: enum_type.to_string(),
364        uri: uri.to_string(),
365        member: member.clone(),
366    })
367}
368
369/// Validate a node-or-embedded's `$types` envelope (0.4.0, runtime#10) and
370/// return the validated set, or `None` when the node is single-typed.
371/// Invariants: sorted by UTF-8 bytes, at least two members, no duplicates, and
372/// `$type` (the dispatch key, chosen by the schema layer's primary rule) is a
373/// member. Enforced wherever the envelope is touched — serialize, deserialize,
374/// and canonicalization — so a producer fails at emit time and a reader never
375/// masks a nondeterministic emitter by silently repairing the set.
376fn validated_types(
377    map: &Map<String, Json>,
378    where_: &str,
379) -> Result<Option<Vec<String>>, CodecError> {
380    let raw = match map.get("$types") {
381        None | Some(Json::Null) => return Ok(None),
382        Some(v) => v,
383    };
384    let items = match raw.as_array() {
385        Some(items) => items,
386        None => {
387            return err(format!(
388                "{}: $types must be a list of non-empty type URIs",
389                where_
390            ))
391        }
392    };
393    let mut types: Vec<String> = Vec::with_capacity(items.len());
394    for item in items {
395        match item.as_str() {
396            Some(s) if !s.is_empty() => types.push(s.to_string()),
397            _ => {
398                return err(format!(
399                    "{}: $types must be a list of non-empty type URIs",
400                    where_
401                ))
402            }
403        }
404    }
405    if types.len() < 2 {
406        return err(format!(
407            "{}: $types with {} member(s) is forbidden — a single-typed node carries \
408             only $type (a second encoding of the same content would be hash-ambiguous)",
409            where_,
410            types.len()
411        ));
412    }
413    for pair in types.windows(2) {
414        match pair[0].as_bytes().cmp(pair[1].as_bytes()) {
415            std::cmp::Ordering::Equal => {
416                return err(format!(
417                    "{}: $types carries duplicate member {}",
418                    where_, pair[1]
419                ))
420            }
421            std::cmp::Ordering::Greater => {
422                return err(format!(
423                    "{}: $types is not sorted by UTF-8 bytes ({} sorts after {}) — \
424                     ordering is the producer's job, never the reader's",
425                    where_, pair[0], pair[1]
426                ))
427            }
428            std::cmp::Ordering::Less => {}
429        }
430    }
431    match map.get("$type").and_then(|t| t.as_str()) {
432        Some(primary) if types.iter().any(|t| t == primary) => Ok(Some(types)),
433        other => err(format!(
434            "{}: $type ({:?}) must be present and a member of $types",
435            where_, other
436        )),
437    }
438}
439
440/// Recursively validate every `$types` envelope in a wire value (the node
441/// itself and any embedded node at any depth). Shared by [`serialize`] (the
442/// producer fails at emit time) and [`deserialize`] (the strict reader rejects
443/// rather than repairs).
444fn assert_types_envelopes(value: &Json, where_: &str) -> Result<(), CodecError> {
445    match value {
446        Json::Array(items) => {
447            for (i, item) in items.iter().enumerate() {
448                assert_types_envelopes(item, &format!("{}[{}]", where_, i))?;
449            }
450            Ok(())
451        }
452        Json::Object(map) => assert_types_envelopes_map(map, where_),
453        _ => Ok(()),
454    }
455}
456
457fn assert_types_envelopes_map(map: &Map<String, Json>, where_: &str) -> Result<(), CodecError> {
458    if map.contains_key("$types") {
459        validated_types(map, where_)?;
460    }
461    for (key, value) in map.iter() {
462        if key != "$types" {
463            assert_types_envelopes(value, &format!("{}.{}", where_, key))?;
464        }
465    }
466    Ok(())
467}
468
469/// Build a single canonical `Value` for one (non-list) field datum, per its
470/// schema prop.
471fn build_value(prop: &Json, raw: &Json, schema: &Json) -> Result<Value, CodecError> {
472    let kind = prop
473        .get("kind")
474        .and_then(|k| k.as_str())
475        .ok_or_else(|| CodecError::Malformed("schema prop is missing 'kind'".into()))?;
476    if kind == "object" {
477        // A node: a reference (`{"$ref"}`) or an embedded resource.
478        if let Some(reference) = raw.get("$ref").and_then(|r| r.as_str()) {
479            return Ok(Value::Reference(reference.to_string()));
480        }
481        if let Some(map) = raw.as_object() {
482            return embedded_value(prop, map, schema);
483        }
484        return err(format!(
485            "Object property expects a reference ({{\"$ref\": ...}}) or an embedded \
486             node (a map), got {}",
487            raw
488        ));
489    }
490    let datatype = prop
491        .get("datatype")
492        .and_then(|d| d.as_str())
493        .ok_or_else(|| CodecError::Malformed("datatype prop is missing 'datatype'".into()))?;
494    match carrier_of(datatype) {
495        None => Ok(Value::Raw(lexical(raw))),
496        Some(carrier) => Ok(Value::Typed {
497            carrier,
498            lexical: lexical(raw),
499        }),
500    }
501}
502
503/// Canonicalize an embedded value (0.2.0): a map with no `$id`, an optional
504/// `$name` (the authored dict-key — hash-relevant), an optional `$type`, and
505/// schema-mapped fields. An explicit `$type` emits a type statement inside the
506/// embedded (hash-relevant even when it equals the range-derived type); without
507/// it, fields map via the containing property's `range` and NO type statement is
508/// emitted — range-derived typing is inference only.
509fn embedded_value(
510    prop: &Json,
511    map: &Map<String, Json>,
512    schema: &Json,
513) -> Result<Value, CodecError> {
514    if map.contains_key("$id") {
515        return err(
516            "An embedded value must not carry $id — to point at a named resource, \
517             pass a reference ({\"$ref\": ...}).",
518        );
519    }
520    let types = validated_types(map, "Embedded value")?;
521    let explicit_type = map.get("$type").and_then(|t| t.as_str());
522    let cls_uri = match explicit_type.or_else(|| prop.get("range").and_then(|r| r.as_str())) {
523        Some(uri) => uri,
524        None => {
525            return err(
526                "Cannot map embedded value: it carries no $type and the property \
527                 declares no range.",
528            )
529        }
530    };
531    let cls = hash_class_for(schema, cls_uri, "embedded type")?;
532    let props = cls
533        .get("props")
534        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", cls_uri)))?;
535
536    let mut statements = field_statements(map, props, schema)?;
537    if let Some(members) = types {
538        // A multi-typed embedded ($types implies an explicit $type): one type
539        // statement per member, in $types (UTF-8 sorted) order — all hash-relevant.
540        let type_predicate = schema
541            .get("typePredicate")
542            .and_then(|p| p.as_str())
543            .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
544        for member in members {
545            statements.push(Statement {
546                predicate: type_predicate.to_string(),
547                value: Value::Reference(member),
548            });
549        }
550    } else if let Some(type_uri) = explicit_type {
551        let type_predicate = schema
552            .get("typePredicate")
553            .and_then(|p| p.as_str())
554            .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
555        statements.push(Statement {
556            predicate: type_predicate.to_string(),
557            value: Value::Reference(type_uri.to_string()),
558        });
559    }
560    let name = map
561        .get("$name")
562        .and_then(|n| n.as_str())
563        .filter(|s| !s.is_empty())
564        .map(|s| s.to_string());
565    Ok(Value::Embedded { name, statements })
566}
567
568/// The statements for one node-or-embedded's modeled fields + its `$extra` —
569/// everything except the type triple (subjects always carry one; embeddeds only
570/// when explicitly typed).
571fn field_statements(
572    source: &Map<String, Json>,
573    props: &Json,
574    schema: &Json,
575) -> Result<Vec<Statement>, CodecError> {
576    let mut out: Vec<Statement> = Vec::new();
577
578    for (key, raw) in source.iter() {
579        if ENVELOPE_KEYS.contains(&key.as_str()) || raw.is_null() {
580            continue;
581        }
582        match props.get(key) {
583            None => out.push(Statement {
584                predicate: key.clone(),
585                value: Value::Raw(lexical(raw)),
586            }),
587            Some(prop) => {
588                let predicate =
589                    prop.get("predicate")
590                        .and_then(|p| p.as_str())
591                        .ok_or_else(|| {
592                            CodecError::Malformed(format!("prop {} is missing 'predicate'", key))
593                        })?;
594                let value = match raw.as_array() {
595                    Some(items) => {
596                        // An empty list contributes NO statement — absent and empty
597                        // are identical at the canonical layer (the wire serialize
598                        // still preserves the empty list).
599                        if items.is_empty() {
600                            continue;
601                        }
602                        let mut list = Vec::with_capacity(items.len());
603                        for item in items {
604                            list.push(build_value(prop, item, schema)?);
605                        }
606                        Value::List(list)
607                    }
608                    None => build_value(prop, raw, schema)?,
609                };
610                out.push(Statement {
611                    predicate: predicate.to_string(),
612                    value,
613                });
614            }
615        }
616    }
617
618    if let Some(extra) = source.get("$extra") {
619        let extra = extra
620            .as_object()
621            .ok_or_else(|| CodecError::Malformed("$extra must be an object".into()))?;
622        for (predicate, raw) in extra.iter() {
623            if raw.is_null() {
624                continue;
625            }
626            out.push(Statement {
627                predicate: predicate.clone(),
628                value: Value::Raw(lexical(raw)),
629            });
630        }
631    }
632    Ok(out)
633}
634
635/// The statements for one subject node: the rdf:type triple(s), then its fields.
636fn statements(node: &Node, schema: &Json) -> Result<Vec<Statement>, CodecError> {
637    let types = {
638        let id = node
639            .get("$id")
640            .and_then(|i| i.as_str())
641            .unwrap_or("(no $id)");
642        validated_types(node, &format!("Node {}", id))?
643    };
644    let type_uri = node
645        .get("$type")
646        .and_then(|t| t.as_str())
647        .filter(|s| !s.is_empty())
648        .ok_or_else(|| CodecError::Malformed("node is missing $type".into()))?;
649
650    let cls = hash_class_for(schema, type_uri, "type")?;
651    let props = cls
652        .get("props")
653        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", type_uri)))?;
654
655    let type_predicate = schema
656        .get("typePredicate")
657        .and_then(|p| p.as_str())
658        .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
659
660    // The rdf:type triple(s) every subject carries: one per $types member for a
661    // multi-typed node (in $types' UTF-8 sorted order), else the single $type.
662    let members = types.unwrap_or_else(|| vec![type_uri.to_string()]);
663    let mut out: Vec<Statement> = members
664        .into_iter()
665        .map(|member| Statement {
666            predicate: type_predicate.to_string(),
667            value: Value::Reference(member),
668        })
669        .collect();
670    out.extend(field_statements(node, props, schema)?);
671    Ok(out)
672}
673
674/// Build the canonical input model: a subject per node + the synthesized
675/// package-wrapper subject (raw label + `Package` type), exactly the subject set
676/// `kanonak hash` produces for the equivalent authored package.
677pub fn build_package(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<Package, CodecError> {
678    let mut subjects: Vec<Subject> = Vec::with_capacity(nodes.len() + 1);
679    for node in nodes {
680        let id = node
681            .get("$id")
682            .and_then(|i| i.as_str())
683            .filter(|s| !s.is_empty())
684            .ok_or_else(|| CodecError::Malformed("node is missing $id".into()))?;
685        subjects.push(Subject {
686            uri: id.to_string(),
687            statements: statements(node, schema)?,
688        });
689    }
690
691    let publisher = pkg
692        .get("publisher")
693        .and_then(|p| p.as_str())
694        .ok_or_else(|| CodecError::Malformed("pkg is missing 'publisher'".into()))?;
695    let package_name = pkg
696        .get("packageName")
697        .and_then(|p| p.as_str())
698        .ok_or_else(|| CodecError::Malformed("pkg is missing 'packageName'".into()))?;
699    let version = pkg
700        .get("version")
701        .and_then(|p| p.as_str())
702        .ok_or_else(|| CodecError::Malformed("pkg is missing 'version'".into()))?;
703
704    let pkg_uri = format!(
705        "{}/{}@{}/{}",
706        publisher, package_name, version, package_name
707    );
708
709    let type_predicate = schema
710        .get("typePredicate")
711        .and_then(|p| p.as_str())
712        .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
713    let label_predicate = schema
714        .get("labelPredicate")
715        .and_then(|p| p.as_str())
716        .ok_or_else(|| CodecError::Malformed("schema is missing 'labelPredicate'".into()))?;
717    let package_type_uri = schema
718        .get("packageTypeUri")
719        .and_then(|p| p.as_str())
720        .ok_or_else(|| CodecError::Malformed("schema is missing 'packageTypeUri'".into()))?;
721
722    let mut pkg_statements: Vec<Statement> = Vec::new();
723    if let Some(label) = pkg.get("label") {
724        if !label.is_null() {
725            let label = label
726                .as_str()
727                .ok_or_else(|| CodecError::Malformed("pkg label must be a string".into()))?;
728            pkg_statements.push(Statement {
729                predicate: label_predicate.to_string(),
730                value: Value::Raw(label.to_string()),
731            });
732        }
733    }
734    pkg_statements.push(Statement {
735        predicate: type_predicate.to_string(),
736        value: Value::Reference(package_type_uri.to_string()),
737    });
738    subjects.push(Subject {
739        uri: pkg_uri,
740        statements: pkg_statements,
741    });
742
743    Ok(Package { subjects })
744}
745
746/// The canonical form (the `{subjects:[...]}` JSON) of a package from nodes.
747pub fn canonical_form(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
748    Ok(canonical_form_pkg(&build_package(nodes, schema, pkg)?)?)
749}
750
751/// The `sha256:` content hash of a package from nodes — matches `kanonak hash`.
752pub fn content_hash(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
753    Ok(canonical_hash_pkg(&build_package(nodes, schema, pkg)?)?)
754}
755
756/// Serialize a typed node to its normalized-JSON wire form. `$extra` entries ride
757/// as sibling fields after the modeled ones; a modeled field wins a name
758/// collision (`[JsonExtensionData]` semantics). No `$extra` key on the wire.
759/// Fallible since 0.4.0: an invalid `$types` envelope (at any depth) is a
760/// producer bug and fails at emit time.
761pub fn serialize(node: &Node) -> Result<Node, CodecError> {
762    let where_ = node
763        .get("$id")
764        .or_else(|| node.get("$type"))
765        .and_then(|v| v.as_str())
766        .unwrap_or("(node)");
767    assert_types_envelopes_map(node, &format!("serialize {}", where_))?;
768    let mut out = Map::new();
769    for (key, value) in node.iter() {
770        if key == "$extra" || value.is_null() {
771            continue;
772        }
773        out.insert(key.clone(), value.clone());
774    }
775    if let Some(extra) = node.get("$extra").and_then(|e| e.as_object()) {
776        for (key, value) in extra.iter() {
777            if !value.is_null() && !out.contains_key(key) {
778                out.insert(key.clone(), value.clone());
779            }
780        }
781    }
782    Ok(out)
783}
784
785/// Parse normalized JSON into a typed node. `$`-envelope keys and fields modeled
786/// on the node's `$type` stay top-level; every other key is collected into
787/// `$extra` so a strongly-typed consumer round-trips it losslessly. Requires
788/// `$type` (the one field that cannot be inferred) and a class in the schema
789/// that can read it: the exact class, or the same class at a compatible later
790/// version (runtime#28 — see [`type_matches`] for the same rule). The node
791/// keeps the `$type` it was WRITTEN with; the producer's bytes are the
792/// producer's.
793pub fn deserialize(json_obj: &Node, schema: &Json) -> Result<Node, CodecError> {
794    let type_uri = json_obj
795        .get("$type")
796        .and_then(|t| t.as_str())
797        .ok_or_else(|| CodecError::Malformed("Cannot deserialize: missing string $type".into()))?;
798
799    // Reader-side $types validation, at every depth: an unsorted / singleton /
800    // duplicate / non-member set is REJECTED, never silently repaired —
801    // determinism belongs to the producer, and a lenient reader would mask a
802    // nondeterministic emitter.
803    {
804        let where_ = json_obj
805            .get("$id")
806            .and_then(|i| i.as_str())
807            .unwrap_or(type_uri);
808        assert_types_envelopes_map(json_obj, &format!("deserialize {}", where_))?;
809    }
810
811    let (cls_uri, cls) = class_for(classes_of(schema)?, type_uri)
812        .map_err(|e| CodecError::Malformed(format!("Cannot deserialize: {}", e)))?;
813    let props = cls
814        .get("props")
815        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", cls_uri)))?;
816
817    let mut node = Map::new();
818    node.insert("$type".to_string(), Json::String(type_uri.to_string()));
819    let mut extra = Map::new();
820    for (key, value) in json_obj.iter() {
821        if key == "$type" {
822            continue;
823        }
824        if key.starts_with('$') || props.get(key).is_some() {
825            node.insert(key.clone(), value.clone());
826        } else {
827            extra.insert(key.clone(), value.clone());
828        }
829    }
830    if !extra.is_empty() {
831        node.insert("$extra".to_string(), Json::Object(extra));
832    }
833    Ok(node)
834}