Skip to content

0033. Validate a delivery policy against the target's writer

Date: 2026-09-18 Status: Accepted

Context

ADR-0016 put update_mode and delete_policy on the rule and said both are "validated against the target writer's declared limits at save time". It was written when a writer declared its capabilities as data. ADR-0021 replaced that with one interface per verb, so there is no declaration left to read: a caller narrows with instanceof.

What ADR-0016 did not settle is which rules the check applies to. The obvious answer, every active rule, is wrong. SyncRule::isDirectionFlowValid(), the direction-matrix check ADR-0005 established, accepts a target that declares push_out or pull_out, and a target that fetches for itself is never handed to a writer. Checking every active rule would refuse exactly the rules for which the question has no meaning.

Decision

A rule is checked when its target declares push_out for that rule's domain. This is the same condition DeliveryRoutes::targetDeclares() uses, so "the push runner will deliver this rule" has one definition rather than two that can drift.

Where the check applies, a missing writer is a refusal, as an unsatisfiable direction already is.

A policy is checked against the verbs it needs, not against whether it is the default. create is unconditional. update follows from overwrite, which is the default, so "only a non-default policy is checked" would leave an overwrite rule saveable against a target that cannot update, and that rule would fail at delivery with UnsupportedDeliveryVerbException. Making that exception unreachable is one of the reasons this check exists.

Deactivating and reactivating are one capability. deactivate is only safe to ask for where the target can also undo it: a record restored at the source would otherwise stay inactive in the target for good. Rather than a second interface and a second check, CanDeactivateRemoteRecords carries both verbs, which departs from the one-interface-per-verb shape of ADR-0021 on purpose. A writer that can deactivate but not reactivate then cannot be written at all, and the deactivate check above covers both verbs without knowing there are two.

Consequences

UnsupportedDeliveryVerbException becomes unreachable from a rule that passed this check. It stays in place for a rule saved before the check existed.

The check cannot re-validate a rule it never saw. A policy accepted earlier is re-checked only on the next save, and DeliveryRunner reports RouteClosed rather than raising when it meets a target with no writer.

An inactive rule is never checked, because saving() returns early for it. A rule parked inactive can therefore hold a policy its writer cannot carry out, and the one automatic path that switches rules back on, SyncRuleBlueprintService::reactivateRestoredDomains(), asks isPolicyDeliverable() before it saves. Left to the hook, the refusal would escape generateFor() before any rule is created, costing every counterpart of the connection instead of the one rule.

SyncRule reaches the service container through app() to resolve the registry. That is a dependency a model would not normally carry, and it is accepted for the same reason hasValidDirectionFlow() reads relations: the question belongs to the rule, and answering it anywhere else would make every caller remember to ask.

The guard is shared rather than duplicated. assertPolicyIsDeliverable() runs privately from the saving() hook; the public isPolicyDeliverable() calls the same guard and converts the refusal to a bool. The Filament relation manager reads it to disable the "active" toggle and explain why in its tooltip, and again to answer the same question for a table column, both without saving the rule. Sharing the guard rather than restating its conditions in the UI is what keeps "the toggle shows this rule as activatable" and "the guard would let this rule save" from drifting apart.

A column default is not enough on its own, because the guard runs inside a model hook that sees the model before the insert applies one. update_mode and delete_policy default to overwrite and ignore at the column, but saving() reads $rule->update_mode against the in-memory attribute, which is null on every creation path through the panel until the row is actually written. A null update_mode skips the update-verb check, and the insert then writes overwrite, so the rule that lands on disk demands a verb the guard was never asked about, which is the exact state the check exists to prevent. SyncRule mirrors both defaults on its $attributes property so the value the guard reads and the value the column would supply agree from the moment the model is constructed.