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_atis written and the other side is marked superseded. - A winner whose write ends
exhaustedleaves 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 anexhaustedrow 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 whyconsentUpdatedAtstays 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.