Authoring & Publishing Intake Accelerators
This is the end-to-end guide for the MGCLattice Intake Accelerators. It documents the publishing model - a content catalog / registry - and how to author a bundle, wire its target, regenerate the registry, and provision a bundle as a consumer.
Publishing model. Intake accelerators are published as a content catalog / registry. Bundles are not shipped inside the X++ model and are not a monolithic content pack. Each bundle is a folder of design-time artifacts under
bundles/; a generator scans them and emits a machine-readablecatalog/registry.jsonindex plus a browsablecatalog/catalog.html. Consumers discover a bundle through the registry and provision it against their own environment.
1. What an intake accelerator is
A packaged, end-to-end file-intake scenario built entirely on surfaces the Lattice model already has:
- File Ingestion (
MGCLatticeFileIngestConnection+ the ingestion pipeline) as the source, - an input format + sample payload (CSV / JSON / XML / fixed-width),
- a field mapping to the target, and
- a target - either a Lattice service operation (
AxServiceGroup/AxService) or a writableLattice*OData entity.
Nothing in a bundle is deployed X++. A bundle describes how to configure existing capabilities.
2. Bundle folder layout
bundles/<bundle-id>/
manifest.json # versioned bundle manifest (schema/bundle.schema.json)
sample.<ext> # representative sample payload (csv|json|xml|txt)
mapping.json # source-field -> target-field map (schema/mapping.schema.json)
transform.liquid # illustrative Liquid transform (recommended)
README.md # what it does, verified target, known gaps
<bundle-id> is stable kebab-case and unique within the catalog (it is also the folder name and
the resolve key).
3. The manifest & mapping schemas
schema/bundle.schema.json(formatVersion1.0) - the manifest. Required top-level:formatVersion, bundleId, name, version, vertical, source, samplePayload, target, mapping.vertical∈P2P | O2C | Logistics | GL | Other.sourcemirrors realMGCLatticeFileIngestConnectionfields:storageType(Blob | FileShare | Sftp),fileFormat(Xml | Json | Csv | FixedWidth),filePattern, optionalfolderPath,recordPath(JSON path, or the XML element name),csv{},fixedWidthColumnSpec[]. Secrets/auth are intentionally excluded - supplied at deployment.targetis aoneOf:customService(serviceClass+operation, plusserviceGroup,requestContract,lineContract,dispatchNote) orodataEntity(publicEntityName+publicCollectionName, plusentityName,writeNote).productDecisions[]records open questions that blockreleasedstatus.
schema/mapping.schema.json-targetKind,targetRef, optionalheader[](envelope-level, customService only), andlines[]. Each field map has atargetand one ofsource/const/transform.
Do not invent names. Every target in mapping.json must be a real contract DataMember or
entity field; every source must be a field present in the sample payload. Verify against the AOT:
- Lattice services:
Lattice/AxServiceGroup/*+Lattice/AxService/*for operation names, and the request/line…Contractclasses for DataMember names. - OData entities:
Lattice/AxDataEntityView/MGCLattice*Entity.xml→PublicEntityName/PublicCollectionNameand the mapped field names.
4. The two target styles
4a. odataEntity
Transformed records are inserted into the entity's OData collection. transform.liquid emits a
JSON array of entity records (see bundles/opportunity-intake/transform.liquid). The ingestion
pipeline has no OData dispatcher, so an external client has to write the records (Activation: current limitations). Watch
SubscriberAccessLevel - a Read-only grant must be widened deliberately before the entity can be a
write/create target. A company-specific entity (PrimaryCompanyContext = DataAreaId) needs
dataAreaId in every record, written from an activation parameter: a write without it lands in the
OData client's default company. Entity writes carry no RequestId; the entity key identifies the
record.
4b. customService - the routing envelope
Lattice operations take typed contracts, but the File Ingestion custom-service dispatcher
(MGCLatticeFileIngestCustomServiceDispatcher) only invokes a class implementing
MGCLatticeIFileIngestServiceTarget, passing the transformed content as a single str to
handleIngestedContent(str). The bridge is the generic ingest-target adapter
MGCLatticeFileIngestServiceInvokeTarget.
Wiring for a customService bundle:
-
On the connection, set
TargetServiceClass = MGCLatticeFileIngestServiceInvokeTargetandTargetServiceOperation = handleIngestedContent, and enter the target operation in Allowed service operations as<ServiceClass>.<operationName>(or<ServiceClass>.*). The adapter refuses any operation that is not listed, and an empty list allows none. -
Author
transform.liquidto emit a routing envelope rather than a bare request body:{"service": "<ServiceClass>","operation": "<operationName>","requestKey": "<stable external id of the record, e.g. an order number>","request": { "...": "the request-contract shape" }}requestKeyis optional and never reaches the operation. Write the posting company intorequest.LegalEntityIdfrom a__legalEntityId__activation placeholder, and the source field that holds the stable id intorequestKey("requestKey": "{{ __requestIdSourceField__ }}", with arequestIdSourceFieldactivation parameter naming the default field).Hard rules for the rendered envelope (the adapter refuses the record otherwise):
- It must be strict JSON (RFC 8259): double-quoted strings and member names only, no comments,
no trailing commas, no number with a leading zero, no hexadecimal, no NaN/Infinity. The adapter
checks every envelope with
FileIngestionService.ValidateStrictJsonbefore reading it. - No object may hold two members whose names differ only in case (
LegalEntityIdandlegalentityidtogether are rejected). - The root holds only
service,operation,requestKeyandrequest, written exactly so (lower case).requestKeyis a string or an integer. - Put every number through the
numberfilter:"Qty": {{ l.quantity | number }}, never a bare{{ x }}in a number position. It writes a JSON number (zero-padded text such as00010gives10,0012.50gives12.50),nullfor a blank value, and fails the record for anything that is not a decimal number. Where a blank value has a meaning, say it first:{{ l.unit_price | default: "0" | number }}. - Put every other value in double quotes:
"ItemId": "{{ l.item_number }}". Output is escaped for a JSON string (",\,'and control characters), so a value can never end its string. - Do not write
RequestIdorCorrelationIdyourself unless the value is a GUID; a value that is not a GUID is rejected. A stable external id belongs inrequestKey.
- It must be strict JSON (RFC 8259): double-quoted strings and member names only, no comments,
no trailing commas, no number with a leading zero, no hexadecimal, no NaN/Infinity. The adapter
checks every envelope with
-
At runtime the adapter parses the envelope, checks the operation against Allowed service operations, and completes
requestwith the run context the dispatcher passes in (the connection, the ingested file and the record). It fills each of these members only when the request leaves it absent or empty - a value the template writes always wins:Member Filled with RequestIdA name-based GUID of the connection, the legal entity, the operation and the requestKey(scheme below). The same external id sent again through the same connection and operation into the same legal entity gets the sameRequestId, and the Lattice posting services replay the first result instead of posting twice. Without arequestKeyit is derived from the connection, the ingested file and the record, so a retried or replayed record replays.LegalEntityIdThe company the processing batch job runs in. UserContextThe user the processing batch job runs as. CorrelationIdA name-based GUID of the connection and the ingested file, shared by all records of the file. The name-based GUID is the SHA-256 hash of a UTF-8 name, whose first 16 bytes are written as a version 8 GUID (the 13th hex digit set to
8, the 17th to8,9,aorb). The names arerequestKey|<connection>|<legal entity>|<service>.<operation>|<requestKey>,segment|<connection>|<bundle id>|<segment id>andcorrelation|<connection>|<bundle id>, with the connection, legal entity, service, operation and bundle id trimmed and lower-cased and the key trimmed. The legal entity is the request'sLegalEntityIdwhen the template writes one, otherwise the company the processing batch job runs in; the segment id is the segment's record id. So theRequestIdof a record can be computed outside D365, for example to look the request up.It then deserializes
requestinto the operation's request contract and invokes it in-process viaMGCLatticeServiceTestRunner::run. A non-success result is thrown as an X++ error so the dispatcher classifies and dead-letters the segment. Run the service-metadata refresh once so the operation is discoverable.
See bundles/vendor-invoice-intake/ for a worked example (targets
MGCLatticeVendorInvoiceJournalService.postInvoiceRegisterJournal).
4c. Record granularity and Liquid variable binding - read this before writing a transform
The pipeline processes one record at a time, not one file at a time:
| Step | Where | Behavior |
|---|---|---|
| split | MGCLatticeFileIngestPull | parser.parseRecords(content) → one MGCLatticeFileIngestSegment per record |
| transform | MGCLatticeFileIngestSegmentService | renders connection.LiquidTemplate against one segment's RawContent |
| dispatch | MGCLatticeFileIngestServiceInvokeTarget | parses exactly one { service, operation, requestKey, request } envelope |
Three consequences:
- One source record becomes one target document. Author the source shape so that granularity
is what you want - e.g.
recordPathpointing at the repeating element that corresponds to one document. There is no aggregation step: every record becomes its own segment and its own service call. Abatching.strategyofonePerFiletherefore records an intent, not current behavior. - Liquid binds the record's members at the top level. The transformer (Fluid, see the
Liquid transformer guide) exposes the members of the parsed
record object directly, so a CSV column is
{{ vendor_account }}. There is norecordscollection in scope; a non-object record (a bare array or scalar) is exposed asdata. - The transform reads JSON. On the scheduled pull, every format reaches the transform as one
JSON object per record: a JSON record as it is, a CSV row as an object of its columns, an XML
record converted with
FileIngestionService.ConvertXmlToJson, and a fixed-width line as an object of the connection'sFixedWidthColumnSpeccolumns (ConvertFixedWidthToJson). For XML:- the conversion drops the record element itself, so its children and its attributes are the top-level members, and every value is a string;
- names are local names: namespace prefixes and declarations are dropped (
<o:Number>isNumber); - an attribute is a property named like the attribute (
<Order id="1">givesid); only when the element also has a child element of that name does the attribute keep an@prefix (@id), which a template cannot address directly - rename one of them at the source; - CDATA is plain text;
- an element with text and attributes becomes its text, and each attribute a sibling property
Element_attribute:<Amount currency="USD">12.50</Amount>givesAmount=12.50andAmount_currency=USD; - a repeated element becomes an array only when it occurs more than once - a lone child
becomes an object. Always loop through the
as_arrayfilter:{% assign lines = Lines.Line | as_array %}{% for l in lines %}..., which gives[]for a missing element, the array itself, or a one-element array.
What a blank recordPath means depends on the format: for JSON the document root is the
record collection (an array gives one record per element, an object is one record); for XML each
child element of the document root is one record. Csv and FixedWidth ignore recordPath.
Every bundle in bundles/ is written against this binding and documents it inline.
5. Add a bundle, then regenerate the registry
-
Pick the vertical and a verified real target (per §3).
-
mkdir bundles/<bundle-id>/and writemanifest.jsonagainstschema/bundle.schema.json. -
Add
sample.<ext>matchingsource.fileFormat. -
Write
mapping.jsonagainstschema/mapping.schema.json- every target is a real member/field, every source is present in the sample. -
Write
transform.liquid(routing envelope forcustomService, with arequestKeynaming the record's stable external id; entity-record array forodataEntity, withdataAreaIdfor a company-specific entity). Mind the record granularity and variable binding in §4c. -
Declare
activation.parameters- every tenant-specific value the bundle refuses to ship (endpoint, credential, journal name, offset account, legal entity, the request key's source field). Mark document-level slots in the transform with__parameterKey__tokens. See Activation. -
Write
README.md; record open questions inproductDecisions. -
Validate the bundle:
cd tools/validatordotnet run -c Release -- ../../bundles/<bundle-id> -
Regenerate the registry (indexes every bundle, re-validating each):
cd tools/catalogdotnet run -c Release -- catalogThis rewrites
catalog/registry.jsonandcatalog/catalog.html. Keep both with the bundle so the published catalog stays in sync.
6. The catalog / registry (publishing surface)
tools/catalog (net8, in-box System.Text.Json only, standalone .sln) is the publisher. It
reuses the validator's logic (tools/validator/BundleValidator.cs is linked into both projects, so
"valid" means exactly one thing).
dotnet run -- catalog- scansbundles/*/manifest.json, validates each, writes:catalog/registry.json- machine-readable index. Each bundle:{ id, title, vertical, version, formatVersion, summary, tags, path, source{ storageType, fileFormat }, target{ kind + concrete service/operation or entity }, validationStatus }.catalog/catalog.html- a self-contained (inline CSS/JS, no CDNs) searchable browse page: filter by vertical / target-kind / file-format, full-text search, validation-status highlight, and per-bundle links to the folder and README.
dotnet run -- resolve <bundleId>- the consumer/loader path: locates the bundle, validates it, and prints its identity, source/target, the loader artifacts (manifest.json,mapping.json,transform.liquid,sample.*,README.md), and an activation summary. Exit0only when the bundle is valid.--root <path>overrides root discovery (defaults to the folder containingbundles/andschema/).dotnet run -- plan <bundleId>- the activation path: the ordered provisioning steps and every tenant-supplied value the bundle declares.--emit-params <file>writes a fill-in-the-blanks parameter file;--params <file>checks a filled-in one for completeness and exits1while anything required is missing. Full design in Activation.
7. Consuming a bundle (provisioning)
A consumer browses catalog/catalog.html (or reads catalog/registry.json), picks a bundle, and:
resolve <bundleId>to confirm it is valid and get the artifact paths, thenplan <bundleId>for the ordered provisioning steps and the list of tenant values to collect (--emit-params/--params; see Activation).- Create a
MGCLatticeFileIngestConnectionfrommanifest.source:StorageType,FileFormat, container/share, folder path,filePattern, and CSV / fixed-width options straight from the manifest;- secrets (auth JSON / Key Vault references) supplied now - they are deliberately absent from the bundle;
- set the connection's Liquid template from
transform.liquid.
- Configure the target on the connection:
odataEntity→ point the pipeline atpublicCollectionName; ensure write access.customService→TargetServiceClass = MGCLatticeFileIngestServiceInvokeTarget,TargetServiceOperation = handleIngestedContent, and the targetService.operationin Allowed service operations (§4b); the transform already emits the routing envelope.
- Supply every value in
activation.parameters- and, for older bundles, every value inbatching.provisioningRequired(e.g. the offset account). These are environment-specific and are not shipped (see §7a / §7b). Substitute any__parameterKey__tokens in the transform before saving it to the connection. - Drop a file matching
filePattern(the shippedsample.<ext>is a ready smoke test) and verify the run againstactivation.verify.
7a. Batching & shippable defaults (the batching block)
Bundles that post documents (journals, orders) declare a batching block. Batching is
configurable, with strategy = onePerFile as the shipped default - all parsed
rows collapse into one document via the request's Lines[] collection. Options:
onePerGroup+groupByField(e.g.vendor_account) - one document per group, for mixed-entity files.onePerNLines+bundleSize- cap document size for very large files.
Shippable defaults vs per-tenant config: batching.defaults are suggested and overridable
(e.g. journalNameId: APInvoice, confirmed at provisioning). Values listed in
batching.provisioningRequired (e.g. the offset account) are environment-specific and must
be supplied at provisioning - deliberately not baked into the bundle, which keeps the accelerator
portable across tenants.
Caveat, per §4c: the pipeline has no aggregation step, so
strategyrecords an intent. Today one record always becomes one document.onePerFileacross many records is not achievable without X++ work.
7b. Activation (the activation block)
batching.provisioningRequired generalizes into activation.parameters[] - the same idea
(the bundle names what it refuses to ship) made typed, first-class, and machine-checked, and
extended beyond document defaults to connection fields, secrets, and target-side grants. Both are
supported; the validator requires every batching.provisioningRequired entry to have a matching
activation.parameters[].key so the two cannot drift.
Design, parameter anatomy, the four scopes, the __parameterKey__ transform convention, the
plan CLI flow, and the validation rules: Activation.
8. Status and current limitations
- Available: the publishing model is a catalog/registry; the
customServiceloop is executable viaMGCLatticeFileIngestServiceInvokeTarget; vendor-invoice batching is configurable with aonePerFiledefault + a tenant-provisioned offset account; O2C order capture has a dedicatedMGCLatticeSalesOrderCaptureService.createSalesOrderservice, andsales-order-intaketargets it. - Verticals: besides
vendor-invoice-intakeandsales-order-intake, the catalog hasasn-product-receipt-intake(Logistics, the 3PL/ASN scenario),customer-payment-intake(O2C lockbox cash application), andopportunity-intake(O2C, and the catalog's onlyodataEntityexemplar). Activation is declared viaactivation.parameters+ theplancommand - see Activation. - Not automated: the catalog is regenerated on demand; there is no scheduled or incremental catalog refresh.
- Known limitations of the Lattice model: no OData dispatcher in the ingestion pipeline, so the
odataEntitystyle has no executable delivery path; no data entity in the model grants subscriberCreate/Update; no record-aggregation step, sobatching.strategycannot be honored; no provisioning page inside F&O to drive activation. Detail in Activation: current limitations, plus per-bundleproductDecisions.