OSFI Model Risk Management Guidelines
OSFI Guideline E-23 sets the supervisory expectation that every model informing a Canadian insurer’s capital, reserving, or pricing decisions is governed across a defined lifecycle — from design and data through independent validation, deployment, and ongoing monitoring — with rigour that scales to each model’s risk rating. This page turns that principles-based expectation into a working subsystem: a code-enforced model inventory, a risk-rating engine, and lifecycle gates that refuse to let an unvalidated or stale model reach production. It sits within the broader Regulatory Architecture & Compliance Mapping discipline and shows the specific Python patterns that make E-23 compliance reproducible rather than aspirational.
The Compliance Problem OSFI E-23 Solves
The failure E-23 guards against is not a single wrong number — it is an ungoverned model reaching a capital calculation without anyone able to prove it was reviewed, by whom, against what data, and when. Under the revised E-23 lifecycle (rationale and design → data → development → validation → deployment → monitoring → decommission), OSFI expects a federally regulated insurer to maintain a complete model inventory, assign each model a risk rating driven by materiality and complexity, subject high-rated models to independent validation before use, and revalidate on a cadence proportionate to that rating. LICAT ties this directly to solvency: a capital model that feeds the Life Insurance Capital Adequacy Test but cannot demonstrate lifecycle governance is a supervisory finding waiting to happen.
The cost of getting this wrong is concrete and asymmetric. A model deployed with a validation sign-off that a spreadsheet claims exists but no immutable record confirms is indistinguishable, during an examination, from a model that was never validated at all. A high-materiality LICAT projection that quietly drifts past its revalidation window becomes an unquantified capital exposure. A validation performed by the model’s own developer violates the independence expectation no matter how thorough it was. Each of these is a control that a manual, document-driven process will eventually miss under filing-deadline pressure. The subsystem described here makes them structurally impossible: a model that has not cleared its gate cannot be promoted, and every gate decision is written to an append-only trail before promotion proceeds.
This is the Canadian counterpart to the US-focused controls in NAIC VM-20 Compliance Frameworks; insurers operating on both sides of the border run a single inventory that carries both regulatory mappings on each record.
Architecture of an E-23-Compliant Validation Subsystem
The subsystem separates three concerns that manual processes routinely conflate: the inventory (what models exist and their attributes), the rating (how much rigour each model warrants), and the gate (whether a model may move to the next lifecycle stage right now). The projection and reserving engines never make promotion decisions themselves — they consume only models the gate has already cleared. Every gate evaluation produces a structured decision that is fingerprinted and appended to the audit trail, so the question “was this model allowed into production, and why?” always has a reproducible answer.
Each stage in this flow is a gate, not a suggestion. A model that fails the independent-validation gate is routed to a compliance queue for remediation rather than silently proceeding, and the routing decision is itself a logged event. This mirrors the deterministic state-machine pattern used by the Assumption Validation & Rule Engine Design engine on the assumption side of the pipeline: inputs are classified, exceptions are routed, and nothing reaches the calculation layer without an attributable record of how it got there.
Prerequisites
Before implementing the gate engine, the following should be in place:
- Python packages.
pydantic>=2for the model-inventory contract and validators, the standard libraryenum,datetime,hashlib, andjsonfor lifecycle stages and deterministic fingerprinting, andpytestfor the gate regression suite. No third-party dependency is required for the core logic — keeping the compliance-critical path dependency-light is itself a governance choice. - Actuarial data contracts. Each model record needs a stable
model_id, its business use (e.g. a LICAT capital projection), a materiality and complexity score, a named owner, and a named independent reviewer distinct from the owner. These typed contracts build on the ingestion-side patterns in Schema Validation with Pydantic & Great Expectations. - Regulatory context. Familiarity with the E-23 model lifecycle stages and the LICAT capital framework they feed. Readers new to the end-to-end pipeline should start at the Actuarial Model Ingestion & Testing Workflows track, which produces the clean, versioned data this subsystem governs.
- An audit trail. A hash-chained, append-only store described in Actuarial Audit Trail Architecture, plus the confidentiality controls in Data Security & PII Boundaries for Filing Systems for any record that carries policyholder-linked detail.
Core Implementation: The Model Inventory and Lifecycle Gate
The canonical pattern is a typed model-inventory record, a risk-rating function that scales rigour to materiality and complexity, and a gate that evaluates whether a requested lifecycle stage may open. The inventory contract enforces the independence expectation at construction time — a record whose reviewer equals its owner cannot even be instantiated.
from __future__ import annotations
from datetime import date, timedelta
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field, ValidationInfo, field_validator
class LifecycleStage(str, Enum):
"""Ordered E-23 model lifecycle stages."""
RATIONALE = "rationale_and_design"
DATA = "data"
DEVELOPMENT = "development"
VALIDATION = "validation"
DEPLOYMENT = "deployment"
MONITORING = "monitoring"
DECOMMISSION = "decommission"
class RiskRating(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class ModelRecord(BaseModel):
"""One entry in the OSFI E-23 model inventory."""
model_id: str
model_name: str
business_use: str # e.g. "LICAT capital projection"
valuation_date: date
materiality_score: float = Field(ge=0.0, le=1.0)
complexity_score: float = Field(ge=0.0, le=1.0)
owner: str
independent_reviewer: str
regulatory_mapping: List[str] = Field(min_length=1) # e.g. ["OSFI E-23", "LICAT"]
last_validated: Optional[date] = None
approved_stages: List[LifecycleStage] = Field(default_factory=list)
@field_validator("independent_reviewer")
@classmethod
def reviewer_is_independent(cls, v: str, info: ValidationInfo) -> str:
owner = info.data.get("owner")
if owner is not None and v.strip().lower() == owner.strip().lower():
raise ValueError("Independent reviewer must differ from model owner (E-23 validation)")
return v
# Lifecycle stages evaluated in their canonical order.
STAGE_ORDER: List[LifecycleStage] = list(LifecycleStage)
def rate_model_risk(record: ModelRecord) -> RiskRating:
"""Combine materiality and complexity into an E-23 risk rating.
OSFI expects the depth of validation and the revalidation cadence to scale
with the model's risk rating rather than applying one uniform standard.
"""
score = 0.60 * record.materiality_score + 0.40 * record.complexity_score
if score >= 0.66:
return RiskRating.HIGH
if score >= 0.33:
return RiskRating.MEDIUM
return RiskRating.LOW
With the inventory contract and rating in place, the gate itself is a pure function of a record, a requested stage, and the date it is evaluated. It refuses to promote a model past any unapproved prior stage and refuses to let anything reach deployment without a current independent validation.
# Revalidation cadence by risk rating — high-rated models are re-examined most often.
REVALIDATION_INTERVAL = {
RiskRating.HIGH: timedelta(days=365), # annual
RiskRating.MEDIUM: timedelta(days=730), # biennial
RiskRating.LOW: timedelta(days=1095), # triennial
}
def revalidation_due(record: ModelRecord, as_of: date) -> bool:
"""A model with no validation on file, or one past its cadence, is due."""
if record.last_validated is None:
return True
rating = rate_model_risk(record)
return as_of >= record.last_validated + REVALIDATION_INTERVAL[rating]
class GateDecision(BaseModel):
model_id: str
requested_stage: LifecycleStage
risk_rating: RiskRating
allowed: bool
reasons: List[str] = Field(default_factory=list)
def evaluate_stage_gate(
record: ModelRecord, requested_stage: LifecycleStage, as_of: date
) -> GateDecision:
reasons: List[str] = []
idx = STAGE_ORDER.index(requested_stage)
# Every preceding lifecycle stage must be signed off before this one opens.
for prior in STAGE_ORDER[:idx]:
if prior not in record.approved_stages:
reasons.append(f"Prior stage not approved: {prior.value}")
# Deployment and everything after it require a current independent validation.
if requested_stage in (LifecycleStage.DEPLOYMENT, LifecycleStage.MONITORING):
if LifecycleStage.VALIDATION not in record.approved_stages:
reasons.append("Independent validation not signed off")
if revalidation_due(record, as_of):
reasons.append("Revalidation overdue for current risk rating")
return GateDecision(
model_id=record.model_id,
requested_stage=requested_stage,
risk_rating=rate_model_risk(record),
allowed=not reasons,
reasons=reasons,
)
The gate returns a structured GateDecision rather than a bare boolean so that every refusal carries its reasons. Those reasons are exactly what a compliance officer needs to route the model to the correct remediation step, and exactly what an examiner expects to see when asking why a model was — or was not — allowed into production.
Configuration and Tuning
Two families of parameter govern how strict the subsystem is, and both belong in version-controlled configuration rather than buried in code, so that a change to supervisory posture is itself an auditable event.
The rating weights and cutoffs decide how materiality and complexity combine into a risk rating. The default weighting leans on materiality (0.60) over complexity (0.40) because a simple model feeding a large capital figure carries more supervisory risk than a complex model feeding an immaterial one. The cutoffs at 0.33 and 0.66 divide the score into three bands. An insurer with a more conservative posture raises the HIGH cutoff downward so that more models attract the strictest cadence.
from dataclasses import dataclass
@dataclass(frozen=True)
class RatingPolicy:
materiality_weight: float = 0.60
complexity_weight: float = 0.40
medium_cutoff: float = 0.33
high_cutoff: float = 0.66
def rate(self, materiality_score: float, complexity_score: float) -> RiskRating:
score = (self.materiality_weight * materiality_score
+ self.complexity_weight * complexity_score)
if score >= self.high_cutoff:
return RiskRating.HIGH
if score >= self.medium_cutoff:
return RiskRating.MEDIUM
return RiskRating.LOW
# Production may tighten the high band so more models fall under annual revalidation.
PROD_RATING_POLICY = RatingPolicy(high_cutoff=0.60)
The revalidation cadence maps each rating to a maximum interval between independent validations. These intervals are the single most examined tuning knob: setting them too loose is the classic path to a stale high-materiality model, while setting them uniformly tight wastes independent-validation capacity on immaterial models. The cadence should be sourced from the insurer’s board-approved model risk policy and pinned in the same configuration file as the rating weights, so that a git history shows exactly when a cadence changed and under whose authority.
For high-volume inventories spanning thousands of models, the gate evaluation runs as a vectorized or asynchronous batch rather than one call at a time — the execution substrate is described in Async Batch Processing for Large Models.
Step-by-Step Walkthrough
The following sequence takes a single model from inventory entry to a gated deployment decision using the code above.
- Register the model. Construct a
ModelRecordwith itsmodel_id, business use, materiality and complexity scores, owner, independent reviewer, andregulatory_mappingof at least["OSFI E-23"](plus"LICAT"for capital models). Construction fails immediately if the reviewer equals the owner, catching the independence violation at the earliest possible point. - Rate the model. Call
rate_model_risk(orRatingPolicy.ratefor environment-specific cutoffs) to derive the risk rating that will govern its validation depth and revalidation cadence. - Advance through the early lifecycle stages. As each of rationale-and-design, data, and development is signed off, append the corresponding
LifecycleStagetoapproved_stages. The gate will block any attempt to skip a stage. - Perform and record independent validation. Once the named independent reviewer completes validation, append
LifecycleStage.VALIDATIONand setlast_validatedto the sign-off date. This is the pivotal record: no capital model reaches deployment without it. - Request the deployment gate. Call
evaluate_stage_gate(record, LifecycleStage.DEPLOYMENT, as_of=today). InspectGateDecision.allowed; ifFalse, thereasonslist names every blocking condition. - Route on the decision. Promote the model only when the gate allows it. Otherwise, route the record and its reasons to the compliance queue for remediation.
- Persist the decision to the audit trail. Fingerprint the
GateDecisionand append it to the immutable log before the promotion takes effect, so the record of the decision cannot post-date the action it authorized. - Monitor and revalidate on cadence. During the monitoring stage, re-run
revalidation_dueon the inventory so that any model crossing its interval is flagged before, not after, it becomes non-compliant.
Validation and Testing
A control subsystem is only trustworthy if its own behaviour is pinned by tests. The gate engine is pure and deterministic, which makes it straightforward to lock down with a regression suite that an examiner can read as executable evidence of the control.
import hashlib
import json
from datetime import date
import pytest
def _sample(**overrides) -> ModelRecord:
base = dict(
model_id="LICAT-CAP-001",
model_name="LICAT capital projection",
business_use="LICAT capital projection",
valuation_date=date(2026, 12, 31),
materiality_score=0.90,
complexity_score=0.70,
owner="a.actuary",
independent_reviewer="b.reviewer",
regulatory_mapping=["OSFI E-23", "LICAT"],
)
base.update(overrides)
return ModelRecord(**base)
def test_high_materiality_rates_high():
assert rate_model_risk(_sample()) is RiskRating.HIGH
def test_reviewer_independence_enforced():
with pytest.raises(ValueError):
_sample(independent_reviewer="a.actuary")
def test_deployment_blocked_without_validation():
record = _sample(approved_stages=[
LifecycleStage.RATIONALE, LifecycleStage.DATA, LifecycleStage.DEVELOPMENT,
])
decision = evaluate_stage_gate(record, LifecycleStage.DEPLOYMENT, date(2027, 1, 15))
assert decision.allowed is False
assert "Independent validation not signed off" in decision.reasons
def test_stale_validation_blocks_deployment():
record = _sample(
last_validated=date(2025, 1, 1), # over a year old for a HIGH model
approved_stages=list(LifecycleStage)[:4], # through VALIDATION
)
decision = evaluate_stage_gate(record, LifecycleStage.DEPLOYMENT, date(2027, 1, 15))
assert "Revalidation overdue for current risk rating" in decision.reasons
def test_clean_model_is_promotable_and_auditable():
record = _sample(
last_validated=date(2026, 12, 31),
approved_stages=list(LifecycleStage)[:4], # through VALIDATION
)
decision = evaluate_stage_gate(record, LifecycleStage.DEPLOYMENT, date(2027, 1, 2))
assert decision.allowed is True
# The decision fingerprint is deterministic and ties the audit record to this exact outcome.
canonical = json.dumps(decision.model_dump(), sort_keys=True, default=str)
checksum = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
assert checksum == hashlib.sha256(canonical.encode("utf-8")).hexdigest()
Beyond unit tests, two assertions belong in the pipeline itself. First, an audit-log assertion: every allowed promotion must have a matching GateDecision record in the append-only store whose checksum recomputes from the archived decision — a promotion with no matching record is a hard failure, not a warning. Second, an inventory-completeness assertion that runs on a schedule to confirm no deployed model is missing from the inventory or past its revalidation window, so drift is caught as a routine non-event. Distributional monitoring of model outputs over time — the statistical drift side of the monitoring stage — reuses the threshold machinery in Dynamic Threshold Tuning for Assumption Drift.
Failure Modes and Gotchas
The subsystem removes the obvious failures, but a production deployment still has sharp edges specific to E-23 automation.
- Lifecycle-stage ordering conflicts. Reordering or renaming a
LifecycleStagesilently changes whatSTAGE_ORDER.indexreturns, so a gate that used to block a skipped stage may start permitting it. Treat the enum order as a versioned contract: pin it in tests and bump a schema version when it changes. - Independence enforced only at construction. The reviewer-independence check fires when a
ModelRecordis built, but a later in-place mutation ofownerbypasses it. Rebuild records through the validated constructor on any change rather than reassigning attributes. - Clock and time-zone skew on the cadence.
revalidation_duecompares against theas_ofdate the caller passes. A batch job that derivesas_offrom local wall-clock time near midnight can classify the same model as due or not-due depending on the runner’s time zone. Pass an explicit, UTC-derived valuation date rather than relying ondate.today()inside the function. - Stale scores freezing the rating. Materiality and complexity are captured once at registration; if a block of business grows materially, a HIGH model can linger as MEDIUM until someone re-scores it. Recompute scores on a schedule and treat a score change that crosses a band boundary as an event that forces revalidation.
- Confidential attributes leaking into the trail. Gate decisions and inventory records can carry names, business context, and model internals. Route them through the confidentiality controls in Data Security & PII Boundaries for Filing Systems before any record crosses a trust boundary, so the audit trail never becomes an unintended disclosure channel.
Worked end to end for a single model, this gate logic is expanded into a full operational sequence in the Step-by-Step OSFI Model Validation Checklist for Life Insurers, which maps each check above to the artifacts an examiner will request.
Related
- Step-by-Step OSFI Model Validation Checklist for Life Insurers — the operational sequence that applies this gate logic model by model.
- NAIC VM-20 Compliance Frameworks — the US counterpart run on the same shared inventory.
- Actuarial Audit Trail Architecture — hash-chained, append-only storage for every gate decision.
- Data Security & PII Boundaries for Filing Systems — keeping confidential attributes out of the artifacts that leave the trust boundary.
- Assumption Validation & Rule Engine Design — the assumption-side rule engine this governance layer complements.
Up a level: Regulatory Architecture & Compliance Mapping — the parent guide covering filing automation, audit trails, and cross-jurisdictional compliance mapping.