Skip to main content

Activation: browsing the catalog to a running intake

This page describes activation: how a customer goes from browsing the catalog to a running intake in their own tenant.

The honest constraint that shapes everything below:

A bundle carries structure. It cannot carry values.

The source layout, the field mapping, the transform, the target service or entity - all of that is the same in every tenant, and all of it ships. The SFTP endpoint, the credential, the journal name, the offset account, the bank account, the legal entity - none of that is the same in any two tenants, and none of it can ship. Baking those in is precisely what would stop an accelerator being an accelerator.

So activation is not "install the bundle". Activation is resolving the bundle's declared blanks against this tenant, in a defined order, with a check that nothing was missed before anyone touches an environment.


1. Where the design comes from​

vendor-invoice-intake already modeled this, in miniature:

"batching": {
"defaults": { "journalNameId": "APInvoice" },
"provisioningRequired": ["offsetAccount", "journalNameId"]
}

That is the right idea - the bundle names what it refuses to ship - expressed as bare strings inside the batching block. It has three limits:

  1. It only reaches document-level values. It cannot describe an endpoint, a credential, or a target-side security grant, because those are not batching concerns.
  2. A bare string carries no type, no prompt, no default, no "this one is a secret".
  3. Nothing enforces it. "offsetAccount" could be misspelled, or contradict the transform, and no tool would notice.

activation.parameters[] is that same idea, promoted and typed. batching.provisioningRequired is retained and still works - and the validator requires that every entry in it has a matching activation.parameters[].key, so the older field cannot drift away from the newer one.

2. What a bundle declares​

"activation": {
"summary": "One line: what provisioning this bundle involves.",
"parameters": [
{
"key": "bankOffsetAccount",
"label": "Bank offset account",
"scope": "document",
"appliesTo": "request.Lines[].BankAccountId",
"dataType": "string",
"required": true,
"secret": false,
"default": null,
"prompt": "The bank account the cash lands in. Environment-specific, deliberately not shipped.",
"example": "BANK-USD-01"
}
],
"verify": "The smoke test that proves activation worked."
}
FieldWhy it exists
keycamelCase, unique per bundle. Also the substitution token in the transform (§4).
labelWhat a prompt or form field calls it.
scopeWhich provisioning step consumes it - see §3.
appliesToThe concrete field, contract member, or metadata element the value is written to. This is what makes the plan actionable rather than a wish list.
dataTypestring | number | boolean | date | secretRef
requiredWhether provisioning can complete without it.
secretCredential material: never logged, never written into a bundle artifact, resolved from Key Vault at run time.
defaultA suggested, overridable value. Absent means the bundle has no defensible default - which is itself information.
promptWhat to ask, and why the bundle cannot answer it. The "why" is the part that stops a customer assuming it is a bug.
allowedValuesA closed set, where one exists.

The one rule that is enforced hard​

A parameter that is secret: true (or dataType: secretRef) must not ship a default.

The validator fails the bundle otherwise. A default credential in a published catalog artifact is the single worst failure mode this format could have, so it is a structural error rather than a convention.

3. The four scopes​

Scope is not decoration - it is the ordering key for the plan. Each scope is one provisioning step, and the steps have to happen in this order.

ScopeLands onExample
connectionA field on MGCLatticeFileIngestConnectionEndpoint, FolderPath, SftpHostKeyFingerprint, FixedWidthColumnSpec, AllowedServiceOperations
secretThe connection's encrypted secret or a Key Vault reference - never the bundlethe SFTP credential
targetTarget-side wiring or grantsentity SubscriberAccessLevel, the OData client identity, dataAreaId
documentBusiness defaults flowing into the request or record bodyjournalNameId, bankOffsetAccount, legalEntityId, requestIdSourceField

4. The transform placeholder convention​

A document-scoped value usually has to appear inside transform.liquid - the journal name and the offset account are part of the request body, not part of the connection record. So the transform marks those slots with a token:

"JournalNameId": "__journalNameId__",
"BankAccountId": "__bankOffsetAccount__",
"MethodOfPayment": "__methodOfPayment__",

Token format is __<parameterKey>__. It is deliberately not Liquid syntax: a Liquid variable would silently render empty if nobody supplied it, whereas an unsubstituted __bankOffsetAccount__ fails loudly at the service boundary.

One parameter is substituted into a Liquid expression instead: requestIdSourceField names the source field that holds a record's stable external id, so its slot is "requestKey": "{{ __requestIdSourceField__ }}" and substituting orderReference yields {{ orderReference }}. The service-invoke target derives the request's RequestId from that key (§5). Left unsubstituted, the slot renders empty and RequestId falls back to one derived from the ingested file and record, which still makes a retried record replay but not an order sent again in another file.

This is machine-checked in both directions:

  • Every __token__ in the transform must have a matching activation.parameters[].key - otherwise the bundle has a blank nobody is ever asked to fill. Validator error.
  • The catalog tool reports, per parameter, whether it has a transform slot, so plan can print the exact substitution list (STEP 4).

A placeholder written in a comment counts as a placeholder too.

5. The activation flow​

browse catalog/catalog.html (or registry.json)
| filter by vertical / target / format; each card now shows
| "activation: N value(s), M required"
v
resolve bundlecatalog resolve <id>
| validates, prints identity + loader artifacts + activation summary
v
plan bundlecatalog plan <id>
| ordered provisioning steps, grouped by scope, with the concrete
| connection fields and the exact placeholder substitutions
v
emit bundlecatalog plan <id> --emit-params values.json
| a fill-in-the-blanks file, pre-seeded with shipped defaults,
| plus every prompt printed to the console
v
[ customer fills values.json - the only manual step ]
|
v
check bundlecatalog plan <id> --params values.json
| missing required values, undeclared keys, and literal secrets that
| should be Key Vault references. Exit 1 until complete.
v
provision create the connection, store the secret, wire the target
| (customService: list the operation in Allowed service operations),
| substitute the transform, confirm document defaults
v
verify replay the shipped sample payload; activation.verify says what
success looks like for this specific bundle

Every step before provision is offline and side-effect free. That is intentional: the point of --params is that a customer discovers they are missing a bank account before they start creating connections, not halfway through.

One provisioning requirement of a customService bundle is not declared as a parameter, and activation fails without it:

  • Allowed service operations. MGCLatticeFileIngestServiceInvokeTarget calls only the operations listed in the connection's Allowed service operations field, and an empty list allows none: every record is then rejected with "The routing envelope names …, which is not in the connection's allowed service operations. Nothing was invoked." Enter the bundle's target as Service.operation - the value plan prints as AllowedServiceOperations in STEP 3 (for example MGCLatticeVendorInvoiceJournalService.postInvoiceRegisterJournal).

The common request members the Lattice posting services validate need no provisioning beyond the legalEntityId and requestIdSourceField parameters. The service-invoke target fills each of them from the run context when the request leaves it empty, and a value the template writes always wins:

  • RequestId - a name-based GUID of the connection, the legal entity, the operation and the envelope's requestKey, so a record sent again (in any file) replays the first result instead of posting twice. Without a requestKey, it is derived from the ingested file and record, so a retried or replayed record replays. Authoring, section 4b gives the exact scheme.
  • LegalEntityId - the company the processing batch job runs in. The shipped bundles write it from legalEntityId, so it is explicit.
  • UserContext - the user the processing batch job runs as.
  • CorrelationId - shared by all records of one file.

The File ingestion simulator, and the sample payload Replay that runs through it, never post. Its last step previews the call: the request always gets a new random RequestId, whatever the template or the requestKey say, so a simulation never shares an idempotency key with the scheduled run. When the operation supports preview mode it is called in preview mode; otherwise only the request is built and checked.

6. What the CLI does​

tools/catalog (bundlecatalog) provides the plan command:

bundlecatalog plan <bundleId> [--root <path>]
[--emit-params <file>]
[--params <file>]
  • plan <id> - the ordered plan. Steps 1-6, each listing the parameters in that scope with their appliesTo target and any shipped default. Step 1 says what the record path means for the file format. Step 3 branches on target kind: a customService bundle gets the adapter wiring, including the Allowed service operations value the connection needs; an odataEntity bundle gets the collection plus the warning that the pipeline has no OData dispatcher (§8).
  • plan <id> --emit-params <file> - writes the parameter template, pre-seeded with defaults, and prints every prompt.
  • plan <id> --params <file> - completeness check. Exits 1 while any required value is blank. Warns on keys the bundle does not declare, and on a secret value that looks like a literal rather than a Key Vault reference.

resolve also reports the activation summary and points at plan. registry.json and each catalog card carry activation { declared, parameterCount, requiredCount, secretCount }, so a customer can see the provisioning cost of a bundle before choosing it.

7. What was deliberately not built​

  • No UI. The design does not need one: activation.parameters is a typed contract that a form, a wizard, or a deployment pipeline can all render. The CLI is a reference consumer, not the intended end-user surface.
  • No writes into F&O. plan never creates a connection, stores a secret, or posts anything. It reads bundle artifacts and a local JSON file. Anything that mutates an environment is left to a human or a deployment pipeline, because the tooling has no authenticated channel into an AOS and should not grow one casually.
  • No secret handling. The tool checks that a secret looks like a Key Vault reference. It never reads, resolves, stores, or transmits secret material.
  • No transform rewriting. plan prints the substitutions; it does not write a substituted template back to disk. Emitting a filled-in transform containing a tenant's bank account would create exactly the artifact this whole design exists to avoid.

8. Current limitations​

Activation runs within these limits of the Lattice model:

  1. No provisioning page inside F&O. No D365 page reads a bundle, renders activation.parameters as a prompt list and creates the MGCLatticeFileIngestConnection; provisioning is done by hand or by a deployment pipeline from the plan output.
  2. No OData dispatcher in the ingestion pipeline. MGCLatticeFileIngestDispatcherFactory::construct always returns MGCLatticeFileIngestCustomServiceDispatcher, and the only implementation of MGCLatticeIFileIngestServiceTarget is MGCLatticeFileIngestServiceInvokeTarget, which invokes Lattice operations. So the odataEntity target style has no executable delivery path today. See the opportunity intake.
  3. No subscriber write access on intake-target entities. No AxDataEntityView in the Lattice model grants subscriber Create or Update: none of the 103 writable entities grants a subscriber more than Read. Every odataEntity bundle therefore carries a metadata-change prerequisite that a customer cannot perform at deploy time.
  4. No record aggregation in the ingestion pipeline. See §9 - batching.strategy currently describes an intent the pipeline cannot honor.
  5. Per-bundle gaps recorded in each manifest's productDecisions: payment-journal settlement chaining, natural-key party resolution.

9. The granularity caveat every activation inherits​

Worth stating here because it changes what a customer is actually provisioning.

The pipeline splits a file into one segment per record and renders the Liquid template once per segment:

  • MGCLatticeFileIngestPull calls parser.parseRecords(content) and writes one MGCLatticeFileIngestSegment per returned record.
  • MGCLatticeFileIngestSegmentService renders connection.LiquidTemplate against that single segment's RawContent.
  • MGCLatticeFileIngestServiceInvokeTarget parses exactly one { service, operation, request } envelope per rendered segment.

So one source record becomes one target document, and the Fluid model binds that record's members at the top level ({{ customer_account }}) - there is no records collection in scope.

There is no aggregation step anywhere in the pipeline: every record becomes its own segment and its own service call. A batching.strategy of onePerFile therefore describes an intention rather than current behavior, and a bundle should be authored so that one record already is one document. See Authoring, section 4c.

10. Validation rules added​

Enforced by tools/validator/BundleValidator.cs, so bundlevalidator and bundlecatalog agree:

RuleSeverity
activation.parameters present and an array, when activation is declarederror
Each parameter has key, label, scope, dataType, boolean requirederror
key is camelCase and unique within the bundleerror
scope ∈ connection | secret | target | documenterror
dataType ∈ string | number | boolean | date | secretReferror
A secret / secretRef parameter ships no defaulterror
Every batching.provisioningRequired entry has a matching parameter keyerror
Every __token__ in the transform has a matching parameter keyerror