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_idfor acustomersrow whose rule targets this connection and whose record the target still holds (OutboundDelivery::scopeCreatedAs(), built onscopeKnownToTarget(): 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 asConsentTargetNotFoundExceptionwithout 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 senduser_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: theSignatureheader must be the hex HMAC-SHA256 of the raw body under the connection'scredentials['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
timestamplies before theconsent_updated_atthe mirror already holds for that contact and channel is skipped with a warning, so a retried older webhook cannot undo a newer change.ConsentRecorderapplies that guard to every source's writes, so the webhook needs no check of its own. - The mirror. The webhook writes one
ConsentDatathroughConsentRecorderinto 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 keyedparent_source_id = <contact id>andsource_id = <contact id>:email, and itscustomer_idstays 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_idis the contact id has the shop customer's id as itssource_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 acustomer_consentsupdate 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.