Skip to content

0044. Tie Positive User consent to contacts Conflux created

Date: 2026-10-05 Status: Accepted

Context

Consent has to travel from Positive User into Conflux: a contact unsubscribing through a User.com email must reach the connected shops. UserPlatformIntegration carries that inbound half. It pulls a contact's consent on demand (CanPullConsent) and applies a change pushed by a Positive User automation (CanHandleWebhooks). Registering the driver was already settled by ADR-0028. Two questions are open: which contact a pulled or pushed consent state belongs to, and where it is recorded.

The first design resolved a contact against Customer rows on the Positive User connection itself, with User.com's numeric id as source_id and the contact's email as a fallback. That stopped working for two reasons.

First, Positive User became a target. ADR-0031 has UserPlatformOutboundWriter create one contact per source record and keep the numeric id the app assigns in the delivery ledger. It also draws a hard boundary: contacts Conflux did not create are never read, matched or written. Where the workspace already holds the same person, our contact sits beside theirs.

Second, nothing fills Customer rows on a Positive User connection. There is no bulk sync from Positive User, and CustomerIdentity (ADR-0003) has not been built. A lookup against the connection's own customers would match nothing. An email fallback would match exactly the contacts ADR-0031 puts out of reach, the workspace's own.

The CFX-180 branch answered both questions by writing the consent onto the shop customer the contact was created for, with the Positive User connection as written_by_system_connection_id. ADR-0041 then made consent a dependent domain with one mirror per system, and superseded the last-write-wins recorder (ADR-0024) that the provenance column served. A consent row written onto another connection's customer no longer fits the model. This record replaces that branch's version of the decision.

Decision

A Positive User consent state is tied to a contact only through the delivery ledger, and it is mirrored on the Positive User connection.

  • Identity. A contact is identified by the numeric id the workspace assigned when Conflux created it. That is the ledger's remote_id for a customers row whose rule targets this connection and whose record the target still holds (OutboundDelivery::scopeCreatedAs(), built on scopeKnownToTarget(): a reference is stored and the row was not withdrawn). An email address is never used to identify a contact.
  • Pull. performPullConsent() checks the ledger before anything else. An id the ledger does not hold for this connection is refused as ConsentTargetNotFoundException without a request going out, because asking the app about it would already be reading one of the workspace's own contacts.
  • Webhook. processWebhook() requires the automation to send user_id, and checks it against the same ledger lookup. A webhook for a contact the ledger does not hold, or one Conflux created in another workspace, is dropped with a warning and writes nothing.
  • Signature. The webhook is verified like every other one, by ConnectionSignatureValidator: the Signature header must be the hex HMAC-SHA256 of the raw body under the connection's credentials['webhook_secret']. Positive User has no handshake that could hand Conflux a secret, so Conflux mints one: UserPlatformIntegration::performVerification() adds 32 random bytes, hex encoded, on a successful verification where the connection holds none. Every route to an active connection passes through a verification whose connection the caller then saves. A secret already held is never replaced, because the automation carries a copy, and a reconnect onto a disconnected sibling keeps the sibling's secret for the same reason. The automation is set up as described in the reference.
  • Order. Webhooks arrive in no guaranteed order. One whose timestamp lies before the consent_updated_at the mirror already holds for that contact and channel is skipped with a warning, so a retried older webhook cannot undo a newer change. ConsentRecorder applies that guard to every source's writes, so the webhook needs no check of its own.
  • The mirror. The webhook writes one ConsentData through ConsentRecorder into the Positive User connection's own mirror, like any source does. pullConsent() returns the contact's state under the same mapping and boundary, and writes nothing; nothing in production calls it yet. The row is keyed parent_source_id = <contact id> and source_id = <contact id>:email, and its customer_id stays null, since this connection mirrors no customers. Nothing on these paths writes to a shop's records.
  • The way back to the shop. The anchor (ADR-0042) carries the consent to the shop. A Positive User → shop consent rule resolves its anchor through the reverse customers rule (shop → Positive User): the ledger row whose remote_id is the contact id has the shop customer's id as its source_id, and that is the record the shop's writer updates.
  • Write. Consent goes out through UserPlatformOutboundWriter, the one writer registered for the driver, as a customer_consents update onto the anchor id (ADR-0045). The anchor is the same ledger id, so the writer, too, can only reach contacts Conflux created.

Both the integration and the writer reach the workspace through UserPlatformClient, so host, token and API version are composed in the one place ADR-0028 put them.

Consequences

The read side keeps the same boundary as the write side. Every contact Conflux reads or writes in a workspace is one it created, and the proof is a local row rather than a guess about whose contact a matching email belongs to.

Consent reaches Conflux only for contacts already delivered to the workspace. A consent change on a contact the workspace created itself, or on one whose delivery never succeeded, is logged and dropped. That is intended, not a gap: those contacts are not Conflux's, and no anchor would lead from them to a shop customer anyway.

Each system keeps its own view. What Positive User reports sits in its own mirror and can be read there as Positive User's statement, without provenance columns to tell it apart from the shop's. Whether it reaches the shop is a rule's decision. The echo check and the two-way conflict between the two mirrors are settled as ADR-0043 describes.

The mirror row exists only while the ledger knows the contact. A contact withdrawn from the target with delete drops out of scopeCreatedAs(), so later webhooks for its id are dropped like those of any other unknown contact.

The automation feeding the webhook has to be configured to send user_id. A payload that identifies the contact only by email or user_custom_id, which are User.com's own example fields, is discarded. This is a per-workspace configuration dependency that Conflux cannot check over the API.

Conflux shows the secret nowhere but the connection's credentials, which only a super admin can read, on the connection's edit page in the admin panel. A connection verified before the secret was minted has none until it is verified again; its webhooks fail the signature check meanwhile. A page or command that shows and rotates the secret is left for a later step.

The pull and the webhook both touch the database before or instead of the app, so the ledger lookup runs once per consent event. outbound_deliveries is indexed for the drain query, not for a lookup by remote_id. At webhook volume that is a scan bounded by the ledger's size. An index on (remote_id, data_domain) is the lever once it shows up in traces.