Skip to main content
Wildo.ai Coming soon

Systems of record

Change ERP records from your application

People using the application can create, change and delete records that live in the company's ERP or CRM, one record at a time. Each change is sent once, stays inside the person's company, is recorded, and is never sent twice when its result is unclear.

An application price correction reaches the ERP through a version check, while an attempt against a changed version is refused.

Change ERP records from your application

A record type that the application reads live from the company’s ERP or CRM can also be written live. The application declares which of create, change and delete it opens, and each request then becomes one change in that system. Nothing is copied locally: the record still lives where it lived before, and the next read shows it as the ERP now holds it.

Every write addresses one record. A request carries an idempotency key, so a retried request replays the outcome of the first one instead of writing a second time. When the request has left and its answer is lost, nobody knows whether it landed. The application then says the outcome is unknown and refuses to send that request again, so a person can look in the ERP before anything else happens.

Writes stay inside the company that owns the data. Only the organization that owns the application writes. A member whose access is limited to one subsidiary can create records only in that subsidiary and change only that subsidiary’s records. A record outside their reach is answered as absent, exactly as a read answers it, and nothing is sent to the ERP.

An update states how it protects a change made since the person read the record. It is either checked against the version they read, or declared as last-writer-wins. Every attempt, whether applied, refused, replayed or of unknown outcome, is recorded in the audit trail with the fields it wrote.

Example — Correct a price in the service catalogue

A unit manager corrects the list price of a service held in the company’s Odoo catalogue. The update carries the version of the record they opened. If a colleague changed that service in Odoo in the meantime, the update is refused rather than silently overwriting the colleague’s change, and the manager reloads and decides again.

For engineers

Writes are opened on the binding of a read-through resource (persistenceAdapter: PersistenceAdapter.HTTP_API). The following is the binding of Wonder Todos’ billableServices catalogue, with its comments omitted. HttpApiWriteVerb, HttpApiWriteConcurrencyMode and HttpApiKeyComponentCodec come from @wildo-ai/saas-models/external-data; the dialect and the subject-rights posture from @wildo-ai/saas-models.

httpApiBinding: {
  dialect: HttpApiTransportDialect.ODOO_JSONRPC,
  system: 'odoo',
  entityRef: 'product.template',
  keyFields: [{ localField: 'odooProductId', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
  fieldMappings: [
    { localField: 'listPrice', remoteField: 'list_price' },
    { localField: 'unitOfMeasure', remoteField: 'uom_name' },
    { localField: 'lastUpdatedAt', remoteField: 'write_date' },
  ],
  subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
  writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE, HttpApiWriteVerb.DELETE],
  writeConcurrency: { mode: HttpApiWriteConcurrencyMode.VERSION_CHECKED, versionField: 'lastUpdatedAt' },
},

writableVerbs opens the writes; leaving it out keeps the binding read-only. Each opened verb needs the matching core operation in coreOperations, and the factory refuses either without the other, so the write door and the route can never drift apart. The write operations in that file declare no idempotencyKey: the factory derives it as required on every write of a read-through resource, refuses a weaker setting, and requires the operation to run inline, because a queued execution could not carry the key. Bulk operations (createMany, updateMany, deleteMany) are refused on a read-through resource whatever the binding opens, since N remote calls with no transaction offer no atomicity.

writeConcurrency is required as soon as UPDATE is opened. VERSION_CHECKED names the local field that carries the version the caller read; it must map onto the vendor’s row version (write_date for Odoo, @odata.etag for an OData service), and the factory checks that an update can carry it and a create cannot send it. LAST_WRITER_WINS states the opposite choice, so it is a decision rather than an accident.

What the engine does with each write

The repository runs every write in one fixed order, and the order is why a refusal is always safe to retry:

  1. It refuses to run inside a database transaction, because a driver may retry a transaction callback and a remote write cannot roll back with it.
  2. It checks that the verb is opened and the dialect can send it (also checked at startup).
  3. It refuses a write scoped to any organization other than the owner of the application.
  4. It refuses, rather than strips, engine-owned fields in the payload — the owner stamp, a key component, the partition field — and renames the rest to the remote spelling.
  5. It spends the Idempotency-Key in the admission ledger before anything reads the remote.
  6. A create is placed in the caller’s unit’s partition; an update or delete reads the record first through every read gate and then asks whether this caller may write it. On update, the version check runs here.
  7. It sends one write through the dialect.
  8. It settles the ledger entry and records the attempt in the audit trail.

Nothing before step 7 has reached the ERP, so every refusal up to there settles the key as not applied and the request can be retried once its cause is fixed.

How retries and lost answers are answered

The ledger identifies a request by caller, resource, verb, target record and a fingerprint of the payload. A retry of an applied request replays it and reads the record back through the read gates, so a grant revoked in between still applies. A key reused with a different payload is answered 422 with the message reference idempotency_key_reused: never retry that request as it is. A request whose first attempt is still in flight, or whose outcome is unknown, is answered 409 with the code SYSTEM_OF_RECORD_WRITE_IN_PROGRESS (wait and retry) or SYSTEM_OF_RECORD_WRITE_OUTCOME_UNKNOWN (look in the ERP first). The audit trail records the attempt either way. A key is kept for one day.

An outcome is classified as unknown only once the write request has actually left: an Odoo write that failed during its authentication handshake wrote nothing, and is not reported as unknown.

What each vendor allows

Vendor and dialectWritesHow an update is protected
Odoo (JSON-RPC)create, write, unlink through execute_kwVERSION_CHECKED on write_date, best-effort, or LAST_WRITER_WINS
Microsoft Dataverse, Business Central, generic OData v4POST, PATCH, DELETE on the entity setVERSION_CHECKED, sent as If-Match and enforced by the service, or LAST_WRITER_WINS
Salesforce, NetSuite (REST JSON)the method and path the binding declares per verbLAST_WRITER_WINS only
SAP S/4HANArefused at startup—
Any OData v2 service rootrefused at startup—

On Odoo the comparison and the write are two calls, so the check narrows the lost-update window without closing it. On OData the service compares the tag itself and answers 412 without applying anything, which the engine reports as SYSTEM_OF_RECORD_WRITE_PRECONDITION_FAILED; even under LAST_WRITER_WINS the engine sends the tag of the record it read first, so a record changed or deleted between that read and the write is not overwritten or recreated.

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.