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 flow — ioSpecification, 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 ItemDefinition —
data.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 sections — laneSet/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 identity —
inputs[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 afloat64zero — not anint(§4.1).proc.DataObjects()→ one object,order.dor1contributed nothing of its own (§4.4 rules 1 and 3).order'sItemDefinition().Structure()is an empty record — notnil, which would import and then fail when the snapshot clones it — andItemDefinition().Import()is the<import>'s three attributes (§4.2, §4.8).proc.DataStoreReferences()→ one reference namedorders, withDataStoreRef() == "ordersDB".res.Dropped→ exactly three entries:idOrder'sstructureRef,ordersDB(the store the host must register, with itscapacity), anddor1'sdataState.
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 ciPASS on the committed branch, judged by.ci/last-run.json.- Diff-coverage ≥95% (NFR-2), measured after each milestone is committed.
- No
pkg/modelchange (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:
- 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). - 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:procBuildbuffers aprocSpec, and pass 2 builds the process right after the items. .Dbuilt the empty record the hard way (M2, §4.2).emptyItemusedvalues.NewRecord()and carried its unreachable error branch;values.EmptyRecordexists 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.