0041. Model child data with its own lifecycle as dependent domains¶
Date: 2026-10-01 Status: Accepted
Context¶
Conflux knew three domains: customers, orders and products. Customer consent was not one of them, yet it behaved differently from its customer in almost every layer.
- Identity. A row per customer and channel, with no
source_idof its own. - Versioning.
consentUpdatedAtwas the only required datetime on any DTO, andConsentRecorderapplied last write wins across several writers (ADR-0024). - Merge semantics. A channel the source did not mention was not a withdrawal.
- Change signal. Consent rode inside
CustomerData::$consents, so a consent change moved its customer'scontent_changed_at, and a change that reached Conflux only through the PrestaShop webhook moved no signal at all: it was stored and never delivered. - Lifecycle. The disconnect, cleanup, restore and purge jobs handled consent by hand, because
the table had no
deletion_reasonand was keyed by customer, not by connection.
The cause is one missing abstraction: data that belongs to a record but has a lifecycle of its own. Two needs make it worth fixing now. Consent changes have to be delivered separately from the customer, with a change signal and a delivery state of their own. And a consent has to be writable back into a shop, which needs an update-only rule with no create.
Stock is the expected second case. A product_stock record would have its own identity (product
plus location), its own timestamp, often its own endpoint, update-only semantics and a far higher
change rate than the product.
Decision¶
A dependent domain is a domain with a parent domain. Its records are ordinary mirrors: each
belongs to exactly one connection and has a source_id, a content_changed_at and a
deletion_reason. customer_consents is the first one; the customer is its parent.
Three criteria. Child data becomes a dependent domain rather than a nested field when at least one of these holds:
- it changes on a different cadence than its parent;
- it has write semantics of its own in a target;
- it can flow in a different direction than its parent.
Consent meets all three, and so would stock. Order line items meet none and stay nested.
One mirror per system. Each connection keeps its own view of a consent. A WooCommerce consent
belongs to the WooCommerce connection and names the WooCommerce customer as its parent. A dependent
record names its parent by the parent's source_id within its own connection (parent_source_id),
not by a foreign key, because a view's parent may have no row in Conflux. customer_id stays as a
nullable shortcut for eager loading and the admin view; identity rests on parent_source_id alone.
There is no merged consent state, and a rule always delivers its source's view.
The shop is the hub. Consent flows between two targets only by going through the shop. This is slower, depends on the shop accepting the write, and loses what the shop cannot store (WooCommerce keeps subscribed or not, and nothing about confirmation, source or IP). It is the direct consequence of delivering per source record without merging identities, and it was chosen over rules between two targets.
null means unknown. A source reports null for what it cannot know. subscribed and
confirmed on ConsentData are ?bool. null never overwrites a known value, for every value
field, and never counts as a difference. The one exception is a withdrawal: subscribed: false
clears confirmed and confirmed_at, because they describe an opt-in that no longer exists, and a
re-subscription a shop reports with confirmed: null must not inherit them and pass as verified. WooCommerce and PrestaShop report confirmed: null: their
newsletter flag is a single opt-in and says nothing about confirmation. A writer handed
subscribed: null sends nothing, never a withdrawal.
A composed source_id. A consent's source_id is {parent_source_id}:{channel}, for example
4711:email, with unique(system_connection_id, source_id). The scan, the ledger, the cursor and
the rule page's records table then work unchanged. The alternative was to give the consent the
parent's source_id and add a discriminator column to every key. That is cleaner in theory and
would have reworked the ledger, the scan and the cursor, so it was rejected.
SystemCapability holds both kinds. sync_rules.data_domain, outbound_deliveries.data_domain
and the direction matrix need one type for both, so CustomerConsents = 'customer_consents' is a
case of SystemCapability. The enum gains parent(), isDependent(), roots() and
rootsWithDependents(). roots() lists customers, orders and products; rootsWithDependents()
adds the dependent domains. An arch test forbids SystemCapability::cases() in app/ and
database/, so every caller has to choose between
the two lists and states whether a dependent domain belongs in its case. Bulk sync, the deletion log
and systems.capabilities use roots(), because a dependent domain has no import of its own and
arrives with its parent. The enum's match expressions stay without a default, so PHPStan forces
an answer for a new case in every method, and deletionLogEntityType() returns null for a
dependent domain.
Inbound. A mapper turns one payload into [parent, ...dependents]: a CustomerData followed by
one ConsentData per channel the payload reports. AbstractTransformer::store() routes each record
by type, and a consent goes to ConsentRecorder::write(SystemConnection, ConsentData). The recorder
keeps three rules: a channel the source did not mention is not touched, null never overwrites a
known value, and a write whose consentUpdatedAt lies strictly before the mirror's is refused. A consent moves its own change signal, and only when a value changes (or a trashed
row is revived). consent_updated_at alone does not count, because WooCommerce fills it with the
customer's modification time and counting it would move the signal on every customer edit. Linking
customer_id does not count either. The PrestaShop webhook writes through the same recorder, so a
change that arrives only by webhook now moves its signal.
Consequences¶
- Last write wins across writers goes. A mirror has exactly one writer, its own connection, so
there is no ordering across writers. This supersedes
ADR-0024.
written_by_system_connection_id, the soft-deleted-customer guard andConsentRecorder::read()are removed. - The stale-write guard stays, per mirror. One source still delivers out of order: a bulk import
page can be processed after a newer webhook, and a retried webhook after the one that followed it.
The recorder refuses a write whose
consentUpdatedAtlies strictly before the one the mirror holds, logs it, and reportsNotTouched. An equal time applies, so a same-time re-report lands. A trashed row is revived whatever its time, because the trash is itself a later observation. consentUpdatedAtstays required. It orders one mirror's writes, and a two-way conflict between two mirrors will be settled by the laterconsentUpdatedAt, so a consent that arrives without one cannot be placed. That delivery work is not part of this decision. Until it lands, no direction matrix entry exists forcustomer_consents, so no rule for the domain can be created and delivery has not changed.- Sync batch counters count roots only. A consent's inserted, updated or not-touched outcome is
not counted, because it arrives with its customer, and
total_source_itemscounts root records only. A dependent's failure is counted, so it stays visible. - A customer's change signal no longer covers its consent.
CustomerDatacarries no consents and the customer payload to a target contains none. A consent is delivered as a record of its own. - Lifecycle needs no special case. Disconnect, cleanup, restore and purge handle consent by
system_connection_idwith adeletion_reason, like every other mirror, and a customer deleted through the deletion log takes its consents with it under the same cause (source_event). - A new dependent domain costs a declaration.
product_stockis expected to follow: an enum case with its parent, a mirror table withparent_source_id, a projector and a mapper that emits it after its parent. It shows whether the criteria above hold up. - The abstraction does not decide delivery. Which rule a dependent record travels on, how its target record is found, and how two directions are reconciled are separate decisions.