Skip to main content

Underwriting lifecycle (quotations, ratings, underwriting)

Phase 1 shipped (2026-07-17)

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 calls SanctionsService.screenReferralIntake() before the rule roll-up. A potential_match / confirmed_match forces outcome = 'auto_decline', appends a synthetic sanctions_gate hit to the breakdown, and emits an underwriting.decision.sanctions_block audit event referencing the screening id. The screening survives on SanctionsScreening(subjectType='referral') for the compliance timeline.
  • gap 3: new Quotation.ratingFactors Json? column + shipped RatingFactorsDto (canonical shape: ageYears, sumInsured, riskCategory, priorClaimsCount, occupancyType, territory, discountPct, loadingPct, plus an extras map for insurer-specific factors). class-validator rejects unknown keys at write time so every rating input landing in the ledger is audit-ready.
  • gap 4: new quotations.cooling_off_hours policy (default 168 h / 7 days) + POST /quotations/:id/withdraw. Allowed only when status='accepted' and now - acceptedAt <= coolingOffHours; outside → INVARIANT_VIOLATION. QuotationStatus gained a terminal withdrawn variant (withdrawnAt + withdrawnReason captured on the row). Emits quotation.withdraw audit 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.

Phase 2 shipped (2026-07-17)

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 the underwriting.protected_fields_allowlist policy (CSV, default clientDob for legitimate age-based underwriting). Every rule create / update emits touchesProtectedClass + protectedFieldsUsed in the audit metadata so a fairness audit is a single query.
  • gap 6: POST /quotations/:id/{accept,decline} and POST /underwriting/evaluate accept Idempotency-Key. IdempotencyService.lookup/remember scoped to quotation.accept, quotation.decline, underwriting.evaluate (24 h TTL). Retries no longer double-supersede sibling quotes or write duplicate UnderwritingDecision rows.
  • gap 7: @Throttle on 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-wide QuotationRead permission must own the referral's agent or receive 403. Closes the rogue-agent enumeration vector.
  • gap 9: UnderwritingEngine.evaluateReferral() emits underwriting.decision audit 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: clientNationalId in the persisted UnderwritingDecision.subject snapshot is now PiiCipher.encrypt-ed at write; a clientNationalIdHash sibling 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.

Phase 3 shipped (2026-07-17)

The retention + governance block — gaps 11, 12 — landed in release underwriting-p3-20260717212027, closing the module at 12 / 12 gaps.

  • gap 11: RetentionPurgeJob extends across Quotation + UnderwritingDecision. Driven by new data_sharing.underwriting_retention_days policy (default 2555 = 7 y — AML §14 + URA §41 floor). Terminal quotations (declined, expired, superseded, withdrawn) past the window get metadata, ratingFactors, declinedReason, withdrawnReason nulled + retentionScrubbedAt stamped. Underwriting decisions past the window have their subject, rulesFired, and evidence cleared; the action + timestamp survive so aggregate reporting stays queryable. Each sweep emits an aggregate *.retention.scrubbed audit event.
  • gap 12: UnderwritingRule.status state machine (draft → pending_review → active | retired) closes the single-actor risk on rule authoring. create always lands in draft; submitForReview() (author-side) captures businessJustification + optional actuarialCertification; approve() requires UnderwritingRuleApprove + a different actor than the submitter (four-eyes). retire() marks the rule inert with a reason. Engine now filters status='active' — inflight drafts + pending rules do not affect evaluations. Existing rules were backfilled to active for 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:

  1. QuotationsQuotation captures the shopping intent per Referral (a proposed premium + validity window + supersession chain). Lifecycle: received → shared → accepted | declined | expired. QuotationExpiryJob runs hourly to flip stale rows.
  2. RatingsRating captures 1–5 star feedback on insurer | agent | product. Note: this module is product-quality feedback, not pricing. Pricing arrives from the insurer as a pre-calculated Quotation.premiumAmount; the platform does not compute the premium.
  3. UnderwritingUnderwritingRule (versioned AST-driven rules scoped by insurerId / productId / planId / agentType) evaluated over an evaluation subject built from the referral; produces an UnderwritingDecision with 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_decline must 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): flips received | shared quotes past validTo to expired with a single aggregate audit event.
  • Versioned underwriting rules: UnderwritingRule.effectiveFrom / effectiveTo + version bump 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 + agentType at any combination. Evaluation applies the most-specific active matching rule set.
  • Decision persistence: every evaluateReferral() invocation writes an UnderwritingDecision capturing 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.