Skip to content

0024. Route consent writes through a single service with last-write-wins

Date: 2026-08-28 Status: Superseded by ADR-0041

ADR-0041 makes consent a dependent domain, mirrored per connection. A mirror has one writer, its own connection, so there is no ordering across writers. That removes last write wins across writers and written_by_system_connection_id. What survives is that one service, ConsentRecorder, owns every write, that a channel the source did not mention is never touched, and the stale-write guard, now per mirror: one source still delivers out of order, so a write older than the mirror's consent_updated_at is refused.

Context

Consent is the one piece of customer data where being wrong has legal consequences. Sending to someone who withdrew consent, or dropping an opt-in that a customer actually gave, are not equivalent to a stale order total.

The path into Conflux makes that easy to get wrong. Consent arrives as CustomerData::$consents on a DTO produced by an integration, and is persisted by a transformer. Before this decision the obvious implementation was an inline CustomerConsent::updateOrCreate() in AbstractTransformer::storeCustomer(), which each new consent source — a webhook handler acting on an unsubscribe event, an on-demand pull (CanPullConsent, ADR-0023), the cross-system propagation of CFX-199 — would then copy. Every copy is a place to forget a rule.

Three rules in particular do not survive being reimplemented per caller:

Writes arrive out of order. Bulk sync fans out into parallel FetchBulkPageRangeJobs, jobs are retried, and a full sync can overlap a delta sync. A re-delivered job carrying a customer's state from an hour ago will happily overwrite the newer state a faster job just wrote, and the result — an opt-in silently reverted to a withdrawal, or vice versa — is invisible.

Absence is not withdrawal. No shop platform separates consent channels: PrestaShop has a single newsletter flag, WooCommerce has no native consent field at all. A sync from such a source can only ever speak for the email channel. Any implementation that reconciles the stored channel set against the incoming collection turns "PrestaShop said nothing about SMS" into "SMS consent withdrawn" and destroys a valid opt-in recorded elsewhere.

Provenance matters once consent flows in more than one direction. CFX-199 propagates a consent change from one connected system to the others; without recording where a state came from, a propagator has no way to avoid writing a change back to the system it originated from.

Decision

One ConsentRecorder owns every read and write of CustomerConsent. Adapters, transformers and future webhook handlers call read() and write(); nothing else touches a CustomerConsent row. It is a plain injectable service with a default-value constructor argument, not part of the Can* capability system of ADR-0023 — it is internal, per-application rather than per-integration, and never instanceof-checked. Integrations produce ConsentData; they do not persist it, so AbstractIntegration does not inject the service and AbstractTransformer does.

Consent is stored per channel, and only per channel. CustomerConsent carries a ConsentChannel (email, sms, general) with a unique index on (customer_id, channel), and write() touches exactly the one channel carried by the given ConsentData. Channels the incoming data says nothing about are left alone. The MVP only needs email, but a single-channel model would have had to be unpicked from the schema, the DTO and every caller later; the channel column costs nothing now.

Last-write-wins, decided by the source's timestamp rather than by arrival order. ConsentData carries a required consentUpdatedAt — when the source last reported this state as current — and write() ignores any write whose timestamp is not strictly newer than the stored row's, returning the existing row unchanged. Requiring the field rather than defaulting it is deliberate: a caller that passes now() defeats the guard, and a required constructor argument forces that choice to be made deliberately at the mapping site. Equal timestamps lose, so a replayed identical write is a no-op.

The timestamp is a typed instant on both sides of that comparison — a CarbonImmutable on the DTO, a timestamp column on the row — so two sources reporting one moment in different offsets compare equal rather than by wall-clock text, and the column can be selected and ordered on in SQL. It is parsed the way ADR-0018 requires every source datetime to be: through SourceDateTimes, against the one format its integration declares. An earlier draft of this decision gave the DTO its own ordered list of accepted formats, which ADR-0018 rules out for a reason that applies here as much as anywhere — the order would silently decide the meaning of an ambiguous value, and this is the field that decides which consent state wins.

What is genuinely different about it is that it is required, the only source datetime on any DTO that is. SourceDateTimes returns null for a value it cannot read, and a null here would not make a consent less precise; it would disable the guard. So the mapper omits the whole consent instead — the same treatment a channel the platform reports no signal for gets — and the customer record lands without it. The cost is stated plainly: a shop whose consent timestamp cannot be read syncs its customers and not their consent, and the parse failure surfaces in the aggregated warning SourceDateTimes already logs rather than passing silently.

Every write states written_by_system_connection_id. The $writtenBy parameter is required and has no default. Defaulting it to the customer's own connection would be correct for every write that exists today and wrong for the one the column exists for: a propagator writing customer A's consent because customer B's system changed it. That caller would have to remember to override the default, and forgetting would record a plausible falsehood rather than fail — so the caller states the writer, always.

A soft-deleted row is revived, not duplicated. CustomerConsent soft-deletes while (customer_id, channel) is a plain unique index, so a trashed row keeps occupying the key and an updateOrCreate() — whose lookup honours the soft-delete scope — would miss it and violate the constraint on insert. write() looks the row up with withTrashed() and clears deleted_at in place. A trashed row is not current state, and read() reports null for it, so there is no fresher consent for the staleness guard to protect: the incoming state wins regardless of its timestamp. Nothing soft-deletes consent rows today, but ADR-0013 soft-deletes a connection's business data for the duration of the disconnect grace period, and the first reconnect-then-resync would otherwise fail on a constraint violation.

Channel filtering for outbound pushes does not live here. Narrowing a set of consents to what a target integration can represent — reading the HasConsentChannels declaration of ADR-0023 — touches no database and holds no state; it answers what a target can accept, not what Conflux has stored. An earlier draft of this decision put it on the same class, which made the store's one job ("owner of the stored state") two. It belongs with the propagator that pushes consent outward, and lands with it in CFX-180. The rule it has to encode is stated in ADR-0023 and in the HasConsentChannels reference: an integration that does not declare its channels is unknown, not supports nothing, so everything passes through — the opposite default would silently stop propagating consent to every integration nobody has annotated yet.

Consequences

The staleness guard is only as good as the timestamps fed to it, and the shop platforms do not supply a good one. Neither WooCommerce nor PrestaShop exposes "when did the newsletter flag last change"; only the customer record's date_modified/date_upd. A mapping that uses that as a proxy inherits its failure mode: if a plugin flips the consent flag without bumping the record timestamp, write() keeps comparing against an unchanged value and silently discards a real, newer consent state. The guard converts a loud wrong answer into a quiet one, which is the right trade for out-of-order jobs and the wrong one for a source whose timestamp lies. Where a source offers a timestamp scoped to the consent field itself, that is the one to map.

Rejected writes leave no trace. write() returns the existing row and logs nothing, so a consent update lost to a bad timestamp is indistinguishable from one that never arrived. That is acceptable while the only consent path is bulk sync, and will not be once consent is propagated between live systems.

Withdrawal has to be stated, never implied. Because absence is not withdrawal, an adapter must emit a ConsentData only for channels it has a real signal for, and must never emit subscribed: false to mean "unknown" — that writes an actual withdrawal. This is a rule adapters have to follow, not one the service can enforce, since the two are indistinguishable at the boundary. It is pinned by the leaves consent for channels the incoming data says nothing about untouched test in tests/Integration/Services/AbstractTransformerIntegrationTest.php.

The abstraction is ahead of its users. No adapter maps consent yet — PrestaShop's newsletter and optin fields are not read, WooCommerce has nothing to read — so read() has no production caller, and HasConsentChannels has no implementer. The write path is exercised only through AbstractTransformer with test data. This is deliberate for the MVP boundary of CFX-179: the internal model and its operations land first, the per-adapter mappings follow in their own tickets. Until they do, the rules above are guaranteed by tests rather than by production traffic.

deleted_at on customer_consents carries exactly one meaning, and the revive depends on it. Per CFX-164 a disconnect soft-deletes a connection's business data so it stops being served while remaining recoverable for the grace period — so a trashed consent row means "this connection is disconnected", never "this consent was removed", and recovering it on the next sync after a reconnect is the intended behaviour rather than an accident. Nothing survives that recovery except the row's id and created_at: every consent column is overwritten with what the source now reports, which is why reviving cannot resurrect a stale state. Should deleted_at ever acquire a second meaning — an erasure request, an operator removing a consent — write() becomes wrong on the spot, because it cannot tell the two apart from a timestamp. That case needs a recorded reason on the row (grace_period vs. erasure) and a write() that revives the first and refuses the second; it is not a change to make speculatively, but it is the change to make then.

The one lifecycle mistake that is detectable is refused: write() throws a LogicException when the customer itself is soft-deleted. A trashed customer means its connection is in the grace period, so no sync and no propagation may be running for it at all. This never fires through AbstractTransformer, which revives the customer before writing its consent — it guards the other callers, the webhook handler and the future propagator, where writing for a disconnected connection is a plausible slip.

The staleness guard is not atomic. write() reads the stored row, decides, and then writes, so two jobs writing the same customer and channel concurrently — an overlapping full and delta sync, say — can both read the same state, both consider themselves newer, and let arrival order decide instead of the timestamp. Two writers that both find no row race into the insert, and one fails on the unique index; the queue retry then finds the row and succeeds, which is noisy rather than wrong. Neither has been observed. The fix, when it is needed, is a transaction with lockForUpdate() around the read and the write, not an INSERT … ON DUPLICATE KEY UPDATE — expressing the comparison per column in SQL would scatter the rule this ADR exists to keep in one readable place.

Consent state has no history. write() overwrites the row, so only the current state per channel exists, along with the connection that last wrote it. Reconstructing when a customer opted in and out over time is not possible from this schema. If a regulator, a dispute or a debugging session ever needs that, it is a new append-only table and a new decision, not a change to this one.