Skip to content

0015. Create a record in the target rather than resolve one

Date: 2026-08-24 Status: Accepted — dependent domains never create: a dependent record is only ever written onto the record its parent's ledger names (ADR-0042)

Context

Outbound delivery writes Conflux's data into a system Conflux does not own. The first delivery of a source record is unambiguous, because nothing is there yet. Every delivery after that one has to address a record that already exists in the target, and there are exactly two ways to do that: search the target for a matching record, or create one and remember the identifier the target returns.

Nothing links Conflux's mirror to the target's records on its own. Conflux keys its own rows by (system_connection_id, source_id), the identity the source system gave them. The target keys its records however it likes, usually by an identifier it generates itself. Neither side knows the other's key unless something stores the pair.

A search would therefore have to match on content. That puts the whole weight of identity on a natural key such as the email address, and it needs an answer for the case where two candidates come back and disagree. ADR-0003 already ruled that answer out. Conflux models cross-system customer identity as linkage between per-connection rows rather than as a golden record, so there is no field-level authority to appeal to.

The first target is the User Platform, and it permits several contacts per email address. A lookup there can legitimately return two records for one person. That is the concrete case this decision was taken against. The decision itself is general, because it governs how Conflux addresses any record it wrote into anybody's system.

None of the delivery layer exists in code yet, and the write side is built by CFX-215 through CFX-218. Fixing the identity rule first means every writer arrives with this question already answered.

Decision

Conflux creates its own record in the target for every source record, and never searches the target for one it already holds. The delivery ledger from ADR-0014 already answers the only question a search would have asked: is there a record in this target for this source id. It answers from a local row, without a network call. It answers exactly, because that row was written by the delivery that created the record. The ledger table outbound_deliveries arrives with CFX-214.

The writer contract therefore has no resolve verb, and a writer maps in one direction only: canonical DTO into the target's shape. A search would have needed the inverse mapping as well, because a candidate comes back in the target's shape and has to be compared against ours. Every writer would carry that second mapping. Nothing else in the pipeline needs it, so dropping resolve removes a verb, a failure mode, and half of every writer's mapping code.

create and update stay separate verbs rather than one upsert. The runner knows which one applies before it calls the writer, because the ledger either holds a reference for this source id or it does not. A combined verb would hide that decision inside every writer, and every writer would have to reimplement it. It would also cost the signal the next decision depends on: under an upsert, a target reporting that the record is gone is indistinguishable from an ordinary create.

A "record not found" answer to an update sets the ledger to remote_gone. It reports, it stops delivering that record, and it never recreates it. This is the only channel through which Conflux learns that a record it created was deleted in the target on purpose. Nobody else uses that record, so the deletion can only have been about it. Recreating it would undo a human decision silently, and it would do so again on every later change to the source row. Reporting and stopping leaves the decision where it was made.

Single-record creation ships before any bulk path, and a bulk path is only usable if it returns a per-record identifier. A bulk path is attractive for one reason. No flag on an ordinary payload suppresses what a target does when a record changes, and a bulk import is the only mechanism that can. Where that suppression is instead available as a parameter on the ordinary routes, the parameter is the better answer, because it keeps one delivery path rather than two. Where only a bulk import can do it, the identifier requirement is absolute. A bulk path that returns no per-record identifiers leaves every record it created without a ledger reference, so the next ordinary run creates all of them a second time. Nothing downstream can detect that or repair it, because the reference that would have told the two apart is the thing the bulk path never returned.

The alternative that was not taken: resolution before creation

The alternative was to resolve before creating: look the record up in the target first, by a natural key such as the email address, and create one only when nothing came back. It is the obvious design, and it is what a person would do by hand.

It was rejected for two reasons. The first is that it does not hold against the first target. The User Platform permits several contacts per email address, so a lookup by email can return two records, one record, or none. None of those answers guarantees one record per person, and correctness cannot rest on a uniqueness the target never promised.

The second reason is cost. Resolution adds a read per record to every delivery, on top of the write, and it adds an inverse mapping to every writer. Both are paid on every run, permanently, to answer a question the ledger answers locally.

A blind upsert on a key Conflux supplies falls to the same reasoning. It avoids the read, but it creates again by design whenever the target does not recognise the key, which is the behaviour remote_gone exists to prevent. It cannot produce that signal at all, because creating is its normal response to a record that is not there.

Consequences

The delivery ledger is the only copy of every pointer into the target, and it cannot be rebuilt. This follows directly from not searching. Nothing can ask the target which of its records are ours, because no query separates them from the target's own. A ledger that is lost or purged leaves every record Conflux created standing in the target: unreachable for updates, consent changes included, and invisible to any later deletion. An uninstall followed by a reinstall therefore produces a second full set of records instead of picking up the first. The purge on uninstall is required even so. A stored reference is a pointer to a person, and ADR-0013 makes uninstall the state that removes personal data for good. Pruning the table to save space is a separate question. It is not a free optimisation, because a pruned row is a record nobody can address again.

One person can carry two consent states, ours and the workspace's. A person the target workspace already knows now exists there twice: once as the workspace's own contact, with whatever consent that contact holds, and once as the record Conflux created, carrying the consent the shop recorded. Nothing reconciles the two. This is a compliance cost accepted deliberately, not an oversight. ADR-0016 accepts this same duplication when several sources feed one target.

A backfill creates every record, so side effects scale with dataset size. A tenant with 50,000 customers means 50,000 creates in the target, and every one of them is a change that target may be configured to act on. No cheaper shape is available, because there is nothing to resolve against and therefore nothing to skip. This is why delivery starts forward from activation rather than with a historical load: the default costs one create per change that happens next, not one per row that ever existed. A historical load stays an explicit opt-in, and CFX-218 carries the guard that makes it survivable.