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 itssource_idis the target's own id. - Same direction: Conflux created the parent in the target, so the row keyed by the parent's
source_idholds the target's id as itsremote_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.