openehr-its 0.0.2

openEHR ITS (Implementation Technology Specifications): canonical JSON + XML serialization, ITS-REST OpenAPI contract, ITS-BMM. Mirrors the specifications-ITS-{JSON,XML,REST,BMM} repos.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! RM (canonical JSON) → simplified tree — the template-driven flattener.
//!
//! Walks the [`WebTemplate`] tree in parallel with the canonical-JSON
//! composition. Level removal is inherent to the walk: a web-template
//! child's RM value(s) are located by the relative RM path between the
//! parent's and child's `aqlPath`, so the container attributes and
//! collapsed wrappers of ITS-REST `simplified_formats/master04` §Level
//! Removal never appear as segments. Each populated leaf emits its datum
//! parts per the `master05-rm_mapping.adoc` tables (`crate::flat::map`); the
//! composition context emits as `ctx/…` (`crate::flat::ctx`).
//!
//! Output scope: `in-context` metadata whose FLAT surface is the `ctx/`
//! vocabulary (composition language/territory/composer, the EVENT_CONTEXT
//! fields, per-entry language/encoding/subject when they carry the
//! defaults) is emitted once as `ctx/…`, not duplicated as path keys —
//! matching the `master04 §Flat format` example. `category` has no `ctx/`
//! key (`master06` defines none), so it emits as a path key. A per-entry
//! `subject` that is not the PARTY_SELF default emits as a path key
//! (`master05 §OBSERVATION` `/subject` row).

#![expect(
    clippy::disallowed_types,
    reason = "canonical JSON / Simplified Formats operate on the wire value by definition \
              (ITS-JSON; ITS-REST Simplified Formats) — serde_json::Value IS the subject matter \
              (#1694)"
)]

use serde_json::Value;

use crate::flat::ctx;
use crate::flat::error::FlatError;
use crate::flat::map;
use crate::flat::rmpath;
use crate::flat::sim::{SimDocument, SimNode};
use crate::flat::webtemplate::{WebTemplate, WebTemplateNode};

/// Flatten a canonical-JSON composition into the simplified tree, driven by
/// `wt`. The result serializes to either wire variant via [`crate::flat::sim`].
///
/// # Errors
/// [`FlatError::Conversion`] if `composition` is not a JSON object.
pub fn flatten_composition(
    composition: &Value,
    wt: &WebTemplate,
) -> Result<SimDocument, FlatError> {
    if !composition.is_object() {
        return Err(FlatError::Conversion(
            "composition must be a JSON object".to_owned(),
        ));
    }
    let mut doc = SimNode::default();
    ctx::emit(composition, doc.occurrence_mut("ctx", None));
    let root = doc.occurrence_mut(&wt.tree.id, None);
    walk(&wt.tree, composition, root);
    doc.prune_empty();
    Ok(doc)
}

/// Whether an RM value's `_type` matches a web-template node's (possibly
/// generic) rm type (base-name comparison, e.g. `DV_INTERVAL<DV_QUANTITY>`).
fn type_matches(rm: &Value, rm_type: &str) -> bool {
    let want = rm_type.split('<').next().unwrap_or(rm_type);
    match rm.get("_type").and_then(Value::as_str) {
        Some(actual) => actual == want,
        None => true,
    }
}

/// A composition-level in-context child whose FLAT surface is the `ctx/`
/// vocabulary (`master06-context_information.adoc`) rather than a path key.
/// `category` is deliberately absent — `master06` defines no `ctx/category`,
/// so it stays a path key (`master05 §COMPOSITION` `/category` row).
fn covered_by_ctx(node: &WebTemplateNode, child: &WebTemplateNode) -> bool {
    // Composition-level in-context shortcuts (master06). The `context`
    // child is ALWAYS walked: its standard EVENT_CONTEXT leaf fields
    // surface as ctx/ scalars (the inner EVENT_CONTEXT rule below keeps
    // them out of the tree), while its archetyped `other_context` content
    // and the lossless `_`-attribute families (`_health_care_facility`,
    // `_participation:i` — master05 §EVENT_CONTEXT) emit as path keys.
    if node.rm_type == "COMPOSITION"
        && matches!(child.id.as_str(), "language" | "territory" | "composer")
    {
        return true;
    }
    // Standard EVENT_CONTEXT fields (start_time, setting, participations, …)
    // surface via ctx/; only archetyped other_context content is tree data.
    if node.rm_type == "EVENT_CONTEXT" {
        return !child.aql_path.contains("other_context");
    }
    false
}

/// A per-entry in-context leaf that emits only when it differs from the
/// value the `ctx/` defaults would rebuild (round-trip stability; the input
/// side accepts the path form regardless, per the `master05` per-entry
/// table rows).
fn is_default_entry_context(child: &WebTemplateNode, rm: &Value) -> bool {
    if child.in_context != Some(true) {
        return false;
    }
    match child.id.as_str() {
        // PARTY_SELF with no external_ref is the ENTRY.subject default
        // (`master05 §OBSERVATION` — "set to PARTY_SELF if not explicitly
        // set").
        "subject" => {
            rm.get("_type").and_then(Value::as_str) == Some("PARTY_SELF")
                && rm.get("external_ref").is_none()
        }
        // ENTRY.language / encoding default from ctx (`master06 §Language
        // and Territory`); their non-default values still round-trip via
        // the ctx emission of the composition language, so per-entry copies
        // are only emitted when they diverge — handled by ctx::emit
        // comparing entries. Here the walk keeps them out of the tree data.
        "language" | "encoding" => true,
        // EVENT time / history origin surface via ctx/history_origin +
        // ctx/time only when they equal the derived defaults; a distinct
        // per-event time is real data and must stay a path key.
        _ => false,
    }
}

fn walk(node: &WebTemplateNode, rm: &Value, out: &mut SimNode) {
    if node.has_input() {
        // A stored value whose `_type` does not conform to the template's
        // declared leaf type cannot be decomposed faithfully into that
        // type's suffixes — embed it verbatim as `|raw` instead
        // (master04 §Raw canonical JSON: pre-existing canonical data /
        // shapes the simplified form cannot express). Lossless both ways.
        if !leaf_type_conforms(rm, &node.rm_type) {
            out.attrs.insert("raw".to_owned(), rm.clone());
            return;
        }
        let list_open = node.inputs.iter().find_map(|i| i.list_open);
        map::emit_leaf(rm, &node.rm_type, list_open, out);
        // Value-level `_` RM attributes of the leaf value itself
        // (`master05` per-type tables: `_normal_range`, `_mapping`, …).
        map::emit_rm_attrs(rm, base_type(&node.rm_type), out);
        return;
    }
    // Container-level `_` RM attribute families (`master05` per-class
    // tables: `_uid`, `_link:i`, `_feeder_audit`, `_work_flow_id`, …).
    map::emit_rm_attrs(rm, base_type(&node.rm_type), out);

    for child in &node.children {
        if covered_by_ctx(node, child) {
            continue;
        }
        let rel = rmpath::relative(&node.aql_path, &child.aql_path);
        // Polymorphic choice alternatives share one aqlPath; emit only under
        // the alternative whose rm type matches this value's `_type`. The
        // filter must apply to EVERY member of a choice group — the first
        // alternative carries no `alt_json_id`, so sharing an aqlPath with a
        // sibling is the group marker.
        let in_choice = child.alt_json_id.is_some()
            || node
                .children
                .iter()
                .any(|c| c.id != child.id && c.aql_path == child.aql_path);
        let occurrences = occurrences_of(child, rm, &rel, in_choice);
        if occurrences.is_empty() {
            continue;
        }
        let repeating = child.max == -1 || child.max > 1;
        if repeating {
            out.children.entry(child.id.clone()).or_default().indexed = true;
        }
        for (i, occurrence) in occurrences.iter().enumerate() {
            if occurrence
                .value
                .is_some_and(|v| is_default_entry_context(child, v))
            {
                continue;
            }
            let slot = out.place_mut(&child.id, u32::try_from(i).unwrap_or(u32::MAX));
            if let Some(value) = occurrence.value {
                walk(child, value, slot);
            }
            // The leaf's wrapping ELEMENT carries its own `_` attribute
            // family (`master05 §ELEMENT`: `_uid`, `_null_flavour`,
            // `_null_reason`, `_link:i`, `_feeder_audit`); the leaf walk
            // above saw only the DV value, so surface the wrapper's here.
            if let Some(element) = occurrence.element {
                map::emit_rm_attrs(element, "ELEMENT", slot);
            }
        }
    }
    emit_direct_rm_paths(node, rm, out);
}

/// One emitted occurrence of a template child: the RM value its relative path
/// reaches, plus the wrapping `ELEMENT` when the child is an ELEMENT-wrapped
/// leaf (the wrapper owns the `master05 §ELEMENT` `_`-attribute rows, which
/// belong on the same simplified node as the datum).
struct Occurrence<'a> {
    /// `None` for a **value-less** ELEMENT. RM data_structures §ELEMENT
    /// (`Inv_null_flavour_indicated`: `is_null() xor null_flavour = Void`)
    /// makes `value` and `null_flavour` mutually exclusive, so a
    /// null-flavoured element carries no datum at all — only the
    /// `/_null_flavour` and `/_null_reason` rows of master05 §ELEMENT.
    value: Option<&'a Value>,
    element: Option<&'a Value>,
}

/// The occurrences of template child `child` under RM value `rm`.
///
/// An ELEMENT-wrapped leaf (its relative RM path ends in the `value` step) is
/// resolved through its **wrappers**, not through the values: the wrapper list
/// is the authoritative occurrence list, so (a) a value-less null-flavoured
/// ELEMENT is still reached (master05 §ELEMENT `/_null_flavour`,
/// `/_null_reason`), and (b) wrapper↔value alignment holds even when only some
/// wrappers carry a value. Every other child resolves straight to its values.
fn occurrences_of<'a>(
    child: &WebTemplateNode,
    rm: &'a Value,
    rel: &[openehr_rm::paths::PathSegment],
    in_choice: bool,
) -> Vec<Occurrence<'a>> {
    let Some(wrappers) = value_step_owners(child, rm, rel) else {
        return rmpath::resolve(rm, rel)
            .into_iter()
            .filter(|value| !in_choice || type_matches(value, &child.rm_type))
            .map(|value| Occurrence {
                value: Some(value),
                element: None,
            })
            .collect();
    };
    wrappers
        .into_iter()
        .filter_map(|wrapper| {
            let element = (wrapper.get("_type").and_then(Value::as_str) == Some("ELEMENT"))
                .then_some(wrapper);
            match wrapper.get("value").filter(|v| !v.is_null()) {
                Some(value) if !in_choice || type_matches(value, &child.rm_type) => {
                    Some(Occurrence {
                        value: Some(value),
                        element,
                    })
                }
                Some(_) => None,
                // NOTE: the `false` arm below — a value-less ELEMENT with no
                // null flavour either — emits nothing. That input is
                // RM-INVALID and unreachable for data this platform accepted:
                // RM data_structures §ELEMENT `Inv_null_flavour_indicated`
                // (`is_null() xor null_flavour = Void`,
                // `RM/docs/UML/classes/org.openehr.rm.data_structures.element.adoc`)
                // makes exactly one of `value` / `null_flavour` present, and
                // the commit choke point runs that invariant unconditionally
                // (`crate::rm_instance::validate_rm_and_terminology`), so
                // such a COMPOSITION is rejected 422 before it can be stored
                // and re-flattened. The flattener therefore does not
                // second-guess it: there is no datum and no null flavour to
                // put on the wire, and inventing either would fabricate
                // clinical meaning.
                //
                // A value-less null-flavoured ELEMENT belongs to no particular
                // alternative of a choice group: the alternatives share one
                // aqlPath and the datum that would select one is exactly what
                // is absent. It is emitted once, under the group's first
                // alternative. NOTE: no openEHR spec governs a null-flavoured
                // choice — our own design/extension, chosen so the null flavour
                // is neither duplicated across alternatives nor dropped.
                None => (element
                    .is_some_and(|e| e.get("null_flavour").is_some_and(|v| !v.is_null()))
                    && (!in_choice || child.alt_json_id.is_none()))
                .then_some(Occurrence {
                    value: None,
                    element,
                }),
            }
        })
        .collect()
}

/// The RM nodes that own the final `value` step of an ELEMENT-wrapped leaf —
/// `Some` only when `child` is a datum leaf whose relative RM path ends in
/// `value`. `None` selects the plain value-driven resolution (a leaf reached
/// without a wrapper, e.g. `EVENT.time`, or a container child).
fn value_step_owners<'a>(
    child: &WebTemplateNode,
    rm: &'a Value,
    rel: &[openehr_rm::paths::PathSegment],
) -> Option<Vec<&'a Value>> {
    if !child.has_input() {
        return None;
    }
    let (last, parents) = rel.split_last()?;
    (last.attribute == "value").then(|| rmpath::resolve(rm, parents))
}

/// Whether the template-child walk already emitted a child that realizes RM
/// attribute `attr` on `node` — i.e. some template child whose relative RM path
/// (master04 §Level Removal) begins with `attr` produced an entry in `out`.
///
/// This is the correct suppression signal for the direct-RM-path fallback: the
/// web template may constrain an attribute under a node-id-specialized child
/// whose id is NOT the attribute name (e.g. the CKM ACTION templates model
/// `ism_transition` as the careflow-state children `ism_transition[at0109,…]`,
/// id `intended`/`completed`/…), so an id-string check on the attribute name
/// misses it. Conversely, when the template models the attribute but no such
/// child matched this instance (e.g. the RM value carries no careflow node id),
/// the walk emits nothing — and the direct path MUST still emit the generic
/// spelling so the datum is not lost. Hence: suppress iff a realizing child was
/// actually emitted; the WT-child realization then wins entirely (it carries
/// the constrained node identity/name the direct path cannot reconstruct).
fn attr_emitted(node: &WebTemplateNode, out: &SimNode, attr: &str) -> bool {
    node.children.iter().any(|c| {
        out.children.contains_key(&c.id)
            && rmpath::relative(&node.aql_path, &c.aql_path)
                .first()
                .is_some_and(|s| s.attribute == attr)
    })
}

/// Emit the direct RM-attribute paths the master05 per-type mapping tables
/// declare on this node but that the OPT left unconstrained (so the compacted
/// web-template carries no child for them) — the mirror of
/// [`crate::flat::build`]'s direct-path handling, keeping the RM⇄FLAT round-trip
/// lossless. Each attribute is emitted ONLY when the template-child walk did
/// not already realize that RM attribute ([`attr_emitted`]); whenever a
/// WT-child realized it, that realization wins entirely.
///
/// `master05-rm_mapping.adoc` §§ACTION (`/time`, `/ism_transition`),
/// INSTRUCTION (`/narrative`), OBSERVATION (`/history_origin`), ACTIVITY
/// (`/timing`, `/action_archetype_id`), POINT_EVENT/INTERVAL_EVENT (`/time`),
/// INTERVAL_EVENT (`/width`, `/math_function`, `|sample_count`). EVENT_CONTEXT `start_time`/
/// `setting` are NOT emitted here — they surface through the `ctx/` vocabulary
/// (master06; [`crate::flat::ctx`]) to avoid a duplicate encoding of the same datum.
fn emit_direct_rm_paths(node: &WebTemplateNode, rm: &Value, out: &mut SimNode) {
    let mut leaf = |name: &str, rm_type: &str, value: Option<&Value>| {
        if attr_emitted(node, out, name) {
            return;
        }
        if let Some(v) = value.filter(|v| !v.is_null()) {
            map::emit_leaf(v, rm_type, None, out.occurrence_mut(name, None));
        }
    };
    match base_type(&node.rm_type) {
        "ACTION" => {
            leaf("time", "DV_DATE_TIME", rm.get("time"));
            if !attr_emitted(node, out, "ism_transition")
                && let Some(ism) = rm.get("ism_transition").filter(|v| !v.is_null())
            {
                emit_ism_transition(ism, out.occurrence_mut("ism_transition", None));
            }
        }
        "INSTRUCTION" => leaf("narrative", "DV_TEXT", rm.get("narrative")),
        "OBSERVATION" => {
            // `history_origin` maps to the nested `data.origin` (master05
            // §OBSERVATION); the HISTORY is compacted away, so `origin` is
            // never a template leaf child — emit it unless the walk already did.
            if !out.children.contains_key("history_origin")
                && let Some(origin) = rm.pointer("/data/origin/value")
            {
                out.occurrence_mut("history_origin", None)
                    .attrs
                    .insert(String::new(), origin.clone());
            }
        }
        "ACTIVITY" => {
            leaf("timing", "DV_PARSABLE", rm.get("timing"));
            // `action_archetype_id` is the match-all `/.*/` when unset
            // (master05 §ACTIVITY: "Will be set to /.*/ if not set explicit.");
            // that default is re-synthesised on build, so emitting it would
            // desync the round-trip — emit only an explicit non-default value.
            if !attr_emitted(node, out, "action_archetype_id")
                && let Some(aid) = rm.get("action_archetype_id").and_then(Value::as_str)
                && aid != "/.*/"
            {
                out.occurrence_mut("action_archetype_id", None)
                    .attrs
                    .insert(String::new(), Value::String(aid.to_owned()));
            }
        }
        "POINT_EVENT" | "EVENT" => leaf("time", "DV_DATE_TIME", rm.get("time")),
        "INTERVAL_EVENT" => {
            leaf("time", "DV_DATE_TIME", rm.get("time"));
            leaf("width", "DV_DURATION", rm.get("width"));
            leaf("math_function", "DV_CODED_TEXT", rm.get("math_function"));
            // master05 §INTERVAL_EVENT: `|sample_count` (INTEGER) is a datum
            // suffix on the event node itself, not a sub-path — the section's
            // second example spells it `…/any_event:0|sample_count: 5`.
            if let Some(n) = rm.get("sample_count").filter(|v| !v.is_null()) {
                out.attrs.insert("sample_count".to_owned(), n.clone());
            }
        }
        _ => {}
    }
}

/// Emit an ISM_TRANSITION as its master05 sub-paths — `/current_state`,
/// `/transition`, `/careflow_step` (DV_CODED_TEXT) and the `/_reason:i`
/// DV_TEXT list (master05 §ISM_TRANSITION). The mirror of
/// [`crate::flat::build`]'s `build_ism_transition`.
///
/// NOTE (the #1719 adjudication — WT careflow children never resolve from the
/// RM side, and the GENERIC spelling is the spec wire form): `ISM_TRANSITION`
/// inherits `PATHABLE`, not `LOCATABLE`
/// (`docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.composition.ism_transition.adoc`
/// §Inherit), so an RM instance carries no `name`/`archetype_node_id` for a
/// node-id-specialized web-template careflow child (`ism_transition[at0109,…]`,
/// ids `intended`/`completed`/…) to match on — template-path predicate
/// matching is `LOCATABLE`-attribute matching by definition (BASE
/// `master11-paths` §Name-based Predicate). That is not a resolution gap to
/// engineer around: the Simplified Formats spec's OWN worked example
/// (`docs/specs/openehr/ITS-REST/docs/simplified_formats/master05-rm_mapping.adoc`
/// §ISM_TRANSITION) flattens a careflow-stepped transition
/// (`careflow_step|code: at0006`, terminology `local`) under the GENERIC
/// `…/ism_transition/…` path, never under a careflow-state child id. So the
/// RM→FLAT direction always emits this generic spelling — the careflow step
/// itself travels as the `/careflow_step` datum — and the FLAT→RM direction
/// accepts it back ([`crate::flat::build`]'s `DirectPath::Ism`). The
/// careflow-child spelling remains ACCEPTED on input (vendor Web Templates
/// emit it — prior art, tolerated, never produced).
fn emit_ism_transition(ism: &Value, out: &mut SimNode) {
    for attr in ["current_state", "transition", "careflow_step"] {
        if let Some(cs) = ism.get(attr).filter(|v| !v.is_null()) {
            map::emit_leaf(cs, "DV_CODED_TEXT", None, out.occurrence_mut(attr, None));
        }
    }
    map::emit_rm_attrs(ism, "ISM_TRANSITION", out);
}

/// The base (generic-stripped) RM type name.
fn base_type(rm_type: &str) -> &str {
    rm_type.split('<').next().unwrap_or(rm_type)
}

/// Whether an RM leaf value's `_type` conforms to the template's declared
/// leaf type, including the specialisations the simplified mappings handle
/// natively: DV_TEXT ⇄ DV_CODED_TEXT (the `|other` open-value-set pair,
/// master04 §Open Value-Sets) and the PARTY_PROXY family (master05
/// §PARTY_PROXY dispatches on the concrete party type). Anything else is a
/// non-conforming stored value and is embedded as `|raw`.
fn leaf_type_conforms(rm: &Value, declared: &str) -> bool {
    let Some(actual) = rm.get("_type").and_then(Value::as_str) else {
        return true;
    };
    let declared = base_type(declared);
    if actual == declared {
        return true;
    }
    matches!(
        (declared, actual),
        ("DV_TEXT", "DV_CODED_TEXT")
            | ("DV_CODED_TEXT", "DV_TEXT")
            | (
                "PARTY_PROXY",
                "PARTY_SELF" | "PARTY_IDENTIFIED" | "PARTY_RELATED"
            )
    )
}