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.