Skip to content

Phase-3 groundwork: IAssertionContainer, a structural schema traversal contract - #685

Open
ewoutkramer wants to merge 2 commits into
developfrom
feature/assertion-container-draft
Open

Phase-3 groundwork: IAssertionContainer, a structural schema traversal contract#685
ewoutkramer wants to merge 2 commits into
developfrom
feature/assertion-container-draft

Conversation

@ewoutkramer

Copy link
Copy Markdown
Member

Phase-3 groundwork (OSS side). Replaces the schema rewriter's hardcoded switch over container types with an internal structural contract, so the enterprise rewriter in phase 3 can walk and rewrite a compiled schema without knowing the concrete assertion types.

Note this is a plan amendment: the approved plan had phase 3 as enterprise-only. Doing this bit in OSS is what lets the enterprise rewriter stop hardcoding OSS internals.

What's in it

Schema/IAssertionContainer.cs, all internal:

  • IAssertionContainer with a single member, IAssertion WithChildren(Func<AssertionStep, IAssertion, IAssertion> rewrite). A delegate rather than a member list, so keyed children (children, slices, subschemas, target cases) keep their structure instead of being flattened into a positional sequence. Implementations return this when every rewrite returned what it was given, so unchanged subtrees keep identity.
  • AssertionStep — a readonly record struct (AssertionStepKind Kind, string? Name) labelling the edge from a container to a nested assertion, with kinds Member / Child / Slice / Subschema / ReferenceTarget. The kinds enumerate what the slot does to the walk context (does the instance position move? does the definition path gain a qualifier?), not what kind of container it is — which is why the set is closed: a consumer has to know how to react to every kind. ToString() renders steps the way PathStack renders its navigation events, so paths built while rewriting read like the definition paths reported on issues.
  • Implemented explicitly on 9 types: ElementSchema (via WithMembers, so its subclasses come along for free), ChildrenValidator, SliceValidator, AllValidator, AnyValidator, DefinitionsAssertion, ReferencedInstanceValidator, KeyedObjectValidator, PathSelectorValidator.
  • Deliberately not children: slice discriminators and AnyValidator.SummaryError — structural machinery rather than validation members, and rewriting them would be meaningless.
  • Guard test (AssertionContainerTests, 17 tests): reflection over [DataMember] assertion-typed state, so any future assertion that holds nested assertions and forgets to implement the interface fails the build. Keyed on DataMember specifically so IssueAssertion.Assertion (an internal back-pointer, not a child) isn't flagged.

Design notes

  • No PublicAPI entries needed — everything here is internal, and adding an internal interface to a public class's base list doesn't touch the analyzer's surface.
  • Slice covers the pseudo-slicing a choice element compiles into (fhir-type-label discriminators). That's deliberate and it's the same criterion phase 2 applied when it removed the reference-target pseudo-slicing: a slice is real when the discriminator interrogates the node at the slot's own position. Choice types pass that test (and FHIR itself defines type slicing with a type discriminator); reference targets failed it, which is why they got their own kind. The Slice doc comment records the one place the analogy leaks — slice names come from two vocabularies, authored slice names and FHIR type codes, so a consumer must not assume the name matches the :sliceName in ElementDefinition.id.

Known follow-up for phase 3

ReferencedInstanceValidator's single-schema (untyped) constructor can't set Checks — only the target-cases constructor takes it. Reference rules on untyped RIVs will need that; this draft works around it with a private copy constructor used only for rewriting.

Verification

Core project builds clean (0 warnings); AssertionContainerTests 17/17 green. No consumer yet — the rewriter that uses this lands in phase 3, so this PR adds contract + guard only and changes no validation behaviour.

🤖 Generated with Claude Code

ewoutkramer and others added 2 commits August 19, 2026 17:42
Adds an internal contract for assertions that hold nested assertions, so a
schema tree can be walked and rewritten without a hardcoded type switch:

* IAssertionContainer.WithChildren(rewrite) - one member, so a container
  cannot have a child set that disagrees with what it rewrites; returns the
  container itself when every child came back unchanged, so unchanged
  subtrees keep their identity.
* AssertionStep - the label on the edge to a child (member / child element /
  slice / subschema / reference target), rendered like the PathStack events
  so rewriter-side paths read like the definition paths on issues.
* Implemented on ElementSchema (through WithMembers, so schema subclasses
  come along), ChildrenValidator, SliceValidator, AllValidator,
  AnyValidator, DefinitionsAssertion, ReferencedInstanceValidator,
  KeyedObjectValidator and PathSelectorValidator. Slice discriminators and
  AnyValidator's summary error are not children: they are not assertions the
  container validates against.
* AssertionContainerTests guards the set: any assertion with [DataMember]
  state holding assertions must implement the interface, so a future
  container breaks the build instead of being silently skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an internal schema traversal and rewriting contract for future enterprise rewriters.

Changes:

  • Introduces assertion-container traversal and edge metadata.
  • Implements immutable child rewriting across assertion containers.
  • Adds reflection guards and behavioral tests.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/Firely.Fhir.Validation.Tests/Schema/AssertionContainerTests.cs Tests traversal and copy behavior.
src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs Defines traversal contract and steps.
src/Firely.Fhir.Validation/Impl/SliceValidator.cs Rewrites slice assertions.
src/Firely.Fhir.Validation/Impl/ReferencedInstanceValidator.cs Rewrites reference targets.
src/Firely.Fhir.Validation/Impl/PathSelectorValidator.cs Rewrites selected assertions.
src/Firely.Fhir.Validation/Impl/KeyedObjectValidator.cs Rewrites entry assertions.
src/Firely.Fhir.Validation/Impl/ElementSchema.cs Rewrites schema members.
src/Firely.Fhir.Validation/Impl/DefinitionsAssertion.cs Rewrites anchored subschemas.
src/Firely.Fhir.Validation/Impl/ChildrenValidator.cs Rewrites named children.
src/Firely.Fhir.Validation/Impl/AnyValidator.cs Rewrites alternative members.
src/Firely.Fhir.Validation/Impl/AllValidator.cs Rewrites required members.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +83 to +84
var members = Members.TryRewriteMembers(AssertionStep.Member, rewrite);
return members is null ? this : WithMembers(members);
Comment on lines +80 to +82
if (rewritten is not ElementSchema rewrittenSchema)
throw new InvalidOperationException(
$"A rewrite of subschema '{schema.Id}' must return an {nameof(ElementSchema)}, but it returned a {rewritten.GetType().Name}.");
if (!ReferenceEquals(rewritten, child))
{
// Only start copying once we actually have a change to record.
updated ??= new Dictionary<string, IAssertion>(_childList);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants