Skip to content

0043. Resolve a two-way conflict on a dependent record by source time

Date: 2026-10-02 Amended: 2026-10-07 Status: Accepted

Context

A dependent domain can flow in both directions between the same two connections. Consent does: a shop customer's newsletter flag goes to Positive User, and a withdrawal recorded in Positive User has to come back to the shop (ADR-0041 makes the shop the hub). With one mirror per system and a rule in each direction, two things go wrong without a decision.

  • Echoes. A consent delivered from the shop into Positive User arrives back in Positive User's mirror, moves its change signal, and the return rule would write the same value into the shop again, and so on.
  • Conflicts. Both sides change the same consent between two ticks. Each rule sees its own source as newer than what it last delivered, and each would overwrite the other. Which value ends up in both systems would depend on which chain runs last.

ADR-0042 says which target record a dependent record is written onto. This decision says when it is written at all, once the anchor is known.

Decision

DependentEchoes::settle() decides for every dependent candidate whose anchor is known, before anything is sent.

Echo against the target's own mirror. The target's mirror is the row on the target connection with parent_source_id equal to the anchor and the same slot (for consent, the same channel). Where it already holds the candidate's value, nothing is sent and the candidate is recorded suppressed at its current version. "Holds the value" is the dependent record's own question, DependentRecord::holdsSameValueAs(), so a later dependent domain such as product_stock defines its own. For consent it compares subscribed and confirmed only, and a null on either side is no difference. Those are the two fields that decide a consent; timestamps, source and ip_address legitimately differ between systems for the same consent, and comparing them would never call anything an echo. Without a target mirror the candidate is delivered.

A mirror row the target cannot report in full counts as no mirror. Where the target's integration declares through HasFullyReportedConsentChannels that it reports a channel one way only, its mirror row on that channel holds the last value seen, not the live state, and the candidate is delivered with neither the echo nor the conflict check. Positive User's SMS mirror is such a row; see ADR-0045.

The conflict check runs only when the return rule is due. The return rule is the non-deleted rule of the same domain from the target back to the source, active or not: an inactive one still delivers the older value once it is switched on. It is due when the target's mirror changed after the version that rule last delivered, or it never delivered it. Without a return rule, or when it is not due, the candidate is delivered.

The later consent_updated_at wins, a tie goes to the candidate in hand. Each side's time is DependentRecord::reportedAt(), the moment the source last reported that state as current. Where the mirror's is later, the candidate loses and is recorded suppressed; the other direction delivers it on its own run. Otherwise the candidate wins. Giving a tie to the candidate in hand makes the outcome deterministic, and the other side is already marked by the time its run gets there.

The winner supersedes the other side. A winning candidate is written, and the return rule's ledger row for the mirror is recorded suppressed at the mirror's current version. Without that mark the return rule would see no conflict on its next run, because the winning value has not reached the mirror yet, and would deliver the older value after all. The mark is set whatever the write returned: a failed write is retried by the drain, and the retry finds the return rule no longer due.

Everything after the echo check runs under a pair lock. Both directions take the same cache lock, keyed by the two rule ids in sorted order, with a TTL of 60 seconds, comfortably above the slowest write made while it is held, and a wait of 5 seconds. Under it the candidate's own ledger row is read again, because the other direction may have superseded it since the scan read the row; then come the due check, the comparison, the write and the supersede mark. A lock that cannot be had within the wait throws, and that is a rule fault under ADR-0029: the run ends faulted and the next tick tries again.

Consequences

  • A consent travels once per change. An echo stops at the first comparison, and the ledger's version check stops a repeat, so a value delivered one way does not come back.
  • Exactly one write per conflict. When both directions are due for the same contact at once, the later consent_updated_at is written and the other side is marked superseded.
  • A winner whose write ends exhausted leaves the two systems apart. The other side was already superseded, so both systems keep their values until either changes again; the target keeps the losing one. This is visible in the ledger as an exhausted row and is accepted rather than replayed.
  • Source time is trusted. The decision rests on each system's own consent_updated_at. A system whose clock is far off can win a conflict it should have lost. This is why consentUpdatedAt stays the one required source datetime (ADR-0041).
  • The lock serialises a pair, not a rule. The two directions of one pair wait on each other for one write at a time, and a wait beyond 5 seconds faults the run rather than holding a worker; rules of other pairs are not affected. A candidate with no target mirror, an echo, or a pair with no return rule takes no lock at all.