Resolving Threshold Conflicts on Mixed-Product Pallets

When one temperature probe covers a zone holding several products, whose limit applies? A pallet consolidating a 2–8 °C biologic, a 15–25 °C controlled-room-temperature tablet, and a ≤ −20 °C lyophilised vaccine cannot honour three envelopes with one sensor — the products’ safe ranges do not even overlap. Where they do share a monitored zone, the engine must resolve a single binding limit, and the only defensible resolution is the tightest one: the intersection of every co-located product’s safe band. This guide implements that resolution deterministically in Python, as a focused technique under dynamic threshold mapping for multi-product pallets, where the sealed manifest binds sensors to SKUs but a shared zone still forces a conflict decision.

Regulatory hook

The controlling clause is 21 CFR Part 11 §11.10(e): the limit that produced any disposition must survive as a secure, time-stamped, tamper-evident record, so the resolution rule — not merely the resolved value — must be reconstructable. ICH Q9 quality risk management supplies the resolution principle: corrective action, and therefore the protective limit, must reflect the most at-risk product in the zone, because a reading that is safe for a robust tablet may already be degrading a fragile biologic. Choosing the tightest intersection is the risk-based choice; choosing anything looser silently accepts exposure on the most sensitive SKU, which surfaces as a data-integrity and product-quality finding rather than a mere configuration preference.

Prerequisites

  • Python 3.11 or newer (the example uses dataclasses, Decimal, and type unions).
  • Standard library only: decimal, hashlib, json, and dataclasses cover the resolution engine; fixed-point Decimal keeps the resolved band reproducible.
  • A sealed pallet manifest mapping each sensor to the SKUs in its zone, produced at palletisation as described in dynamic threshold mapping for multi-product pallets.
  • Validated per-SKU envelopes — nominal band, tolerance, and cumulative allowance — sourced from establishing temperature excursion thresholds by product.
  • Access control: the profile repository must be read over an authenticated channel so a resolved limit is provably built from certified envelopes (§11.10(g)).
  • A downstream scorer: the resolved band feeds duration-based scoring for temperature excursions.

The conflict cases at a glance

Before the code, it helps to see the three outcomes a resolver must distinguish. The matrix below shows what happens when two co-located products’ bands relate in each possible way.

Three threshold-conflict outcomes: intersect, degenerate, and reject Three stacked cases compare two per-SKU bands on one shared zone. Overlapping bands resolve to their intersection and monitor; touching bands resolve to a degenerate point that is flagged unmonitorable; disjoint bands have no intersection and the co-location is rejected at seal. Overlap → intersect A · 2–8 °C B · 5–15 °C bind 5–8 °C Touch → flag A · 2–8 °C B · 8–25 °C point 8 °C Disjoint → reject frozen A · 2–8 °C no bind

Step-by-Step Implementation

Step 1 — Model an envelope and the intersection operation

Represent each product’s safe band as a fixed-point interval and define the intersection as the maximum of the lows and the minimum of the highs. Using Decimal keeps the resolved boundary bit-identical on replay, which matters because the resolved limit is an audited value.

python
from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from decimal import Decimal


@dataclass(frozen=True)
class Envelope:
    sku_id: str
    low_c: Decimal      # validated nominal low, inclusive
    high_c: Decimal     # validated nominal high, inclusive
    profile_version: str

    def __post_init__(self):
        if self.high_c <= self.low_c:
            raise ValueError(f"{self.sku_id}: high must exceed low")


@dataclass(frozen=True)
class BindingBand:
    low_c: Decimal
    high_c: Decimal
    monitorable: bool   # False when the intersection is a degenerate point


def intersect(a: Envelope, b: Envelope) -> BindingBand | None:
    # ICH Q9 tightest-limit rule: the binding band is the intersection, protecting
    # the most at-risk product. Return None when the bands are disjoint.
    low = max(a.low_c, b.low_c)
    high = min(a.high_c, b.high_c)
    if low > high:
        return None                         # disjoint: no single band can protect both
    return BindingBand(low, high, monitorable=(low < high))  # low == high is degenerate

Step 2 — Resolve a whole zone deterministically

A zone can hold more than two SKUs, so fold the intersection across every co-located envelope. Sort by sku_id first so the fold order — and therefore any error message — is deterministic and reproducible under §11.10(e).

python
def resolve_zone(envelopes: list[Envelope]) -> BindingBand:
    if not envelopes:
        raise ValueError("cannot resolve an empty zone")
    # Deterministic order so a replay produces identical resolution and errors.
    ordered = sorted(envelopes, key=lambda e: e.sku_id)
    band: BindingBand | None = BindingBand(ordered[0].low_c, ordered[0].high_c, True)
    running = ordered[0]
    for nxt in ordered[1:]:
        result = intersect(running, nxt)
        if result is None:
            # §11.10(e): a disjoint co-location is an integrity error, not a
            # silently loosened limit. Reject so the manifest cannot ship blind.
            raise ValueError(
                f"disjoint envelopes in zone: {running.sku_id} vs {nxt.sku_id}"
            )
        band = result
        # Carry the tightened band forward as a synthetic envelope for the fold.
        running = Envelope("_folded_", band.low_c, band.high_c, running.profile_version)
    assert band is not None
    return band

Verify the three cases from the matrix resolve as specified:

python
bio = Envelope("BIO-774", Decimal("2"), Decimal("8"), "3.1.0")
crt = Envelope("CRT-112", Decimal("5"), Decimal("15"), "2.4.0")
touch = Envelope("CRT-112", Decimal("8"), Decimal("25"), "2.4.0")
frozen = Envelope("VAC-990", Decimal("-25"), Decimal("-15"), "4.0.0")

assert resolve_zone([bio, crt]) == BindingBand(Decimal("5"), Decimal("8"), True)  # overlap
assert resolve_zone([bio, touch]).monitorable is False                            # degenerate point
try:
    resolve_zone([bio, frozen])                                                   # disjoint
    assert False, "expected rejection of disjoint co-location"
except ValueError:
    pass

Step 3 — Refuse to ship an unmonitorable or disjoint zone

Resolution that yields a degenerate point (bands merely touching) or no intersection is not a limit an engine can enforce with one sensor. Surface it at pallet seal, where a planner can split the load or add a dedicated probe, rather than at 2 a.m. when a reading is already out of band.

python
def validate_zone_or_raise(envelopes: list[Envelope]) -> BindingBand:
    band = resolve_zone(envelopes)          # raises on disjoint
    if not band.monitorable:
        # A single-point band cannot distinguish compliant from excursion, so it
        # is unmonitorable with one probe — reject at seal (§11.10(a) reliability).
        raise ValueError(
            f"zone resolves to a single point {band.low_c} °C; "
            "add a dedicated sensor or split the load"
        )
    return band

Step 4 — Seal the resolution into a tamper-evident record

The binding decision must be provable after the fact, so bind the resolved band, the contributing SKUs, and their pinned profile versions into a fingerprinted record. An investigator can then confirm exactly which limit governed the zone and how it was derived.

python
def seal_resolution(zone_id: str, envelopes: list[Envelope],
                    band: BindingBand) -> dict:
    record = {
        "zone_id": zone_id,
        "binding_low_c": str(band.low_c),
        "binding_high_c": str(band.high_c),
        # Which SKUs and profile versions produced the resolution — §11.10(e)
        # makes the derivation, not just the value, reconstructable. Sorted so the
        # canonical record and its hash are order-independent.
        "contributors": [
            {"sku_id": e.sku_id, "version": e.profile_version}
            for e in sorted(envelopes, key=lambda e: e.sku_id)
        ],
    }
    canonical = json.dumps(record, sort_keys=True, separators=(",", ":"))
    # §11.10(e) tamper evidence: a fingerprint over the resolution so a later edit
    # of the binding limit is detectable.
    record["resolution_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    return record

Confirm the sealed record is reproducible for the same inputs:

python
band = validate_zone_or_raise([bio, crt])
r1 = seal_resolution("ZONE-A", [bio, crt], band)
r2 = seal_resolution("ZONE-A", [crt, bio], band)   # input order must not matter
assert r1["resolution_hash"] == r2["resolution_hash"]

Compliance Validation Checklist

Run this as part of computerized-system validation; every item is something an auditor can independently confirm for this control.

Troubleshooting

Symptom Root cause Fix
A biologic degrades while readings look compliant Zone bound to a loose or averaged limit Bind to the intersection (max(lows), min(highs)), not a mean or the widest band
Resolution differs between runs for the same pallet Non-deterministic fold order or float boundaries Sort envelopes by sku_id and compute in Decimal
A frozen and a chilled SKU share one probe Disjoint bands accepted at seal Reject disjoint co-locations; split the load or add a dedicated sensor
Engine cannot distinguish compliant from excursion Bands merely touch, giving a single-point limit Flag monitorable = False and refuse the zone at seal
Auditor cannot prove which limit was active Only the resolved value stored, not its derivation Seal contributing SKUs and versions under a resolution_hash (§11.10(e))

For architectural context, this guide supports Dynamic Threshold Mapping for Multi-Product Pallets, part of the broader Temperature Excursion Detection & Automated Rule Engines section.