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.