Skip to content

0042. Resolve a dependent record's target through its parent's ledger

Date: 2026-10-02 Status: Accepted

Context

ADR-0041 made customer consent a dependent domain: a mirror of its own, with a change signal of its own, naming its parent by the parent's source_id within its own connection. It left open how such a record is delivered.

A dependent record cannot be delivered the way a root record is. It is never created on its own in a target: a consent is written onto a contact, and the contact has to be there first. So delivery has to answer three questions a root domain never asks. Which record in the target does this consent belong to? What happens to it while that record does not exist yet? And how much of the parent rule's state does the consent rule share?

ADR-0015 already rules out searching the target, and ADR-0003 rules out merging identities. What is left is the ledger the parent domain already keeps between the same two connections: every link in it is 1:1, and it names the target's own id for every parent it has delivered.

Decision

The anchor rule. A dependent rule is bound to a rule of its parent domain between the same two connections, the anchor rule. SyncRule::anchorRules() lists the non-deleted parent-domain rules between the pair, the reverse direction first and the same direction second, and anchorRule() is the first of them, the one whose state the dependent rule inherits. A dependent rule without any is not valid: SyncRuleObserver::saving() refuses an active one with InvalidSyncRuleDirectionException::forMissingAnchor(), and ValidSyncRuleFlow refuses it in the form. A dependent rule whose anchor rule is deleted later is not deleted with it. It reads "Config problem" ("There is no customers rule between these connections") and is valid again the moment the anchor rule is restored.

The anchor is looked up per record, the reverse direction first. AnchorResolver::remoteIdFor() asks the reverse rule's ledger first and the same-direction rule's ledger where the reverse one does not know the parent.

  • Reverse direction (the parent rule goes from target to source): the parent came from the target, so the row that delivered it has our parent's id as its remote_id, and its source_id is the target's own id.
  • Same direction: Conflux created the parent in the target, so the row keyed by the parent's source_id holds the target's id as its remote_id.

The reverse direction wins because it points at the original rather than at a copy Conflux made. The same direction still answers for a parent that originated in the dependent rule's own source. A ledger row that is withdrawn, was never delivered (no remote_id), or is in a terminal status (remote_gone, exhausted) is no anchor.

The anchor is resolved afresh on every delivery. It is never read back from the dependent rule's own ledger row. The row still stores the anchor it was written to as remote_id, but only as a record for the operator. A parent created again after a withdrawal has a new remote id, and a fresh lookup finds it without any bookkeeping.

Update only, with a fixed policy. A dependent record is only ever written onto the record its anchor names: an update() with a RemoteReference carrying the anchor, never a create, not even on the first delivery. A dependent rule's writer needs CanUpdateRemoteRecords and nothing else. update_mode is always overwrite, because only the latest value means anything, and delete_policy is always ignore, because a consent absent from the source is not a withdrawal. SyncRuleObserver::saving() sets both on every save.

Inheritance from the anchor rule. A dependent rule runs only while its anchor rule runs.

State of the anchor rule Effect on the dependent rule Status shown
Manual stop on the rule stopped Stopped through the customers rule
Inactive route closed Route closed: "The customers rule this rule depends on is inactive"
Route closed route closed Route closed: "Through the customers rule: …" with the anchor's reason
Deleted not valid Config problem
Throttled, withdrawals held, failing, stale none its own

DeliveryRoutes, DeliveryStops::manualStopFor() and SyncRuleStatusResolver carry this, and the dispatcher needs nothing of its own. Only the anchor's rule-scope stop is inherited: a connection or global stop already reaches the dependent rule directly.

Stops are inherited because a dependent rule running while its anchor is stopped writes onto target records whose own data is no longer kept current. A consent granted while the contact still carries an old email address lands on an address it was never given for. Failing is not inherited: when the customers rule's creates fail, consent to existing contacts keeps flowing, and nothing drifts, because consent only goes to contacts whose anchor exists.

awaiting_anchor. A candidate whose anchor the target does not hold yet is recorded with the new status awaiting_anchor. It uses no attempt, the cursor moves on, and the row records the version it was seen at, so the scan offers it again only on the record's next change or once a parent's create wakes it.

A parent's create makes its dependents due. When the anchor rule creates a parent in the target, DependentWakeups::afterCreate() rewrites every current dependent row of that parent, whatever its status, for every dependent rule between the pair whose anchor rules include the creating rule: status = awaiting_anchor, next_attempt_at = now, attempts = 0, and delivered_content_changed_at = null. Clearing the version matters: without it, the version check would suppress a consent last delivered to an earlier copy of the parent. It also covers dependents the scan never visited, a consent unchanged for months whose customer is only now created in the target. The drain picks these rows up like due failed rows, so no order between the two rules' chains is needed: a consent chain that runs first waits and is woken. The wake runs after the anchor's ledger write, outside a transaction. A failure in between leaves the dependents asleep until they next change; a transaction around the create would instead risk a duplicate create when the step is retried.

Except for a parent the anchor's backfill creates. A parent whose change signal lies before its anchor rule's activation, the moment that rule's delivery cursor was opened, is a record the anchor's first-activation backfill of ADR-0037 delivers. Its create wakes only the dependent rows already awaiting_anchor, the dependents that changed since the dependent rule's activation and are waiting for this parent; every other dependent stays asleep and arrives on its next change or through the guarded backfill. Without this, the anchor's backfill would be the dependent backfill the next paragraph rules out: connecting a Positive User workspace to an existing shop creates every contact, and a full wake would send one double opt-in mail per subscriber and one consent_granted withdrawal event per non-subscriber. The rule is read off the record, not off the pass, so a backfill create that failed and is retried by the drain is treated the same way. The user decided this. A parent created by delta delivery, a record that changed since the activation, wakes all its dependents as before, and so does a reactivation, which only gives back what Conflux's own deactivation held up.

Withdrawal on delete only. When the anchor rule withdraws a parent with delete, every same-direction dependent row written onto that parent becomes withdrawn and loses its remote_id, so a parent created again later receives its dependents again. With deactivate they stay as they are, because deactivation never touches consent (ADR-0016). A dependent record deleted in the source sends nothing.

remote_gone is resolved again. A target that reports the parent gone makes the dependent row remote_gone, at the version that failed. Unlike for a root domain, this is not terminal: on the record's next change it is offered again and its anchor is looked up afresh.

No first-activation backfill for a dependent rule. DeliveryCursor::openFor() opens a dependent rule's cursor in delta with backfill_until = null, against the default of ADR-0037. This was the user's decision. A consent backfill into Positive User fires consent_requested for every unconfirmed contact, which sends one double opt-in mail each. A parent that already existed in the target when the dependent rule was activated gets its consent once that consent changes. CFX-252 brings the guarded backfill, and its side-effect guard has to cover dependent rules explicitly.

The blueprint creates the dependent rule with its parent. SyncRuleBlueprintService creates a dependent rule right after its parent rule, for every dependent domain the direction matrix allows between the pair, and never on its own. Whether the source may hand over a dependent domain is decided by whether it may hand over the parent domain, because systems.capabilities lists root domains only, and eligibleCapabilities() keeps the widget opt-in in force.

Consequences

  • A stopped customers rule also holds back withdrawals. Stopping the customers rule stops its consent rule entirely, including a consent being withdrawn. The support runbook says so, and the way out is to lift the stop on the customers rule.
  • A new contact reads "never asked" until the consent rule's next chain. The contact is created without consent, and the consent rule writes it right after: in the same tick where its chain runs after the customers chain, otherwise on the next one. The window is accepted.
  • A burst may meet a 429. The delivery budget takes one token per writer call, while a Positive User consent write is up to three requests (field, suppression flag, event). The target counts more than Conflux does, and the rate-limit handling of ADR-0036 catches what the budget misses. Should 429s show up in traces, a writer can declare a request cost per call.
  • A failing anchor lookup is a rule fault. It ends the run under ADR-0029, and the next tick tries again.
  • Existing parents wait for CFX-252. Until the guarded backfill exists, a consent rule activated over an existing dataset delivers only what changes from then on. That holds where the customers rule is new as well: the contacts its backfill creates do not wake their consents.
  • A parent that changed after the activation counts as new. The cut is the parent's change signal, so an existing customer whose record changed after the anchor's activation is created by delta and wakes its consents. That is a real change the customer made, and it is the same moment a shop subscription would be asked for anyway.
  • Two-way conflicts are a separate decision. Where a dependent rule exists in both directions, echo and conflict are settled as ADR-0043 describes.