
Keep records in step when both sides edit them
Some records are edited in two places: the ERP team maintains them in the ERP, and people using the application change them too. A two-way pipeline keeps one copy in the application and moves changes in both directions, field by field, according to who owns each field.
A field owned by the ERP is loaded and read-only in the application. A field owned by the application is sent and never loaded. A field owned by both is loaded and sent. Only a shared field can conflict, so the common case, ERP master data with a few fields of the application’s own, never meets conflict handling at all.
A change in the ERP is recognized by the ERP’s own record version, such as Odoo’s last-updated stamp or an OData entity tag, compared with the version both sides last agreed on, together with a fingerprint of the shared values. When both sides changed a shared field, the pipeline applies the declared rule: the ERP wins, the application wins, or, by default, neither is overwritten and the record waits for a person to choose. Every resolved conflict is audited, with the fields overwritten and whether the declared rule or a person decided it.
The decision is made per field when both sides say which fields they changed: a field only the ERP changed loads, a field only the application changed is sent, and only a field both changed to different values is a conflict. A local edit made while a page of ERP records is being loaded is never overwritten: the load is conditioned on the local version it read, and a record edited in between is set aside and read again. After the application writes to the ERP, it reads the record back and checks, field by field, that it holds what was sent before treating the two sides as agreeing again.
Example — A price changed in two places
Sales updates a service’s list price in the application while finance changes the same price in the ERP. On the next run the pipeline sees both changes. Under the default rule it overwrites neither, lists the service as a conflict on every run summary, and keeps loading the product name, which only the ERP owns. An operator chooses the finance price; the next run loads it, and the choice is recorded.
For engineers
No application declares a two-way pipeline yet, so the following is a design example, checked against the declaration schema. The product name belongs to Odoo, the list price to both sides, and a sales note to the application. ExternalDataFieldOwner and the other pipeline enums come from @wildo-ai/saas-models/external-data; ExternalDataConflictPolicy from @wildo-ai/saas-models.
externalDataPipeline: {
direction: ExternalDataPipelineDirection.BIDIRECTIONAL,
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.UPDATE],
writeConcurrency: { mode: HttpApiWriteConcurrencyMode.LAST_WRITER_WINS },
},
remoteKeyLocalField: 'odooProductRef',
partitionUnitLocalField: 'organizationUnitId',
partitionKeyLocalField: 'odooCompanyRef',
mapping: [
{ kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'name', localField: 'name' },
{ kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'list_price', localField: 'listPrice', owner: ExternalDataFieldOwner.BOTH },
{ kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'description_sale', localField: 'salesNote', owner: ExternalDataFieldOwner.LOCAL },
],
conflictPolicy: ExternalDataConflictPolicy.REFUSE_AND_REPORT,
scheduleCron: '*/10 * * * *',
},
owner is accepted only on a two-way pipeline and defaults to REMOTE. The binding must open UPDATE; opening CREATE as well sends records born in the application, and then population: OPEN is required. writeConcurrency is LAST_WRITER_WINS because the pipeline checks every update against the version it last agreed with the ERP, not against one a caller read; a change on both sides is the conflict policy’s to decide. Declared expressions may only be REMOTE-owned, since they have no inverse to send. The pipeline reads and loads like an inbound one, so it takes sourceFilter, incremental and both partition fields as an inbound pipeline does.
How a run decides each record
A run loads first and sends second, under one lease, sharing the run’s deadline so that a long load still leaves the sending half time.
| Changed in the application | Changed in the ERP | This run |
|---|---|---|
| no | either | loads the ERP’s values and records the version as agreed |
| yes | no | keeps the local values; the sending half sends them |
| yes | yes | a conflict, decided by the policy or by an operator’s choice for that record |
Four cases stand outside the table. A record served with no version cannot be compared, so the local values stand and the record is counted as unversioned. A new ERP record that could be the result of a create whose outcome is unknown is held rather than loaded, to avoid a duplicate. A record whose own send is in flight decides nothing. And when a write of the application’s landed without its version being agreed, a conflict on that record goes to an operator even under REMOTE_WINS, because loading could overwrite a newer local edit with the application’s own older values.
Conflict policies and the operator door
conflictPolicy | What happens to the shared fields |
|---|---|
REMOTE_WINS | the ERP’s values load; the local change to them is discarded; audited |
LOCAL_WINS | the local values stand and are sent over the ERP’s; audited |
REFUSE_AND_REPORT (default) | nothing is overwritten; the record is listed on every run summary until an operator chooses |
The operator door lists refused conflicts with GET /internal/admin/external-data/outbound-changes/conflicts?resourceType=… and answers one with POST /internal/admin/external-data/outbound-changes/conflicts/resolve, naming the record (localResourceId), the winner ('remote' or 'local') and a required reason. The choice is applied by the next run and audited as decided by an operator. Fields owned only by the ERP keep loading whatever the policy, because nothing local writes them.
After the application writes
The version read after a write and the write itself are two calls. The new version is adopted as agreed only if the record’s shared fields are exactly as the write left them; anything else is treated as a change made in the ERP in that instant, and the next run decides it. A value the ERP stores differently from what was sent, or a side effect of the write on another shared field, also withholds agreement. That costs a reload or an operator’s decision, the direction that never loses an edit made in the ERP.