AI Model Governance in Production: Model Cards, Lineage Tracking, and Approval Workflows for Regulated Industries
A practical guide to governing AI/ML models in production for regulated industries: model cards, lineage tracking, approval workflows, drift detection, audit trails, and the tooling that holds it together.
Most teams treat model governance as a documentation task you do after shipping. That framing causes real problems at the worst possible time: a regulatory audit, a model failure in a high-stakes context, or a legal challenge to a model decision. The SEC’s 2024 AI guidance, HIPAA’s updated requirements, and EU AI Act enforcement timelines have all moved model governance from “nice to have” to “required before deployment.”
This article covers what production model governance actually looks like, with code. Not the theory from compliance frameworks, but the schema design, the approval workflow implementation, the lineage records, and the audit queries that prove your model governance holds up under scrutiny.
The Problem with Ad-Hoc Model Management
The typical pre-governance workflow goes like this: a data scientist trains a model, evaluates it locally, serializes it to S3 or a shared drive, and someone runs an inference script pointing to that path. Months later, nobody can answer basic questions: which dataset was it trained on, what hyperparameters were used, who approved it for production, and what changed between version 2 and version 3.
In general-purpose B2B software, this is a maintenance problem. In healthcare, finance, or insurance, it is a liability. A HIPAA audit requires documentation of the safeguards applied to PHI used in model training. A credit model deployed under ECOA requires the ability to produce adverse action reasons traceable to the model’s logic. A fraud detection model challenged in court requires a complete chain of custody from training data to prediction.
Governance solves this with four capabilities: a structured artifact record (the model card), a complete lineage graph (where did the model come from), a controlled promotion path (who approved this model for production), and an append-only audit trail (what happened and when).
Model Cards: What to Capture and How to Store It
A model card is a structured document that records everything a downstream consumer needs to know to use, audit, or evaluate a model. The Google and Hugging Face variants are good starting points, but for regulated industries you need to extend the schema with compliance-specific fields.
Here is a TypeScript schema for a production model card that covers the requirements for SOC 2 and HIPAA contexts:
interface ModelCard {
// Identity
modelId: string; // Unique, stable identifier
modelName: string;
version: string; // Semantic version: 1.4.2
createdAt: string; // ISO 8601
createdBy: string; // Engineer or pipeline identity
teamOwner: string;
// Intended Use
primaryUseCase: string;
outOfScopeUses: string[];
targetPopulation: string; // e.g., "US adults filing insurance claims"
decisionImpact: "low" | "medium" | "high" | "critical";
// Model Details
modelType: string; // e.g., "XGBoost classifier"
inputFeatures: FeatureDescriptor[];
outputSchema: OutputSchema;
performanceMetrics: PerformanceRecord[];
// Training Data
trainingDatasetId: string; // Foreign key to dataset registry
trainingDatasetVersion: string;
dataRetentionPolicy: string;
phiInvolved: boolean; // HIPAA: was PHI used in training?
phiHandlingRecord: string | null;
// Evaluation
evaluationDatasetId: string;
evaluationDatasetVersion: string;
biasEvaluation: BiasReport;
performanceBySubgroup: SubgroupPerformance[];
// Regulatory
complianceFrameworks: ("HIPAA" | "SOC2" | "ECOA" | "GDPR" | "EU_AI_ACT")[];
riskClassification: string; // EU AI Act: limited, high, unacceptable
adverseActionExplainability: boolean; // Required for credit/insurance
humanOversightRequired: boolean;
// Lineage (references, not embedded)
parentModelId: string | null; // Fine-tuned from which base?
trainingRunId: string;
pipelineRunId: string;
artifactStorePath: string;
artifactChecksum: string; // SHA-256 of serialized model file
}
interface FeatureDescriptor {
name: string;
dtype: string;
description: string;
sensitiveAttribute: boolean; // PII, protected class, etc.
source: string; // Which upstream system provides this feature
}
interface PerformanceRecord {
metricName: string;
value: number;
evaluationDatasetId: string;
evaluatedAt: string;
evaluatedBy: string;
}
interface BiasReport {
evaluatedAt: string;
evaluatedBy: string;
demographicParityDifference: number | null;
equalizedOddsDifference: number | null;
findings: string;
mitigationApplied: string | null;
}
Store model cards in a dedicated registry service, not in the experiment tracking tool’s artifact store. The model card needs to be queryable for audit purposes, versioned independently of the model weights, and accessible to non-engineers (risk officers, legal, compliance teams).
A Postgres-backed registry with a JSON column for the full card plus indexed columns for modelId, version, complianceFrameworks, and status is sufficient for most teams. The artifact checksum in the card lets you verify at any time that the file in storage has not been tampered with.
Lineage Tracking: The Provenance Graph
Lineage answers the question: if I have a model artifact in production right now, what produced it? The answer needs to cover:
- Which datasets were used (training, validation, test), including their versions and hashes
- Which training run produced the model (hyperparameters, compute environment, framework versions)
- Which code commit drove the training job
- Which base model it fine-tuned from, if any
The simplest lineage schema is a directed acyclic graph where nodes are artifacts (datasets, models, code snapshots) and edges are transformations (training runs, preprocessing jobs).
interface LineageNode {
nodeId: string;
nodeType: "dataset" | "model" | "training_run" | "code_snapshot" | "pipeline";
name: string;
version: string;
createdAt: string;
checksum: string | null;
metadata: Record<string, unknown>;
}
interface LineageEdge {
edgeId: string;
fromNodeId: string;
toNodeId: string;
relationship: "trained_on" | "fine_tuned_from" | "evaluated_on" | "produced_by" | "preprocessed_from";
createdAt: string;
createdBy: string; // pipeline identity or human
metadata: Record<string, unknown>;
}
// Recording a training run
async function recordTrainingRun(
db: DatabaseClient,
params: {
trainingRunId: string;
datasetNodeId: string;
codeSnapshotNodeId: string;
outputModelNodeId: string;
hyperparameters: Record<string, unknown>;
computeEnvironment: {
image: string;
imageDigest: string; // Pin to digest, not tag
gpuType: string | null;
frameworkVersions: Record<string, string>;
};
startedAt: string;
completedAt: string;
triggeredBy: string;
}
): Promise<void> {
const runNode: LineageNode = {
nodeId: params.trainingRunId,
nodeType: "training_run",
name: `training-run-${params.trainingRunId}`,
version: "1",
createdAt: params.startedAt,
checksum: null,
metadata: {
hyperparameters: params.hyperparameters,
computeEnvironment: params.computeEnvironment,
startedAt: params.startedAt,
completedAt: params.completedAt,
triggeredBy: params.triggeredBy,
},
};
await db.lineageNodes.upsert(runNode);
// Dataset -> TrainingRun
await db.lineageEdges.insert({
edgeId: crypto.randomUUID(),
fromNodeId: params.datasetNodeId,
toNodeId: params.trainingRunId,
relationship: "trained_on",
createdAt: params.startedAt,
createdBy: params.triggeredBy,
metadata: {},
});
// TrainingRun -> Model
await db.lineageEdges.insert({
edgeId: crypto.randomUUID(),
fromNodeId: params.trainingRunId,
toNodeId: params.outputModelNodeId,
relationship: "produced_by",
createdAt: params.completedAt,
createdBy: params.triggeredBy,
metadata: {},
});
}
To reconstruct full lineage for an audit, traverse the DAG backwards from any model node:
async function getFullLineage(
db: DatabaseClient,
modelNodeId: string,
visited: Set<string> = new Set()
): Promise<LineageNode[]> {
if (visited.has(modelNodeId)) return [];
visited.add(modelNodeId);
const node = await db.lineageNodes.findById(modelNodeId);
if (!node) return [];
const inboundEdges = await db.lineageEdges.findByToNodeId(modelNodeId);
const ancestors: LineageNode[] = [];
for (const edge of inboundEdges) {
const parentNodes = await getFullLineage(db, edge.fromNodeId, visited);
ancestors.push(...parentNodes);
}
return [...ancestors, node];
}
This gives you the complete provenance chain for any model in production, going back through every dataset version, preprocessing step, and training run that contributed to it. For regulated industries, store this graph in an immutable store or use database-level write protections so that lineage records cannot be altered after creation.
Approval Workflows: Controlled Promotion
The gap between “model passes eval” and “model runs in production” is where governance lives. An approval workflow formalizes the gates a model must pass through, and the humans who must approve it at each stage.
A minimal workflow for regulated industries has three stages: staging, preproduction, and production. Each stage has required checks, a quorum of approvers, and an expiry (approvals older than N days must be reconfirmed before promotion proceeds).
type PromotionStage = "dev" | "staging" | "preproduction" | "production";
type ApprovalStatus = "pending" | "approved" | "rejected" | "expired";
interface PromotionRequest {
requestId: string;
modelId: string;
modelVersion: string;
fromStage: PromotionStage;
toStage: PromotionStage;
requestedBy: string;
requestedAt: string;
rationale: string;
requiredChecks: CheckResult[];
approvals: Approval[];
status: "open" | "approved" | "rejected" | "cancelled";
resolvedAt: string | null;
}
interface CheckResult {
checkName: string;
status: "passed" | "failed" | "skipped";
detail: string;
runAt: string;
runBy: string; // pipeline identity for automated checks
}
interface Approval {
approvalId: string;
approvedBy: string;
role: "model_owner" | "risk_officer" | "compliance" | "security";
decision: ApprovalStatus;
comment: string;
decidedAt: string;
expiresAt: string; // Approvals expire to prevent stale sign-offs
}
interface StagePolicy {
stage: PromotionStage;
requiredChecks: string[]; // Must all pass
requiredApproverRoles: string[]; // Each role must have an approval
approvalQuorum: number; // Minimum number of approvals needed
approvalExpiryDays: number;
automatedChecks: AutomatedCheck[];
}
const STAGE_POLICIES: Record<string, StagePolicy> = {
staging: {
stage: "staging",
requiredChecks: ["unit_tests", "integration_tests", "schema_validation"],
requiredApproverRoles: ["model_owner"],
approvalQuorum: 1,
approvalExpiryDays: 30,
automatedChecks: ["performance_regression", "bias_check"],
},
preproduction: {
stage: "preproduction",
requiredChecks: [
"performance_regression",
"bias_check",
"data_lineage_complete",
"model_card_complete",
],
requiredApproverRoles: ["model_owner", "risk_officer"],
approvalQuorum: 2,
approvalExpiryDays: 14,
automatedChecks: [
"shadow_mode_comparison",
"load_test",
"adversarial_input_check",
],
},
production: {
stage: "production",
requiredChecks: [
"shadow_mode_comparison",
"load_test",
"security_scan",
"compliance_review",
],
requiredApproverRoles: ["model_owner", "risk_officer", "compliance"],
approvalQuorum: 3,
approvalExpiryDays: 7,
automatedChecks: ["canary_deployment_metrics"],
},
};
async function evaluatePromotionRequest(
db: DatabaseClient,
requestId: string
): Promise<{ approved: boolean; blockers: string[] }> {
const request = await db.promotionRequests.findById(requestId);
const policy = STAGE_POLICIES[request.toStage];
const blockers: string[] = [];
// All required checks must pass
for (const required of policy.requiredChecks) {
const check = request.requiredChecks.find((c) => c.checkName === required);
if (!check || check.status !== "passed") {
blockers.push(`Required check not passed: ${required}`);
}
}
// Each required role must have a non-expired approval
const now = new Date();
for (const role of policy.requiredApproverRoles) {
const approval = request.approvals.find(
(a) =>
a.role === role &&
a.decision === "approved" &&
new Date(a.expiresAt) > now
);
if (!approval) {
blockers.push(`Missing valid approval from role: ${role}`);
}
}
// Quorum check
const validApprovals = request.approvals.filter(
(a) => a.decision === "approved" && new Date(a.expiresAt) > now
);
if (validApprovals.length < policy.approvalQuorum) {
blockers.push(
`Insufficient approvals: ${validApprovals.length}/${policy.approvalQuorum}`
);
}
return { approved: blockers.length === 0, blockers };
}
Every approval decision writes an immutable audit record. The promotion itself is atomic: the model registry updates the production pointer, and an audit event is emitted simultaneously in the same database transaction. If the transaction rolls back, neither happens.
Drift Detection and Automated Retraining Triggers
Governance does not end at deployment. Production models need continuous monitoring with automated signals that trigger the review cycle when behavior changes materially.
Three triggers warrant automated retraining initiation (not automatic retraining: a human still approves the new model before promotion):
- Input distribution drift: the distribution of incoming feature values diverges from the training distribution by more than a configured threshold, using KL divergence or PSI.
- Performance degradation: business metrics or labeled outcome data shows the model’s accuracy has dropped below a defined floor.
- Regulatory trigger: a dataset used in training has a new known-bias finding or data quality issue, requiring retraining on a cleaned version.
interface DriftMonitorConfig {
modelId: string;
features: FeatureMonitorConfig[];
performanceMetric: string;
performanceFloor: number;
psiThreshold: number; // > 0.2 is significant by convention
klDivThreshold: number;
evaluationWindowDays: number;
referenceWindowDays: number;
}
interface DriftEvent {
eventId: string;
modelId: string;
detectedAt: string;
driftType: "input_distribution" | "performance" | "regulatory";
affectedFeature: string | null;
severity: "warning" | "critical";
psiValue: number | null;
currentMetricValue: number | null;
thresholdBreached: number;
automaticAction: "notify" | "initiate_retraining_review";
}
async function checkAndEmitDriftEvents(
db: DatabaseClient,
config: DriftMonitorConfig,
referenceStats: FeatureStats,
currentStats: FeatureStats
): Promise<DriftEvent[]> {
const events: DriftEvent[] = [];
for (const featureConfig of config.features) {
const psi = computePSI(
referenceStats[featureConfig.name],
currentStats[featureConfig.name]
);
if (psi > config.psiThreshold) {
const event: DriftEvent = {
eventId: crypto.randomUUID(),
modelId: config.modelId,
detectedAt: new Date().toISOString(),
driftType: "input_distribution",
affectedFeature: featureConfig.name,
severity: psi > config.psiThreshold * 1.5 ? "critical" : "warning",
psiValue: psi,
currentMetricValue: null,
thresholdBreached: config.psiThreshold,
automaticAction:
psi > config.psiThreshold * 1.5
? "initiate_retraining_review"
: "notify",
};
await db.driftEvents.insert(event);
events.push(event);
}
}
return events;
}
The key constraint: automated drift detection initiates a retraining review request in the approval workflow system. It does not deploy a new model. The governance workflow still requires human approval before the retrained model reaches production, which is what regulators require.
Audit Trails for Compliance
The audit trail is the legal artifact. It must be append-only, tamper-evident, and queryable for specific events. For HIPAA, you need records of every access to PHI-derived model artifacts. For SOC 2 CC6, you need change records for every production model promotion. For SEC AI oversight, you need records sufficient to reconstruct what model was making decisions at any given point in time.
interface AuditEvent {
eventId: string; // UUID, generated at insert
occurredAt: string; // ISO 8601, server-side clock
eventType: AuditEventType;
actorId: string; // Human user or service identity
actorType: "human" | "pipeline" | "system";
resourceType: "model" | "dataset" | "promotion_request" | "model_card";
resourceId: string;
resourceVersion: string | null;
action: string; // created | approved | rejected | promoted | accessed | retrained
outcome: "success" | "failure";
detail: Record<string, unknown>; // Event-specific data
correlationId: string | null; // Links events in the same workflow
previousStateHash: string | null; // Hash of resource state before change
newStateHash: string | null; // Hash after
}
type AuditEventType =
| "model.created"
| "model.promoted"
| "model.retired"
| "model.accessed"
| "approval.granted"
| "approval.rejected"
| "drift.detected"
| "retraining.initiated"
| "retraining.completed"
| "model_card.updated"
| "lineage.recorded";
The state hashes make the audit trail tamper-evident without a blockchain: if someone modifies a past record, the hash chain breaks and the discrepancy surfaces during the next integrity check. Run a nightly integrity check that recomputes hashes across the audit log and alerts if any record has been altered.
For HIPAA-specific requirements, add a PHI access log that records every query to any model trained on PHI, including the requestor identity, the input data category (not the raw PHI), and the business justification.
Tradeoffs
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| MLflow + custom approval layer | Open source, well-understood, low vendor lock-in | Approval workflow requires custom build; audit trail is thin out of the box | Teams with ML engineers who can extend the platform |
| Weights & Biases Registry | Strong experiment tracking, good lineage UX | Approval workflows are limited; audit log export requires plan tier | Teams prioritizing experiment visibility over compliance depth |
| Custom registry on Postgres | Full control over schema, approval logic, and audit trail | High build cost; ongoing maintenance burden | Regulated industries that need compliance-specific fields |
| Vertex AI Model Registry | Integrated with Google Cloud IAM; good audit logging via Cloud Audit Logs | GCP lock-in; model card schema is not compliance-specific | GCP-native teams in lightly regulated verticals |
| SageMaker Model Registry | Deep AWS integration; approval workflows built in | Approval flow is basic; lineage is tied to SageMaker Pipelines | AWS shops that already use SageMaker for training |
The correct answer for most regulated-industry teams is a hybrid: MLflow or W&B for experiment tracking and artifact storage, plus a custom thin registry for the compliance-specific overlay (model cards, approval workflows, audit trail). The experiment tracking tool does what it does well; the compliance layer adds what it lacks.
Production Considerations
Schema versioning from day one. Your model card schema will change as regulatory requirements evolve. Use a versioned schema with a schemaVersion field in the card, and store the schema definition alongside the card data. Do not break old cards when you extend the schema.
Immutable audit log storage. The audit events table must be append-only at the database level: no UPDATE or DELETE permissions on the table, even for the service account. Use a separate read replica for compliance queries so that audit reads do not contend with write throughput. For HIPAA, the retention period is 6 years; design storage accordingly.
Approval expiry is not optional. Time-boxed approvals prevent the common failure mode where a model gets risk-officer sign-off in January and ships to production in July after the risk landscape has changed. The expiry window should scale inversely with the risk classification: 30 days for low-risk, 7 days for high-risk.
Shadow mode before production promotion. Run the new model version in shadow mode (receives real traffic, does not serve responses) for a configurable period before the production promotion request opens. Attach the shadow mode comparison report as a required check in the promotion policy. This is the single most effective gate for catching behavioral regressions.
Checksum verification at serving time. The inference service should verify the model file checksum against the registry entry at startup and log a compliance event if there is a mismatch. This detects unauthorized file replacement without requiring access controls alone.
Separate prod and non-prod registries. The production model registry should be in a separate account or project from the development and staging registries. Cross-environment promotion requires an explicit handoff with a full governance record. This prevents the “we’ll just copy the file” shortcut that destroys lineage.
Regulatory framework coverage is additive. A model that must comply with both HIPAA and SOC 2 needs to satisfy both frameworks’ requirements in a single governance record. Design the compliance fields as arrays of requirements, not a single enum, so models can carry multiple framework obligations simultaneously.
Closing Thought
Model governance in regulated industries is not fundamentally different from release management in safety-critical software: the goal is a defensible, auditable chain of custody from requirement to deployment, with human review at the points where mistakes have consequential costs. The tooling matters less than the discipline of capturing lineage at every transformation, requiring explicit approval at every stage boundary, and storing the evidence in a form that holds up when someone outside your team is reading it six months later under adversarial conditions.
The teams that get this right treat the model registry as the source of truth, not a nice-to-have alongside the experiment tracker. The compliance artifacts are the byproduct of a well-run process, not a separate paperwork exercise tacked on at the end.
More in AI / ML
How Mixture of Experts Works: Sparse Gating, Expert Routing, and the Architecture Behind Efficient Large Language Models
A deep dive into MoE architecture: how the gating network routes tokens to experts, top-k selection, load balancing losses, capacity factor, token dropping, expert parallelism for serving, and the real production tradeoffs between dense transformers and sparse MoE models.
AI Agent Frameworks Compared: CrewAI, LangGraph, AutoGen, and Mastra for Production Systems
A practical comparison of CrewAI, LangGraph, AutoGen, and Mastra for building production AI agent systems. Covers architecture philosophy, state management, tool integration, observability, and deployment patterns with TypeScript code examples.
Google's Agent2Agent Protocol: How A2A Enables Cross-Framework Agent Communication in Production Systems
A deep dive into Google's Agent2Agent (A2A) protocol covering agent cards, task lifecycle, message parts, streaming via SSE, push notifications, and how A2A complements MCP. Includes TypeScript implementation examples, comparison with MCP and direct API integration, and production deployment patterns for multi-vendor agent ecosystems.
How Transformer Models Work: Self-Attention, Positional Encoding, and the Architecture Behind Modern LLMs
A technical deep dive into the Transformer architecture: tokenization, positional encoding, self-attention with Q/K/V matrices, multi-head attention, the encoder-decoder split, training dynamics, and what it all means for engineers building on top of LLMs.