Skip to content

0016. Set delivery policy per rule

Date: 2026-08-24 Status: Partially superseded by 0019 — the three-value cause taxonomy in the Decision below is reduced to two there, because the reconciled_absent paragraph no longer describes anything the system can produce; the per-rule policy decision, the deactivate consent boundary and everything else remain in effect, except the Decision's own framing of ignore as the default that closes the uninstall case — that closure is 0019's connection_lifecycle cause filter, not the column default. A dependent rule's policy is fixed rather than set per rule: always overwrite and ignore (ADR-0042). The create_only consent warning is gone: consent is a dependent domain with a rule of its own, so a create_only customer rule no longer holds back a consent change (ADR-0041).

Context

Outbound delivery is a capability before it is a behaviour. The direction matrix says what a system is able to receive, and ADR-0005 already refuses a SyncRule whose target cannot receive the domain that rule carries. Neither of them says what a customer wants done with the ability. The system says what is possible. The rule says what is wanted. Those are two different statements, and they need two different homes.

The second home already exists. A SyncRule is the (source connection, domain, target connection) triple, so it is the narrowest row in the model that a customer's answer can hang off. It also already carries validation of exactly this kind. The saving() hook checks the flow against the direction matrix, and it refuses a rule whose two ends belong to different tenants (ADR-0010). Delivery policy on the rule therefore adds columns to a row that is already the per-connection statement of intent, and it invents no entity to hold them.

The two obvious alternatives fail on the same point. At system level, one customer who wants deletions propagated forces that behaviour on everyone connected to the same system, because a system row is shared by every connection to it. In global configuration the value is the same for every tenant. Every later exception then becomes a code change plus a migration rather than a field somebody sets on one rule. The requests this record anticipates are per customer: one WooCommerce shop wants a removal in the shop to reach the consuming system, and the next shop considers exactly that data loss.

None of this exists in code yet. The update_mode and delete_policy columns arrive with CFX-217, and the deletion_reason column the third decision reads arrives with CFX-212. Deciding the policy now means the writers built by CFX-215 through CFX-218 arrive knowing what a rule may ask of them, rather than each answering that question its own way.

Decision

update_mode defaults to overwrite, and create_only stays selectable. Every update sends the full canonical payload, and there is no third-party work on the record to preserve by omission. Conflux created that record, and under ADR-0015 nobody else uses it. The target merges what it receives, so a full payload blanks nothing. The ledger from ADR-0014 therefore stores no hashes at all, because nothing has to decide which fields to send. The table itself arrives with CFX-214. create_only is for a rule that seeds a target once and then leaves it alone. It is the wrong choice for any rule that has to carry a consent change, which is every customer rule. A withdrawal recorded in the shop after the first delivery would never leave Conflux.

No longer true for consent

This paragraph describes consent riding on the customer. Consent is now a dependent domain with its own rule (ADR-0041), whose policy is fixed at overwrite, so a create_only customer rule loses no consent change. The warning that once flagged such a rule — UpdateMode::losesConsentChangesFor(), the hint on the rule form and the blueprint's log line — was removed with it.

delete_policy defaults to ignore, with deactivate and delete available. A WooCommerce operator uninstalling the plugin has not thereby decided that the consuming system should lose its data. What an uninstall removes is Conflux's mirror of that shop, and ADR-0013 scopes it that way on purpose. ignore is the only default that cannot destroy data somebody else is answerable for. A customer who does want removals to reach the target says so on the rule: deactivate where the target has a lifecycle state to move the record into, delete where removal is what they mean.

Whatever a writer maps deactivate onto has to mean lifecycle state only, never consent or opt-in. A record leaving a shop says nothing about what that person agreed to. Mapping the deactivation onto a consent field would make an ordinary removal read as an objection inside the target. That is a consent error rather than a bookkeeping one.

A deletion propagates according to its recorded cause, never according to the policy alone. The policy says what the customer permits. The cause says what actually happened. Removing a record from a system Conflux does not own needs both. The deletion_reason column that carries the cause arrives with CFX-212, and it has three values.

source_event propagates according to the policy. The shop stated that the record is gone, through a webhook or an equally explicit signal. The statement therefore belongs to the source, and the policy is free to act on it.

reconciled_absent propagates only where the policy says so explicitly, and never under the default. Absence in a full sync is an inference rather than a statement. A truncated source response, a filter that quietly narrowed the set, and a real deletion all look identical from here. Acting on the inference by default would turn one bad response into a mass delete instruction inside somebody else's live system.

connection_lifecycle never propagates, whatever the policy says. A disconnect or an uninstall removes rows from Conflux's mirror, and it says nothing about the records those rows were delivered to. That is the boundary the default above rests on, stated as a rule the runner checks per record.

Both columns are validated against the target writer's declared limits at save time. A writer declares what it can do with a record it created, including whether the target has an inactive state to move one into. Choosing deactivate against a target that has none is refused when the rule is saved, not discovered during a delivery. The difference matters. A delivery that finds out mid-run has no good answer left: it can fail the record, or it can quietly do something the customer never asked for. The check lives in the existing saving() hook on SyncRule, alongside the direction and tenant checks, and it arrives with the two columns in CFX-217. It inherits that hook's bypass for inactive rows, so an unusable policy can be staged on a draft exactly as an invalid direction can.

When several sources feed one target, the duplicates are deliberate, across all three domains. Conflux creates its own record per source record, so one person who bought in a tenant's WooCommerce shop and in its PrestaShop shop arrives in the target twice. Orders and products behave the same way. ADR-0015 already accepts this duplication between a record Conflux created and a contact the target workspace held before. Two sources are the same situation, with a second shop in place of the workspace. Deduplicating across sources would need a per-field answer to which source wins. ADR-0003 declined to provide one. Cross-system identity is linkage between per-connection rows there, never a golden record with field-level authority. Deduplication therefore stays out of scope here, and the decision that changes it will be its own record.

The source owns every field an active rule delivers. For the domains a rule carries, the shop is the authority and the target holds a copy. Conflux does not learn about an edit made inside the target, and v1 has no mechanism that could. Nothing reads a record back, so nothing can compare the two copies. This is written down rather than left implied, because the opposite assumption is the natural one. A reader who assumed otherwise would expect a delivery to notice a local edit and leave it standing.

A write into a target is indistinguishable from a person editing that record by hand, and that is verified per target instance rather than solved in code. A write does not trigger anything by itself. It sets off whatever that target instance has been configured to do when a record changes, which may include contacting people. Nothing happens without such a configuration. That makes the exposure a property of one customer's setup rather than of the target's API. It is also why nothing in the request removes it. There is no payload flag that rules it out.

The answer is therefore procedural. Before a backfill runs against a live target, somebody who can read that instance's configuration confirms that a change of this size is safe there, and that confirmation is recorded. CFX-218 AC 8 carries the guard. Whether a bulk path can suppress the side effects at all is a separate capability question per target, and ADR-0015 already sets the condition a usable bulk path has to meet.

Consequences

A manual edit in the target is overwritten, and nobody finds out. Somebody with access to the target can correct a name or a phone number on a record Conflux created. The next delivery for that source record sends the full payload and puts the old value back. Conflux never reads the record, so it cannot report the overwrite. The person who made the edit sees no error either, because from the target's side an ordinary update arrived and was applied. This is a cost of the default rather than a defect awaiting a fix. Detecting drift by reading records back is a per-rule policy of its own, and it needs an answer for the case where the two copies disagree. That decision is not taken here.

A full payload every time means writes that change nothing. content_changed_at records that a source row changed, and CFX-139 sets it without knowing which fields any given writer sends. ADR-0014 requires that neutrality, because the read mode reads the same signal. A rule whose writer never sends the field that changed therefore still produces a delivery: a request into a live system, a ledger row, and whatever that target instance does when a record changes. It is a pointless call, and it is paid per change rather than per delivered difference. Narrowing the signal per writer would push writer knowledge into the stage that must not hold it. The pointless call is the accepted cost instead, not a defect to fix.

Field-level authority and provenance stay out. Recording which system last wrote each field, and then deciding per field which system wins, is the machinery of a golden record. ADR-0003 declined that model deliberately. A policy that granted one field to the shop and the next to the target would need exactly the authority ADR-0003 says Conflux does not have. The rule-level answer above is the coarse one on purpose: either the source owns the record, or the rule is not active.