Skip to content

SRD-089.F — BPMN import: item definitions, data objects, stores and properties

Field Value
Status Accepted
Date 2026-08-13
Owner Ruslan Gabitov
Implements ADR-024 v.4 §2.9 (the element set), §2.14 (the report contract every unmappable data attribute exercises)
Upstream SAD-001 v.1.1 §14.1 (the BPMN→model translation rules this stage implements rather than invents), ADR-030 v.1 (the scope-resident Data Object and the engine-global Data Store), ADR-011 v.7 §2.1 (the item-aware model), ADR-010 v.2 §2.7 (the name/path vocabulary a data element's name must not collide with), ADR-032 v.1 §2.3 (numeric unification), ADR-024 §2.16 (which refusals are staged, capability-blocked or standing)
Related SRD-089.A (the two-pass spine and the dispatch tables), SRD-089.D (the catalog and its structureLoss report, which §4.2 generalizes), SRD-089.E (the containers a data element lands in)
Tracking #284 — Part of

BPMN's data side comes in two halves. This stage imports the elements — what data a definition declares and where it lives: item definitions, data objects, data stores and properties. The flowioSpecification, data inputs and outputs, and the two association kinds that move values between them — is SRD-089.G, on this branch and in this PR.

The split is not a schedule. The elements are declarations resolved against the document; the flow is a wiring pass that has to reconcile item identity between a parameter and the object feeding it (§4.9), and mixing the two would put a mechanism nobody can review beside a table anybody can.

What makes this stage unusual among the .A–.E set is how little of it is a decision. SAD-001 v.1.1 §14.1 already carries BPMN→model translation rules written for this parser, down to numbered clauses for dataObjectReference. Where an earlier stage had to choose a mapping and defend it, this one mostly has to find the rule and obey it — and §4 says which of its decisions are which.


§1 Background (verified)

The whole data family is refused today. sections (pkg/convert/bpmn/dispatch.go:184-235) carries nine of its members — dataObject, dataObjectReference, dataStoreReference, ioSpecification, property, dataInput, dataOutput, dataInputAssociation and dataOutputAssociation (:209-220). No parser table claims any of them, so ADR-024 v.4 §2.9's default disposition refuses them — nothing in this family is silently accepted today.

Two members have no row at all. <itemDefinition> and <dataStore> are absent from sections, so their refusals carry no § — the table's documented behaviour for an unlisted tag (dispatch.go:172-183). Both become importable here, and SRD-089.E's lesson says an element becoming importable does not retire its row but earns one: the tables claim an element in a context, and a <dataStore> written inside a <process> is exactly the reader who needs the reference.

The model has every constructor this stage needs. Checked before a line of this document was written, per ADR-024 §2.16:

Element Model way in
<itemDefinition> data.NewItemDefinition(value, opts…) (pkg/model/data/item.go:74), data.WithKind (item_options.go:45), data.WithImport (item_options.go:60)
<import> foundation.Import{Type, Location, Namespace} (pkg/model/foundation/import.go:4-8) — a 1:1 match for the element's three attributes
structure values values.NewVariable[T] (values/variable.go:18), values.NewArray[T] (values/array.go:22), values.EmptyRecord (values/record.go:66-77) — the latter total by construction, so production code can build an empty record "without a panicking constructor"
<dataObject> dataobjects.New(name, idef, state, baseOpts…) (pkg/model/data_objects/data_object.go:36)
<dataStoreReference> datastores.New(name, dataStoreRef, idef, state, baseOpts…) (pkg/model/data_stores/data_store_reference.go:37)
adding either to a container Process.Add (process.go:284-304) and SubProcess.Add (subprocess.go:191-212) both accept flow.DataObjectElement and flow.DataStoreReferenceElement
<property> data.NewProperty(name, item, state, baseOpts…) (property.go:21), attached with data.WithProperties(props…) (property_option.go:23)

All three property owners accept the option. data.PropertyOption is dispatched by *processConfig (process.go:79), *activityConfig (activity.go:70) and the event configs (end.go:77, and event.go:158-196 gathers them for every event kind). So BPMN's rule that only a Process, an Activity or an Event may hold a Property (semantics/data.md:80-91) maps onto exactly the three model types that take one — no fourth owner to refuse and no missing third.

The importer already initializes the model's data states. build calls data.CreateDefaultStates() (importer.go:1367) before anything is constructed, guarded and idempotent, because a message-typed event needed it in .D. Every item-aware element this stage builds inherits that; nothing new is required.

A missing item is refused at construction; a missing structure is not. Every constructor here rejects a nil ItemDefinitiondata.NewItemAwareElement (item.go:187-192), dataobjects.New (data_object.go:56-59), datastores.New (data_store_reference.go:62-66). But a nil structure inside a non-nil item passes: data.NewItemDefinition stores its value unchecked (itemConfig.itemDef, item_options.go:28-40).

Exactly one constructor closes that gap, and only for itself: data.NewProperty's rejectValueless (property.go:62-71) — "its item has no structure and can never be filled; declare it with a typed value" — which is the model half of SAD-001 v.1.1 §14.1's "underspecified item-aware element" row.

A DataObject gets no such check, so one built over a structure-less item constructs fine and fails later: ItemAwareElement.Clone refuses a nil value (item.go:326-331), and cloning is what a snapshot does per instance (CloneDataObjects, data_object.go:206-227). §4.2 turns on that.

A data element's name may not contain /, ., [ or ]. data.CheckName (data/name.go:23-32) rejects them, because they are the provider separator (ADR-010 v.2 §2.7) and the structural-path characters (name.go:12-19). BPMN names are free text, so a modeler's Order.v2 is a legal document and an illegal data name — the model refuses it, and the converter's job is to attach the file's element id to that refusal.

The engine's numeric type is float64, and the check is strict. lite's evaluator unifies every Go integer and float kind to float64 (expression/lite/eval.go:68-96, grounding ADR-032 v.1 §2.3), while values.checkValue is a plain type assertion with no coercion (values/array.go:402-414). A Variable[int] therefore rejects every value an expression produces. §4.1 is decided by that pair.

An empty collection panics on read, by the collection's own contract. Array.Get calls errs.Panic("collection is empty") when the index is negative (values/array.go:39-47), which NewArray[T]() with no values leaves it (array.go:22-33). errs.Panic really panics (errs/errors.go:200-202) — the same file explains two functions later why errs.Invariant returns instead. Array also has Add, Clear and Count, so empty is a reachable state of a hand-built model too. §4.3 turns on that.

<import> is skipped today, and its own comment schedules this stage. policy (dispatch.go:162-169) skips <import> under <definitions> and says why: "foundation.Import exists, but only as an ItemDefinition field … this stage maps no itemDefinition, so nothing could consult one. Skipping it changes nothing TODAY; when itemDefinition lands with the data stage, its typeRef makes the declaration meaningful and this row has to be revisited with it." FR-7 is that revisit.

The extract keeps the family in scope, and pins it at §10.4.1. docs/bpmn-spec/semantics/data.md carries DataObject §10.4.1, DataObjectReference §10.4.1 Table 10.53, DataStore §10.4.1 Table 10.55, Property §10.4.1 Table 10.57, DataInput/DataOutput Tables 10.59/10.60, and ItemDefinition §8.4.10 Table 8.47; its header names §10.4 as "Items and Data". docs/bpmn-spec/elements/data.md gives the structural attributes of each.

And the § pins in the code are wrong. The nine data rows in sections say §10.3. That number comes from the extract's own section index (conformance.md:186-189: Activities §10.5, Events §10.4, Data §10.3), which contradicts every detail file and contradicts its own body — line 174 cites "§10.5.4" for boundary events on a call activity, which is only true if Events is §10.5. Consistent across the detail files, and matching the index's one surviving row (Gateways §10.6), the mapping is Activities §10.3, Data §10.4, Events §10.5. Two further families in sectionslaneSet/lane at §10.5 and collaboration/participant/messageFlow at §10.1 — have no grounding anywhere in the extract; they read as asserted from memory, which the table's own doc comment forbids for the Choreography family in the very next paragraph. The rows arrived on master with 4b179b56; FR-8 fixes them here, because the stage that re-pins the data family cannot leave the two beside it wrong.

§2 Requirements

Functional

FR-1 — <itemDefinition> imports as a typed-zero item. A definitions-level <itemDefinition> becomes a data.ItemDefinition whose structure is the Go zero value of the type its structureRef names, per SAD-001 v.1.1 §14.1's prescribed form for "declare empty, fill at runtime". itemKind maps to data.WithKind. isCollection="true" produces a collection value of the same element type.

FR-2 — A structureRef the converter cannot type imports as an empty record, and says so. A reference into an external XSD or WSDL yields an item whose structure is values.EmptyRecord() — permissive, fillable, clonable — plus one Dropped entry naming structureRef. The item is never left with a nil structure, which would import cleanly and fail at registration (§4.2).

FR-3 — <dataObject> imports into its container. It lands on the <process> or <subProcess> that holds it, named by its name (its id when unnamed), carrying the item its itemSubjectRef names.

FR-4 — <dataObjectReference> collapses to its target, implementing SAD-001 v.1.1 §14.1 rules 1, 3 and 4: a reference maps to its dataObjectRef DataObject; many references to one object collapse to that single object; the reference's own dataState is not preserved and is reported. Rule 2 — retargeting an association's sourceRef/targetRef — belongs to the association pass and lands in SRD-089.G.

FR-5 — <dataStoreReference> imports, and <dataStore> is reported as a host obligation. The reference imports carrying its dataStoreRef verbatim. The definitions-level <dataStore> declares a store the engine supplies through its registry (ADR-030 v.1 §2.5), which no import can create; it is reported so a host learns which store ids the file expects, together with capacity / isUnlimited, which ADR-030 v.1 §2.6 makes advisory.

FR-6 — <property> imports on a process, an activity and an event, the three owners BPMN allows and the model accepts.

FR-7 — <import> stops being skipped. A definitions-level <import> is collected by namespace, and an <itemDefinition> whose structureRef prefix resolves to one carries that foundation.Import. An <import> no item definition refers to is reported rather than silently dropped.

FR-8 — The sections § pins are corrected and completed. The nine data rows are re-pinned at §10.4.1 on the detail files' authority; itemDefinition (§8.4.10) and dataStore (§10.4.1) gain the rows they never had; the ungrounded laneSet/lane and Collaboration-family pins are removed rather than guessed; and the extract's own section index is corrected so the next reader does not re-derive the same error.

Non-functional

NFR-1 — No pkg/model change. Every element uses an existing constructor. If implementation finds otherwise, the capability lands first under its own document (ADR-024 §2.16).

NFR-2 — Diff-coverage ≥95% on the lines this stage adds, measured by make cover-check after each milestone is committed.

NFR-3 — No fabricated data. The converter never invents a value, a structure or a collection element the document did not describe. Where the model demands something the file did not supply, the import either carries the model's own empty form or refuses — it does not seed a plausible one.

NFR-4 — No converter-local copy of a model rule. Value-lessness, name uniqueness across a scope, and the Property/DataObject name collision (process.go:147-152) are all the model's checks; the converter passes the document through and lets those errors surface with the file's id attached.

NFR-5 — Every standard-claim in this document is pinned to a section of the vendored extract or to a file:line in the repository, and every § the code emits is one the extract supports.

§3 Models

// itemSpec is a definitions-level <itemDefinition> as read. Built into a
// data.ItemDefinition once the document's imports are complete, since a
// structureRef may name a namespace whose <import> follows it (§4.8).
type itemSpec struct {
    id            string
    structureRef  string
    kind          string
    isCollection  bool
    docs          []docSpec
}

// importSpec is a definitions-level <import>: BPMN's three attributes, in
// the shape foundation.Import already has.
type importSpec struct {
    importType, location, namespace string
}

// dataSpec is a <dataObject>, <dataObjectReference> or
// <dataStoreReference> as read. One type for the three because they differ
// only in which fields are set, and the builder table decides the rest —
// the nodeSpec/nodeBuilders shape .A established.
type dataSpec struct {
    local         string // the element name, keying the builder
    id, name      string
    container     string // "" is the process (SRD-089.E §4.1)
    itemRef       string // itemSubjectRef
    targetRef     string // dataObjectRef / dataStoreRef
    state         string // <dataState name=…>, reported not mapped (§4.7)
    docs          []docSpec
}

// propSpec is a <property>. It carries no container id: a property reaches
// its owner as a construction option, so it is buffered by that owner
// rather than placed afterwards (§4.6).
type propSpec struct {
    id, name string
    itemRef  string
    state    string
    docs     []docSpec
}

assembly gains items map[string]*data.ItemDefinition, imports []importSpec and datas []dataSpec. procBuild and nodeBody each gain props []propSpec.

§4 Analysis & decisions

§4.1 An XSD type maps to a Go zero value, and every number is a float64

structureRef names a type; the model needs a value. SAD-001 v.1.1 §14.1 already fixes the form — "the 'declare empty, fill at runtime' intent is expressed with a typed-zero value (NewVariable(0) / "")" — so the only thing left to decide is the type table:

structureRef value
xsd:string, xsd:normalizedString, xsd:token, xsd:anyURI, xsd:QName values.NewVariable("")
xsd:boolean values.NewVariable(false)
xsd:int, integer, long, short, byte, decimal, double, float values.NewVariable(float64(0))
xsd:dateTime, date, time values.NewVariable(time.Time{})
absent, or anything else values.EmptyRecord() — §4.2

time.Time is in the table because lite carries it as a first-class operand kind alongside the other three (expression/lite/eval.go:72); a type the expression tier cannot hold would not belong here however well XSD names it.

A package-level table, not a switch: it is a fixed classification whose one useful property — which types are in and which are out — should be readable in a glance, and adding a type should be a row. It is keyed by local name and maps to a pair of constructors, scalar and collection, so isCollection selects rather than branches (§4.3).

Every numeric type maps to float64, including the integers, and that is the decision worth the space. NewVariable(0) — the literal form SAD-001 offers — would produce a Variable[int], and values.checkValue is a strict type assertion (values/array.go:402-414): an int variable rejects any value that is not exactly an int. Meanwhile lite unifies every numeric kind to float64 before returning (expression/lite/eval.go:68-96, ADR-032 v.1 §2.3). So an xsd:int property imported as Variable[int] would accept no expression result at all — it would import cleanly and fail on the first assignment, which is the failure mode the whole feedback contract exists to prevent. float64 is the type the engine can actually write.

The cost is that a host reading such a property back gets a float64 where the file said xsd:int. That is visible at the API and cheap to convert, whereas the alternative is invisible until run time.

§4.2 A type the converter cannot resolve becomes an empty record, never a nil structure

structureRef="ex:PurchaseOrder" names a type in a schema the converter does not have and cannot fetch. .D met this already for <message> and <escalation> and answered it with structureLoss (catalog.go:96-99) plus emptyItem, whose own comment is the rule this stage generalizes: "An empty record is truthful: the object exists and carries no payload structure, which is exactly what a document with no resolvable structure said" (catalog.go:296-300).

So the item gets values.EmptyRecord() and one report. Three candidates were weighed:

Candidate Verdict
nil structure Rejected. NewItemDefinition accepts nil (item_options.go:28-40), so the import succeeds — and then ItemAwareElement.Clone refuses a nil value (item.go:326-331), so the process fails when a snapshot clones its data objects, far from the file and with none of its context. A converter whose output fails at registration has reported nothing useful.
refuse the document Rejected. A file whose flow graph is entirely supported would be rejected over a type annotation on one object, and .D already decided that trade the other way for messages.
empty record Taken. Non-nil, so it clones; permissive, so SetField can fill it (values/record.go:11-15); Get on an empty record returns an empty map rather than panicking (record.go:88-101).

The choice also settles what SAD-001 v.1.1 §14.1's "underspecified item-aware element" row means for the importer. That row refuses a value-less element because it "can never be filled" — an empty record can be, so it is not the element the row rejects, and the row is honoured rather than worked around. The same reasoning covers an <itemDefinition> with no structureRef at all, which BPMN permits at [0..1] (semantics/data.md:36, §8.4.10 Table 8.47): it imports as an empty record and is not reported, because nothing was dropped. The file asserted no structure; the import asserts none either.

One thing found while grounding this and fixed with it: emptyItem (catalog.go:300-307) builds its placeholder with values.NewRecord(), whose error return is unreachable for a call with no fields — which is precisely why values.EmptyRecord exists. .D predates it. M2 switches the call, dropping the unreachable branch (NewItemDefinition still returns an error, so the signature stands) and leaving one construction of the empty record rather than two.

A consequence to state plainly: an xsd:int property and an unresolvable one are now both importable but not interchangeable — the first is a float64 the engine can compare, the second a record it can only navigate. The report is what tells the two apart, which is why FR-2 makes it mandatory rather than best-effort.

§4.3 An empty collection is the truth, and the panic is the model's contract

isCollection="true" with nothing to put in it maps to values.NewArray[T]() — empty. Reading an empty Array panics (values/array.go:39-47), and errs.Panic genuinely panics the embedder's process (errs/errors.go:200-202).

Seeding one zero element would avoid that, and is rejected: it fabricates content the document did not describe (NFR-3), and a collection that reports Count() == 1 when the file declared an empty one is a lie the MI mediator would act on (ADR-030 v.1 §2.8 feeds loopDataInputRef from exactly such an object).

Empty is a reachable state of a hand-built model too — Array.Clear() produces it — so the panic is the collection's documented contract rather than something the import creates. What the import must not do is make it more likely silently, which is why FR-1's collection case is a distinct row in §6 rather than a footnote to the scalar one.

isCollection="true" over an unresolvable structureRef is reported and imports as the scalar empty record. An Array[T] needs a T, and the element type is exactly what could not be read; Array[any] would be a collection of a type the expression tier cannot evaluate, which is a shape rather than a value. So the collection flag joins the structure in the report, and the item carries what §4.2 gives it. Two losses, two entries — the report is per-construct, not per-element, because a host fixing one of them needs to know the other is still there.

§4.4 dataObjectReference collapses, because SAD-001 already said how

This is the clearest case in the stage of a rule that exists and only had to be found. SAD-001 v.1.1 §14.1 carries four numbered translation rules "for the future XML parser", written when ADR-030 v.1 §2.7 decided not to build the element. Three of them are this stage's:

SAD-001 §14.1 rule This stage
(1) a dataObjectReference maps to its dataObjectRef target DataObject implemented
(2) an association whose sourceRef/targetRef is a reference retargets to that DataObject SRD-089.G — nothing to retarget until associations import
(3) multiple references to one object all collapse to the single DataObject implemented
(4) a reference's dataState is not preserved implemented, and reported (§4.7)

Rule 2 is recorded here rather than left in the SAD alone so .G does not re-derive it.

A <dataObjectReference> whose dataObjectRef names nothing in the document is a dangling reference, and gets the refusal every dangling reference in this converter gets (SRD-089.A §FR-2's refSite), not a silently-invented object.

§4.5 A <dataStore> is a host obligation, and the report is the only honest form

BPMN's <dataStore> lives in <definitions> and is the storage itself; gobpm's store is an engine-level infrastructure port supplied by the host through the runtime environment (ADR-030 v.1 §2.5), and the model's DataStoreReference carries only a dataStoreRef string (data_store_reference.go:37, :91).

So there is nothing for the converter to construct, and three ways it could behave: refuse the element, skip it, or report it. Refusing rejects a legal file whose flow graph is fine. Skipping it silently is worse than either, because the consequence is real and deferred — the process imports, and the first task that reads the store faults because the host never registered one under that id. Reporting is what convert.Dropped is for: a construct recognized and deliberately not mapped, named in the source's own vocabulary so a host can grep the file for it (convert/document.go:11-34).

capacity and isUnlimited ride the same report. ADR-030 v.1 §2.6 makes capacity advisory in the in-memory adapter, so importing it would assert an enforcement the engine does not perform.

§4.6 A property is buffered by its owner, not placed afterwards

A <dataObject> is added to its container after the fact (Add); a <property> cannot be, because data.WithProperties is a construction option on all three owners.

For an activity or an event that is already solved: nodeChildParsers reads a node's children into nodeBody before its constructor runs (dispatch.go:503-509 says why), so <property> becomes one more child parser and one more field.

For the process it is SRD-089.E §4.3's problem again, and takes .E's answer: procBuild buffers leading <documentation> and <laneSet> and refuses either after the process has been built (importer.go:483-522). <property> joins them, with the same refusal for the same reason — the option cannot be applied to a process that already exists, and dropping it silently is what the feedback contract exists to prevent.

Amended at M5. Buffering alone was not enough: the process was constructed in pass 1, on its first flow element, and a leading property's itemSubjectRef may name an <itemDefinition> declared after the </process> — the same root-element order freedom that already defers every node's construction (nodeSpec's doc comment). So the process's construction moved to pass 2 with the nodes: procBuild buffers a procSpec (id, name, docs, laneSets, props), and constructProcess builds it first thing after buildItems, before buildNodes adds anything to it. The T-18 refusal is unchanged — properties still may not follow flow elements, because that is where BPMN serializes them — only the construction moment moved.

§4.7 <dataState> is reported, never mapped

BPMN's DataState is an open label the standard assigns no semantics (semantics/data.md:27: "State value semantics are out of scope"). gobpm replaced it with a closed pair — unavailable and ready — that the engine acts on, and SAD-001 v.1.1 §14.1 records the replacement and its reason — "the standard defines no behaviour for any state name, so an arbitrary state would be an inert passenger that looks like it governs data flow".

The engine's two state names are internal sentinels (StateUnavailable = "UNAVAILABLE_DATA", StateReady = "READY_DATA_STATE", data/state.go:11-18), so no modeler's <dataState name="Approved"/> will ever match one, and matching on them would be a coincidence rather than a mapping. Every <dataState> is therefore reported, and its element imports in the model's default state.

§4.8 <import> becomes meaningful, exactly as its skip row predicted

foundation.Import is {Type, Location, Namespace} (foundation/import.go:4-8) — a 1:1 match for <import importType location namespace> — and data.WithImport attaches one to an ItemDefinition (item_options.go:60). With item definitions landing, the chain the policy comment described is complete: collect the document's imports by namespace, resolve an <itemDefinition>'s structureRef prefix against the document's namespace declarations, and attach the matching Import.

This does not make the structure resolvable — the converter still fetches no schema, so §4.2 still applies. What it preserves is where the type came from, which is exactly what ItemDefinition.Import() exposes and what an exporter would need to write the declaration back.

An <import> that no item definition refers to is reported rather than skipped: it declares a dependency the imported definition does not carry, and that is the kind of quiet loss ADR-024 v.4 §2.14 exists to surface.

§4.9 What this stage deliberately does not do

<ioSpecification>, <dataInput>, <dataOutput>, <inputSet>, <outputSet>, <dataInputAssociation> and <dataOutputAssociation> stay refused, and their refusals stay exactly as they are: staged (ADR-024 §2.16), naming SRD-089.G.

The reason is a concrete one found while grounding this document, and it belongs on the record so .G does not rediscover it: DataObject.AssociateTarget matches a node's input by item-definition identityinputs[idx].ItemDefinition().ID() == do.ItemDefinition().ID() (data_object.go:151-160), and AssociateSource matches an output the same way (:105-108). So an association cannot be wired unless the parameter and the data object were built over the same item id. That is a reconciliation pass over two element families rather than a table, and it is where .G's analysis starts.

§4.10 The § pins are corrected here, not filed

Three families in sections carry § numbers the extract does not support, and two importable-here elements carry none at all (§1). The rule this repository already applies is in the table's own doc comment — "a section asserted from memory is worse feedback than none" — and the fix follows it: the data family is re-pinned at §10.4.1, itemDefinition and dataStore gain rows, and the laneSet/lane and Collaboration rows lose their pins rather than gain guessed ones.

Removing a pin removes feedback, which is a real cost. It is the right cost: a modeler sent to §10.5 for a misplaced <laneSet> reads about events, concludes the tool is wrong about their file, and stops trusting the rest of the message.

The extract's own index (conformance.md:186-189) is corrected in the same change, because leaving the source of the error in place guarantees the next stage re-derives it — and the correction is small, mechanical and grounded in that file's own body (line 174's "§10.5.4").

What is not fixed: the correct § for Lane/LaneSet and the Collaboration family. The vendored extract pins neither, and the BPMN notebook — the second authority in the project's grounding order — needs an interactive login this session does not have. Guessing them is precisely the defect being removed. They are re-pinned when the notebook is available, and the absence is recorded here so the gap is visible rather than forgotten.

§4.11 The document's ids are one ledger

Added at M4, when implementing the store family exposed the gap; landed as the unplanned milestone M4a, the way M2a landed .E's error paths.

The parser kept five per-kind id tables — the flow elements, the catalog objects (with item definitions and now data stores), the sequence flows, the interfaces and the operations. Three guarded against duplicates within themselves, two (flows, interfaces) against nothing, and none against each other. A probe confirmed all three cross-table directions imported silently: a <dataStore> reusing a node's id, a <dataObject> reusing an <itemDefinition>'s, a <task> reusing a <message>'s.

The consequence is not cosmetic. Every reference attribute in a document — dataObjectRef, itemSubjectRef, operationRef, attachedToRef — resolves by id, and resolution probes those tables in a fixed order; two elements sharing an id collide in no table and instead make every reference to that id silently ambiguous, the resolver finding whichever the probe order reaches first. Two sequence flows sharing an id were worse still: the second silently overwrote the first in pass 2's id→flow table.

The fix is one ledger: every element that declares an id claims it in a single parser-level map (claimID) at declaration, whatever per-kind table it lands in afterwards. The per-kind tables stay — they are lookups, not guards. The duplicate message unifies to "duplicate id %q on <%s>; <%s> already declared it" (previously "duplicate flow-element id" / "duplicate operation id"), and a duplicate flow id is now refused at its declaration instead of surfacing as a pass-2 link error.

An honesty note the grounding rules require: the vendored extract records BaseElement.id as a plain String at [0..1] (elements/foundation.md) and is silent on uniqueness. The one-ledger rule is therefore stated as an engine choice grounded in the converter's own resolution mechanics, not attributed to the standard — the same discipline §4.10 applies to the missing Lane pins.

§4a Worked example

<bpmn:definitions xmlns:bpmn="…" xmlns:ex="http://example.com/schema">
  <bpmn:import importType="http://www.w3.org/2001/XMLSchema"
               location="order.xsd" namespace="http://example.com/schema"/>
  <bpmn:itemDefinition id="idCount" structureRef="xsd:int"/>
  <bpmn:itemDefinition id="idOrder" structureRef="ex:PurchaseOrder"/>
  <bpmn:dataStore id="ordersDB" name="Orders" capacity="1000"/>
  <bpmn:process id="P" name="P">
    <bpmn:property id="p1" name="retries" itemSubjectRef="idCount"/>
    <bpmn:dataObject id="do1" name="order" itemSubjectRef="idOrder"/>
    <bpmn:dataObjectReference id="dor1" dataObjectRef="do1">
      <bpmn:dataState name="Approved"/>
    </bpmn:dataObjectReference>
    <bpmn:dataStoreReference id="dsr1" name="orders" dataStoreRef="ordersDB"
                             itemSubjectRef="idOrder"/>
    <bpmn:startEvent id="s1"/>
  </bpmn:process>
</bpmn:definitions>

yields:

  • proc.Properties() → one property, retries, whose value is a float64 zero — not an int (§4.1).
  • proc.DataObjects()one object, order. dor1 contributed nothing of its own (§4.4 rules 1 and 3).
  • order's ItemDefinition().Structure() is an empty record — not nil, which would import and then fail when the snapshot clones it — and ItemDefinition().Import() is the <import>'s three attributes (§4.2, §4.8).
  • proc.DataStoreReferences() → one reference named orders, with DataStoreRef() == "ordersDB".
  • res.Dropped → exactly three entries: idOrder's structureRef, ordersDB (the store the host must register, with its capacity), and dor1's dataState.

Three is the number to check by hand at implementation. Two means one of the three losses is silent; four means something the engine implements is being reported as lost, which trains a host to ignore the report.

§5 API deltas

None. Every element here uses an existing constructor and pkg/model is untouched — NFR-1. If implementation finds otherwise, the capability lands first under its own document (ADR-024 §2.16).

§6 Test scenarios

# Scenario Asserts
T-1 <itemDefinition structureRef="xsd:string"> structure is a "" variable
T-2 <itemDefinition structureRef="xsd:int"> structure is a float64 zero, not an int (§4.1)
T-3 each row of the XSD table the mapped Go zero, one sub-test per row
T-4 <itemDefinition isCollection="true"> IsCollection(); Count() == 0; nothing seeded (§4.3)
T-5 <itemDefinition structureRef="ex:Foo"> structure is an empty record, not nil; one Dropped naming structureRef
T-5a a <dataObject> over that item, cloned Clone() succeeds — the nil-structure trap (§4.2) stays closed
T-5b <itemDefinition> with no structureRef empty record; nothing reported (§4.2)
T-5c isCollection="true" over an unresolvable ref two Dropped entries; the item is the scalar empty record (§4.3)
T-6 <property> over an unresolvable item imports; its value is a fillable empty record
T-7 <itemDefinition itemKind="Physical"> Kind() is PhysicalKind
T-8 <dataObject> on a process in DataObjects(), named by name
T-9 <dataObject> with no name named by its id, as .A's fallbackName does for nodes
T-10 <dataObject> inside a <subProcess> lands on the sub-process, not the process (SRD-089.E §4.1)
T-11 two <dataObjectReference>s to one object one DataObject; both references contribute nothing (§4.4)
T-12 <dataObjectReference> with a dangling dataObjectRef refused with the converter's reference message
T-13 <dataState> on any item-aware element reported; the element imports in the default state (§4.7)
T-14 <dataStoreReference dataStoreRef="S"> imports; DataStoreRef() == "S"
T-15 <dataStore> at definitions level reported as a host obligation, with capacity (§4.5)
T-16 <property> on a process in Properties()
T-17 <property> on a task and on an event in each owner's Properties()
T-18 <property> after a process's flow elements refused, as <laneSet> is (§4.6)
T-19 a data object whose name collides with a property the model's duplicate message (process.go:147-152)
T-19a <dataObject name="Order.v2"> the model's reserved-character message, with the file's element id attached (data/name.go:23-32)
T-20 <import> + an <itemDefinition> referring to it ItemDefinition().Import() carries all three attributes
T-21 <import> nothing refers to reported (§4.8)
T-22 duplicate id between a data object and a node still refused (the .A id table is one table)
T-23 dialect attribute on a <dataObject> reported — the .D funnel covers it
T-24 a refused data-flow element (<ioSpecification> …) still refused, the reason naming .G and not saying "yet"
T-25 sections after FR-8 every data row is §10.4.1; itemDefinition is §8.4.10; no laneSet or Collaboration pin remains
T-25a <dataStore> written inside a <process> refused with its § — the row an importable element keeps for the contexts that do not claim it (SRD-089.E)
T-26 end-to-end run of a process carrying a data object and a property the engine executes it
T-27 a cross-table duplicate id, one case per direction (store/node, object/item, task/message, node/flow, flow/flow, interface/interface, operation/interface) refused with the ledger's one message (§4.11)
T-28 a root element declared after the <process> reusing one of its ids still refused — BPMN orders root elements freely (§4.11)

§7 Milestones

# Scope
M1 The § pins: sections and the extract's index (FR-8)
M2 <itemDefinition> and <import>: the item catalog, the XSD table, the import binding (FR-1, FR-2, FR-7)
M3 <dataObject> and <dataObjectReference> on both containers (FR-3, FR-4)
M4 <dataStore> and <dataStoreReference> (FR-5)
M5 <property> on all three owners (FR-6)
M6 <dataState> reporting and the refusal-wording sweep for the .G family (FR-4 rule 4, §4.9)

§8 Cross-doc

Outgoing references are SAD-001 v.1.1, ADR-024 v.4, ADR-030 v.1, ADR-011 v.7, ADR-010 v.2, ADR-032 v.1 and ADR-024 §2.16, plus the sibling SRD-089.A/.D/.E — all upward or sideways. This document pins the versions it was written against, per the convention that an SRD records what its author read.

FR-8 edits docs/bpmn-spec/conformance.md, which is a vendored extract rather than a design document: the change is a correction of a transcription error in its section index, grounded in the same file's body, and it carries no new claim.

SRD-089.G will consume §4.4's rule 2 and §4.9's item-identity finding. Both are recorded here rather than left to be rediscovered, and neither is a commitment this document can make on .G's behalf.

§9 Definition of Done

  • FR-1…FR-8 wired and covered by §6.
  • make ci PASS on the committed branch, judged by .ci/last-run.json.
  • Diff-coverage ≥95% (NFR-2), measured after each milestone is committed.
  • No pkg/model change (NFR-1).
  • No fabricated value, structure or collection element (NFR-3).
  • Every § the code emits is one the extract supports (NFR-5).
  • §10 filled at landing.

§10 Implementation summary

Six planned milestones and two unplanned ones, in the order they landed.

M Commit What landed
M1 97ee0179 the § pins: data family at §10.4.1, the extract's index corrected, the ungrounded Lane and Collaboration pins removed (FR-8)
M2 8fb92337 <itemDefinition> and <import>: the XSD table, the float64 decision, the namespace binding (FR-1, FR-2, FR-7)
M2a 8acc7d25 (unplanned) thirteen error paths closed, six unreachable guards said as errs.Invariant
M3 349d7529 + 6be508a9 <dataObject> / <dataObjectReference>: the collapse, the per-element item copy (FR-3, FR-4) — code and tests split across a machine handoff
M4+M4a 225ce3a6 <dataStore> as a host obligation, <dataStoreReference> verbatim (FR-5) — and the one id ledger (§4.11)
M5 1cc5f9fd <property> on all three owners (FR-6), and the process moving to pass-2 construction (§4.6 amended)
M6 65b62557 the staged refusals naming SRD-089.G, and T-13's last kind (§4.9)

NFR-1 held. pkg/model is untouched across the branch — every element used a constructor §1's table had already located. The one API delta landed in pkg/convert instead, and is additive: UnsupportedElementError.Planned, the field a staged refusal carries its plan in.

NFR-2 held. Diff-coverage after the last milestone: 98.5% of 1916 changed lines (min 95). dataobject.go's only uncovered blocks are its three errs.Invariant guards; datastore.go and property.go are at 100%.

Three things the plan did not anticipate, each fixed where it was found:

  1. The document's ids were five ledgers, none watching the others (M4a, §4.11). Implementing <dataStore> — a definitions-level element the flow elements can reference — exposed that a cross-table duplicate id imported silently in every direction, that two sequence flows sharing an id overwrote each other in pass 2, and that interfaces had no guard at all. One parser-level ledger (claimID) now takes every declaration, the process's own id included (M5 caught that last one).
  2. A leading property can name a trailing item (M5, §4.6 amended). The process was built in pass 1, on its first flow element — but BPMN's root-element order freedom means the <itemDefinition> a process property names may follow the </process>. The same freedom that already deferred every node's construction now defers the process's: procBuild buffers a procSpec, and pass 2 builds the process right after the items.
  3. .D built the empty record the hard way (M2, §4.2). emptyItem used values.NewRecord() and carried its unreachable error branch; values.EmptyRecord exists precisely to be total. The call switched, one construction of the empty record remains.

§4a's three-losses check held: the worked example runs as a test and reports exactly idOrder/structureRef, ordersDB/dataStore and dor1/dataState — none silent, none over-reported.

What §4.10 left open stays open: the Lane/LaneSet and Collaboration § pins await the BPMN notebook login; the rows keep no invented numbers.

Open questions

None.