Deterministic UBL Base emit (records + interfaces, StripEmbedded)
Policies that keep the org coherent
name: UBL Base strip emit overview: Add a deterministic Roslyn emit profile that generates InvoiceBase / CreditNoteBase / ReminderBase as records + interfaces (plus a shared billing spine interface), rewriting BinaryObject embeddings to metadata-only refs so large attachments cannot inflate heap. todos:
- id: codegen-base-profile
content: StripEmbeddedBaseProfile + BinaryObjectRef + IBillingDocumentBase; EmitOptions; unit tests ≥85% status: completed
- id: xsd-host-ubl-base
content: RoslynXsdCodeGenerator.GenerateBase + XsdGen ubl-base → Ubl.Lean/Generated status: completed
- id: mappers-tests
content: Wire↔Base mappers for Invoice/CreditNote/Reminder; no-byte[] + heap tests; retire hand LeanModels status: completed
- id: publish-docs
content: Publish CodeGen.Xsd + Ubl.Lean; README/canvas pointers; nuget-only check status: completed isProject: false
Derived from ubl-type-hierarchies and current Wire/Lean stack. Extends `Novolis.CodeGen.Xsd` and dogfoods via `novolis-xsd`.
Locked decisions
- Shape: positional/init records + matching `I{Name}Base` interfaces (not XmlSerializer Wire classes).
- Names:
InvoiceBase/CreditNoteBase/ReminderBase(and line/CAC analogues as*Base), plus shared spineIBillingDocumentBase. - Package: commit generated sources under `Novolis.Xsd.Ubl.Lean` in
Generated/(replace hand-thinInvoiceLeansurface over time; keep mappers as Wire↔Base). - StripEmbedded: BinaryFacet /
byte[]Value omitted; keep metadata attributes (mimeCode,filename,uri,format,encodingCode,characterSetCode) on a generatedBinaryObjectRefrecord.ExternalReferencestays as-is (already non-byte). - Scope v1: document closures of Invoice, CreditNote, Reminder only (
SchemaGraphScope+ three roots). - Determinism: SchemaGraph ordered type ids; stable property order; one file per type; no timestamps in output.
flowchart TB
xsd[UBL XSD] --> graph[SchemaGraph]
graph --> scope[Filter Invoice CreditNote Reminder closure]
scope --> profile[StripEmbeddedBaseProfile]
profile --> binRef[BinaryObjectRef metadata only]
profile --> spine[IBillingDocumentBase]
profile --> docs["InvoiceBase / CreditNoteBase / ReminderBase"]
profile --> ifaces["IInvoiceBase / ..."]
docs --> leanPkg[Novolis.Xsd.Ubl.Lean Generated]
wire[Wire InvoiceType] --> mapper[ToBase StripEmbedded]
mapper --> docsCodeGen (`novolis-codegen`)
New emit profile
Add `StripEmbeddedBaseProfile` implementing IEmitProfile:
- For each included complex type
FooType→ emit: public interface IFooBase { ... }public sealed record FooBase(...) : IFooBase;(orFooBasewithoutTypesuffix for document roots: `InvoiceBase` fromInvoiceType)- Naming rule (deterministic):
- Document roots (
IsDocumentRoot): strip trailingType→InvoiceBase, interfaceIInvoiceBase - Other complex:
{CSharpName}withType→Base(InvoiceLineType→InvoiceLineBase) - Collections:
IReadOnlyList<TBase>(notCollection<>) - Simple CBC wrappers with non-binary
Value: collapse to CLR scalar or keep thin*BasewithValueonly — collapse to CLR for identifier/amount/code/date (smaller graphs; matches current Lean intent). Keep nested CAC as Base records. - Binary rewrite (core OOM guard):
- When particle/attr type has
BinaryFacet != Noneor resolves tobyte[]: emit property typeBinaryObjectRef?instead of nested binary type /byte[] - Do not emit
EmbeddedDocumentBinaryObjectType/ UDTBinaryObjectTypeas full types - Emit single shared:
public sealed record BinaryObjectRef(
string? MimeCode,
string? Filename,
string? Uri,
string? Format,
string? EncodingCode,
string? CharacterSetCode);- Skip emitting any type whose only content would be
byte[] Valueafter strip (no orphan empty types).
Shared billing spine
After emitting the three document Bases, emit `IBillingDocumentBase` listing the exact triple-intersection property set (35 props from hierarchy analysis), using Base/scalar types. Each of IInvoiceBase, ICreditNoteBase, IReminderBase extends IBillingDocumentBase. Document-specific props remain only on the concrete iface/record.
Role analogues stay document-specific (InvoiceTypeCode vs CreditNoteTypeCode vs ReminderTypeCode) — not forced onto the spine.
EmitOptions additions
Extend `EmitOptions`:
StripEmbeddedPolicy=MetadataOnly(default for this profile)BillingSpineInterfaceName="IBillingDocumentBase"- Reuse
IncludeTypeIds/ root namespace / one-file-per-type
Tests (`Novolis.CodeGen.Unit/Xsd/`)
| Test | Assert |
|---|---|
| BaseEmitDeterministic | Two emits → identical formatted text |
| BaseHasInterfaces | Syntax contains `InterfaceDeclaration` + `RecordDeclaration` |
| NoByteArrays | Emitted text / compiled types have no `byte[]` |
| BinaryObjectBecomesRef | Attachment-like fixture → `BinaryObjectRef` props, metadata names present |
| BillingSpineOnDocs | `IInvoiceBase` base list includes `IBillingDocumentBase` |
| InvoiceCreditNoteReminderScope | Fixture or tiny multi-root graph emits three `*Base` roots |
Coverage: keep CodeGen.Xsd line ≥ 85%.
novolis-xsd host
Generator API
Extend `XsdGenerationOptions` + thin RoslynXsdCodeGenerator.GenerateBase(...):
Profile = Base(StripEmbedded)ScopeLocalNames = { Invoice, CreditNote, Reminder }- Output:
src/Novolis.Xsd.Ubl.Lean/Generated
Tool
ubl-base # regenerate Lean/Generated Bases (roslyn StripEmbedded)Wire existing ubl-lean to call the same path (or alias).
Mappers (hand + tests)
Replace stub mappers in `Mappers.cs`:
InvoiceBaseMapper.ToBase(InvoiceType)/FromBase(FromBase does not restore bytes)- Same for CreditNote + Reminder
- Strip path:
DocumentReference.Attachment.EmbeddedDocumentBinaryObject→BinaryObjectReffrom attrs;Valueignored - Also strip
Signature.DigitalSignatureAttachmentembeddings in closure
Retire or thin-wrap old InvoiceLean names as aliases to InvoiceBase if needed for one release, then delete duplicates in LeanModels.cs.
Validation tests (`Novolis.Xsd.Unit`)
- Reflection: no
byte[]anywhere underInvoiceBasegraph - OASIS Invoice →
ToBasesucceeds; attachment-heavy synthetic Wire with 2MBValue→ Base allocated footprint ≪ Wire (reuse/strengthen heap test) - ReminderBase round-trip of scalar spine fields
- Regenerated
Generated/is committed; CI can optionally runubl-baseand fail on dirty tree
Determinism checklist
- Graph build order already sorted in `SchemaGraphBuilder`
- Property emit order: attributes alpha, then particle DFS (document); same as Wire
SyntaxEmitWriter.Formatonly (no wall-clock)- Unit test golden: hash of concatenated relative path + source
Publish / hygiene
- Bump/publish
Novolis.CodeGen.XsdthenNovolis.Xsd.Ubl.Lean(+ Generator/Tool) to GPR (2026.1.*) verify-nuget-only; regen Platform map if new packables (none expected)- Update Lean README + hierarchy canvas “Proposed bases” to point at generated
*Base
Out of scope
- Changing Wire
Generated/XmlSerializer models (bytes remain on Wire for fidelity) - All ~65 maindocs as Base
- Schematron / Peppol rules
- Restoring binary on
FromBase