Skip to content
Updated Jul 4, 2026

Rating Engine

Status: on a feature branch, not yet merged or deployed. The rating engine and its Boundary Service dependency live in unmerged worktree branches (feat/rating-engine, feat/boundary-service); the code is absent from the current main line and is not running in the deployed stack. The behaviour below describes what is built on that branch, with the gaps called out.

Rating computes the monthly premium for a member's coverage: given a risk profile, a product version's rate tables, and an effective date, it returns a per-member-per-month (PMPM) amount.

Rating is not a standalone service. It is a shared Go package (packages/go/rating) that Enrollment calls during quote pricing, with the rate-table configuration owned by Policy Admin. The engine is factor-agnostic: a new rating dimension (smoking status, BMI band) is added by creating a factor definition and uploading a rate table, with no Go changes.

The pipeline as built is base_rate × factor1 × factor2 × ... = computed_premium, rounded to 2 decimal places. It supports group health (age-banded) and individual health (multi-factor) products.

Field reference: column types and nullability for rating_factors, rate_tables and rate_evaluations live in the catalog. This page is the narrative.

How rating runs

There is no /rate endpoint. Rating is a side effect of pricing a quote (PATCH /quotes/{locator}/price) and of the equivalent endorsement/transaction path. QuoteService.Price() calls rateQuoteElements() internally before rule evaluation, then advances the quote to PRICED. Pricing changes status; it is not a status-neutral preview.

For each element, Rate():

  1. Looks up the base rate for the product version + element type + effective date (e.g. employee = £120/month, dependent = £85/month).
  2. For each rating factor on the product version, resolves the member's value from the quote/element data and looks up the multiplier in the rate table.
  3. Multiplies: base_rate × factor1 × factor2 × ..., rounds to 2dp, and stores it as the element's premium.
  4. Sums the element premiums into the quote's total_premium.

The effective date passed from Enrollment is time.Now() on the day of pricing, not a caller-supplied date.

Rating factors

A factor is a named risk dimension with a data_type and a source_path:

data_typeLookupExample
RANGEnumeric value falls in a [range_min, range_max] rowage 42 → range 40-49 → 1.15
ENUMexact-key matchplan tier "Gold" → 1.40
BOOLEANkey "true"/"false"smoker true → 1.50

data_type is constrained by a DB CHECK to RANGE | ENUM | BOOLEAN.

Source paths resolve against the quote document and element data:

  • element.data.<key> and element.coverage_terms.<key> (nested keys allowed, e.g. element.data.age).
  • policy.document.<key>.
  • A path needs at least namespace.sub.key; shorter paths are rejected.

A factor is required (default true) or optional. A required factor whose value cannot be resolved (missing key, or no matching rate-table row) fails the whole rating call; an optional factor is skipped, leaving the running premium unchanged.

Rate tables

Rate tables map factor values to amounts. Each row carries an effective date range (effective_from NOT NULL, effective_to nullable), so rates can change without rewriting existing policies.

  • Base-rate rows: rating_factor_id = NULL, value_type = BASE_RATE, scoped by element_type. One per element type per product version.
  • Multiplier rows: rating_factor_id set, value_type = MULTIPLIER. ENUM/BOOLEAN rows use key; RANGE rows use range_min/range_max.

value_type is constrained by a DB CHECK to BASE_RATE | MULTIPLIER. All amounts are NUMERIC(14,4).

Geographic factors (planned, not wired)

The rating package and the Boundary Service support spatial factors:

  • $boundary:N resolves a member's lat/lng to an administrative boundary at level N.
  • $zone_group:code resolves lat/lng to a custom boundary group (e.g. actuarial zones like ZONE-METRO / ZONE-URBAN / ZONE-RURAL), decoupling rating zones from administrative geography.

This path is not usable from Enrollment today. Both rating call sites (quote pricing and the transaction path) pass the boundary resolver as nil. With a nil resolver, any $boundary: or $zone_group: factor returns the error "requires boundary resolver but none configured" and fails rating rather than resolving. The Boundary Service endpoints exist in Policy Admin, but Enrollment never calls them during rating. Geographic factors cannot be used until a resolver is injected into the Rate() calls.

Rule effects on premium (planned, not wired)

The doc previously described pricing-rule SURCHARGE/DISCOUNT effects multiplying against the rated premium. No such adjustment is applied. The stored premium is ComputedPremium directly; in the package FinalPremium is set equal to ComputedPremium, and no SURCHARGE/DISCOUNT/rule-adjustment logic exists on the enrollment rating path. The pricing ruleset still runs (it can DENY a quote or raise a blocking underwriting flag), but it does not scale the premium. The pipeline ends at base_rate × factors.

Audit trail: implemented in package, disabled in enrollment

The package can write an append-only rate_evaluations row per element (base rate, factors applied with source value and multiplier, computed premium, effective date) via an AuditWriter. Enrollment does not enable it: both call sites pass auditWriter = nil, and the write is gated on a non-nil writer, so no rate_evaluations rows are produced in practice. GET /rate-evaluations/{entityType}/{entityId} exists and is wired, but returns an empty list until an AuditWriter is injected into the enrollment Rate() calls.

Database

Rate-table configuration lives in the policy_admin schema (it sits alongside rule sets and product config). Premium results land in the enrollment schema.

SchemaTable / columnPurpose
policy_adminrating_factorsPer-product-version factor definitions (name, data_type, source_path, required, display_order)
policy_adminrate_tablesBase rates and multiplier rows with effective date ranges
policy_adminrate_evaluationsAppend-only audit rows (written only when an AuditWriter is wired; empty today)
enrollmentpolicy_elements.rated_premium, .premium_currencyPer-element PMPM premium (currency defaults GBP)
enrollmentquotes.total_premium, .premium_currencySum of element premiums (currency defaults GBP)

API routes

Rate-table management (Policy Admin)

Routes are nested under /products/{productLocator}/versions/{version}.

MethodPathDescription
POST/rating-factorsCreate a rating factor
GET/rating-factorsList rating factors
DELETE/rating-factors/{locator}Delete a rating factor
PUT/rate-tables/baseSet base rates
GET/rate-tables/baseGet base rates
PUT/rate-tables/{factorLocator}Replace rate-table rows for a factor
GET/rate-tables/{factorLocator}List rate-table rows for a factor
GET/rate-evaluations/{entityType}/{entityId}Get audit rows (empty until AuditWriter is wired)
GET/product-versions/{id}/ratingInternal: fetch factors + rate tables for Enrollment

Bulk per-factor replacement is done with PUT /rate-tables/{factorLocator} (it replaces all rows for that factor). There is no CSV/JSON rate-tables/import endpoint.

Rating (Enrollment)

There is no dedicated rating route. Rating is triggered as a side effect of:

MethodPathDescription
PATCH/quotes/{locator}/priceRates elements, persists premiums, advances quote to PRICED

The endorsement/transaction path rates the same way internally.

Integration points

ServiceDirectionPurpose
Policy AdminreadsEnrollment fetches factors + rate tables via GET /product-versions/{id}/rating
EnrollmentembedsCalls rating.Rate() inside QuoteService.Price()
Boundary Service(planned)Would resolve geographic factors; resolver is nil today, so unused
BillingfeedsRated premium becomes the premium charge amount on policy issuance

Package structure

packages/go/rating/
├── types.go      # RateRequest, RateResult, AppliedFactor, provider/resolver/audit interfaces
├── rate.go       # Rate() - base rate × factors per element
└── resolve.go    # source-path resolution (element/policy JSON, $boundary, $zone_group)

Invariants

  • A quote's total_premium is the sum of its elements' rated_premium; each element premium is base_rate × Π multipliers, rounded to 2dp.
  • A required factor that cannot be resolved fails the rating call; an optional one is skipped at multiplier 1.0.
  • Rating runs inside Price() and advances the quote to PRICED; it is not a preview.
  • Rate-table rows are scoped by effective date, so a quote priced today uses today's rates even if rates change later.

Caveats

  • Not deployed. The engine is on feat/rating-engine and not merged into the running stack.
  • No premium-level rule adjustments. Pricing rules can deny a quote but do not surcharge or discount the rated premium.
  • Geographic factors are inert. The boundary resolver is nil on the enrollment path; a $boundary:/$zone_group: factor would error, not resolve.
  • Audit trail is empty. rate_evaluations writes are gated on an AuditWriter that Enrollment passes as nil.

Design doc

Full specification: docs/superpowers/plans/2026-04-16-rating-engine.md.

Olly Health Insurance Platform