Skip to content

0031. Create Positive User contacts rather than upserting them

Date: 2026-09-17 Status: Accepted

Context

Positive User (user.com) becomes the first system Conflux writes records into. Until now it was a consumer: it pulled data out over its own connection token, and the integration registered for it (User Platform Integration) declares an inbound half — CanPullConsent, so far a throwing placeholder — for a consent change travelling from the workspace into Conflux. Nothing wrote a record there. UserPlatformOutboundWriter does, and it has to pick how a contact is addressed.

ADR-0015 already settled the general shape: Conflux creates its own record in the target and remembers the identifier the target hands back, rather than searching the target for one that might match. The delivery ledger holds the pair, so create against update is decided from a local row before a writer is asked. What that decision leaves open is which endpoints a given target offers for the two verbs, and User.com offers three that all look like candidates:

  • POST /api/public/users/ — a plain create. Every field is optional, and the response carries the numeric id the app assigned.
  • PUT /api/public/users/{id}/ — an update addressed by that same numeric id.
  • POST /api/public/users/update_or_create/ — an upsert keyed by custom_id, which User.com's own backend-integration guide presents as the way to write a contact.

The upsert is the path the documentation steers an implementer towards, and it is the one that cannot be used. Two things rule it out, and only the second is fatal.

First, it is keyed by custom_id. That field belongs to the workspace — whatever a customer keeps there is their own bookkeeping — so writing it would mean Conflux claiming a field it does not own, and reading it would mean matching against contacts Conflux never created.

Second, and decisively: an upsert cannot report that a record is gone. Asked to update a contact that no longer exists, it creates one. That destroys the single channel through which Conflux ever learns that a contact it created was deleted on purpose in the workspace. Without that signal, a deletion somebody made deliberately is silently undone on the next delivery, and again on every delivery after it.

Guarding the upsert with a lookup in front of every write was considered and rejected. It costs a request per delivery, and it still leaves a race: a contact deleted between the lookup and the write is recreated carrying whatever the payload held.

Decision

UserPlatformOutboundWriter uses POST /api/public/users/ to create and PUT /api/public/users/{id}/ to update, addressing the numeric id the create returned and the ledger stored. update_or_create/ is not used, and custom_id is neither read nor written.

Three consequences of that pairing are load-bearing enough to state as part of the decision:

  • A create whose response carries no usable id fails loudly, as UnaddressableRemoteRecordException rather than as a retryable failure. The contact exists by then, nothing points at it, and no query separates the contacts Conflux created from the workspace's own — so a retry produces a second unreachable contact rather than repairing the first.
  • A 404 on update is RemoteRecordGoneException and nothing else is. An id User.com assigned is never reissued, so the answer is unambiguous.
  • A 404 on create is an ordinary failure, not a gone record: there is no contact yet, so the status means a wrong base url or a workspace that no longer exists.

Positive User's direction matrix gains push_out for customers to match.

Consequences

Conflux creates one contact per source record. A workspace connected to two shops therefore holds two contacts for a person who bought in both, and where the workspace already had its own contact for that person, ours sits beside theirs. That is accepted rather than mitigated: merging would need the field-level authority ADR-0003 rules out. The conflux_source custom attribute is what makes the duplicates legible to somebody working in the workspace, which is why it is written on every contact rather than only where a collision is suspected. It only arrives where the workspace declares the attribute: the app accepts a contact carrying an undeclared one and drops the value in silence, so verification declares conflux_source itself where it is missing rather than leaving a workspace to store contacts with no marker and nothing reporting it.

Deleting is offered, and the delete is a hard one because the API has no other: the contact and everything the workspace recorded about it go with the call, and nothing Conflux holds can put either back. Declaring the verb says the target can perform a deletion, not that performing one is always right — whether a record disappearing at the source is answered with one is the rule's delete_policy (ADR-0016), and a rule that leaves the policy at ignore reaches the verb never. deactivate is not offered beside it: the target has no inactive state of its own, the only way to build one is a custom attribute the workspace would have to create before the first delivery, and reusing unsubscribed for it is ruled out by ADR-0016 — a record leaving a shop says nothing about what that person agreed to. So the choice for this target is between removing the contact and leaving it untouched, with no third state in between that Conflux could write without inventing one in somebody else's schema. Bulk ingestion is not offered either, because the CSV import returns no per-record identifier and a batch taken through it would leave every contact unaddressable.

The old framing — Positive User as a system Conflux only reads from — no longer holds. Nothing about the inbound consent path changes; what changes is that the system now sits on both sides of the platform.

This decision governs Positive User. It does not generalise to a target whose update path genuinely cannot be separated from its create path; such a target cannot satisfy the writer contract at all, and that is a finding about the target rather than a shape to work around in a writer.