Automating OSHA chemical hygiene inventory checks

On this page

Problem statement

You need a Python routine that reads a laboratory chemical inventory, joins each container to the rules in your Chemical Hygiene Plan, and decides — deterministically and the same way every time — whether that container breaches the OSHA Laboratory Standard, then records the verdict in an audit ledger you can hand an inspector without a duplicate row or a silent gap.

This task sits under OSHA Laboratory Standard Automation, part of the broader Hazardous Material & Chemical Inventory Compliance practice. The scope here is a single evaluation pass: load rows, normalize identifiers, apply 29 CFR 1910.1450 checks, and write findings idempotently. It does not adjudicate remediation — a flag is surfaced for the chemical hygiene officer, never auto-cleared — consistent with the flag-don’t-fix contract the parent cluster establishes.

Prerequisites

Before running the checker, confirm the environment and policy configuration:

  • Python 3.10+ (the code uses X | None type hints and datetime.now(timezone.utc)).
  • Libraries: pydantic>=2.5 for strict inventory-row validation and SQLAlchemy>=2.0 with psycopg[binary] for the transactional ledger upsert. Install with pip install "pydantic>=2.5" "SQLAlchemy>=2.0" "psycopg[binary]".
  • Environment variables (never hard-code credentials): HAZMAT_DB_URL — the SQLAlchemy connection string for the compliance ledger, e.g. postgresql+psycopg://svc_hazmat:***@db.internal:5432/ehs.
  • Policy config: a version-controlled CHP rule set keyed by CAS number and hazard class, carrying exposure thresholds, required controls, and peroxide-class expiry windows. Keep it beside the inventory feed produced by Equipment Calibration & Lab Inventory Tracking; the safety-data-sheet linkage each row needs is resolved by Safety Data Sheet Management.

Step-by-step implementation

The flow below is enforced by the checker: raw inventory rows are validated and CAS-normalized, joined to the CHP rule set, evaluated against 1910.1450 quantity, control, expiry, and SDS rules, and written to the ledger through an idempotent upsert keyed on an audit hash. The hash is what makes a re-run safe.

OSHA chemical hygiene check data flow Raw inventory rows are validated and CAS-normalized into a Pydantic model, joined to the Chemical Hygiene Plan rule set, then evaluated against quantity threshold, required control, peroxide expiry and safety-data-sheet checks. Findings are written to the audit ledger with a deterministic hash; flagged containers are copied to a quarantine list. Load rows Join CHP Evaluate Write ledger Compliant Quarantine Pydantic by CAS 1910.1450 audit hash append-only reason codes
Figure: inventory rows flow through validation, a CHP join, and the 1910.1450 evaluator into an append-only ledger, with flagged containers copied to quarantine.

Step 1 — Load and validate inventory rows with CAS normalization

The Pydantic model is the boundary where a container row becomes trustworthy. Its validators normalize the CAS number to canonical NNNNNNN-NN-N form and verify the check digit, because a mistyped CAS number would silently miss the join into the CHP rule set and leave a hazardous container un-evaluated. A row that cannot be normalized is rejected here, never passed downstream.

python
import hashlib
import logging
from datetime import date, datetime, timedelta, timezone
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator

logging.basicConfig(level=logging.INFO, format="%(asctime)s [OSHA-CHK] %(message)s")
logger = logging.getLogger(__name__)


class ContainerRow(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    container_id: str = Field(..., min_length=1, max_length=64)
    cas_number: str = Field(..., alias="cas")
    chemical_name: str = Field(..., min_length=2, max_length=200)
    quantity: float = Field(..., ge=0)
    unit: str = Field(..., pattern=r"^(g|kg|mL|L)$")
    control_present: str | None = None
    received_date: date
    expiry_date: date | None = None
    sds_id: str | None = None

    @field_validator("cas_number")
    @classmethod
    def normalize_and_checksum_cas(cls, v: str) -> str:
        # Strip stray whitespace/prefixes, then verify the CAS check digit:
        # the last digit equals (sum of each preceding digit times its
        # position from the right, starting at 1) mod 10. A bad checksum
        # means a typo that would detach the container from CHP policy.
        digits = v.strip().upper().removeprefix("CAS").strip(" -")
        parts = digits.split("-")
        if len(parts) != 3 or not all(p.isdigit() for p in parts):
            raise ValueError(f"Malformed CAS number: {v!r}")
        body = parts[0] + parts[1]
        check = int(parts[2])
        total = sum(int(d) * i for i, d in enumerate(reversed(body), start=1))
        if total % 10 != check:
            raise ValueError(f"CAS checksum failed: {v!r}")
        return f"{parts[0]}-{parts[1]}-{parts[2]}"


def load_inventory(raw_rows: list[dict[str, Any]]) -> tuple[list[ContainerRow], list[dict]]:
    """Validate every row; return (accepted, rejected) so a bad CAS never
    silently drops a hazardous container from the evaluation."""
    accepted: list[ContainerRow] = []
    rejected: list[dict] = []
    for raw in raw_rows:
        try:
            accepted.append(ContainerRow.model_validate(raw))
        except Exception as exc:  # ValidationError and CAS ValueError
            rejected.append({"row": raw, "error": str(exc)})
            logger.warning("rejected container %s: %s", raw.get("container_id"), exc)
    return accepted, rejected

Step 2 — Join to the CHP rule set and evaluate the checks

Each validated container is looked up in the CHP rule set by its normalized CAS number. The evaluator applies the four substantive checks the Laboratory Standard requires — quantity against the exposure threshold, presence of the required control for particularly hazardous substances, peroxide-former expiry, and SDS linkage — and returns a list of reason codes. A container with no matching rule is itself flagged, because a chemical the CHP does not cover is an exposure the plan cannot bound.

python
# Peroxide-former expiry windows in days, keyed by peroxide class.
# Sourced from the CHP — Class I formers (e.g. isopropyl ether) expire fast;
# stabilized Class III formers get the longest window.
PEROXIDE_WINDOWS_DAYS: dict[str, int] = {"I": 90, "II": 180, "III": 365}


def evaluate(container: ContainerRow, rule: dict[str, Any] | None, today: date) -> list[str]:
    """Return reason codes for a single container per 29 CFR 1910.1450."""
    if rule is None:
        return ["unmapped_hazard_class"]  # 1910.1450(e): outside the CHP

    reasons: list[str] = []

    # 1910.1450(d): exposure determination via quantity threshold.
    if container.quantity > rule["threshold_qty"]:
        reasons.append("over_threshold")

    # 1910.1450 App A: particularly hazardous substances need the declared
    # control physically present and attested.
    required = rule.get("control_required")
    if required and container.control_present != required:
        reasons.append("control_missing")

    # Peroxide-former expiry: 3/6/12-month window by peroxide class.
    pclass = rule.get("peroxide_class")
    if pclass:
        window = timedelta(days=PEROXIDE_WINDOWS_DAYS[pclass])
        past_window = today > (container.received_date + window)
        past_stamp = container.expiry_date is not None and today > container.expiry_date
        if container.expiry_date is None or past_stamp or past_window:
            reasons.append("expired")

    # 1910.1200: hazard communication requires a linked SDS.
    if not container.sds_id:
        reasons.append("no_sds")

    return reasons

Step 3 — Emit findings and write to the ledger idempotently

The driver validates, joins, evaluates, and writes. Each (container, reason, matrix_version) triple is fingerprinted into a deterministic audit_hash that serves as the ledger’s natural key, so the PostgreSQL ON CONFLICT DO NOTHING clause turns a re-run over an unchanged inventory into a no-op. Flagged containers are copied to a quarantine list for the officer; compliant containers still record a single ledger row so coverage can be proven.

python
from sqlalchemy import Column, String, DateTime, create_engine
from sqlalchemy.orm import declarative_base, Session
from sqlalchemy.dialects.postgresql import insert as pg_insert

Base = declarative_base()


class ComplianceLedger(Base):
    __tablename__ = "chp_ledger"
    audit_hash = Column(String(64), primary_key=True)  # natural idempotency key
    container_id = Column(String(64), nullable=False)
    cas_number = Column(String(16), nullable=False)
    reason = Column(String(32), nullable=False)
    matrix_version = Column(String(20), nullable=False)
    recorded_at = Column(DateTime, nullable=False)


def run_checks(
    db_url: str,
    rows: list[dict[str, Any]],
    chp_rules: dict[str, dict[str, Any]],
    matrix_version: str,
) -> dict[str, int]:
    engine = create_engine(db_url, pool_pre_ping=True)
    Base.metadata.create_all(engine)
    today = datetime.now(timezone.utc).date()
    stats = {"recorded": 0, "skipped": 0, "quarantined": 0, "rejected": 0}

    accepted, rejected = load_inventory(rows)
    stats["rejected"] = len(rejected)

    with Session(engine) as session:
        # Sort by container_id so processing order is stable and reproducible.
        for container in sorted(accepted, key=lambda c: c.container_id):
            rule = chp_rules.get(container.cas_number)
            reasons = evaluate(container, rule, today) or ["compliant"]
            if reasons != ["compliant"]:
                stats["quarantined"] += 1

            for reason in reasons:
                audit_hash = hashlib.sha256(
                    f"{container.container_id}:{reason}:{matrix_version}".encode("utf-8")
                ).hexdigest()
                stmt = pg_insert(ComplianceLedger).values(
                    audit_hash=audit_hash,
                    container_id=container.container_id,
                    cas_number=container.cas_number,
                    reason=reason,
                    matrix_version=matrix_version,
                    recorded_at=datetime.now(timezone.utc),
                ).on_conflict_do_nothing(index_elements=["audit_hash"])
                result = session.execute(stmt)
                stats["recorded" if result.rowcount else "skipped"] += 1

        session.commit()

    logger.info("run complete: %s", stats)
    return stats

The same validated container feeds the broader evaluation described in the parent guide on OSHA Laboratory Standard Automation; this page is the single-pass implementation of that design.

Schema and field reference

The checker reads these container fields and joins them to the CHP rule set. Widen the set in version-controlled policy config, not in code.

Field Type Constraint Source rule (29 CFR)
container_id str required, unique per bottle inventory key; idempotency anchor
cas_number str normalized NNNNNNN-NN-N, checksum-valid 1910.1450(d) CHP join key
chemical_name str 2–200 chars 1910.1200 GHS label name
quantity float ≥ 0; compared to threshold 1910.1450(d) exposure determination
control_present str | None must match control_required when set 1910.1450 App A / 1910.1000 PEL
received_date date required; anchors peroxide window 1910.1450 App A peroxide formers
expiry_date date | None required for peroxide formers 3/6/12-month window by class
sds_id str | None non-null 1910.1200 hazard communication
audit_hash str 64-char SHA-256; ledger key non-repudiation audit trail

Verification

Confirm a run behaved correctly before trusting its output:

  1. Validate in isolation: run ContainerRow.model_validate(row) in a REPL against a sample with a deliberately mistyped CAS number and confirm the checksum validator raises — proof the normalization gate is active.
  2. Reproduce the hash: recompute sha256(f"{container_id}:{reason}:{matrix_version}") for a known finding and confirm it equals the stored audit_hashSELECT audit_hash FROM chp_ledger WHERE container_id = :cid.
  3. Dry-run idempotency: execute run_checks twice over the identical inventory. The second call must return recorded = 0 and skipped equal to the first call’s recorded, proving no duplicate rows landed.
  4. Coverage parity: confirm the count of distinct container_id in the ledger equals the number of accepted rows; any shortfall is a silently dropped container.

Troubleshooting

Three failure modes specific to OSHA chemical-hygiene checks:

  • CAS-number normalization or checksum failures. Inventories imported from spreadsheets carry CAS numbers with stray spaces, a CAS prefix, or a transposed digit that passes visual inspection but fails the check-digit rule. Let the Pydantic validator reject these into the rejected list rather than coercing them — a container with an un-verifiable CAS number cannot be safely joined to CHP policy, so it must be corrected at the source and re-run, not force-fitted.
  • Peroxide-former expiry windows applied inconsistently. The three-, six-, and twelve-month windows correspond to peroxide classes, not to a single global default. If every former is treated as Class III, a Class I ether can sit six months past when it should have been discarded. Confirm each peroxide-forming CAS carries an explicit peroxide_class in the rule set, and anchor the window on received_date (or the opened date, if you track it) so the clock starts when the container was actually exposed to air.
  • Particularly-hazardous-substance designation missing from the rule set. If a select carcinogen or reproductive toxin has no control_required value, the control_missing check never fires and the container passes silently. When the CHP is revised to designate a new particularly hazardous substance, bump matrix_version and re-evaluate, so previously compliant containers are re-judged under the stricter rule rather than grandfathered.

Frequently asked questions

Why validate the CAS check digit instead of just matching the format?
Because a format match accepts a typo that a checksum catches. A CAS number like 108-88-3 (toluene) can be mistyped as 108-88-4 and still look like a valid NNN-NN-N string, but the transposed check digit means it will miss the join into the CHP rule set — leaving a regulated solvent evaluated as if the plan never mentioned it. The check-digit rule recomputes the last digit from the preceding ones, so a single-digit error is rejected at the validation boundary rather than surfacing later as a mysteriously un-flagged hazardous container.
Is it safe to re-run the checker over the same inventory?
Yes. Each ledger row is keyed on a deterministic audit_hash computed from the container, the reason code, and the CHP matrix_version, and the write uses ON CONFLICT DO NOTHING. A second run over an unchanged inventory inserts nothing and returns recorded = 0; a container that newly expired since the last run inserts exactly one expired row. Re-running to pick up an inventory change is always safe.
Should the checker auto-clear a flag once a container is fixed?
No. The checker never edits the ledger to remove a finding. When the underlying container is corrected — a control attested, an SDS re-linked, an expired bottle disposed — the next pass simply stops emitting that reason code, so the finding naturally ceases to recur while its history stays in the append-only ledger. Remediation under the Laboratory Standard is a human decision an EHS officer records, not something the pipeline resolves on its own.