Underwriting lifecycle (quotations, ratings, underwriting)
The four criticals — gaps 1, 2, 3, 4 — landed in
release underwriting-p1-20260717205157.
- gap 1: new
SystemPermissions.UnderwritingDecisionRead+GET /underwriting/decisions/referral/:referralId. Compliance callers see the full row (including the subject snapshot); agent-scoped callers (QuotationReadOwn+ owning-agent match) see a redacted view (rules-fired + action + timestamp, no subject). Non-owning agents receive 403. - gap 2:
UnderwritingEngine.evaluateReferral()now callsSanctionsService.screenReferralIntake()before the rule roll-up. Apotential_match/confirmed_matchforcesoutcome = 'auto_decline', appends a syntheticsanctions_gatehit to the breakdown, and emits anunderwriting.decision.sanctions_blockaudit event referencing the screening id. The screening survives onSanctionsScreening(subjectType='referral')for the compliance timeline. - gap 3: new
Quotation.ratingFactors Json?column + shippedRatingFactorsDto(canonical shape:ageYears,sumInsured,riskCategory,priorClaimsCount,occupancyType,territory,discountPct,loadingPct, plus anextrasmap for insurer-specific factors).class-validatorrejects unknown keys at write time so every rating input landing in the ledger is audit-ready. - gap 4: new
quotations.cooling_off_hourspolicy (default 168 h / 7 days) +POST /quotations/:id/withdraw. Allowed only whenstatus='accepted'andnow - acceptedAt <= coolingOffHours; outside →INVARIANT_VIOLATION.QuotationStatusgained a terminalwithdrawnvariant (withdrawnAt+withdrawnReasoncaptured on the row). Emitsquotation.withdrawaudit event.
Schema migration 20260717204500_underwriting_phase1 adds
Quotation.ratingFactors, withdrawnAt, withdrawnReason, and
extends QuotationStatus with withdrawn.
New underwriting-phase1.spec locks all 4 contracts (8 tests:
sanctions block + clear paths, compliance-vs-agent decision reads,
cross-agent 403, cooling-off in-window / out-of-window / wrong
status). 56/56 suites, 410/410 tests green.
The hygiene block — gaps 5, 6, 7, 8, 9, 10 —
landed in release underwriting-p2-20260717210447.
- gap 5: rule validator now rejects any expression that
references a protected-class field (
clientNationalId,clientNationality,clientTribe,clientGender,clientDisability) unless the field is on theunderwriting.protected_fields_allowlistpolicy (CSV, defaultclientDobfor legitimate age-based underwriting). Every rule create / update emitstouchesProtectedClass+protectedFieldsUsedin the audit metadata so a fairness audit is a single query. - gap 6:
POST /quotations/:id/{accept,decline}andPOST /underwriting/evaluateacceptIdempotency-Key.IdempotencyService.lookup/rememberscoped toquotation.accept,quotation.decline,underwriting.evaluate(24 h TTL). Retries no longer double-supersede sibling quotes or write duplicateUnderwritingDecisionrows. - gap 7:
@Throttleon evaluate + accept + decline (10/min per caller), compare (30/min), rule create + update (20/min).UnderwritingEngine.evaluateReferral()adds an in-service sliding-window guard: 5 evaluations per referralId per 60 s. Excess → 429. - gap 8:
QuotationsService.compareForReferral()gains an in-service ownership check — callers without the org-wideQuotationReadpermission must own the referral's agent or receive 403. Closes the rogue-agent enumeration vector. - gap 9:
UnderwritingEngine.evaluateReferral()emitsunderwriting.decisionaudit event carrying{ referralId, action, ruleIds, ruleCodes, sanctionsScreeningId }in the after payload — chained into the AuditEvent hash chain so a challenged decision is verifiable end-to-end. - gap 10:
clientNationalIdin the persistedUnderwritingDecision.subjectsnapshot is nowPiiCipher.encrypt-ed at write; aclientNationalIdHashsibling supports rule-eval equality lookups without decrypt. Plaintext no longer survives on the decision row.
New underwriting-phase2.spec locks 11 additional contracts
(protected-field accept + reject + allow-list override,
touchesProtectedClass audit stamp, decision audit event, subject
cipher round-trip, per-referral throttle, compare ownership pass /
fail / compliance). 57/57 suites, 421/421 tests green.
The retention + governance block — gaps 11, 12 — landed in
release underwriting-p3-20260717212027, closing the module at
12 / 12 gaps.
- gap 11:
RetentionPurgeJobextends acrossQuotation+UnderwritingDecision. Driven by newdata_sharing.underwriting_retention_dayspolicy (default 2555 = 7 y — AML §14 + URA §41 floor). Terminal quotations (declined,expired,superseded,withdrawn) past the window getmetadata,ratingFactors,declinedReason,withdrawnReasonnulled +retentionScrubbedAtstamped. Underwriting decisions past the window have theirsubject,rulesFired, andevidencecleared; the action + timestamp survive so aggregate reporting stays queryable. Each sweep emits an aggregate*.retention.scrubbedaudit event. - gap 12:
UnderwritingRule.statusstate machine (draft → pending_review → active | retired) closes the single-actor risk on rule authoring.createalways lands indraft;submitForReview()(author-side) capturesbusinessJustification+ optionalactuarialCertification;approve()requiresUnderwritingRuleApprove+ a different actor than the submitter (four-eyes).retire()marks the rule inert with a reason. Engine now filtersstatus='active'— inflight drafts + pending rules do not affect evaluations. Existing rules were backfilled toactivefor continuity.
Schema migration 20260717211500_underwriting_phase3 adds
retentionScrubbedAt on Quotation + UnderwritingDecision, the
UnderwritingRuleStatus enum, and the rule governance-chain
fields (submittedAt, submittedBy, approvedAt, approvedBy,
retiredAt, retiredBy, businessJustification,
actuarialCertification).
New underwriting-phase3.spec locks 7 contracts on the rule state
machine (create-in-draft, submit captures justification, submit
refuses non-draft, four-eyes approval, approve refuses non-pending,
retire records reason, double-retire refused). 58/58 suites,
428/428 tests green.
All 12 / 12 gaps closed for the Underwriting lifecycle module.
Scope
Three tightly-coupled modules that together form the quote-to-decision lifecycle:
- Quotations —
Quotationcaptures the shopping intent perReferral(a proposed premium + validity window + supersession chain). Lifecycle:received → shared → accepted | declined | expired.QuotationExpiryJobruns hourly to flip stale rows. - Ratings —
Ratingcaptures 1–5 star feedback oninsurer | agent | product. Note: this module is product-quality feedback, not pricing. Pricing arrives from the insurer as a pre-calculatedQuotation.premiumAmount; the platform does not compute the premium. - Underwriting —
UnderwritingRule(versioned AST-driven rules scoped by insurerId / productId / planId / agentType) evaluated over an evaluation subject built from the referral; produces anUnderwritingDecisionwith an action (allow | flag | refer_for_review | auto_decline) and the rules that fired.
Together they answer: "What premium was quoted, was it accepted, and would ZFA (or the insurer) actually write the policy?"
Compliance envelope
- IRA Insurance Act 2017 §35 — underwriting standards + written guidelines every insurer must maintain; ZFA-side rules acting as an intermediary must be at least as strict as the insurer's own.
- IRA Insurance Act 2017 §36 — fair pricing; no unfair rating differentials across protected classes (nationality, tribe, gender-only pricing where prohibited, disability). Applies to underwriting rules too, not just rating factors.
- CPA 2011 §37 — 7-day cooling-off on consumer financial contracts. Once a client accepts a quotation, they must have a programmatic withdrawal path inside the window.
- AML Act 2013 §6 — CDD at policy issuance. Sanctions screening is done at referral intake; a re-screen at the underwriting gate (immediately before the insurer binds) closes the gap between intake and issue.
- DPPA 2019 §11 — automated-decision transparency +
explainability. A client subjected to
auto_declinemust be able to see (a) the fact of the decision, (b) the factors that drove it, (c) a path to challenge it. - DPPA 2019 §21 — minimum-necessary retention for the rating
inputs snapshotted into
UnderwritingDecision.subject. - BOU Cybersecurity Guidelines §5.4 — audit-log integrity; every decision + rule change chained.
Current state (2026-07-17)
Module footprint
src/modules/quotations/quotations.module.ts — 252 LOC
src/modules/ratings/ratings.module.ts — 209 LOC
src/modules/underwriting/underwriting.module.ts — 474 LOC
src/modules/scheduled-jobs/quotation-expiry.job.ts — hourly cron
Prisma models: Quotation, Rating, UnderwritingRule,
UnderwritingDecision.
What works today
- Quotation lifecycle:
received → shared → accepted | declined | expired. Supersession chain persists sibling quotes;accept()atomically supersedes siblings + flips status. - Hourly expiry sweep (
QuotationExpiryJob): flipsreceived | sharedquotes pastvalidTotoexpiredwith a single aggregate audit event. - Versioned underwriting rules:
UnderwritingRule.effectiveFrom/effectiveTo+versionbump on update; historical rules stay queryable so a past decision can be reconstructed. - Multi-level rule scoping: rules can be scoped to
insurerId + insurerProductId + productPlanId + agentTypeat any combination. Evaluation applies the most-specific active matching rule set. - Decision persistence: every
evaluateReferral()invocation writes anUnderwritingDecisioncapturing the rules that fired, the subject snapshot, and the roll-up action. - Rating feedback:
Rating(1–5 stars + category + optional comment) persists insurer / agent / product feedback with a soft-delete moderation path.
Gaps
All 12 gaps closed across Phase 1, Phase 2, and Phase 3 (all
shipped 2026-07-17). See the shipped-notes above for the exact scope
of each phase; every gap is now enforced by locked spec coverage in
underwriting-phase1.spec / underwriting-phase2.spec /
underwriting-phase3.spec.