Building OSFI Model Validation Attestation Forms
An OSFI E-23 attestation is the sign-off artifact that proves an independent validator examined a model, recorded what they found, and either approved it or approved it with conditions — and unless that record is structured and sealed, it is just an email an examiner cannot rely on. This page shows how to build a machine-checkable attestation record in Python, and it sits within the OSFI Model Risk Management Guidelines discipline, picking up where the step-by-step OSFI model validation checklist for life insurers leaves off — the checklist runs the review, the attestation records its verdict.
The Problem
OSFI’s Guideline E-23 expects model risk to be managed across the whole lifecycle, and Principle 4 places independent validation and approval at the centre of that lifecycle: a party who did not build the model must assess it and formally sign off before it is used in production. In practice the sign-off is often a paragraph in a deck or a scanned signature page, which loses the structure a supervisor needs — who validated it, over what scope, what they found, what limitations remain, whether approval is conditional, and when it expires. The consequence is that a model in production cannot be tied to a current, valid attestation on demand. The fix is to make the attestation a typed record whose required fields are enforced at construction, whose approval status is constrained to a known set of verdicts, and which is sealed the moment it is issued so the sign-off cannot be edited after the fact.
A Minimal Working Example
The pattern is an attestation record with enforced required fields, an approval status restricted to a defined enumeration, a rule that conditional approvals must carry open findings, and an immutable seal computed at issue time. It runs with only the standard library:
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field, asdict
from datetime import date, datetime, timedelta, timezone
from enum import Enum
class ApprovalStatus(str, Enum):
APPROVED = "approved"
APPROVED_WITH_CONDITIONS = "approved_with_conditions"
REJECTED = "rejected"
@dataclass(frozen=True)
class Finding:
finding_id: str
severity: str # "low" | "medium" | "high"
description: str
remediation_due: date | None = None
@dataclass(frozen=True)
class Attestation:
"""An E-23 independent-validation sign-off, sealed at issue."""
model_id: str
model_version: str
validator_name: str
validator_role: str
developer_name: str
scope: str
findings: tuple[Finding, ...]
residual_limitations: str
approval_status: ApprovalStatus
effective_date: date
expiry_date: date
seal: str = ""
def open_findings(self) -> tuple[Finding, ...]:
return tuple(f for f in self.findings if f.severity in ("medium", "high"))
REQUIRED_TEXT_FIELDS = (
"model_id", "model_version", "validator_name",
"validator_role", "developer_name", "scope", "residual_limitations",
)
def _seal(record: dict) -> str:
payload = json.dumps(record, sort_keys=True, default=str)
return hashlib.sha256(payload.encode()).hexdigest()
def issue_attestation(**kwargs) -> Attestation:
# 1. Required-field enforcement: no blank sign-off fields.
for name in REQUIRED_TEXT_FIELDS:
if not str(kwargs.get(name, "")).strip():
raise ValueError(f"attestation field {name!r} must not be empty")
# 2. Segregation of duties: validator cannot be the developer.
if kwargs["validator_name"].strip() == kwargs["developer_name"].strip():
raise ValueError("independent validation requires validator != developer")
draft = Attestation(seal="", **kwargs)
# 3. Conditional approval must be justified by open findings.
if draft.approval_status is ApprovalStatus.APPROVED_WITH_CONDITIONS:
if not draft.open_findings():
raise ValueError("approved_with_conditions requires open findings")
if draft.approval_status is ApprovalStatus.APPROVED and draft.open_findings():
raise ValueError("unconditional approval cannot carry open high/medium findings")
# 4. Seal the record so the sign-off is immutable after issue.
body = {k: v for k, v in asdict(draft).items() if k != "seal"}
body["issued_at_utc"] = datetime.now(timezone.utc).isoformat()
return Attestation(**{**asdict(draft), "seal": _seal(body)})
if __name__ == "__main__":
att = issue_attestation(
model_id="lifepar-lapse-2024",
model_version="4.1.0",
validator_name="R. Nakamura, FCIA",
validator_role="Independent Model Validation",
developer_name="T. Adeyemi, FSA",
scope="Dynamic lapse assumption engine, LICAT-relevant cash flows",
findings=(
Finding("F-01", "medium", "Lapse sensitivity undocumented",
remediation_due=date(2025, 3, 31)),
),
residual_limitations="Not validated for segregated-fund riders.",
approval_status=ApprovalStatus.APPROVED_WITH_CONDITIONS,
effective_date=date(2025, 1, 1),
expiry_date=date(2025, 1, 1) + timedelta(days=365),
)
print(att.approval_status.value, att.seal[:16])
How It Works, Block by Block
ApprovalStatus constrains the verdict to a closed set. An attestation whose status is a free-text string invites values like “approved pending” or “OK for now” that no downstream system can act on. Modelling the verdict as an enumeration of approved, approved with conditions, and rejected means every consumer — a model inventory, a production gate, a supervisory report — reads the same three states, and an unknown status is impossible to construct.
Required-field enforcement runs before anything is built. Step 1 walks the mandatory text fields and rejects any that are blank, so an attestation cannot be issued without naming the model and version, the validator and their role, the developer, the scope, and the residual limitations. These are the fields a supervisor asks for first, and enforcing them at construction converts “we’ll fill that in later” into a failure at issue time.
Segregation of duties is checked, not assumed. Step 2 compares the validator’s name against the developer’s and refuses to proceed if they match, because E-23’s independence expectation is meaningless if the person who built the model also signs off on it. This is a crude but effective guard; in a real deployment you would resolve both parties against an identity directory rather than a string, as the edge-case section notes.
Status consistency ties the verdict to the evidence. Step 3 enforces two directions of the same rule: an approval with conditions must actually carry open medium or high findings — otherwise it should be a clean approval — and an unconditional approval must not carry any. This prevents the common contradiction of a “conditional” sign-off with no conditions attached, or a “clean” approval that quietly hides unresolved findings. The open_findings helper defines open as medium or high severity, leaving low-severity observations as documentation rather than blockers.
The seal makes the sign-off immutable. Step 4 serializes the record with a UTC issue timestamp and hashes it, then stores the digest on the returned attestation. Because the dataclass is frozen, the record cannot be mutated in place, and any later change would produce a different seal — so an attestation that was edited after issue is detectable. Persisted into the actuarial audit trail, the sealed record becomes the tamper-evident evidence of independent validation.
Edge Cases and Production Hardening
Segregation of duties needs identity, not a name string. Two people can share a name, and one person can appear under different spellings, so a string comparison both misses real conflicts and raises false ones. Resolve the validator and developer to stable identifiers and confirm they belong to different teams before issuing:
def assert_independent(validator_id: str, developer_id: str, directory: dict) -> None:
v_team = directory[validator_id]["team"]
d_team = directory[developer_id]["team"]
if validator_id == developer_id or v_team == d_team:
raise ValueError("validator and developer must be different people on different teams")
This closes the gap where a developer’s teammate rubber-stamps the model, which satisfies the letter of “different person” but not E-23’s intent of genuinely independent challenge.
Conditional approvals must not become permanent. An approval with conditions is a promise that open findings will be remediated, and without a due date it quietly hardens into a standing exception. Give every open finding a remediation_due date and refuse to issue a conditional attestation whose findings have no deadline, then run a periodic job that flags any conditional attestation whose remediation dates have passed. The residual limitations field should say plainly what the model may not be used for, so the scope of the conditional approval is unambiguous.
Attestations expire, and an expired one is not a valid control. A sign-off carries an effective_date and an expiry_date because a model’s validity is time-bounded — assumptions drift, experience emerges, and the environment moves. A production gate must treat an attestation as valid only between those dates, and re-validation must produce a fresh sealed record rather than extending the old one:
def is_currently_valid(att: Attestation, as_of: date) -> bool:
return att.effective_date <= as_of <= att.expiry_date and \
att.approval_status is not ApprovalStatus.REJECTED
Feeding this expiry logic into the model inventory keeps a lapsed attestation from silently authorising a model in production, the same lifecycle discipline that SR 11-7 Model Risk Governance applies on the US side of the border.
Compliance Note
This attestation record is the mechanical evidence E-23 Principle 4 asks for: independent validation performed by a party other than the developer, with the assessment and its approval documented before the model is relied upon. The enforced validator identity, the scope, the findings, and the residual limitations are precisely the elements a supervisor expects to see, and the seal plus effective and expiry dates make the sign-off both tamper-evident and time-bounded. For a Canadian life insurer, the models being attested frequently feed the LICAT (Life Insurance Capital Adequacy Test) calculation, where governance over the models that drive required capital is itself an OSFI expectation — so a lapsed or contradictory attestation on a LICAT-relevant model is not a paperwork gap but a capital-governance finding. Running the review through the step-by-step OSFI model validation checklist for life insurers and sealing its verdict here gives the model lifecycle a defensible start and end point.
Frequently Asked Questions
What must an OSFI E-23 validation attestation contain?
It should record the validator’s identity and role, the developer, the scope of the validation, the findings, the residual limitations, the approval status, and the effective and expiry dates. Enforcing these as required fields at construction means an attestation cannot be issued with a blank sign-off field a supervisor would immediately ask for.
How do you enforce independence between the developer and the validator?
Compare the validator against the developer and refuse to issue the attestation if they match, and in production resolve both parties to stable identifiers and confirm they sit on different teams. This enforces E-23’s expectation that a party who did not build the model performs a genuinely independent challenge, not a rubber stamp from a teammate.
Can a model be approved while findings are still open?
Yes, through an approved-with-conditions status, but only when open medium or high findings are actually attached and carry remediation due dates. An unconditional approval must carry no open findings, which prevents both an empty conditional sign-off and a clean approval that hides unresolved issues.
Why does an attestation need an expiry date?
Because a model’s validity is time-bounded as assumptions drift and experience emerges, so a sign-off that never expires becomes a standing exception. Recording an effective and expiry date lets a production gate treat the attestation as valid only within that window and forces re-validation to produce a fresh sealed record rather than extending the old one.
Related
- OSFI Model Risk Management Guidelines — the E-23 lifecycle discipline this attestation formalises.
- Step-by-Step OSFI Model Validation Checklist for Life Insurers — the review whose verdict this record seals.
- SR 11-7 Model Risk Governance — the parallel US validation-and-approval regime for cross-border carriers.
- Actuarial Audit Trail Architecture — the tamper-evident ledger the sealed attestation is stored in.
Up a level: OSFI Model Risk Management Guidelines · Regulatory Architecture & Compliance Mapping
Regulatory references: OSFI Guideline E-23 (Model Risk Management) and the LICAT (Life Insurance Capital Adequacy Test) guideline.