
Send records created in the application to the ERP
A pipeline can run in the other direction. Instead of copying ERP records into the application, it sends the application’s own records to the ERP: a record created here is created there, and a later edit becomes an update of that same ERP record. The application owns the mapped fields, people edit them in the application, and the ERP’s identifier is written onto the local record by the engine once the ERP answers the create.
A change is noted in the same transaction as the local edit, so nothing changed is forgotten. On its schedule, the pipeline sends one write per changed record, oldest first. Only records of the organization that owns the application are sent, and on an ERP split by company, only records whose unit is assigned to one of its companies.
The send uses the same safeguards as a person’s direct write: an idempotency key per change, placement in the right company, and an audit trail that names the member whose change was sent. A local deletion is never sent. Removing a record from the ERP is that system’s operators’ decision, not the side effect of a local edit.
When a write leaves and its answer is lost, the change is not retried, because a create sent twice could make the record twice. It waits for an operator who has looked in the ERP: they answer that it landed, giving the identifier of the record they found, or that it did not, and only then is it settled or sent again.
Example — Publish new services to the ERP catalogue
A team defines the services it sells in the application. Each new service appears in the company’s Odoo product catalogue on the next pass, in the subsidiary its unit belongs to, and a later price change in the application updates that same Odoo product. Deleting a service in the application leaves the Odoo product in place.
For engineers
No application declares an outbound pipeline yet, so the following is a design example, checked against the declaration schema and its startup rules. It sends a destination’s name and listPrice to Odoo’s product model. ExternalDataPipelineDirection, ExternalDataMappingEntryKind, HttpApiWriteVerb, HttpApiWriteConcurrencyMode and HttpApiKeyComponentCodec come from @wildo-ai/saas-models/external-data.
externalDataPipeline: {
direction: ExternalDataPipelineDirection.OUTBOUND,
binding: {
dialect: HttpApiTransportDialect.ODOO_JSONRPC,
system: 'odoo',
entityRef: 'product.template',
keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE],
writeConcurrency: { mode: HttpApiWriteConcurrencyMode.LAST_WRITER_WINS },
},
remoteKeyLocalField: 'odooProductRef',
partitionUnitLocalField: 'organizationUnitId',
mapping: [
{ kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'name', localField: 'name' },
{ kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'list_price', localField: 'listPrice' },
],
scheduleCron: '*/15 * * * *',
},
Each mapping entry now names the local field that is sent and the remote field it lands on. The mapped fields are the application’s, so callers write them. remoteKeyLocalField is empty until the first create is answered; it must be optional, unique, and excluded from create and update, so no caller can point a local row at an arbitrary ERP record. partitionUnitLocalField is required on an ERP split by company: a created record lands in the company assigned to the row’s unit, and that placement never moves afterwards.
What the declaration may and may not say
| Rule | Why |
|---|---|
writableVerbs must include CREATE; UPDATE is optional | a row gets its remote key only from the create that sends it |
DELETE is refused | a local delete is never sent |
writeConcurrency must be LAST_WRITER_WINS | no caller read the remote version; detecting a change on both sides is what a two-way pipeline does |
only REMOTE_FIELD and REMOTE_REFERENCE entries | a concatenation, a code map or a constant has no inverse to send |
sourceFilter, incremental, archivedRecords, partitionKeyLocalField refused | they describe reading a source, and an outbound pipeline reads none |
required and coercion on an entry refused | they apply to values loaded from a source |
subjectRights.posture may not be FORWARDED_TO_SOURCE or OWNED_COPY | the rows are the application’s own, and the ERP answers requests about the records it received |
At startup the engine also refuses a dialect that cannot write (OData v2), a vendor whose writes need a handshake it does not perform (SAP S/4HANA), a mapping that sends the instance’s partition field, and, on a company-split instance, updates over a dialect that cannot read one record’s partition (REST JSON).
What a pass sends
A pass runs under the pipeline’s lease, so it is the only sender for that destination. For each due change it reads the row as it stands now, drops the change when the row is gone, hidden by an erasure, or belongs to another organization, and sends a create when the row has no remote key or an update when it has one. A row whose reference points at a record not yet in the ERP waits for a later pass, or is sent after its parent in the same pass; its create then carries the parent’s ERP identifier. A change that cannot be placed in a company is deferred, reported, and retried after a back-off.
The Idempotency-Key is built from the change, its cycle, its generation and its attempt. A newer local edit is a new request; a retry after a definite refusal is a new request; a retry after an unknown outcome keeps its key, so the ledger answers it and nothing is sent twice. Once the ERP answers success, the write is treated as applied whatever happens afterwards. If recording the remote key fails, the next pass settles the change from the row or the ledger and never turns it back into a pending create.
A change is recorded by every service-tier create and update of the destination, including a custom operation that borrows one of them. The pipeline’s own bookkeeping goes through the repository tier and records nothing, which is why a pass never echoes its own write.
Answer an unknown outcome
The operator routes live under /internal/admin/external-data and require the application’s admin secret:
| Route | Use |
|---|---|
GET /outbound-changes?resourceType=… | list the changes of unknown outcome |
POST /outbound-changes/resolve | answer one: answer: 'applied' with the remoteKey found for a create, or answer: 'not-applied'; a reason is required |
POST /outbound-changes/unlink | detach a row from a remote record it holds wrongly, naming both |
An applied answer writes the remote key to the row, so later edits reach that record as updates. A not-applied answer sends the change again on the next pass, under a new key. The door refuses, with a stable code, an answer that would link a second row to the same ERP record, re-point a row that already holds another key, or unlink a row whose own send is still in flight. Every answer is audited.