Out-of-Control Action Plans (OCAP): Codifying the Reaction to Every Signal

An out-of-control action plan is the documented, pre-agreed reaction to a control-chart signal: a decision table that maps each out-of-control condition to a specific containment step, a corrective action, a named owner, and an escalation tier. It exists so that when a rule fires at 2 a.m. the operator does not improvise — the response is already written, deterministic, and traceable. This guide sits under SPC alerting and OCAP response and turns the plan from a laminated wall chart into an engine that resolves a signal to an action record in code.

The alternative to a codified OCAP is the failure mode every audit dreads: a signal is acknowledged and then ignored. A point breaches the upper control limit, someone clicks "seen," and production continues while the disposition of the suspect material is never decided. A real OCAP closes that loop. Every signal produces a containment decision, a root-cause assignment, a disposition of affected product, and a timestamped record that a quality engineer — or an IATF 16949 auditor — can reconstruct months later.

What breaks without this

Without a codified reaction plan, three things break in production. First, the response depends on who is on shift. An experienced line lead contains and quarantines; a new operator resets the machine and keeps running. The same signal yields different outcomes, which is itself an uncontrolled process. Second, containment lags detection. The out-of-control rule detection layer produces a precise rule code within seconds, but if no plan maps that code to an action, the suspect window of product keeps growing while the floor debates what to do. Third, the record is lost. An alert that is merely displayed and dismissed leaves no evidence that the nonconformity was addressed, which is a direct finding against ISO 9001:2015 clause 10.2 (nonconformity and corrective action).

A deterministic OCAP fixes all three by making the reaction a pure function of the signal. Given a rule code, a severity, and the characteristic that alarmed, the engine returns exactly one action record — always the same one — and writes it to an immutable log. The human still executes containment and investigates root cause; the machine guarantees that the decision about what to do is instant, consistent, and recorded.

From a rule violation code to a disposition and audit record A left-to-right flow of five stages. A rule violation code and severity arrive from the detection layer. The out-of-control action plan lookup resolves that code and the alarming characteristic to one row. That row names a containment action such as quarantine and hold. It also names an owner and an escalation tier, from operator up to quality engineer. The final stage writes a disposition and an audit record that closes the loop. A dashed fallback branch beneath the lookup routes any unmatched code to a safe default action and a data-quality alert. Signal rule code + severity OCAP lookup code × characteristic Containment quarantine + hold Owner + escalation tier operator → QE Disposition + audit record closes the loop Fallback default action unmatched code → safe hold + alert no match
A signal resolves deterministically through the OCAP lookup to a containment action, an owner and escalation tier, and a disposition plus audit record. Any unmatched rule code falls back to a safe default hold rather than silently doing nothing.

Decision-table specification

An OCAP is fundamentally a lookup keyed on the identity of the signal, not on its numeric value. The detection layer has already decided that the process is out of control and which pattern fired; the OCAP decides what to do about it. The key is the pair (rule code, characteristic), and where reactions differ by seriousness, severity refines the row.

The reason the reaction must differ by rule code is that the rules carry very different false-alarm rates and therefore different confidence. A single point beyond three sigma — Western Electric rule 1, Nelson rule 1 — has a false-alarm probability per point on a normal, in-control process of

$$P_1 = 2\,\bigl(1 - \Phi(3)\bigr) \approx 0.0027$$

so roughly one false alarm in every 370 points. That is a high-confidence signal warranting immediate containment. A run-based pattern such as nine consecutive points on one side of the centerline has a per-window false-alarm probability of

$$P_{\text{run9}} = 2 \times \left(\tfrac{1}{2}\right)^{9} \approx 0.0039$$

which flags a sustained mean shift rather than a gross excursion, and typically maps to an investigation-and-adjust action instead of an immediate quarantine. Ranking these reactions is exactly the job of a Failure Mode and Effects Analysis risk priority number,

$$\text{RPN} = S \times O \times D$$

where $S$ is severity of the potential nonconformity, $O$ is occurrence, and $D$ is detectability. The OCAP's severity field and escalation tier should track this ranking: a high-severity, hard-to-detect characteristic escalates faster and holds more product than a cosmetic one. The detail of how tiers and their reaction-time budgets are set is covered in escalation tiers and reaction time for OCAP.

Three properties make a decision table trustworthy in production:

  • Determinism. The same key must always resolve to the same action. No randomness, no dependence on wall-clock time or which node processes the event.
  • Total coverage. Every rule code the detector can emit must have an entry, or an explicit, safe default must catch the gap. A KeyError on the shop floor is an outage.
  • Containment before correction. Each row separates the three responses SPC and corrective-action standards require: containment (stop the bleeding — quarantine, hold, 100% inspect), correction (fix this occurrence — adjust, re-center, replace tooling), and preventive/corrective action (stop it recurring — the formal ISO 9001:2015 clause 10.2 loop). Collapsing them into one "fix it" instruction is the most common OCAP design error.

When to use a codified OCAP vs. ad-hoc reaction

A codified engine is not always the right weight of solution. Choose deliberately:

  • Codified OCAP dispatch is correct wherever signals are frequent, the line runs unattended or across shifts, or the product is regulated. If detection is already automated through rule detection, the reaction should be too — otherwise the fast half of the loop feeds a slow, inconsistent human half.
  • A documented manual OCAP (the classic reaction-plan column on a control plan) is sufficient for a low-volume, single-shift cell where an engineer sees every chart in real time. The plan still must exist and be followed; it simply does not need an engine.
  • No OCAP at all is never acceptable for a characteristic under formal SPC. IATF 16949 requires a defined reaction plan for every controlled characteristic; a chart with no documented reaction is an audit non-conformance regardless of volume.

The engine's output does not stop at the decision. Once an action record is resolved it feeds two downstream layers: it is dispatched to the plant systems through routing SPC alerts to ticketing and MES, and it is announced to the responsible humans through real-time alert notification channels. The OCAP engine is the decision core those two layers carry out.

Production-ready Python implementation

The engine below resolves a violation to an action record through a decision table keyed on (rule_code, characteristic) with a characteristic-agnostic fallback and a final safe default. It is deterministic, logs every resolution, and never raises on an unknown code — an unmatched signal returns a conservative hold rather than crashing the alerting pipeline.

from __future__ import annotations

import logging
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from enum import IntEnum
from typing import Optional

logger = logging.getLogger("ocap.engine")


class Severity(IntEnum):
    """Severity classes; higher escalates faster and holds more product."""
    LOW = 1
    MEDIUM = 2
    HIGH = 3
    CRITICAL = 4


@dataclass(frozen=True)
class ActionKey:
    """Immutable lookup key. characteristic=None matches any characteristic."""
    rule_code: str
    characteristic: Optional[str] = None


@dataclass(frozen=True)
class OcapAction:
    """One row of the OCAP: the codified reaction to a class of signal."""
    containment: str          # stop the bleeding: quarantine / hold / 100% inspect
    correction: str           # fix this occurrence: adjust / re-center / replace
    owner_role: str           # role responsible for executing the action
    escalation_tier: int      # 1=operator, 2=line lead, 3=quality engineer
    hold_product: bool        # quarantine suspect material until dispositioned
    severity: Severity = Severity.MEDIUM


@dataclass
class ActionRecord:
    """Resolved, auditable outcome for a single signal."""
    rule_code: str
    characteristic: str
    action: OcapAction
    matched: str              # "exact" | "characteristic_default" | "fallback"
    resolved_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )

    def to_audit_row(self) -> dict:
        row = asdict(self)
        row["action"]["severity"] = int(self.action.severity)
        return row


class OcapEngine:
    """Deterministic dispatcher from a control-chart signal to an action record."""

    def __init__(
        self,
        table: dict[ActionKey, OcapAction],
        default_action: OcapAction,
    ) -> None:
        if not isinstance(table, dict) or not table:
            raise ValueError("OCAP table must be a non-empty dict of ActionKey -> OcapAction.")
        self._table = dict(table)
        self._default = default_action

    def resolve(self, rule_code: str, characteristic: str) -> ActionRecord:
        """Resolve a signal to exactly one action record.

        Resolution order is deterministic:
          1. exact (rule_code, characteristic) match
          2. characteristic-agnostic (rule_code, None) match
          3. safe default fallback (never raises)

        Parameters
        ----------
        rule_code : str
            Identifier emitted by the rule-detection layer, e.g. "WE1", "NELSON3".
        characteristic : str
            The measured characteristic that alarmed, e.g. "bore_diameter".

        Returns
        -------
        ActionRecord
            The resolved action plus which tier of the table matched.
        """
        if not rule_code:
            raise ValueError("rule_code must be a non-empty string.")
        if not characteristic:
            raise ValueError("characteristic must be a non-empty string.")

        exact = self._table.get(ActionKey(rule_code, characteristic))
        if exact is not None:
            return self._record(rule_code, characteristic, exact, "exact")

        generic = self._table.get(ActionKey(rule_code, None))
        if generic is not None:
            return self._record(rule_code, characteristic, generic, "characteristic_default")

        logger.warning(
            "No OCAP entry for rule_code=%s characteristic=%s; using safe default.",
            rule_code, characteristic,
        )
        return self._record(rule_code, characteristic, self._default, "fallback")

    def _record(self, rule_code: str, characteristic: str,
                action: OcapAction, matched: str) -> ActionRecord:
        record = ActionRecord(rule_code, characteristic, action, matched)
        logger.info(
            "OCAP resolved rule_code=%s characteristic=%s match=%s tier=%d hold=%s",
            rule_code, characteristic, matched, action.escalation_tier, action.hold_product,
        )
        return record


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)

    # A conservative default: hold product and escalate to a quality engineer.
    DEFAULT = OcapAction(
        containment="Quarantine suspect product; hold the line for review",
        correction="Investigate before resuming; no automatic adjustment",
        owner_role="quality_engineer",
        escalation_tier=3,
        hold_product=True,
        severity=Severity.HIGH,
    )

    table: dict[ActionKey, OcapAction] = {
        # Point beyond 3-sigma on a safety-critical bore: immediate hard stop.
        ActionKey("WE1", "bore_diameter"): OcapAction(
            containment="Stop machine; quarantine parts since last good subgroup",
            correction="Verify tool offset and re-qualify with a first-article check",
            owner_role="line_lead",
            escalation_tier=2,
            hold_product=True,
            severity=Severity.CRITICAL,
        ),
        # Any point beyond 3-sigma, characteristic not otherwise specified.
        ActionKey("WE1", None): OcapAction(
            containment="Quarantine parts since last good subgroup",
            correction="Confirm setup and re-center the process",
            owner_role="operator",
            escalation_tier=1,
            hold_product=True,
            severity=Severity.HIGH,
        ),
        # Nine points one side of center: sustained shift, investigate, do not stop.
        ActionKey("NELSON2", None): OcapAction(
            containment="Flag the run; increase inspection frequency",
            correction="Investigate mean shift; adjust after root cause is confirmed",
            owner_role="line_lead",
            escalation_tier=2,
            hold_product=False,
            severity=Severity.MEDIUM,
        ),
    }

    engine = OcapEngine(table, DEFAULT)

    for code, char in [("WE1", "bore_diameter"), ("WE1", "surface_finish"),
                       ("NELSON2", "torque"), ("UNKNOWN9", "torque")]:
        rec = engine.resolve(code, char)
        print(f"{code:9s} {char:14s} -> {rec.matched:22s} "
              f"tier={rec.action.escalation_tier} hold={rec.action.hold_product}")

The three-tier resolution order is the heart of the design. An exact (rule_code, characteristic) row lets a safety-critical dimension react harder than the generic case; the (rule_code, None) row provides a sensible reaction for every characteristic that shares a rule; and the default guarantees that even a code the table author never anticipated still produces a conservative, product-holding action instead of an unhandled exception on the line.

Validation and testing

  • Coverage against the detector. Enumerate every rule code the detection layer can emit and assert each resolves to something other than the raw KeyError — either an explicit row or the intended fallback. A missing high-severity code that silently drops to the default is a latent hazard; the coverage test surfaces it in CI, not in production.
  • Determinism. Resolve the same (rule_code, characteristic) a thousand times and assert the returned action is identical every call. Any non-determinism means state or clock has leaked into what must be a pure lookup.
  • Fallback safety. Assert the default action holds product (hold_product is True) and escalates to a quality engineer. The safe default must fail closed, never open.
  • Serialization round-trip. to_audit_row() must produce a dict that survives a JSON encode/decode and a CSV write without losing the severity, owner, or timestamp. Auditors read the log, not the objects.
  • Severity monotonicity. Where two rows share a characteristic, assert the higher-severity rule does not escalate to a lower tier than the lower-severity rule. Inverted escalation is a classic table-authoring bug.

Failure modes and edge cases

Symptom Root cause Fix
Signal acknowledged then nothing happens Alert display with no OCAP mapping; loop never closed Route every code through the engine so each signal yields an action record and disposition
KeyError crashes the alerting worker New rule code emitted by an upgraded detector, no table entry Rely on the (code, None) and default tiers; add a coverage test tied to the detector's code list
Same signal handled differently by shift Reaction left to operator judgment, not codified Make the reaction a pure function of the key; humans execute, the engine decides
Suspect product ships during investigation hold_product not set, or containment and correction conflated Separate containment from correction per row; default must hold product
Audit cannot reconstruct a past reaction Action never persisted, or timestamp/owner missing Persist to_audit_row() immutably with UTC timestamp, matched tier, and owner
Low-severity cosmetic flaw stops the whole line Escalation tier set too high for the characteristic Tune tier and severity per characteristic using the RPN ranking

Compliance notes

  • IATF 16949 (reaction plans). The standard requires a defined reaction plan for every characteristic monitored with SPC. A deterministic OCAP engine is direct evidence that the reaction is defined, consistent, and executed rather than improvised.
  • ISO 9001:2015, clause 10.2 (nonconformity and corrective action). Requires that nonconformities are reacted to, controlled, corrected, and that the results of corrective action are recorded. The ActionRecord and its audit row are that record; separating containment from corrective action mirrors the clause's structure.
  • ISO 9001:2015, clause 8.7 (control of nonconforming outputs). The hold_product flag and the containment field implement the requirement to prevent unintended use or delivery of suspect product until it is dispositioned.
  • AIAG SPC Reference Manual (2nd ed.). Frames the control chart as an input to a reaction plan, not an end in itself; the OCAP is the documented reaction the manual assumes exists behind every out-of-control rule.

Frequently Asked Questions

What is the difference between an OCAP and a corrective action?

An OCAP is the immediate, pre-agreed reaction to a specific control-chart signal — it contains the situation and names who acts. Corrective action is the formal ISO 9001:2015 clause 10.2 process of finding root cause and preventing recurrence. The OCAP triggers containment and correction in minutes; it may also open a corrective-action record that plays out over days. Good OCAP rows separate the two so containment is never delayed by the slower investigation.

Why key the decision table on the rule code instead of the measured value?

Because the detection layer has already interpreted the value into a pattern. A raw value of 10.4 means nothing without limits, but a rule code like "point beyond three sigma" or "nine points on one side of center" carries a known false-alarm rate and a known physical meaning. Keying on the code lets the OCAP react to the confidence and type of the signal, and keeps the plan stable even when limits are recalibrated.

What should happen when a rule code has no entry in the table?

It must fall back to a safe default that holds product and escalates to a quality engineer, never to an unhandled exception. An unmatched code usually means the detector was upgraded to emit a pattern the table author had not planned for. Failing closed contains any suspect material while a human decides, and a coverage test tied to the detector's code list should flag the gap before the next release.

How does severity relate to the escalation tier?

Severity ranks how serious the potential nonconformity is, in the spirit of an FMEA risk priority number of severity times occurrence times detectability. The escalation tier is who must respond and how fast. Higher severity should map to a faster, higher tier and to holding more product. Keeping severity and tier consistent across rows — so a more severe signal never escalates lower than a milder one — is a core validation check.

Where does the OCAP engine sit relative to alerting and routing?

It is the decision core. Rule detection produces the code, the OCAP engine resolves the code to an action record, and then two downstream layers carry that record out: routing pushes it to ticketing and the MES, and notification channels announce it to the responsible people. The engine decides; the surrounding layers deliver and record.

Up one level: SPC Alerting and OCAP Response. For the full automation picture, start from the home page.