Skip to main content
Wildo.ai Coming soon

Systems of record

Send records created in the application to the ERP

Records people create or change in the application can be sent to the company's ERP on a schedule. Each change becomes one write, is never sent twice, never deletes anything in the ERP, and the record keeps the identifier the ERP gave it.

Application services and price updates reach the ERP catalogue on the next pass, while a locally deleted service remains in the ERP.

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

RuleWhy
writableVerbs must include CREATE; UPDATE is optionala row gets its remote key only from the create that sends it
DELETE is refuseda local delete is never sent
writeConcurrency must be LAST_WRITER_WINSno caller read the remote version; detecting a change on both sides is what a two-way pipeline does
only REMOTE_FIELD and REMOTE_REFERENCE entriesa concatenation, a code map or a constant has no inverse to send
sourceFilter, incremental, archivedRecords, partitionKeyLocalField refusedthey describe reading a source, and an outbound pipeline reads none
required and coercion on an entry refusedthey apply to values loaded from a source
subjectRights.posture may not be FORWARDED_TO_SOURCE or OWNED_COPYthe 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:

RouteUse
GET /outbound-changes?resourceType=…list the changes of unknown outcome
POST /outbound-changes/resolveanswer one: answer: 'applied' with the remoteKey found for a create, or answer: 'not-applied'; a reason is required
POST /outbound-changes/unlinkdetach 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.

Building a B2B product or an internal tool?

Wildo is not self-service yet. Tell us what you have in mind and we will say plainly whether it fits, and what happens next.