Fallback Application Chains
A primary application plan says this product, at this rate, on this field, in this window. Reality rarely cooperates: a wind gust closes the drift window, a cool spring pushes the crop past the product’s label stage, a pivot reprojects a buffer geofence over a waterway, or the tank mix simply is not in the shed yet. A fallback application chain is the pre-approved, ordered set of contingency directives that decides — deterministically — what happens next when the primary window is invalidated. It is the agronomic-decision counterpart to the transport-level Fallback Routing Logic: where that subsystem decides how a record reaches the server when a network path dies, this one decides whether and how a treatment happens when a field condition dies. Both sit under the Crop Application Timing & Agronomic Validation engine, which mandates a conservative, auditable default whenever the ideal action is no longer legal or effective.
Problem Framing: The Cost of an Ad-Hoc Retry
The sub-problem this section owns is narrow and unforgiving: given an application request that has failed its primary readiness check, choose exactly one downstream action — reschedule, substitute, hold, or abort — such that the chosen action is still both agronomically effective and legally compliant, and record why. The failure mode is not a crash. It is a human in a cab making a judgment call under time pressure and getting it subtly wrong.
The cost is asymmetric across the wrong answers. Applying the same product a day late when the crop has already advanced past its label stage wastes the input and can trigger phytotoxicity or an illegal residue. Substituting a different product to “save the pass” when a restricted-entry interval is still active is a worker-safety violation, not just an efficacy question. And skipping the treatment entirely when a simple weather deferral would have worked forfeits yield to pest or nutrient pressure. A deterministic fallback chain removes the judgment call: every degraded window resolves to the single highest-value action whose guard conditions still hold, and the conservative default — hold and escalate to scouting — is chosen only when nothing else is compliant. Because the chain is data-driven and versioned, the same degraded inputs always produce the same directive, which is what makes the decision defensible in an audit months later.
Prerequisites and Dependencies
The chain evaluator is a pure decision layer. It assumes the readiness verdict and the inventory/compliance facts have already been computed upstream and handed to it as a typed request; it never reads sensors or opens sockets itself.
- A normalized application request with compliance flags. The upstream Crop Application Timing & Agronomic Validation engine emits the verdict this chain reacts to. That verdict aggregates the drift window from Weather Window Logic, the phenological gate from Growth Stage Mapping, the setback state from Buffer Zone Calculations, and the limits published by Threshold Tuning.
- A live inventory and equipment snapshot. Substitution is only real if the alternate product is physically in stock and label-legal, so the chain needs a current view of chemical inventory and applicator readiness ingested through the Farm Data Ingestion & Field Boundary Synchronization pipeline.
- Encoded regulatory constraints. Restricted-entry intervals, maximum rates, and buffer mandates arrive as executable predicates from the EPA/USDA Rule Mapping service inside the Agricultural Automation System Architecture & Compliance framework.
- A stable data contract. Every field identifier and typed metric conforms to the Field Schema Design specification so a malformed request is rejected before it can reach a guard.
- Python runtime and libraries. The evaluator targets Python 3.10+ so it can use
match/casefor directive routing. It depends onpydanticv2 for request validation (see the Pydantic models reference) plus standard-librarylogging,dataclasses,enum, anddatetime.
| Dependency | Minimum version | Role in the subsystem |
|---|---|---|
python |
3.10 | match/case directive routing, structural typing |
pydantic |
2.0 | Strict validation of the inbound application request |
logging (stdlib) |
— | One-object-per-line audit emission |
| Inventory/equipment feed | — | Confirms a substitute is in stock and the applicator is ready |
Architecture of This Subsystem
The evaluator is an ordered chain of guarded directives. Each link pairs a tier (the action) with a guard (a predicate over the request). The engine walks the chain top to bottom and selects the first link whose guard passes; the final link is an unconditional catch-all, so the walk is guaranteed to terminate on a conservative hold rather than falling through to nothing. Ordering encodes priority: a hard legal bar is evaluated before any productive action, a simple weather deferral before a product substitution, and the hold-and-monitor default last.
Five tiers compose the default chain:
COMPLIANCE_BLOCKED— a hard legal bar (an active restricted-entry interval) forbids any application; abort and flag.DEFER— the block is a transient weather condition only; reschedule the same product and rate to the next viable window.SUBSTITUTE— the primary product is ineligible (stage closed or out of stock) but a label-legal alternate is in inventory and conditions otherwise permit; apply the substitute.HOLD_MONITOR— no compliant productive action exists; suppress the application and open a scouting task for human review.PRIMARY— evaluated before the chain walk: the readiness check actually passed, so execute as planned.
Integration points with sibling subsystems:
- Inputs: a validated
ApplicationRequestcarrying the primary verdict and the weather, growth-stage, buffer, inventory, and re-entry flags, plus an optionally injected chain definition. - Outputs: a single
FallbackDecision(tier + rationale + audit entry) and a downstream dispatch directive. ADEFERhands a reschedule obligation back to the weather scheduler; aSUBSTITUTEqueues a new tank mix; aHOLD_MONITORopens a scouting task. - Cross-references: transport delivery of the resulting record is governed by Fallback Routing Logic, and the reschedule cadence for a deferred pass is driven by Async Polling Strategies.
Step-by-Step Implementation
The evaluation runs in four deterministic stages. Each either terminates with a FallbackDecision or advances to the next guard.
- Validate the request. Reject a structurally invalid payload at the Pydantic boundary before any guard runs, so a bad frame never selects a treatment.
- Short-circuit on a valid primary window. If the readiness check passed and weather, stage, and buffer are all clear, return
PRIMARYimmediately — no fallback is needed. - Walk the guarded chain in priority order. Evaluate each guard against the request, recording the pass/fail of every guard for the audit trail, and select the first that passes.
- Map the selected tier to a dispatch directive. Translate the tier into the concrete downstream action (
reschedule_next_window,queue_substitute_product,open_scouting_task, orabort_and_flag_compliance) withmatch/case.
The decision inputs that drive the guards:
| Name | Type | Default | Purpose |
|---|---|---|---|
primary_failed |
bool |
— | Whether the upstream readiness check rejected the primary window |
weather_ok |
bool |
— | Drift/precipitation window is currently open |
growth_stage_ok |
bool |
— | Crop is still inside the product’s label stage |
buffer_clearance_ok |
bool |
— | Setback geofence is satisfied for the applied geometry |
primary_inventory_ok |
bool |
— | Primary product is physically in stock and applicable |
substitute_inventory_ok |
bool |
— | A stage-appropriate alternate product is in stock |
substitute_label_ok |
bool |
— | The alternate product is label-legal under current conditions |
within_reentry_interval |
bool |
— | A restricted-entry interval is active, barring field entry |
The following module is production-shaped: strict validation, a data-driven guarded chain, a per-decision audit record listing every guard evaluated, and match/case directive routing.
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Callable
from pydantic import BaseModel, Field
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("fallback_application_chain")
class FallbackTier(str, Enum):
PRIMARY = "primary"
DEFER = "defer_same_product"
SUBSTITUTE = "substitute_product"
HOLD_MONITOR = "hold_and_monitor"
COMPLIANCE_BLOCKED = "compliance_blocked"
class ApplicationRequest(BaseModel):
field_id: str = Field(..., min_length=4, max_length=12)
crop_code: str
product_sku: str
target_rate: float = Field(..., gt=0)
evaluated_at: datetime
primary_failed: bool
weather_ok: bool
growth_stage_ok: bool
buffer_clearance_ok: bool
primary_inventory_ok: bool
substitute_inventory_ok: bool
substitute_label_ok: bool
within_reentry_interval: bool
@dataclass(frozen=True)
class ChainLink:
tier: FallbackTier
guard: Callable[[ApplicationRequest], bool]
rationale: str
# Ordered by priority: a hard legal bar first, the productive actions next,
# and an unconditional conservative default last so the walk always terminates.
DEFAULT_CHAIN: tuple[ChainLink, ...] = (
ChainLink(
FallbackTier.COMPLIANCE_BLOCKED,
lambda r: r.within_reentry_interval,
"Active restricted-entry interval; no application permitted",
),
ChainLink(
FallbackTier.DEFER,
lambda r: (
not r.weather_ok
and r.growth_stage_ok
and r.buffer_clearance_ok
and r.primary_inventory_ok
),
"Transient weather block; reschedule same product to next viable window",
),
ChainLink(
FallbackTier.SUBSTITUTE,
lambda r: (
r.weather_ok
and r.buffer_clearance_ok
and (not r.growth_stage_ok or not r.primary_inventory_ok)
and r.substitute_inventory_ok
and r.substitute_label_ok
),
"Primary product ineligible; apply stage-appropriate labelled substitute",
),
ChainLink(
FallbackTier.HOLD_MONITOR,
lambda r: True,
"No compliant option available; hold and escalate to scouting",
),
)
@dataclass
class FallbackDecision:
tier: FallbackTier
rationale: str
audit: dict[str, Any]
def _finalize(
request: ApplicationRequest,
tier: FallbackTier,
rationale: str,
guards_seen: list[dict[str, Any]],
) -> FallbackDecision:
entry: dict[str, Any] = {
"event_id": f"{request.field_id}:{request.evaluated_at.isoformat()}",
"field_id": request.field_id,
"product_sku": request.product_sku,
"selected_tier": tier.value,
"rationale": rationale,
"guards_evaluated": guards_seen,
"decided_at": datetime.now(timezone.utc).isoformat(),
}
emit = logger.info if tier is FallbackTier.PRIMARY else logger.warning
emit("CHAIN_DECISION | field=%s tier=%s | %s", request.field_id, tier.value, rationale)
return FallbackDecision(tier=tier, rationale=rationale, audit=entry)
def evaluate_chain(
request: ApplicationRequest,
chain: tuple[ChainLink, ...] = DEFAULT_CHAIN,
) -> FallbackDecision:
"""Select exactly one fallback tier for a failed application window."""
guards_seen: list[dict[str, Any]] = []
# Stage 2: a genuinely valid primary window needs no fallback.
if (
not request.primary_failed
and request.weather_ok
and request.growth_stage_ok
and request.buffer_clearance_ok
):
return _finalize(request, FallbackTier.PRIMARY,
"Primary window valid; execute as planned", guards_seen)
# Stage 3: walk the guarded chain in priority order, first match wins.
for link in chain:
passed = link.guard(request)
guards_seen.append({"tier": link.tier.value, "passed": passed})
if passed:
return _finalize(request, link.tier, link.rationale, guards_seen)
# Defence in depth: never trust an externally supplied chain to be terminal.
return _finalize(request, FallbackTier.HOLD_MONITOR,
"Chain exhausted with no catch-all; forcing conservative hold",
guards_seen)
def dispatch_directive(tier: FallbackTier) -> str:
"""Map a selected tier to a concrete downstream action."""
match tier:
case FallbackTier.PRIMARY:
return "execute_primary"
case FallbackTier.DEFER:
return "reschedule_next_window"
case FallbackTier.SUBSTITUTE:
return "queue_substitute_product"
case FallbackTier.HOLD_MONITOR:
return "open_scouting_task"
case FallbackTier.COMPLIANCE_BLOCKED:
return "abort_and_flag_compliance"
A weather-only block on an otherwise healthy request selects DEFER and emits a single structured decision line:
2026-07-02 14:03:11 | WARNING | CHAIN_DECISION | field=NW-14 tier=defer_same_product | Transient weather block; reschedule same product to next viable window
A request whose crop has advanced past the label stage while a legal substitute sits in inventory selects SUBSTITUTE; a request with an active restricted-entry interval short-circuits to COMPLIANCE_BLOCKED before any productive tier is even considered:
2026-07-02 14:07:52 | WARNING | CHAIN_DECISION | field=NW-14 tier=substitute_product | Primary product ineligible; apply stage-appropriate labelled substitute
2026-07-02 15:22:03 | WARNING | CHAIN_DECISION | field=SE-02 tier=compliance_blocked | Active restricted-entry interval; no application permitted
Edge Cases and Known Failure Modes
Most incidents in this subsystem trace back to stale inputs or a chain edited without regard to guard ordering. Each has a deterministic remediation.
| Condition | Symptom | Fix |
|---|---|---|
| Growth-stage flag lags a fast-advancing crop | DEFER selected repeatedly, then a late same-product pass past the label stage |
Bound the acceptable staleness of growth_stage_ok; if the stage reading is older than tolerance, treat it as False so the chain prefers substitution or hold |
| Inventory feed times out mid-evaluation | substitute_inventory_ok defaults to a stale True, queuing a mix that is not in the shed |
Fail closed: an unreachable inventory API yields False, dropping the request to HOLD_MONITOR rather than a phantom substitution |
| Buffer geofence not yet reprojected after a wind shift | buffer_clearance_ok still True over a now-restricted setback |
Recompute the setback on the current wind vector before evaluation; a breach forces HOLD_MONITOR, never a productive tier |
| Custom chain edited to drop the catch-all | Some inputs match no guard | The evaluate_chain fallthrough forces HOLD_MONITOR; keep it — do not rely on the config being terminal |
| Re-entry interval encoded as advisory | SUBSTITUTE fires while a restricted-entry interval is active |
Keep within_reentry_interval a hard COMPLIANCE_BLOCKED guard ordered first; it must outrank every productive tier |
| Schema mismatch in the inbound request | Guard reads a missing attribute and raises | Validate at the Pydantic boundary; a ValidationError is rejected upstream and never reaches a guard |
Compliance and Audit Integration
Every decision is recoverable because _finalize records not just the selected tier but the ordered list of every guard it evaluated and whether each passed. An inspector can therefore reconstruct why a field was held — for example, that the weather guard failed, the substitute guard failed for lack of stock, and the catch-all fired — rather than seeing only the outcome. That audit record is appended, unmodified, to the immutable ledger owned by the compliance layer; the evaluator only ever produces new entries and never rewrites one.
Two guarantees are non-negotiable. First, legal precedence: the restricted-entry-interval guard is ordered ahead of every productive tier, so a re-entry restriction can never be out-voted by an efficacy or inventory consideration — a rule that maps directly to the worker-protection re-entry provisions the EPA enforces under the Worker Protection Standard and the label-rate limits codified in 40 CFR. Second, honest state: a deferred or substituted pass is written as exactly that, not as a completed primary application, so downstream reporting to bodies such as USDA NRCS reflects what actually reached the field. The chain definition itself is versioned so that a decision made last season can be replayed against the exact guard set in force at the time, keeping historical decisions reproducible even after the chain is retuned.
Verification
Confirm correct operation in staging with a reproducible injected-fault scenario before any chain change reaches production. Construct a request that fails only the weather guard and assert the exact expected tier and directive.
def test_weather_only_block_defers_same_product():
request = ApplicationRequest(
field_id="NW-14",
crop_code="ZEAMX",
product_sku="HERB-221",
target_rate=1.2,
evaluated_at=datetime.now(timezone.utc),
primary_failed=True,
weather_ok=False, # the only failing condition
growth_stage_ok=True,
buffer_clearance_ok=True,
primary_inventory_ok=True,
substitute_inventory_ok=True,
substitute_label_ok=True,
within_reentry_interval=False,
)
decision = evaluate_chain(request)
assert decision.tier is FallbackTier.DEFER
assert dispatch_directive(decision.tier) == "reschedule_next_window"
# The audit trail must show the compliance guard was checked and cleared first.
tiers_checked = [g["tier"] for g in decision.audit["guards_evaluated"]]
assert tiers_checked[0] == FallbackTier.COMPLIANCE_BLOCKED.value
The run must select DEFER and record the COMPLIANCE_BLOCKED guard as evaluated-and-failed before the deferral. Then flip growth_stage_ok to False and substitute_inventory_ok to False and confirm the decision drops to HOLD_MONITOR with directive open_scouting_task; flip within_reentry_interval to True and confirm it short-circuits to COMPLIANCE_BLOCKED regardless of every other flag. A staging run that selects a productive tier while a re-entry interval is active, or that returns anything other than HOLD_MONITOR when no guard matches, is a release blocker.
Frequently Asked Questions
Why not let the operator pick the fallback action in the cab?
Because the wrong pick is expensive and easy to make under time pressure — a same-product pass past the label stage, or a substitution during an active restricted-entry interval, both read as violations during an audit. The chain encodes the priority once, versioned and reviewed, so every degraded window resolves the same way and the decision is defensible later. The operator still sees the rationale and can escalate, but the default is deterministic, not discretionary.
What happens when no fallback tier is compliant?
The chain terminates on HOLD_MONITOR: the application is suppressed and a scouting task is opened for human review, never a silent skip or a forced pass. The evaluate_chain fallthrough enforces this even if a custom chain definition accidentally omits the catch-all, so a misconfigured chain fails conservative rather than fails open.
How is deferring different from the transport-level fallback routing?
This chain decides the agronomic action — reschedule, substitute, or hold — when a field condition invalidates the plan. Once an action produces a record, delivering that record to the server across flaky connectivity is a separate concern handled by Fallback Routing Logic. One is decision fallback; the other is transport fallback, and they compose without overlapping.
Related
- Weather Window Logic — produces the drift-window verdict that triggers a
DEFER. - Growth Stage Mapping — the phenological gate that decides when a substitute is required.
- Buffer Zone Calculations — the setback state that can force a hold regardless of product choice.
- Threshold Tuning — recalibrates the limits the upstream readiness check applies.
- Fallback Routing Logic — the transport-level counterpart that delivers the resulting record.
- EPA/USDA Rule Mapping — encodes the re-entry and rate constraints the guards enforce.