Skip to content

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_id of its own.
  • Versioning. consentUpdatedAt was the only required datetime on any DTO, and ConsentRecorder applied 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's content_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_reason and 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 and ConsentRecorder::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 consentUpdatedAt lies strictly before the one the mirror holds, logs it, and reports NotTouched. 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.
  • consentUpdatedAt stays required. It orders one mirror's writes, and a two-way conflict between two mirrors will be settled by the later consentUpdatedAt, 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 for customer_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_items counts root records only. A dependent's failure is counted, so it stays visible.
  • A customer's change signal no longer covers its consent. CustomerData carries 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_id with a deletion_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_stock is expected to follow: an enum case with its parent, a mirror table with parent_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.