Skip to content

[Feature]: Tolerate multiplicity violations in derived properties and collect them via a model-violation service #340

Description

@antoineatstariongroup

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..2Flow::flowEnd
  • 1..2MultiplicityRange::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

  • A model violating any bounded multiplicity serializes to textual notation without throwing; output
    otherwise unchanged
  • Each violation is reported once, identifying the element by id and qualified name
  • The 5 properties listed above report both under- and over-population
  • Strict mode reproduces today's throwing behaviour
  • Existing suites stay green

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions