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.

Fallback application chain: ordered guards and audit ledger A failed ApplicationRequest enters a left-to-right chain of three guards. The first guard checks whether a restricted-entry interval is active; on a match it branches down to COMPLIANCE_BLOCKED (abort and flag). If not, flow continues to a weather-only guard that, when stage, buffer and primary stock are all clear, branches down to DEFER (reschedule the same product). If not, flow continues to a substitute guard that, when a label-legal alternate is in stock, branches down to SUBSTITUTE (queue a new mix). If no guard matches, flow reaches the unconditional HOLD_MONITOR catch-all, which opens a scouting task. Dashed lines run from each selected tier down into a single append-only per-decision audit ledger. ApplicationRequest primary_failed Re-entry active? restricted-entry Weather-only block? stage + buffer ok Substitute legal? + in stock HOLD_MONITOR catch-all · scout COMPLIANCE_BLOCKED abort + flag DEFER same product SUBSTITUTE queue new mix Append-only per-decision audit ledger every guard evaluated is recorded · immutable · replayable against the versioned chain no no no no yes yes yes

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.

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:

  1. COMPLIANCE_BLOCKED — a hard legal bar (an active restricted-entry interval) forbids any application; abort and flag.
  2. DEFER — the block is a transient weather condition only; reschedule the same product and rate to the next viable window.
  3. 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.
  4. HOLD_MONITOR — no compliant productive action exists; suppress the application and open a scouting task for human review.
  5. PRIMARY — evaluated before the chain walk: the readiness check actually passed, so execute as planned.
FallbackTier selection: priority-ordered guard walk to a single directive A vertical guard walk on the left maps to terminal tier directives on the right. evaluate_chain enters a primary-window check; if the window is valid it branches right to PRIMARY, which dispatches execute_primary. If the primary failed the walk falls down to the restricted-entry guard; a match branches right to COMPLIANCE_BLOCKED, dispatching abort_and_flag_compliance. If not, it falls to the weather-only guard; a match branches right to DEFER, dispatching reschedule_next_window. If not, it falls to the substitute guard; a match branches right to SUBSTITUTE, dispatching queue_substitute_product. If no guard matches, the walk reaches the unconditional HOLD_MONITOR catch-all, dispatching open_scouting_task. The first guard that passes ends the walk. evaluate_chain(request) Primary window valid? not primary_failed · weather · stage · buffer ok Restricted-entry active? hard legal bar — evaluated first Weather-only block? stage + buffer + primary stock ok Substitute legal + in stock? primary stage-closed or out of stock HOLD_MONITOR unconditional catch-all — terminal PRIMARY execute_primary COMPLIANCE_BLOCKED abort_and_flag_compliance DEFER reschedule_next_window SUBSTITUTE queue_substitute_product HOLD_MONITOR open_scouting_task valid re-entry active weather only substitute ok no option left primary_failed no re-entry not weather-only no substitute

Integration points with sibling subsystems:

  • Inputs: a validated ApplicationRequest carrying 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. A DEFER hands a reschedule obligation back to the weather scheduler; a SUBSTITUTE queues a new tank mix; a HOLD_MONITOR opens 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.

  1. Validate the request. Reject a structurally invalid payload at the Pydantic boundary before any guard runs, so a bad frame never selects a treatment.
  2. Short-circuit on a valid primary window. If the readiness check passed and weather, stage, and buffer are all clear, return PRIMARY immediately — no fallback is needed.
  3. 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.
  4. 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, or abort_and_flag_compliance) with match/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.

python
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:

text
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:

text
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.

python
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.

Up: Crop Application Timing & Agronomic Validation