moonrockz/bpmn

Portable BPMN document values, a lossless namespace-aware XML CST, source maps, writing, structural validation, explicit reference resolution and containment walking. The module imports only MoonBit core. It reads decoded strings without filesystem, network, clock or environment access.

Reading stages

read(source, source_id?, limits?) scans XML, constructs the owned CST, resolves namespace scopes and lowers supported BPMN values. An XML error prevents typed lowering. XML recovery keeps malformed regions and resumes at tag boundaries. tree.complete describes the XML stage; typed lowering can still report an unsupported BPMN element/field or exhaust the diagnostic budget.

The XML stage supports XML 1.0 names, namespaces, quoted attributes, comments, processing instructions, CDATA, mixed text, the five predefined entities and numeric character references. Its rules follow XML 1.0, fifth edition and Namespaces in XML 1.0, third edition. Namespace names compare as literal strings. Declarations apply to the whole start tag, and a default namespace applies to elements. Ordinary unprefixed attributes have an empty namespace. Duplicate expanded attribute names fail. The reserved xml binding is present without a declaration.

///|
test "inspect XML namespaces and mixed content without a renderer" {
  let source = "<r xmlns='urn:r' xmlns:p='urn:p' p:a='&#x1F600;'>left<![CDATA[<raw>&]]><p:c/>right</r>"
  let result = @bpmn.read(source)
  assert_eq(result.tree.text(), source)
  assert_true(result.tree.complete)
  let root = result.tree.nodes[1]
  assert_eq(root.name.unwrap().namespace_uri, "urn:r")
  assert_eq(root.attributes[2].value, "๐Ÿ˜€")
  assert_eq(root.children.map(i => result.tree.nodes[i].kind).iter().to_array(), [Text, CData, Element, Text])
  // This XML vocabulary has no typed BPMN projection yet.
  assert_eq(result.document, None)
}

The CST has flat, immutable nodes and lexical tokens. Node zero is the document; child and parent indexes refer to that CST. Element nodes expose raw and expanded names, ordered attributes, local namespace declarations, child indexes and source spans. Text/CDATA values are decoded and normalized; processing-instruction values contain data after the target. Each token retains its original text and half-open UTF-16 span. Tokens partition the source. Use tree.text() to reproduce the original spelling, including invalid XML.

Literal CRLF and CR become LF in decoded values. Literal attribute whitespace becomes spaces; referenced whitespace retains its referenced character. CDATA and processing instructions do not expand references. Outside the root, only literal XML whitespace is allowed; references to whitespace are errors. Malformed comments, forbidden characters, invalid scalars and undeclared entities give findings. DTD, entity and other markup declarations are prohibited, with no expansion or resource resolution. Neither schema locations nor namespace URIs cause I/O.

Input is already decoded. The optional XML declaration must name version 1.0; accepted encoding labels are case-insensitive UTF-8 and US-ASCII. An ASCII declaration fails if the input has non-ASCII characters. File decoding belongs to a host adapter. An initial Unicode BOM is retained in source reproduction.

Source locations

Offsets and columns count UTF-16 code units; lines and columns start at one. CRLF is one line break. source_map.position(offset) and location(span) reject out-of-range offsets. Diagnostics have a stable code, severity, message and optional span/location. A supplied source ID is data, not a path to open.

///|
test "a diagnostic after an emoji uses source coordinates" {
  let result = @bpmn.read("<r>\r\n๐Ÿ˜€<p:x/></r>", source_id="memory:example")
  let location = result.diagnostics[0].location.unwrap()
  assert_eq(location.source_id, Some("memory:example"))
  assert_eq(location.start.line, 2)
  assert_eq(location.start.column, 4)
  assert_eq(result.document, None)
}

Processing limits

All Limits::new fields are inclusive and must be positive.

Field Default What it bounds
source_units 16,777,216 Decoded source in UTF-16 units
output_units 16,777,216 Complete generated XML in UTF-16 units
nodes 1,000,000 Document, element/content nodes, attributes and namespace declarations
depth 4,096 Element depth, including empty elements
token_units 1,048,576 One lexical token or complete attribute/text value
diagnostics 256 Findings shared by XML reading and typed lowering

A limit finding prevents a successful typed document. The diagnostic that exceeds the finding budget replaces its last slot with xml/diagnostic-limit. Reading stops at a parser limit. The remaining raw source is retained as one error preservation token, exempt from token size; it is not decoded or expanded. Oversized source is rejected before constructing a source map or node arena. Its finding identifies offset zero and the source ID. Invalid limits produce one configuration finding without an invented source location, even when the configured diagnostic limit is zero. Source reproduction remains available.

///|
test "limit exhaustion preserves the complete source" {
  let source = "<r><s/></r>"
  let result = @bpmn.read(source, limits=@bpmn.Limits::new(depth=1))
  assert_eq(result.tree.text(), source)
  assert_eq(result.document, None)
  assert_eq(result.diagnostics[0].code, "xml/depth-limit")
}

XML diagnostic inventory

All codes below have error severity. BPMN lowering codes are separate.

Code Meaning
xml/invalid-limits At least one configured bound is not positive
xml/source-limit Source exceeds its UTF-16 bound
xml/node-limit Node/attribute records exceed their bound
xml/depth-limit Element depth exceeds its bound
xml/token-limit A lexical token or attribute/text value is too large
xml/diagnostic-limit The next finding exceeds the shared budget
xml/invalid-character A literal character is forbidden in XML 1.0
xml/invalid-name An element/attribute name is not a qualified XML name
xml/invalid-tag Attribute quoting, spacing or tag punctuation is malformed
xml/unclosed-tag An open element remains at end of input
xml/mismatched-tag A closing tag does not match the current open element
xml/duplicate-attribute Attribute expanded names or namespace declarations repeat
xml/undeclared-prefix A qualified element/attribute uses an unbound prefix
xml/invalid-namespace A reserved binding or prefix undeclaration is invalid
xml/character-reference A reference is malformed, undeclared or not an XML scalar
xml/outside-root Non-whitespace text or CDATA occurs outside the document element
xml/root-required The source does not contain exactly one document element
xml/invalid-comment A comment contains -- or ends with -
xml/unclosed-comment A comment has no closing delimiter
xml/unclosed-cdata A CDATA section has no closing delimiter
xml/invalid-pi A processing instruction has an invalid or reserved target
xml/unclosed-pi A processing instruction has no closing delimiter
xml/invalid-declaration The declaration has invalid position, version or pseudo-attributes
xml/encoding The declared encoding is unsupported or conflicts with the decoded input
xml/prohibited-declaration DTD/entity/other markup declarations are prohibited
xml/cdata-close ]]> occurs in ordinary character data
///|
test "the documented inventory agrees with the exported codes" {
  assert_eq(@bpmn.xml_diagnostic_codes.iter().to_array(), [
    "xml/invalid-limits", "xml/source-limit", "xml/node-limit", "xml/depth-limit",
    "xml/token-limit", "xml/diagnostic-limit", "xml/invalid-character",
    "xml/invalid-name", "xml/invalid-tag", "xml/unclosed-tag", "xml/mismatched-tag",
    "xml/duplicate-attribute", "xml/undeclared-prefix", "xml/invalid-namespace",
    "xml/character-reference", "xml/outside-root", "xml/root-required",
    "xml/invalid-comment", "xml/unclosed-comment", "xml/unclosed-cdata",
    "xml/invalid-pi", "xml/unclosed-pi", "xml/invalid-declaration", "xml/encoding",
    "xml/prohibited-declaration", "xml/cdata-close",
  ])
}

Typed BPMN values and writing

The five frozen BPMN 2.0.2 schemas drive the standard Kind alternatives, TypeId, type derivation, substitution groups, fields, occurrence bounds, choices and named enumeration types. schema_types, schema_elements, schema_fields and schema_groups expose this read-only inventory. Run mise run bpmn-schema:generate to regenerate it. validate:bpmn-schema checks input digests and byte-identical regeneration, including the declaration coverage manifest under tests/corpus/bpmn-2.0.2/coverage.json.

Element holds a standard kind, schema type identity, typed Scalar attributes, optional scalar text, and ordered Content. Content distinguishes text, elements, comments and inert processing instructions. Constructors copy input collections into read-only arrays. Updating an element returns a new value. Parsed elements retain their effective namespace context in a shared immutable core map, so content updates keep inherited QName bindings and prefix shadowing without copying every ancestor binding into every descendant. Document holds its root plus ordered prolog and epilog comments/instructions. Outside-root whitespace is source trivia and is omitted from document values.

Definitions, Import, BaseElement, RootElement and Documentation give typed foundation construction or inspection. Abstract base/root views require a concrete element whose schema type derives from that base. Process and collaboration wrappers cover activities, subprocesses, calls, loop and multi-instance values, events/definitions, gateways, flows, pools/participants, lanes, data, artifacts, resources and shared definitions. The generated typed_model_types and typed_model_fields inventories let qualification check every selected schema type and inherited field. Choreography, conversation, BPMNDI/DI/DC and preserved vendor XML are also qualified. Every complex type in the five frozen schemas has a typed view, including the anonymous DI extension container. Unknown standard elements or unknown types give findings and remain available as preserved XML inside the document. Foreign namespace content does not require a vendor plugin. Structure validation, reference-resource resolution and full traversal follow in later groups. Reading or writing does not validate all BPMN constraints.

///|
test "construct immutable foundations without inserting defaults" {
  let documentation = @bpmn.Documentation::new([Text("Review & approve")])
  let process = @bpmn.Element::new(Process, attributes=[("id", "P")], children=[documentation.element])
  let definitions = @bpmn.Definitions::new("urn:example", children=[process])
  let document = definitions.document()
  assert_eq(definitions.roots()[0].base().id(), Present("P"))
  assert_eq(process.boolean("isExecutable"), Absent)
  assert_eq(documentation.text_format(), Absent)
  let compact = @bpmn.write(document)
  let pretty = @bpmn.write(document, format=Pretty)
  assert_false(compact.contains("textFormat"))
  assert_eq(@bpmn.read(compact).document, Some(document))
  assert_eq(@bpmn.read(pretty).document, Some(document))
}

Field[T] distinguishes Absent, Present(value) and InvalidValue(scalar). Absent schema defaults stay absent. Booleans, identifiers, URIs, exact integers, exact numeric tokens, references and generated enumeration alternatives have separate Scalar cases. ExactNumber retains the DI token without passing through a host float. Decimal fractions and exponent tokens therefore keep all digits. XML Schema double also admits INF, -INF and NaN; is_finite() distinguishes them. ExactInteger has no host integer-size limit; fields declared as XML Schema int enforce its signed 32-bit range. String whitespace is retained; token, URI, numeric and reference types apply their schema whitespace policy. Invalid scalar spellings remain inspectable in a typed document with findings, and cannot be written as valid values.

References retain original spelling plus expanded namespace identity. ReferenceKind::Id uses a local NCName; QName resolves prefixes against explicit bindings. No external document is opened. Construct references from an ExpandedName to avoid inventing a source prefix. Element::new accepts string attributes with explicit local bindings; Element::from_values accepts typed attributes, optional typed scalar text and ordered content. A local child name whose schema type depends on its parent needs an explicit value_type when constructed outside that context. The writer rejects a missing or conflicting type identity instead of producing a document that reads back differently. An explicit type override adds the corresponding xsi:type reference so it survives serialization. No IDs or DI are generated.

///|
test "constructed references carry expanded identity" {
  let source = @bpmn.Reference::new(@bpmn.ExpandedName::new("urn:other", "Task"))
  let flow = @bpmn.Element::from_values(MessageFlow, values=[
    @bpmn.Attribute::new(@bpmn.ExpandedName::new("", "sourceRef"), Reference(source)),
  ])
  let document = @bpmn.Definitions::new("urn:example", children=[flow]).document()
  let xml = @bpmn.write(document)
  assert_true(xml.contains("xmlns:ns1=\"urn:other\""))
  assert_true(xml.contains("sourceRef=\"ns1:Task\""))
  assert_eq(@bpmn.read(xml).document, Some(document))
}

Equality, source reproduction and canonical output

Document equality compares standard kind/type, expanded attribute names, typed scalar values and ordered semantic content. It ignores source spans, raw prefix aliases, attribute ordering and element-only formatting whitespace. Adjacent text/CDATA normalize to text values. Mixed content, comments, processing instructions and inherited xml:space are retained. Exact number tokens compare exactly. Namespace aliases on known QName values compare by expanded identity.

tree.text() reproduces the source bytes represented by the decoded string, including quote style, CDATA, declarations and invalid regions. write produces this library's deterministic XML format, not XML C14N. The default is compact; format=Pretty uses two spaces for element-only content. Both keep mixed text and containment order. Pretty output adds no whitespace inside mixed content, scalar elements or an inherited xml:space='preserve' scope.

Canonical namespaces use bpmn, bpmndi, dc, di, xml and xsi for known URIs, and ns1, ns2, ... for other used QName identities in sorted lexical URI order. Known bindings are declared at the root. Unused aliases on ordinary known values disappear. Attributes sort by namespace URI, then local name. Namespace names are literal strings; they do not trigger I/O. Known QName tokens are rewritten with matching bindings. Preserved XML and opaque attributes retain their effective bindings in local scopes. Their source prefixes are retained when valid, and canonical parent prefixes cannot rebind opaque values.

///|
test "source reproduction and canonical writing serve different purposes" {
  let source = "<d:definitions xmlns:d='http://www.omg.org/spec/BPMN/20100524/MODEL' targetNamespace='urn:example'><d:process id='P'/></d:definitions>"
  let result = @bpmn.read(source)
  assert_eq(result.tree.text(), source)
  let document = result.document.unwrap()
  let canonical = @bpmn.write(document)
  assert_false(canonical == source)
  assert_eq(@bpmn.read(canonical).document, Some(document))
  assert_eq(@bpmn.write(@bpmn.read(canonical).document.unwrap()), canonical)
}

Writer failures and limits

try_write(document, format?, limits?) returns WriteResult with either complete output or diagnostics. It never exposes a partial buffer. write is a convenience API that returns the string or raises WriteError::Failed(diagnostics). This adds a checked error to the earlier provisional convenience API.

The writer checks XML names/characters, namespace bindings, duplicate expanded attributes, typed scalar consistency, comments and processing instructions. It rejects unbound QName references, invalid scalars and incompatible xsi:type values. Namespace bindings must obey XML reserved-prefix rules. Comments cannot contain -- or end with -. Comment/PI CR and leading PI data whitespace are rejected because XML normalization would change their value. Construct text CR is preserved through a character reference instead.

///|
test "invalid QName binding returns an explicit failure" {
  let flow = @bpmn.Element::new(MessageFlow, attributes=[("sourceRef", "missing:T")])
  let document = @bpmn.Definitions::new("urn:example", children=[flow]).document()
  let result = @bpmn.try_write(document)
  assert_eq(result.output, None)
  assert_eq(result.diagnostics[0].code, "bpmn/unbound-qname")
  let reserved = @bpmn.Element::new(Definitions, bindings=[("xml", "urn:wrong")])
  assert_eq(@bpmn.try_write(@bpmn.Document::new(reserved)).diagnostics[0].code, "bpmn/namespace-conflict")
}

The writer uses explicit stacks for preflight and emission, including very deep constructed documents. It checks depth and node/attribute/binding counts before descending. It bounds complete output and individual escaped tokens before appending. Canonical namespace declarations count toward output records. Input and output record counts each obey nodes; neither is the sum of both passes. Pretty indentation also obeys the token/output bounds. Limits must be positive; source_units applies to reading, while output_units applies to writing. Writer failure produces one error with an optional original span. Constructed values have no invented source location.

Writer codes are bpmn/output-limit, bpmn/write-node-limit, bpmn/write-depth-limit, bpmn/write-token-limit, bpmn/write-invalid-limits, bpmn/forbidden-text, bpmn/invalid-name, bpmn/namespace-conflict, bpmn/invalid-scalar, bpmn/scalar-type-mismatch, bpmn/unbound-qname, bpmn/type-conflict, bpmn/duplicate-attribute, bpmn/invalid-content, bpmn/invalid-comment, bpmn/invalid-pi, bpmn/outside-root, bpmn/definitions-required and bpmn/unsupported-type. Lowering uses bpmn/unsupported-element, bpmn/unsupported-type, bpmn/unsupported-attribute, bpmn/invalid-scalar, bpmn/unbound-qname and bpmn/definitions-required. These findings do not change the XML CST's completeness. A simple walk visits containment in preorder and never follows reference attributes.

Typed process and collaboration APIs

Named wrappers expose their immutable element and schema-typed accessors. UserTask::from_element, for example, checks type derivation before returning a view. FlowElement, Activity, CatchEvent and other abstract wrappers can inspect their concrete subtypes. new constructors exist for concrete global elements. Constructor arguments include inherited attributes with their scalar, reference or named enum type. Child order is explicit in contents.

Single child accessors return Field[T]. Repeated children return Field[ReadOnlyArray[T]]; no children gives Absent. A mismatched scalar or child type gives InvalidValue, and a duplicate singleton child is invalid. The original ordered elements remain available on element.children. Names use snake case; reserved words gain _, as in default_ and method_. The BPMN Error wrapper is BpmnError because Error is reserved by MoonBit.

///|
test "construct and inspect a typed process" {
  let task = @bpmn.UserTask::new(id="Review", name="Review request", implementation=Unspecified)
  let process = @bpmn.Process::new(id="P", is_executable=false, contents=[Element(task.element)])
  let document = @bpmn.Definitions::new("urn:example", children=[process.element]).document()
  assert_eq(task.is_for_compensation(), Absent)
  assert_eq(process.flow_element().value().unwrap()[0].element.kind, UserTask)
  let reread = @bpmn.read(@bpmn.write(document)).document.unwrap()
  let view = @bpmn.Process::from_element(reread.root.children[0]).unwrap()
  assert_eq(view.is_executable(), Present(false))
  assert_eq(@bpmn.UserTask::from_element(view.flow_element().value().unwrap()[0].element).unwrap().name(), Present("Review request"))
}

References keep their schema distinction. A sequence-flow endpoint is an ID reference; message-flow endpoints and participant process references are QNames. Use kind=Id when constructing an ID reference. The writer reports a mismatch rather than changing one alternative into another. Formal expressions remain inert text. To put one under a property name such as conditionExpression, use with_kind; its explicit type identity is retained through xsi:type.

///|
test "construct descriptive conditions and ID references" {
  let condition = @bpmn.FormalExpression::new(language="urn:description", contents=[Text("approved == true")]).element.with_kind(ConditionExpression)
  let flow = @bpmn.SequenceFlow::new(id="F", source_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "A"), kind=Id), target_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "B"), kind=Id), contents=[Element(condition)])
  let process = @bpmn.Process::new(id="P", contents=[Element(@bpmn.Task::new(id="A").element), Element(@bpmn.Task::new(id="B").element), Element(flow.element)])
  let document = @bpmn.Definitions::new("urn:example", children=[process.element]).document()
  assert_eq(flow.condition_expression().value().unwrap().element.content, "approved == true")
  assert_eq(@bpmn.read(@bpmn.write(document)).document, Some(document))
}
///|
test "construct a collaboration independently of rendering" {
  let buyer = @bpmn.Process::new(id="Buyer", contents=[Element(@bpmn.SendTask::new(id="Send").element)])
  let seller = @bpmn.Process::new(id="Seller", contents=[Element(@bpmn.ReceiveTask::new(id="Receive").element)])
  let buyer_pool = @bpmn.Participant::new(id="BuyerPool", process_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "Buyer")))
  let seller_pool = @bpmn.Participant::new(id="SellerPool", process_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "Seller")))
  let message = @bpmn.MessageFlow::new(id="Request", source_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "Send")), target_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "Receive")))
  let collaboration = @bpmn.Collaboration::new(id="Exchange", contents=[Element(buyer_pool.element), Element(seller_pool.element), Element(message.element)])
  let document = @bpmn.Definitions::new("urn:example", children=[buyer.element, seller.element, collaboration.element]).document()
  assert_eq(collaboration.participant().value().unwrap().length(), 2)
  assert_eq(@bpmn.read(@bpmn.write(document, format=Pretty)).document, Some(document))
}

Construction can represent an incomplete intermediate value. The writer checks XML/scalar representability and type identity; required-field and structural validation are completed in group 6. These document APIs do not expand the current renderer's coverage or execute conditions, events, loops or scripts.

The unpublished corpus compares core identities, containment, references and minimal DI with bpmn-moddle@10.3.1. mise run bpmn-corpus:reference refreshes those frozen JSON fixtures using its private lockfile. Ordinary module tests use the committed data, without JavaScript dependencies or resource access. See tests/corpus/bpmn-model/README.md for normalization rules and the explicit GlobalScriptTask schema/reference difference. Exact numeric preservation and source order have separate four-target laws, beyond the reference's numeric and property-order conventions.

Choreography, conversation and DI values

Choreography, GlobalChoreographyTask, ChoreographyTask, SubChoreography and CallChoreography retain participant/message references, nested flow content, associations and loop descriptions. Conversation, SubConversation, CallConversation, GlobalConversation, links and associations have their own typed views. These are document values. Choreography/conversation rendering is explicitly deferred, and the adapter returns bpmn/unsupported-view for those views. They are not converted into generic tasks.

///|
test "construct a choreography document without rendering it" {
  let task = @bpmn.ChoreographyTask::new(id="Exchange", initiating_participant_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "A")), contents=[
    Element(@bpmn.Element::new(ParticipantRef, content="A")),
    Element(@bpmn.Element::new(ParticipantRef, content="B")),
    Element(@bpmn.Element::new(MessageFlowRef, content="M")),
  ])
  let flow = @bpmn.MessageFlow::new(id="M", source_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "A")), target_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "B")))
  let choreography = @bpmn.Choreography::new(id="C", contents=[Element(@bpmn.Participant::new(id="A").element), Element(@bpmn.Participant::new(id="B").element), Element(flow.element), Element(task.element)])
  let document = @bpmn.Definitions::new("urn:example", children=[choreography.element]).document()
  assert_eq(task.loop_type(), Absent)
  assert_eq(@bpmn.read(@bpmn.write(document)).document, Some(document))
}
///|
test "construct a conversation and its participant links" {
  let conversation = @bpmn.Conversation::new(id="Order", name="Order details", contents=[Element(@bpmn.Element::new(ParticipantRef, content="Buyer")), Element(@bpmn.Element::new(ParticipantRef, content="Seller"))])
  let link = @bpmn.ConversationLink::new(id="BuyerLink", source_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "Buyer")), target_ref=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "Order")))
  let collaboration = @bpmn.Collaboration::new(id="C", contents=[Element(@bpmn.Participant::new(id="Buyer").element), Element(@bpmn.Participant::new(id="Seller").element), Element(conversation.element), Element(link.element)])
  let document = @bpmn.Definitions::new("urn:example", children=[collaboration.element]).document()
  assert_eq(collaboration.conversation_node().value().unwrap().length(), 1)
  assert_eq(@bpmn.read(@bpmn.write(document)).document, Some(document))
}

BpmnDiagram, BpmnPlane, BpmnShape, BpmnEdge, BpmnLabel and BpmnLabelStyle expose the BPMNDI fields. Di* views expose their DI bases; DcBounds, DcPoint and DcFont retain exact numeric tokens and display flags. A document can have multiple diagrams/planes or no DI. Diagram-element IDs, label styles, waypoints and participant-band references remain distinct fields.

///|
test "construct exact DI bounds and a plane" {
  let bounds = @bpmn.DcBounds::new(x=@bpmn.ExactNumber::parse("123.456789012345678901").unwrap(), y=@bpmn.ExactNumber::parse("0").unwrap(), width=@bpmn.ExactNumber::parse("100").unwrap(), height=@bpmn.ExactNumber::parse("80").unwrap())
  let shape = @bpmn.BpmnShape::new(id="Shape", bpmn_element=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "T")), is_expanded=false, contents=[Element(bounds.element)])
  let plane = @bpmn.BpmnPlane::new(id="Plane", bpmn_element=@bpmn.Reference::new(@bpmn.ExpandedName::new("", "P")), contents=[Element(shape.element)])
  let diagram = @bpmn.BpmnDiagram::new(id="Diagram", contents=[Element(plane.element)])
  let process = @bpmn.Process::new(id="P", contents=[Element(@bpmn.Task::new(id="T").element)])
  let document = @bpmn.Definitions::new("urn:example", children=[process.element, diagram.element]).document()
  assert_eq(shape.bounds().value().unwrap().x().value().unwrap().token, "123.456789012345678901")
  assert_eq(@bpmn.read(@bpmn.write(document, format=Pretty)).document, Some(document))
}

Preserved XML and opaque namespace scopes

XmlElement and XmlContent construct preserved XML from expanded names, string-valued attributes, ordered text/elements/comments/instructions and explicit namespace bindings. Insert its element under an extension container. element.xml() inspects foreign or unsupported standard XML. A known BPMN name inside an opaque payload stays XML payload data, with no inferred BPMN meaning. opaque_attributes() exposes uninterpreted fields on an otherwise typed node.

Parsed payloads keep their complete effective namespace context in shared immutable maps, including inherited and unused bindings. namespace_binding on either Element or XmlElement queries this captured scope. Constructed standalone values expose their explicit bindings; a constructed tree inherits bindings from its containing values. Copying a parsed payload keeps its captured context. Prefix shadowing is kept locally, including when a vendor uses bpmn for another URI. Names use source prefixes inside preserved scopes and fresh aliases when required. No existing binding is silently reassigned to change an opaque value.

///|
test "construct a mixed vendor extension with a QName-sensitive value" {
  let payload = @bpmn.XmlElement::new(@bpmn.ExpandedName::new("urn:vendor", "payload"), attributes=[(@bpmn.ExpandedName::new("", "ref"), "q:Target")], contents=[Text("before "), Comment("inert"), Text(" after")], bindings=[("q", "urn:target"), ("unused", "urn:retained")])
  let extensions = @bpmn.ExtensionElements::new(contents=[Element(payload.element)])
  let process = @bpmn.Process::new(id="P", contents=[Element(extensions.element)])
  let document = @bpmn.Definitions::new("urn:example", children=[process.element]).document()
  let copy = @bpmn.read(@bpmn.write(document)).document.unwrap()
  let retained = copy.root.children[0].children[0].children[0].xml().unwrap()
  assert_eq(retained.namespace_binding("q"), Some("urn:target"))
  assert_eq(retained.namespace_binding("unused"), Some("urn:retained"))
  assert_eq(copy, document)
}

Unknown standard elements give bpmn/unsupported-element. Unknown xsi:type values give bpmn/unsupported-type; the affected subtree remains opaque XML. Unknown standard attributes give bpmn/unsupported-attribute and retain their value. These findings can coexist with an inspectable, writable document. Its CST still reproduces the original text. A root with an unknown type can have no Definitions view, while its preserved root remains inspectable. A syntax error or processing-limit failure still prevents a successful complete document.

Opaque equality compares expanded names, raw values, ordered content, the default namespace and bindings used by QName-looking tokens. It does not claim to understand vendor semantics. The writer separately retains every original binding, including bindings whose use is unknown. Prefixes that were unbound in opaque tokens are kept unbound; canonical prefix allocation avoids those names. If a copied closed scope would require undeclaring an inherited nonempty prefix, XML Namespaces 1.0 cannot represent it, so writing returns bpmn/namespace-conflict. An empty-namespace identity conflicting with a preserved default namespace also returns that error. No partial XML is exposed.

Namespace output uses only changed declarations. Source contexts and changes share immutable data across siblings; the acceptance suite exercises wide scopes with 1,000 bindings and 2,000 shadowing children. Preserved declarations, names and escaped values obey the same node, token and output limits as known BPMN data. No vendor text, URI or expression triggers I/O or execution.

The provisional group-4 API returned raw Element values from collaboration conversation-node, conversation-link and conversation-association accessors. These accessors now return their respective typed views. Use .element when an existing caller needs the underlying element.

Structural validation and explicit reference resources

validate_structure(document, resource_id?, limits?) returns an immutable source-order DocumentIndex and structural findings. It checks required fields, occurrences, sequence order, schema choices, abstract declarations, scalar alternatives and containment types across every generated schema family. IDs come from declared ID fields. IDs inside opaque XML do not become BPMN targets. index.lookup(id) returns Found, Ambiguous, Missing or Incomplete. Duplicate occurrences retain their source order.

resolve_references(index, resources?, resource_id?, limits?) checks all 114 declared QName/IDREF fields against reference_rules. The semantic target table supplements XSD primitive types with the BPMN 2.0.2 metamodel, including its normative CMOF. Checks include target kinds, boundary attachments, sequence-flow scope, default/incoming/outgoing endpoints and lane membership. DI plane references must name one of the allowed process, collaboration or choreography containers. This does not validate execution, expressions, vendor semantics or every BPMN modeling constraint. It never simulates tokens or evaluates conditions.

validate(document, ...) combines both stages. validate_read(read_result, ...) also retains earlier findings with explicit Syntax, Structure or References stages. complete means processing finished within bounds. A complete result can have error findings and still retain the original inspectable document.

///|
test "validate a valid process and inspect incomplete authored fields" {
  let task=@bpmn.Task::new(id="T")
  let process=@bpmn.Process::new(id="P",contents=[Element(task.element)])
  let document=@bpmn.Definitions::new("urn:example",children=[process.element]).document()
  let valid=@bpmn.validate(document)
  assert_true(valid.complete)
  assert_eq(valid.findings,[])
  assert_true(valid.index.unwrap().lookup("T") is Found(_))
  let bad=@bpmn.SequenceFlow::new(id="MissingEndpoints")
  let document=@bpmn.Definitions::new("urn:example",children=[@bpmn.Process::new(contents=[Element(bad.element)]).element]).document()
  let invalid=@bpmn.validate(document)
  assert_true(invalid.complete)
  assert_eq(invalid.document,Some(document))
  assert_true(invalid.findings.iter().any(f => f.diagnostic.code == "bpmn/required-field" && f.element_id == Some("MissingEndpoints")))
  assert_true(invalid.findings.iter().all(f => f.diagnostic.location == None))
}

ResourceSet::new(documents?, external?) copies caller arrays. A DocumentResource has a literal location key and a typed document. Import lookup uses exactly that key, declared namespace and import type. The caller handles relative paths or URLs before supplying the set. A supplied resource becomes visible only through the root's direct or transitive imports. Cycles stop at already visited resources. Two keys can supply documents with the same target namespace; duplicate target IDs then produce ambiguity. Duplicate location keys produce an ambiguous-import finding. No candidate wins by array order.

An ExternalResource supplies a location, namespace, import type and explicit NCName symbol identities for another language. This is a symbol table supplied by the caller, not a parser or validator for XML Schema, WSDL or vendor content. External symbols can satisfy generic language references, and cannot satisfy a reference that requires a BPMN process, task or other specific BPMN type.

///|
test "resolve an imported process using only explicit resources" {
  let model="http://www.omg.org/spec/BPMN/20100524/MODEL"
  let imported=@bpmn.Definitions::new("urn:imported",children=[@bpmn.Process::new(id="Reusable").element]).document()
  let reference=@bpmn.Reference::new(@bpmn.ExpandedName::new("urn:imported","Reusable"))
  let participant=@bpmn.Participant::new(id="Pool",process_ref=reference)
  let document=@bpmn.Definitions::new("urn:main",children=[@bpmn.Import::new("urn:imported","reusable.bpmn",model).element,@bpmn.Collaboration::new(contents=[Element(participant.element)]).element]).document()
  let resources=@bpmn.ResourceSet::new(documents=[@bpmn.DocumentResource::new("reusable.bpmn",imported)])
  let index=@bpmn.validate_structure(document).index
  let result=@bpmn.resolve_references(index,resources~)
  assert_eq(result.findings,[])
  assert_true(result.resolve(reference) is Found(found) && found.resource_id == Some("reusable.bpmn"))
  let missing=@bpmn.resolve_references(index)
  assert_true(missing.complete)
  assert_true(missing.findings.iter().any(f => f.diagnostic.code == "bpmn/unresolved-import"))
}

IDREF and empty-namespace QName values are document-local. Every nonempty QName namespace matches a definitions target namespace literally. XML default namespaces apply to QName values too. A model-vocabulary namespace is not an alias for the current target namespace. Bind a prefix to the intended target namespace when needed. This policy preserves expanded identity through writing and prevents a qualified reference from falling back to a same-named local ID. Missing unrelated imports remain findings while local queries still work. resolve returns Found, External, Ambiguous, Unresolved, Invalid or Limit; the original reference value always remains available.

Source-backed elements and attributes retain their own immutable source origin. location() stays correct when they are copied between documents. New or edited element values have no element span. Unchanged copied attributes can still have their original locations. Findings identify the owner ID and field without inventing locations for constructed values. Source provenance is excluded from document semantic equality.

Validation uses Limits. Structure counts document, element, content, attribute and namespace records; depth and token bounds are inclusive. An index built after limit exhaustion cannot answer a lookup as missing. Reference resources share the node budget across imported documents and external symbols. Candidate visits and matched target occurrences consume a separate query-work budget of limits.nodes across field resolution, and each explicit resolve call gets that budget independently. Namespace and ID indexes avoid scanning unrelated resources. Diagnostic overflow replaces the final slot with the limit finding.

The exported diagnostic inventory below is checked by this runnable example. All listed codes have error severity.

///|
test "document the complete structural and reference diagnostic inventory" {
  let codes : ReadOnlyArray[String] = [
    "bpmn/validation-invalid-limits", // A bound is not positive.
    "bpmn/validation-node-limit", // Structure record budget exhausted.
    "bpmn/validation-depth-limit", // Containment depth exhausted.
    "bpmn/validation-token-limit", // One value exceeds its token bound.
    "bpmn/validation-diagnostic-limit", // Finding budget exhausted.
    "bpmn/duplicate-id", // Multiple declared IDs have the same value.
    "bpmn/required-field", // Required field or selected branch is absent.
    "bpmn/field-cardinality", // Too many occurrences of one field.
    "bpmn/invalid-containment", // Wrong root, child or text containment.
    "bpmn/child-order", // Child is outside schema sequence order.
    "bpmn/choice-conflict", // Multiple choice branches are present.
    "bpmn/abstract-element", // Abstract declaration was instantiated.
    "bpmn/invalid-field", // Undeclared field or invalid scalar alternative.
    "bpmn/invalid-type", // Incompatible actual or containment type.
    "bpmn/unresolved-reference", // No identity in the explicit import closure.
    "bpmn/ambiguous-reference", // Several identities match.
    "bpmn/invalid-reference", // Invalid/unbound QName, IDREF or external symbol.
    "bpmn/wrong-reference-kind", // Identity exists with an incorrect type.
    "bpmn/sequence-flow-scope", // Flow endpoint crosses its flow scope.
    "bpmn/boundary-attachment", // Attachment is outside the owning flow scope.
    "bpmn/reference-endpoint", // Default, lane, incoming/outgoing inconsistency.
    "bpmn/unresolved-import", // Location key was not supplied.
    "bpmn/ambiguous-import", // Several resources have the same location key.
    "bpmn/import-namespace", // Import and supplied namespace differ.
    "bpmn/import-type", // Import and supplied resource type differ.
    "bpmn/resource-limit", // Resource or lookup-work budget exhausted.
  ]
  assert_eq(@bpmn.validation_diagnostic_codes,codes)
}

Read-only traversal

DocumentTree::new(document, resource_id?, limits?) builds an immutable index over the existing document values. It does not require semantic validity and does not resolve references. Its document root contains prolog content, the definitions element and epilog content. Element children include retained text, comments and processing instructions, with vendor XML in original model order. XML formatting trivia omitted during typed lowering stays in the lossless CST.

NodeRef exposes value, category, XML kind, declared id, attributes, scalar, fields, children, parent, step, depth, span, location, resource_id, path and handle. A NodeField groups present attributes and children without adding defaults. Known containment uses schema field names, including substitution heads such as flowElement; opaque children use their expanded XML names. PathField distinguishes named fields, text, comments, PI, root, prolog and epilog. PathStep records the index within that field and the absolute ordered-content index. Unbound attribute names remain inspectable via attributes, without an invented expanded identity.

A NodePath is a constant-size view; steps() allocates the requested path. Depth and paths are relative to the document root, including when a traversal starts at a subtree. Source-backed content has optional source locations. Constructed or replaced content has no invented span. Copied elements keep their own origins. Replacing contents preserves child-element origins, and clears source spans for newly supplied text/comment/PI values.

tree.lookup(handle) and path.resolve(tree) return Found, DocumentMismatch or InvalidHandle. Revision checks compare resource identity, complete source identity and constructed values, including unused bindings and lexical metadata. Semantic document equality alone is insufficient. Two trees built from the same immutable revision can exchange handles. Canonical writing and reading creates a new source revision. There is no global allocation counter or hash-only ownership test.

///|
test "locate a nested task and reject a handle from another revision" {
  let task=@bpmn.Task::new(id="T")
  let sub=@bpmn.SubProcess::new(id="Sub",contents=[Element(task.element)])
  let doc=@bpmn.Definitions::new("urn:t",children=[@bpmn.Process::new(id="P",contents=[Element(sub.element)]).element]).document()
  let tree=@bpmn.DocumentTree::new(doc).tree.unwrap()
  let tasks=[]
  @bpmn.walk(tree.root(),n => { if n.id() == Some("T") { tasks.push(n) }; Continue })
  let node=tasks[0]
  assert_eq(node.parent().unwrap().id(),Some("Sub"))
  assert_eq(node.path().steps().last().unwrap().field,Named(@bpmn.Kind::FlowElement.name()))
  assert_eq(node.location(),None)
  assert_true(node.path().resolve(tree) is Found(found) && found == node)
  let changed=@bpmn.Definitions::new("urn:changed").document()
  assert_true(@bpmn.DocumentTree::new(changed).tree.unwrap().lookup(node.handle()) is DocumentMismatch)
}

walk takes an enter callback and an optional leave callback. Enter returns Continue, SkipChildren or Stop. A skipped node still receives leave; stop emits no later enter or leave callbacks. fold threads an accumulator through the same order. reduce combines child results bottom up; reduce_pruned also takes enter control, supplies an empty child-result view for skipped children, and returns None after stop.

EventReader::new(root) exposes the same order as pull TraversalEvent::Enter and Leave values. Each event contains its NodeRef, including path and source identity. skip_children() acts immediately after enter; elsewhere it does nothing. stop() makes subsequent reads return None. EventSource permits other event producers, and push_events drives them with the same controls.

///|
test "walking folding reduction and pull events count the same nodes" {
  let doc=@bpmn.Definitions::new("urn:t",children=[@bpmn.Process::new(contents=[Element(@bpmn.Task::new(id="T").element)]).element]).document()
  let root=@bpmn.DocumentTree::new(doc).tree.unwrap().root()
  let mut entered=0
  @bpmn.walk(root,_ => { entered+=1; Continue })
  let folded=@bpmn.fold(root,0,(count,_) => (count+1,Continue))
  let reduced=@bpmn.reduce(root,(_ : @bpmn.NodeRef,children : ArrayView[Int]) => 1+children.iter().fold(init=0,(sum,n) => sum+n))
  let reader=@bpmn.EventReader::new(root)
  assert_eq(reader.iter().count(),entered*2)
  assert_eq(folded,entered); assert_eq(reduced,entered)
  let cursor=@bpmn.TreeCursor::new(root)
  assert_true(cursor.goto_first_child())
  assert_eq(cursor.node().category(),Definitions)
  assert_true(cursor.goto_parent()); assert_eq(cursor.node(),root)
}

Visitor has default methods, so implement only the categories you need. Its optional enter(NodeRef) runs first; if it returns Continue, a typed family method runs next. Typed methods cover definitions, processes, activities, events, gateways, sequence flows, collaborations, participants, lanes, choreography, choreography activities, conversations, links and opaque XML. visit_diagram_interchange and visit_other expose other families through their read-only node views and typed element projections. Text, comment, PI and the document have their own methods. leave runs for every completed visited node. accept(root, visitor) uses the common traversal engine.

TreeCursor supports parent, first/last child and adjacent siblings. A failed move preserves its position. It stays within its chosen start subtree; depth is relative to that start while path remains document-relative. Each walk, reader, reduction and cursor owns its state. Nested walks are independent. Explicit heap stacks handle 10,000 nested constructed elements on all targets when the configured element-depth bound allows them. The older Document::walk(Element -> Unit) remains an element-only convenience.

Structural graph queries

DocumentGraph::new(document, resources?, resource_id?, limits?, graph_limits?) indexes the explicit document/import closure. It retains validation findings, and checks each imported document against that document's own import visibility. GraphNode::Document holds a revision-bound NodeRef. External holds an explicit external-language symbol and its occurrence, as a leaf. No URL or filesystem lookup occurs. An invalid reference becomes a query failure when the selected search encounters it; an unrelated disconnected component can still be queried. Processing exhaustion returns no apparently complete graph.

GraphEdgeKind distinguishes containment, sequence flow, message flow, association, data association, conversation links and general references. General references run from the owning element to its target. A sequence-flow edge runs between its authored endpoints and retains the flow as connection. Data associations with several sources have one edge per source. Valid source-less data assignments retain their target as a general reference; no visual source is inferred. DI coordinates, labels and XML adjacency never create connections.

GraphEndpoint selects a root-document Id, a namespace-aware Qualified identity, a revision-bound Node handle, or an explicit external Symbol. lookup returns the selected graph node or a typed failure. Root ID lookup does not select an imported element with a same-named ID.

reachable includes the start once. route returns one shortest directed route, with authored connection order breaking ties. Both default to sequence flow in the forward direction; callers explicitly select edge kinds and Forward, Reverse or Both. A reverse route keeps each edge's authored source and target. Returned nodes and connection owners preserve source identities. These are structural routes, not execution paths or token simulations.

///|
test "query a bounded structural route without executing a process" {
  let source="<b:definitions xmlns:b='http://www.omg.org/spec/BPMN/20100524/MODEL' targetNamespace='urn:t'><b:process><b:task id='A'/><b:task id='B'/><b:task id='C'/><b:sequenceFlow id='AB' sourceRef='A' targetRef='B'/><b:sequenceFlow id='BC' sourceRef='B' targetRef='C'/></b:process></b:definitions>"
  let graph=@bpmn.DocumentGraph::new(@bpmn.read(source).document.unwrap()).graph.unwrap()
  guard graph.route(Id("A"),Id("C")) is Found(route) else { fail("route missing") }
  assert_eq(route.nodes.iter().map(n => n.id()).to_array(),[Some("A"),Some("B"),Some("C")])
  assert_eq(route.edges[0].connection.unwrap().id(),Some("AB"))
  assert_true(graph.route(Id("C"),Id("A")) is Unreachable)
  assert_true(graph.route(Id("A"),Id("C"),limits=@bpmn.GraphLimits::new(nodes=1)) is Failed(Limit(Nodes)))
}

ReachabilityResult is Complete or Failed; RouteResult is Found, Unreachable or Failed. GraphFailure distinguishes InvalidEndpoint, DocumentMismatch, InvalidReference with its owner handle, Ambiguous with candidate nodes, Limit and InvalidLimits. A limit never reports unreachable or returns a partial result as complete. GraphLimits has positive inclusive node, edge, result and work bounds. Defaults are 100,000 nodes, 200,000 edges, 100,000 results and 1,000,000 work units.

During graph construction, the edge bound counts indexed adjacency entries, including reverse entries and broken-edge markers. Work includes indexed resource records, reference candidates and conservative resource-record charges before imported resolution. Query edge bounds count selected adjacency entries inspected; work counts selection processing, expanded nodes and inspected entries. Selection is deduplicated once. Node/result bounds are checked before allocation; route results count path nodes. Each query has fresh state and its own budget. Document validation and traversal bounds also apply while building.

///|
test "document traversal and graph construction code inventories" {
  assert_eq(@bpmn.traversal_diagnostic_codes,["bpmn/traversal-invalid-limits","bpmn/traversal-node-limit","bpmn/traversal-depth-limit","bpmn/traversal-token-limit"])
  assert_eq(@bpmn.graph_diagnostic_codes,["bpmn/graph-invalid-limits","bpmn/graph-work-limit","bpmn/graph-node-limit","bpmn/graph-edge-limit"])
}

Traversal diagnostics identify invalid limits, arena/metadata exhaustion, element-depth exhaustion or oversized names/values. Graph construction adds invalid-limit, work, node and adjacency-entry findings. It can also propagate the validation and traversal inventories documented above. All have error severity. Query failures use the typed alternatives described here.

ReferenceResult.indexed_resources() exposes the immutable indexes in the explicit import closure. Each IndexedResource keeps its resource ID, document index and bounded record count. Adapters can reuse these indexes without reparsing or fetching resources. The record count includes attributes, content and namespace bindings, so repeated processing can be charged before allocation.

View source on GitHub