Problem
Derived properties throw MultiplicityViolationException / IncompleteModelException on multiplicity
violations. This is the wrong default for an SDK that reads models produced elsewhere: an element that is
incomplete or over-populated is a normal intermediate state, and one such element should not take out an
entire read or write.
Measured on the current tree:
- 11 throw sites, 35
SingleStrict / SingleOrDefaultStrict call sites, 0 catches anywhere in the
solution.
SysML2.NET.Serializer.TextualNotation/Writers/ catches NotSupportedException 12x, and the
multiplicity exceptions 0x.
- The message is
$"{subjectName} contains more than one element of type {typeof(T).Name}", where
subjectName is nameof(viewpointUsageSubject) — a parameter name, not the element. No Id, no
qualified name.
A single malformed element therefore aborts a whole textual-notation write with an unhandled exception
that cannot locate the offender, and produces no partial output.
Coverage gap
Only single-valued properties are checked at all. Distribution across SysML2.NET/Core/AutoGenPoco/:
| Multiplicity |
Declarations |
Checked today |
0..* |
7854 |
n/a — unconstrained |
0..1 |
2881 |
upper bound, via SingleOrDefaultStrict |
1..1 |
2638 |
both bounds, via SingleStrict |
1..* |
11 |
no |
0..2 |
5 |
no |
1..2 |
2 |
no |
The 18 unchecked declarations are 5 distinct properties (the rest are redeclarations in subtypes):
1..* — AnnotatingElement::annotatedElement, Dependency::client, Dependency::supplier
0..2 — Flow::flowEnd
1..2 — MultiplicityRange::bound
An empty 1..* or a third flowEnd is currently accepted in silence. Related:
SysML2.NET.Semantics/Implied/Rules/FeatureFlowFeatureRedefinitionRule.cs already returns nothing for a
FlowEnd beyond position 2 — correct degradation, but the violation goes unreported.
Proposed change
1. Derivations become total. [0..1] with 2+ → first; [1..1] with 0 → null; bounded collections
return what they have. Makes bulk traversal robust.
2. Violations are reported, not swallowed. Lenient must not mean silent — returning the first of two
values on a [0..1], or a 3-element 0..2, is data loss on round-trip. Introduce a collector service:
public interface IModelViolationCollector
{
void Report(ModelViolation violation);
}
ModelViolation carries the offending IElement (id + qualified name), the property name, the declared
bounds and the actual count.
3. Reaching the collector from POCO code. Derived properties are C# properties and take no
parameters, so a collector cannot be passed through the call. POCOs must not gain a dependency either —
they are generated data objects created by deserializers, ElementFactory and plain new Type(), so a
required service would poison every construction path and a hand-built POCO would silently get null.
DI therefore owns the instance and an ambient scope owns the reach; they meet at a boundary object,
following the existing IImpliedRelationshipProvider pattern in
SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationWriterContext.cs:
// composition root
services.AddTransient<IModelViolationCollector, CollectingModelViolationCollector>();
// boundary object — resolves from DI, opens the scope
public TextualNotationWriterContext(INamespace contextNamespace, /* … */ IModelViolationCollector collector = null)
{
this.violationScope = ModelViolationScope.Begin(collector ?? NullModelViolationCollector.Instance);
// …
}
// static extension, deep in the derivation — reads ambient, never DI
ModelViolationScope.Current.Report(new ModelViolation(subject, nameof(flowEnd), 0, 2, actual));
A Null Object default keeps the no-collector path free of null checks. Boundary objects that would open a
scope: the writer context, the DAL assembler, the REST session.
Constraints, which differ from SysML2.NET/Extensions/InheritanceScope.cs — the two scopes are the same
shape but are NOT copy-pasteable:
ModelViolationScope must use AsyncLocal<T>, not [ThreadStatic]. InheritanceScope can be
thread-static only because the builder pipeline is fully synchronous; a violation scope wraps model
reads that span QueryXmiDataAsync and must flow across awaits.
IModelViolationCollector implementations must be thread-safe: AsyncLocal flows the same instance
into parallel continuations, so Report can be called concurrently.
- Lifetime is transient or per-operation, never singleton — a collector accumulating across an
application's life grows unbounded and bleeds violations between unrelated reads.
4. Strictness becomes policy. Default lenient; opt-in strict for validation and CI, where the
collector throws on first violation. SingleStrict / SingleOrDefaultStrict keep their call sites and
consult the policy instead of hardcoding throw.
5. Collection bounds checks are emitted by the generator, driven by lowerValue / upperValue in the
XMI rather than by reflection over [Property] — consistent with the repo's statically-generated
approach, and it stays correct if the metamodel changes. Storage-backed properties are checked by a
validation pass over a loaded model rather than on every read.
Scope
SysML2.NET/Extensions/ElementExtensions.cs — the four *Strict overloads
- New:
IModelViolationCollector, ModelViolation, ModelViolationScope, a collecting and a throwing
implementation, and a Null Object
SysML2.NET.CodeGenerator — emit collection bounds checks
- DI registration following
SysML2.NET.Semantics/Extensions/ServiceCollectionExtensions.cs
- Pass the subject element instead of
nameof(...) — a defect regardless of which policy wins
Acceptance criteria
Notes
Contradicts the SingleStrict multiplicity table in CLAUDE.md, which must be updated in the same
change. Behaviour change for callers relying on the throw — currently theoretical, since nothing
in-solution catches them.
Problem
Derived properties throw
MultiplicityViolationException/IncompleteModelExceptionon multiplicityviolations. This is the wrong default for an SDK that reads models produced elsewhere: an element that is
incomplete or over-populated is a normal intermediate state, and one such element should not take out an
entire read or write.
Measured on the current tree:
SingleStrict/SingleOrDefaultStrictcall sites, 0 catches anywhere in thesolution.
SysML2.NET.Serializer.TextualNotation/Writers/catchesNotSupportedException12x, and themultiplicity exceptions 0x.
$"{subjectName} contains more than one element of type {typeof(T).Name}", wheresubjectNameisnameof(viewpointUsageSubject)— a parameter name, not the element. NoId, noqualified name.
A single malformed element therefore aborts a whole textual-notation write with an unhandled exception
that cannot locate the offender, and produces no partial output.
Coverage gap
Only single-valued properties are checked at all. Distribution across
SysML2.NET/Core/AutoGenPoco/:0..*0..1SingleOrDefaultStrict1..1SingleStrict1..*0..21..2The 18 unchecked declarations are 5 distinct properties (the rest are redeclarations in subtypes):
1..*—AnnotatingElement::annotatedElement,Dependency::client,Dependency::supplier0..2—Flow::flowEnd1..2—MultiplicityRange::boundAn empty
1..*or a thirdflowEndis currently accepted in silence. Related:SysML2.NET.Semantics/Implied/Rules/FeatureFlowFeatureRedefinitionRule.csalready returns nothing for aFlowEnd beyond position 2 — correct degradation, but the violation goes unreported.
Proposed change
1. Derivations become total.
[0..1]with 2+ → first;[1..1]with 0 →null; bounded collectionsreturn what they have. Makes bulk traversal robust.
2. Violations are reported, not swallowed. Lenient must not mean silent — returning the first of two
values on a
[0..1], or a 3-element0..2, is data loss on round-trip. Introduce a collector service:ModelViolationcarries the offendingIElement(id + qualified name), the property name, the declaredbounds and the actual count.
3. Reaching the collector from POCO code. Derived properties are C# properties and take no
parameters, so a collector cannot be passed through the call. POCOs must not gain a dependency either —
they are generated data objects created by deserializers,
ElementFactoryand plainnew Type(), so arequired service would poison every construction path and a hand-built POCO would silently get
null.DI therefore owns the instance and an ambient scope owns the reach; they meet at a boundary object,
following the existing
IImpliedRelationshipProviderpattern inSysML2.NET.Serializer.TextualNotation/Writers/TextualNotationWriterContext.cs:A Null Object default keeps the no-collector path free of null checks. Boundary objects that would open a
scope: the writer context, the DAL assembler, the REST session.
Constraints, which differ from
SysML2.NET/Extensions/InheritanceScope.cs— the two scopes are the sameshape but are NOT copy-pasteable:
ModelViolationScopemust useAsyncLocal<T>, not[ThreadStatic].InheritanceScopecan bethread-static only because the builder pipeline is fully synchronous; a violation scope wraps model
reads that span
QueryXmiDataAsyncand must flow across awaits.IModelViolationCollectorimplementations must be thread-safe:AsyncLocalflows the same instanceinto parallel continuations, so
Reportcan be called concurrently.application's life grows unbounded and bleeds violations between unrelated reads.
4. Strictness becomes policy. Default lenient; opt-in strict for validation and CI, where the
collector throws on first violation.
SingleStrict/SingleOrDefaultStrictkeep their call sites andconsult the policy instead of hardcoding
throw.5. Collection bounds checks are emitted by the generator, driven by
lowerValue/upperValuein theXMI rather than by reflection over
[Property]— consistent with the repo's statically-generatedapproach, and it stays correct if the metamodel changes. Storage-backed properties are checked by a
validation pass over a loaded model rather than on every read.
Scope
SysML2.NET/Extensions/ElementExtensions.cs— the four*StrictoverloadsIModelViolationCollector,ModelViolation,ModelViolationScope, a collecting and a throwingimplementation, and a Null Object
SysML2.NET.CodeGenerator— emit collection bounds checksSysML2.NET.Semantics/Extensions/ServiceCollectionExtensions.csnameof(...)— a defect regardless of which policy winsAcceptance criteria
otherwise unchanged
Notes
Contradicts the
SingleStrictmultiplicity table inCLAUDE.md, which must be updated in the samechange. Behaviour change for callers relying on the throw — currently theoretical, since nothing
in-solution catches them.