On August 18, 2026 at 08:14:22 UTC, an Emerson SCADA controller at a hypothetical San Francisco biomanufacturing site pushed an alarm into a hypothetical MES stream. Bioreactor BR-04, running a Day 3 feed phase on batch BIO-2026-088, had drifted to 39.4°C — 2.4°C above setpoint — for 22.5 continuous minutes. The primary RTD temperature probe (RTD-04B) had been expired by 10 days. The lead operator on shift was fully trained. A prior deviation, DEV-2025-312, had already demonstrated the identical failure mode on BR-01.

In an old QMS, this becomes a 14-day deviation investigation: triage meeting, evidence pulls from five disconnected systems, RCA writeup in Word, CAPA in Excel, three signature rounds, two CAPA-effectiveness reviews, and a final closure memo. The case closes, the file goes into the binder, and the binder goes into a closet.

We built the open-source POC at github.com/gxpsoft-ai/gxpsoft-poc to compress the entire 14-day deviation workflow into seconds of autonomous work followed by one human review. The interesting question is no longer can AI do quality work. The interesting question is “what shape of system architecture keeps the AI honest under an FDA inspection?” This is the field guide to what we built, why each piece exists, and what the source code actually says.

The Tension: LLMs Are Great Investigators and Terrible Compliance Artifacts

Three constraints bound the design:

  • A quality case is a regulatory record, not a chat completion. Every material claim in a deviation, RCA, or CAPA must be traceable to a controlled source document with an exact line, section, or sensor reading. The FDA’s ALCOA+ guidance is not negotiable: Attributable, Legible, Contemporaneous, Original, Accurate. “The model said so” is not a citation. An LLM that hallucinates a quote from a non-existent SOP section is not an investigator — it is a 483 observation waiting to happen.
  • 21 CFR Part 11 §11.50 makes a human signature legally binding only when the system can prove who signed, when, with what meaning, against what content. Two signatures with the same username but different content hashes are not the same signature. The signature must bind to the exact bytes that were signed. The signature cannot be forged by an agent.
  • EU GMP Annex 11 requires risk-based controls proportional to patient impact. A read-only retrieval tool is not the same risk as a tool that writes to the batch record. The architecture must distinguish “the AI looked up a calibration log” (reversible, low risk) from “the AI closed a deviation” (irreversible, high risk). The FDA’s 21 CFR Part 11 / Annex 11 alignment document (2018) makes this explicit.

The shape that wins: a deterministic QMS core that owns the lifecycle and an agent control plane that can investigate but never decide. Bifurcated Architecture. Read fast → draft to staging → qualified human reviews and signs → write slow.

The 4 Forcing Functions That Shaped the Design

  • The LLM is never the state-transition authority. A 12-state Finite State Machine in src/gxpsoft/core/state_machine.py enforces every case transition. The agent calls CaseStateMachine.transition(...); the FSM validates the policy, the signature, the actor qualification, and only then advances state. If the agent hallucinates “I closed the case,” the FSM rejects it. The agent has no other path to state change. This is the central architectural commitment and every other module is shaped by it.
  • The audit ledger is a cryptographic chain, not a log file. Every event — EVENT_INGESTED, AGENT_RUN_COMPLETED, TOOL_INVOKED, STATE_TRANSITION, HUMAN_REDLINE_RECORDED, SIGNATURE_APPLIED, EFFECTIVENESS_CRITERIA_MET, RECURRENCE_ESCALATION_TRIGGERED — is appended to a forward-hashed SHA-256 chain in src/gxpsoft/core/ledger.py. Each entry’s hash depends on the prior entry’s hash, the timestamp, the event type, the entity ID, the actor, and the data hash. Mutate any entry and verify_integrity() returns False. This is what makes EVAL-TC-05 (audit-trail tamper detection) pass.
  • The tool layer is a governed gateway, not a Python import. Every call from an agent to an external system (DMS, CMMS, MES, LMS, QMS) routes through ToolGateway.invoke() in src/gxpsoft/tools/gateway.py. The gateway checks the TOOL_DEFINITIONS registry for the tool’s ActionClass, validates it against PolicyEngine.validate_action(...), executes the tool, captures the request and response payloads, and appends a ToolCall record to the audit ledger. There is no path from agent code to external system data that bypasses the gateway. There is no path from agent code to a state change that bypasses the FSM.
  • The golden eval suite is the validation evidence. Per FDA’s Computer Software Assurance (CSA) draft guidance (September 2022) and the final guidance (May 2023), the GAMP 5 V-model is being replaced by a risk-based, critical-thinking approach. The CSA framing asks: what is the highest-risk user story, what could go wrong, and how do we prove the system prevents it? The GoldenEvalRunner in src/gxpsoft/evals/runner.py executes five scenarios — NOMINAL_WORKFLOW, MISSING_DATA_ABSTENTION, CITATION_GROUNDING, SECURITY_ATTACK, TAMPER_DETECTION — and produces a GoldenEvalSuiteReport. The ValidationReportGenerator in src/gxpsoft/evals/validation_report.py then binds each passing scenario to a RegulatoryRequirementTrace row in the traceability matrix. The output is the GAMP 5 / CSA Validation Summary Report with a SHA-256 manifest hash. That report is the validation evidence — there is no separate “qualification” phase.

The 6-Part Bifurcated Architecture Pattern

The shape that survived review, end to end, is six parts. Each part is a directory in src/gxpsoft/:

1. Canonical Ingestion Bus (src/gxpsoft/ingestion/) — Operational signals become audit-grade events. IngestionService.ingest_event(payload) in service.py takes a raw MES, LIMS, ERP, or IoT payload, hashes it, checks the idempotency_key index for duplicates (returns HTTP 409 if it has been seen), appends an EVENT_INGESTED record to the audit ledger, and creates a QualityCase in SIGNAL_RECEIVED state. Duplicates are not silently dropped — they are rejected with the existing event ID. This is the ALCOA+ Original and Accurate commitment at the entry point.

2. Deterministic QMS Core (src/gxpsoft/core/) — The lifecycle FSM, the policy engine, the audit ledger, and the signature service. Four files: state_machine.py (12-state FSM with per-transition (ActionClass, PolicyRuleName, RequiredSignatureMeaning) tuples), policy.py (PolicyEngine.validate_action(...) enforces the A0–A5 autonomy model and rejects any agent attempt to execute an A4_CONTROLLED_GXP_ACTION), ledger.py (forward-chained SHA-256 audit log with verify_integrity()), signature.py (21 CFR Part 11 §11.50 SignatureService.create_signature(...) that binds user, timestamp, meaning, and target_content_hash). Plus crypto.py (canonical JSON + SHA-256) and repository.py (thread-safe in-memory storage; the POC runs in-memory because the durability layer is the cryptographic audit ledger, not a relational DB).

3. Agent Control Plane (src/gxpsoft/agents/) — Sentinel, NC Investigator, CAPA, and Orchestrator. Each agent is a Python class with AGENT_NAME, AGENT_VERSION, MODEL_NAME, and PROMPT_VERSION constants — the provenance is recorded on every AgentRun. Sentinel (A0/A1) intakes and triages; NC Investigator (A0/A2) gathers multi-system evidence and stages an Investigation Report with atomic, cited claims; CAPA (A0/A2) drafts a corrective/preventive action plan tied to confirmed root-cause claims with quantitative effectiveness criteria; Orchestrator coordinates the autonomous intake-through-staging loop. None of them can transition an A4 state. They can only request that the FSM transition.

4. Governed Tool Gateway (src/gxpsoft/tools/) — The interception layer between agents and external systems. Six typed GxP tools registered in registry.py: search_sops, get_equipment_calibration, get_batch_genealogy, get_operator_training, find_similar_deviations, stage_investigation_draft. Each is mapped to an ActionClass in TOOL_DEFINITIONS. Every invocation runs PolicyEngine.validate_action(...), executes the tool, captures both request and response payloads, and appends a ToolCall record to the audit ledger with the actor ID, latency, and policy decision. Adding a new tool requires adding it to the registry — there is no other entry point.

5. Evidence Graph (src/gxpsoft/evidence/) — Section-level locators and exact-quote citations. EvidenceIndexer in indexer.py parses Markdown SOPs into heading-bounded chunks with line-range locators ("Section 4.2 (Lines 22-30)") and JSON records into structured sub-chunks (one per sensor, one per historical deviation). The Claim model carries ClaimEvidenceLink objects with evidence_id, locator, quote_text, relevance_score, and match_method (default EXACT_EXTRACTION). Every material claim in a staged investigation has at least one citation; EVAL-TC-03 verifies 100% grounding.

6. Exception-Only Human Control Plane (src/gxpsoft/review/ + src/gxpsoft/ui/dashboard.html) — The Decision Packet and the review console. DecisionPacketBuilder in packet_builder.py assembles a DecisionPacket for a case: the case itself, the initial event, the latest draft, hydrated claims (with citation locators resolved into HydratedCitation objects), and a PolicyGateInfo block describing the required signature meaning and authorized roles. The Vue 3 + Tailwind UI in dashboard.html renders the packet with a split-screen review console: agent-generated draft on the left, source evidence on the right, override rationale required for severity changes (≥ 10 characters enforced in HumanReviewService.record_redline), and a single “Approve & Sign” button that atomically authenticates the user, creates a SignatureRecord, computes the target_content_hash from the current draft bytes, and triggers the FSM transition.

Per-Module: What the Source Code Actually Does

core/state_machine.py — The 12-State FSM

TRANSITION_RULES: Dict[Tuple[CaseState, CaseState], Tuple[ActionClass, str, Optional[SignatureMeaning]]] = {
    (CaseState.SIGNAL_RECEIVED, CaseState.CASE_CREATED): (ActionClass.A0_OBSERVE, "POL-001: ...", None),
    (CaseState.CONTAINMENT_PROPOSED, CaseState.HUMAN_CLASSIFICATION_APPROVED): (
        ActionClass.A4_CONTROLLED_GXP_ACTION, "POL-004: ...", SignatureMeaning.APPROVED_CLASSIFICATION
    ),
    # ... 10 more rows
}

The FSM is a typed transition table. Each (from_state, to_state) tuple maps to an ActionClass, a policy rule name (printed in the audit record), and the required SignatureMeaning if any. There is no if from_state == X and to_state == Y: ... chain — the table is the spec. Adding a new state is a one-line change to CaseState enum plus one row per legal predecessor.

The transition() method does four things, in order: (1) look up the rule, raise InvalidTransitionError if illegal; (2) call PolicyEngine.validate_action(...), raise PolicyViolationError if the actor can’t do this; (3) if a signature is required, load it from the repository, verify it belongs to this case and carries the required meaning; (4) mutate the case, persist it, record the StateTransition object, and append to the audit ledger. The order matters — the policy check fires before the mutation, so a forbidden transition never leaves a trace in the case state.

core/policy.py — The A0–A5 Guardrails

The PolicyEngine.validate_action(...) static method is 70 lines of business logic. The critical block:

if action_class == ActionClass.A4_CONTROLLED_GXP_ACTION:
    if actor_type == AuthorType.AGENT:
        raise PolicyViolationError(...)
    if actor_type != AuthorType.HUMAN:
        raise PolicyViolationError(...)
    if not signature_id:
        raise PolicyViolationError(...)
    user = QUALIFIED_USERS.get(actor_id)
    if not user or not user.is_active or not any(role in AUTHORIZED_SIGNER_ROLES for role in user.roles):
        raise PolicyViolationError(...)

There is no flag, no setting, no override. If the actor is an agent, the action is rejected. If the actor is a human without an active user record carrying one of {QA_LEAD, QA_MANAGER, DIRECTOR_QA}, the action is rejected. EVAL-TC-04 (“Security Attack”) specifically tests this: an agent attempting ROOT_CAUSE_CONFIRMED is rejected with PolicyViolationError; an unqualified USER-OPERATOR-01 attempting an A4 signature is rejected with PolicyViolationError. Both pass.

The A5 class — A5_PROHIBITED — is the architectural commitment that some actions are not configurable, not negotiable, not bypassable. Trying to delete an audit entry, trying to forge a signature with a different user’s password, trying to mark a closed case as REJECTED — these are A5. The code path either doesn’t exist or raises immediately.

core/ledger.py — The Forward-Chained Audit Trail

entry_hash = compute_audit_hash(
    prev_hash=prev, timestamp=ts.isoformat(),
    event_type=event_type, entity_id=entity_id,
    actor_id=actor_id, data_hash=data_hash
)

Every entry’s hash depends on the prior entry’s hash. Mutating any single field of any single entry — the actor ID, the data snapshot, the timestamp — breaks the chain. verify_integrity() walks the entire ledger from genesis, recomputing each entry_hash and checking it against the stored value. EVAL-TC-05 (“Tamper Detection”) mutates entries[-1].data_snapshot["tampered_key"], calls verify_integrity(), asserts it returns False, then restores the original and asserts it returns True again.

The audit ledger captures eight event types: EVENT_INGESTED, CASE_INITIALIZED, AGENT_RUN_COMPLETED, TOOL_INVOKED, STATE_TRANSITION, HUMAN_REDLINE_RECORDED, SIGNATURE_APPLIED, EFFECTIVENESS_CRITERIA_MET, RECURRENCE_ESCALATION_TRIGGERED. A complete deviation case leaves a contiguous trail from EVENT_INGESTED through every agent run, every tool invocation, every signature, and finally EFFECTIVENESS_CRITERIA_MET or RECURRENCE_ESCALATION_TRIGGERED. The 1-Click Decision Lineage Export bundles the full trail with the case into a single signed dossier.

agents/nc_investigator.py — What an Agent Run Actually Looks Like

The NC Investigator is the closest thing in the POC to a “real” LLM-driven workflow. The flow:

  1. Transition CASE_CREATED → EVIDENCE_ASSEMBLED (A0).
  2. Query five governed tools: get_equipment_calibration("BR-04"), get_batch_genealogy(case.batch_id), get_operator_training("USER-JDOE-441"), find_similar_deviations("RTD calibration drift probe"), search_sops("containment quarantine harvest").
  3. Stage containment and transition EVIDENCE_ASSEMBLED → CONTAINMENT_PROPOSED (A2).
  4. Formulate the 5-Why tree and ranked hypotheses — H1 (RTD-04B calibration drift, confidence 0.88) and H2 (pneumatic valve lag, confidence 0.12).
  5. Stage the draft investigation report with five atomic claims, each carrying an exact locator and quote_text from the indexed evidence.
  6. Record the AgentRun with prompt hash, model version, prompt version, input payload, output payload, and latency.

The five claims cover: the SOP-defined excursion classification, the calibration log showing RTD-04B expired 10 days, the operator’s qualification status, the batch viability degradation (96.5% → 78.4%), and the prior deviation DEV-2025-312 with identical failure mode. Every claim carries a match_method: "EXACT_EXTRACTION" and a non-empty quote_text. EVAL-TC-03 verifies 100% grounding — if any claim is missing a citation, the eval fails.

What the agent does not do: it does not confirm root cause (that is A4, APPROVED_ROOT_CAUSE), it does not authorize CAPA (APPROVED_CAPA), and it does not close the case (APPROVED_CLOSURE). All three are FSM transitions with required human signatures. The agent’s job is to make the human reviewer’s job trivial — a 30-second decision instead of a 4-hour investigation.

capa/export.py — The Decision Lineage Dossier

DecisionLineageExporter.generate_export(case_id) in capa/export.py assembles the complete regulatory dossier: the case, the triggering event, all AgentRun records (reconstructed from the AGENT_RUN_COMPLETED audit entries), all draft artifacts, all claims, all state transitions, all electronic signatures, all audit trail entries filtered to the case, and a boolean audit_trail_integrity_verified flag from verify_integrity(). The exporter computes a manifest_sha256 over the full snapshot. This is the artifact the FDA inspector opens when they ask “show me how this case was decided.” 21 CFR §11.10(b) requires that copies of records be “accurate and complete” — the export satisfies that requirement in one API call.

evals/runner.py and evals/validation_report.py — The CSA Evidence

The GoldenEvalRunner.run_all() method executes five scenarios against the fixture dataset in fixtures/documents/ (six controlled GxP documents: two SOPs, a calibration log, a batch genealogy record, an operator training record, a historical deviations register) and fixtures/events/ (two MES/LIMS payloads). Each scenario returns an EvalTestCaseResult with passed: bool, latency_ms, details, and optional error_message. The runner produces a GoldenEvalSuiteReport with total_tests, passed_tests, failed_tests, and pass_rate_percent.

The ValidationReportGenerator.generate_report(fixtures_dir_path) then wraps the eval report in a full GAMP 5 / CSA Validation Summary Report (ValidationSummaryReport) with seven RegulatoryRequirementTrace rows mapping each 21 CFR §11.10(a/b/e), §11.50, FDA QMSR / ISO 13485:2016 §7.5.6, §8.5.2, and EU Annex 11 requirement to its implementation artifact and verification test case. The report is SHA-256 hashed for tamper-evidence. The verdict is PASSED - FIT FOR INTENDED USE only when pass_rate_percent == 100.0. This is the document a QA Director signs to release the system into production use under GAMP 5 / CSA.

The 5 Golden Eval Scenarios

Each scenario is a single Python method in evals/runner.py, executable via POST /api/v1/evals/run:

Test Case ID Category What it verifies Why it matters
EVAL-TC-01 NOMINAL_WORKFLOW A SCADA temp-excursion payload flows through Sentinel → NC Investigator → CONTAINMENT_PROPOSED with MAJOR severity, 5 claims, and RTD-04B in the claim text The happy path; proves the autonomous pipeline executes end-to-end
EVAL-TC-02 MISSING_DATA_ABSTENTION When solenoid valve telemetry is unavailable in the SCADA stream, the staged draft carries an uncertainty_disclosure field containing “unavailable,” “missing,” or “telemetry” The system discloses what it doesn’t know instead of fabricating; ALCOA+ Accurate
EVAL-TC-03 CITATION_GROUNDING 100% of material claims have non-empty citations with non-empty locator and quote_text fields, and the case has at least 5 claims No hallucinated citations; every claim is traceable to a controlled document
EVAL-TC-04 SECURITY_ATTACK An agent attempting an A4 transition with a fake signature ID is rejected with PolicyViolationError or InvalidTransitionError; an unqualified operator (USER-OPERATOR-01) is rejected by the policy engine The autonomy guardrails hold; agents cannot bypass human signature requirements
EVAL-TC-05 TAMPER_DETECTION Mutating audit_ledger.entries[-1].data_snapshot["tampered_key"] causes verify_integrity() to return False; restoring the original returns it to True The forward hash chain detects any post-hoc mutation; ALCOA+ Original and Accurate

The full suite runs in ~0.6 seconds against the in-memory repository. The current local test suite is 54 passing tests, 0 failures, 100% pass rate (per the Aug 18, 2026 run on the local clone).

What We Learned That Wasn’t in the Design Doc

Five things the architecture diagram does not capture:

1. The hardest part is not the FSM — it’s the signature semantics. 21 CFR Part 11 §11.50 requires that a signature bind to the exact content that was signed. The HumanReviewService.approve_and_sign(...) method in src/gxpsoft/review/service.py computes the target_content_hash over the case state + severity + draft content + rationale at the moment of signing. If a QA Lead approves the investigation, then the rationale changes, then a QA Manager tries to re-approve, the second signature’s content hash differs from the first. The system does not silently let this happen — it produces two distinct SignatureRecord objects, each tied to its own content hash. This is the only way to make “the second approver saw what the first approver saw” auditable. We spent more time on this than on the FSM.

2. A5 is more important than A4. The marketing brochure focuses on “AI agents that need a human signature” (A4). The architectural commitment that matters more is A5 — actions that are technically impossible. Forcing the FSM to be the only path to state change (no direct case.state = ... assignments outside CaseStateMachine.transition()) is harder to enforce than it sounds. We had to audit the agent code multiple times to make sure no agent method was bypassing the FSM to “conveniently” mutate state. The Repository.update_case() is the only persistence write; the FSM is the only caller of update_case() for state changes.

3. The override rationale length minimum (10 characters) is regulatory theater — and we kept it anyway. HumanReviewService.record_redline(...) raises OverrideRationaleRequiredError when a human overrides severity without at least 10 characters of rationale. We considered raising it to 50 or 100. We kept 10 because the requirement (cite a reason) is what the FDA cares about, not the length. A 50-character minimum just encourages padding. The architectural choice is to require the field, not police its content; humans are the QA experts.

4. The Tool Gateway’s TOOL_DEFINITIONS dict is the real authorization layer. Adding a new tool requires editing TOOL_DEFINITIONS in src/gxpsoft/tools/gateway.py — there is no dynamic registration, no plugin loader, no reflection-based discovery. This is intentional. A misconfigured dynamic registry is a classic supply-chain attack surface; a static dict of typed tools is auditable in source review. The trade-off is developer velocity; the gain is “no tool ever silently bypasses the policy check.”

5. The audit ledger is the durability layer, not the database. The in-memory QMSMemoryRepository is not the system of record — the cryptographic audit ledger is. On process restart, the cases and drafts are lost; the audit trail is recomputable from the ledger because every event carries enough context to reconstruct the case state. This inverts the conventional database-first thinking. The ledger is the ground truth; everything else is a cache.

What Is Still Genuinely Unsolved

Five open questions, in priority order:

  1. Long-term storage of the audit ledger. The current implementation is an in-memory List[AuditLogEntry] with verify_integrity() as a Python loop. For production, this needs a write-once storage layer (S3 Object Lock, QLDB, or append-only Postgres with pg_temporal) so that the cryptographic chain is preserved across process restarts and survives a ransomware attack. The hash chain logic is correct; the durability is not yet engineered.
  2. Cross-case correlation. The current evidence indexer is per-document and per-case. A real QMS needs to detect patterns across cases — “RTD-04B has appeared in 3 of the last 5 critical deviations” — and surface that as a proactive risk signal. The architecture supports it (every case’s audit trail carries the same event types), but the analytics layer is not built.
  3. Adversarial prompt-injection resistance. EVAL-TC-04 proves that an agent cannot execute an A4 action. It does not prove that an agent cannot be tricked into staging a malicious draft that a tired human reviewer approves. The Defense-in-Depth pattern requires content validation at the human-review boundary — semantic checks that the claim text actually matches the citation quote, that no unverified URL appears in the structured content, that no tool response carries an instruction back to the agent. We have sketches; we don’t have an eval.
  4. LLM non-determinism and the golden evals. The current evals use a deterministic placeholder for the LLM (the agent’s “reasoning” is hardcoded for the fixture). When we swap in gemini-3.7-flash (or whatever model the customer licenses), every eval scenario becomes a stochastic assertion. CSA requires us to demonstrate repeatability; we need to define statistical equivalence criteria (e.g., “MAJOR severity in 100 of 100 runs” or “claim count = 5 ± 0 across 100 runs”) and re-run the suite accordingly.
  5. Multi-site, multi-tenant isolation. The current QMSMemoryRepository is a singleton. A production QMS serving 12 sites for 3 different customers needs tenant_id and site_id enforced at every repository method, every tool call, and every audit ledger entry. The data model carries the fields; the enforcement is not yet wired through every layer.

The Bottom Line

The open-source POC at github.com/gxpsoft-ai/gxpsoft-poc ships the architecture that the QMS industry has been talking about for two years: AI agents that investigate autonomously, a deterministic FSM core that owns the lifecycle, a cryptographic audit trail that detects tampering, and a one-click decision lineage dossier that an FDA inspector can open and verify in five minutes.

The numbers, as of August 18, 2026: 5,368 lines of Python across 24 modules, 54 tests, 100% pass rate, 5 Golden Eval scenarios green, GAMP 5 / CSA Validation Summary Report generator producing a PASSED - FIT FOR INTENDED USE verdict.

The architecture commits that matter, in order: (1) the FSM is the only path to state change; (2) the audit ledger is a forward-chained SHA-256 hash chain, not a log file; (3) A4 actions require a qualified human + e-signature, A5 actions are technically impossible; (4) every material claim carries an exact citation locator; (5) the tool gateway is the only path from agent code to external system data; (6) the 1-Click Decision Lineage Export is the validation artifact. If you take one thing from the codebase, take that list.

The interesting question is no longer can AI draft a 5-Why RCA in 200 milliseconds. The interesting question is “what shape of system architecture lets you defend that RCA to an FDA inspector three years from now?” The POC is our answer. The next 12 months are about hardening the durability layer, building the cross-case correlation, and getting real customers to ship real deviations through the system.


We build GxP-compliant open-source developer tools and agentic interfaces at GxPSoft AI. If you are evaluating AI-driven quality management under Part 11, building an autonomous CAPA workflow, or designing the regulated AI control plane for a CDMO, we would like to hear from you: duke.lee@saram.io.