Event-Sourced Policy Administration: Building a Tri-Temporal Insurance Pricing Engine
Modeling the full lifecycle of an insurance policy as an append-only event log — and deriving pricing, billing, and previews from the same projection engine.
Insurance policies aren’t static. A business owner buys a policy in January, adds hired auto coverage in March, changes their deductible in June, and cancels in November. Each change affects the price of the policy — sometimes retroactively. Getting billing right means knowing not just what changed, but when it took effect, when we learned about it, and what the world looked like at the time we’re asking.
We built a policy administration system that treats the full lifecycle of an insurance policy as an append-only event log. From that log, we can derive the current state of any policy, compute its price at any point in time, preview what a change would cost before committing it, and generate accurate billing schedules. Same projection engine for all of it.
Policies as event streams
Every meaningful action on a policy produces an event. There are only a handful of event types:
enum PolicyEventType {
case policyCreated
case endorsementCreated
case policyCancelled
case policyReinstated
}
Each event carries two timestamps and a payload:
protocol PolicyEvent: Comparable {
var id: UUID { get }
var effectiveAt: Date { get } // when the change takes effect on the policy
var publishedAt: Date { get } // when we recorded the event
var payload: [String: Any] { get }
}
effectiveAt is the business time — the date the endorsement kicks in, or the date the cancellation takes effect. publishedAt is the transaction time — when the system learned about it. These are often different. An agent might process a cancellation request on March 15th that should take effect on March 1st. The event would have publishedAt: March 15 and effectiveAt: March 1.
This is the first two axes of our tri-temporal model. The third axis is the query time: when you’re asking the question. “What did we think this policy looked like on February 20th?” gives a different answer than “What do we think it looks like now?” — because events that were published after February 20th hadn’t happened yet from that perspective.
From events to segments
Events on their own aren’t directly useful for pricing. We need to project them into a timeline of segments — contiguous time ranges where the policy parameters are stable.
struct PolicySegment: Comparable {
let time: ClosedRange<Date> // start...end
var params: [String: Any] // rater inputs for this period
let version: String? // rater version
var replaced: Bool // was this segment superseded?
var effectiveAt: Date { time.lowerBound }
var endAt: Date { time.upperBound }
var length: TimeInterval { endAt.timeIntervalSince(effectiveAt) }
func split(at timestamp: Date) -> (PolicySegment, PolicySegment) {
let before = PolicySegment(
time: time.lowerBound...timestamp,
params: params, version: version, replaced: replaced
)
let after = PolicySegment(
time: timestamp...time.upperBound,
params: params, version: version, replaced: replaced
)
return (before, after)
}
}
Step through the events below to see how segments are built up. Each event either creates the initial timeline, splits an existing segment and changes its parameters, or marks segments as replaced.
The replaced flag matters — it means “this time period was covered by the prior version of the timeline but is no longer active.” It drives both the visual representation and the billing math.
The projection engine
The segment projection is where it all comes together. Given a list of events, it produces the current timeline:
struct PolicyEventList {
private var events: [PolicyEvent]
/// Replay events to produce the current segment timeline
func segmentProjection(firstN: Int? = nil) -> [PolicySegment] {
let n = firstN ?? events.count
let modifiers = events.prefix(n).compactMap { $0 as? ModifierEvent }
let applicable = modifiers.filter { modifier in
!reversalEvents(firstN: n)
.map(\.eventIdToReverse).contains(modifier.id)
}
return applicable.reduce([initialSegment]) { segments, event in
event.modify(previousSegments: segments)
}
}
}
Each event type knows how to modify the existing segment list. For an endorsement, that means finding the segment covering the effective date, splitting it, and applying the parameter changes to the right-hand side:
struct EndorsementCreatedEvent: ModifierEvent {
let effectiveAt: Date
let params: [String: Any]
func modify(previousSegments: [PolicySegment]) -> [PolicySegment] {
guard let index = previousSegments.firstIndex(
where: { $0.time.contains(effectiveAt) }
) else { fatalError("Effective date not covered") }
let target = previousSegments[index]
// If it lands on a boundary, just merge params
if target.effectiveAt == effectiveAt {
var updated = target
updated.params.merge(params) { _, new in new }
var result = previousSegments
result[index] = updated
return result
}
// Otherwise, split and apply
let (before, after) = target.split(at: effectiveAt)
var endorsed = after
endorsed.params.merge(params) { _, new in new }
var result = Array(previousSegments.prefix(index))
result.append(before)
result.append(endorsed)
result.append(contentsOf: previousSegments.dropFirst(index + 1))
return result
}
}
Cancellations work similarly — split at the cancellation date and mark everything after it as replaced. Reinstatements do the reverse: find the replaced segments and un-replace them.
The tri-temporal model
With effectiveAt, publishedAt, and query time, we get three dimensions to slice the data. The projectionHistory method replays events one at a time, producing a snapshot of the timeline after each event:
extension PolicyEventList {
/// Show how the timeline looked after each event was applied
func projectionHistory() -> [[PolicySegment]] {
(1...events.count).map { i in
segmentProjection(firstN: i)
}
}
/// What did the timeline look like at a specific point in time?
func projectionHistory(at date: Date) -> [[PolicySegment]] {
let relevant = events.filter { $0.publishedAt <= date }
return (1...relevant.count).map { i in
segmentProjection(firstN: i)
}
}
}
This produces a table of snapshots, one row per event, showing how the understanding of the policy evolved over time. Each row is a complete timeline. Compare row N to row N-1 to see exactly what a single event changed.
Use the slider below to move the query time. Earlier query times mean we haven’t “seen” events that were published later, so the timeline is simpler. You can ask “what did we think this policy looked like on any given date?” and get a real answer.
Pricing segments
Each segment gets priced independently. We send the segment’s parameters and time range to a rating engine, which returns an annual premium. We then pro-rate it by the segment’s duration:
struct PricedSegment {
let segment: PolicySegment
let premium: Decimal // pro-rated premium for this segment
var start: Date { segment.effectiveAt }
var end: Date { segment.endAt }
var total: Decimal { premium }
}
func priceSegments(_ segments: [PolicySegment]) -> [PricedSegment] {
let termLength = segments.last!.endAt
.timeIntervalSince(segments.first!.effectiveAt)
return segments.map { segment in
let annual = rater.rate(
params: segment.params, version: segment.version
)
let proportion = segment.length / termLength
return PricedSegment(
segment: segment,
premium: annual.premium * proportion
)
}
}
A ProjectionRow aggregates priced segments into a single view of the policy’s cost, filtering out replaced segments:
struct ProjectionRow {
let segments: [PricedSegment]
var activeSegments: [PricedSegment] {
segments.filter { !$0.segment.replaced }
}
var cost: Decimal {
activeSegments.map(\.total).reduce(0, +)
}
var cancelled: Bool { activeSegments.isEmpty }
}
Preview: “what would this cost?”
Because the entire pricing model is derived from events, previewing a change is straightforward. Construct the hypothetical event, append it to the event list, and project. The difference between after.cost and before.cost is the price of the change.
func previewEndorsement(
currentEvents: [PolicyEvent],
endorsement: EndorsementCreatedEvent
) -> (before: ProjectionRow, after: ProjectionRow) {
let eventList = PolicyEventList(events: currentEvents)
let before = ProjectionRow(
segments: priceSegments(eventList.segmentProjection())
)
var withEndorsement = eventList
withEndorsement.append(endorsement)
let after = ProjectionRow(
segments: priceSegments(withEndorsement.segmentProjection())
)
return (before, after)
}
This is exactly what gets shown to an underwriter before they commit the change — and it’s the same code path that computes the actual billing adjustment after the fact. There’s no separate “preview” mode; the projection engine doesn’t know or care whether the events have been persisted.
Try it: toggle the endorsement below and drag its effective date to see how it changes the price in real time.
Before
After
Billing: segments all the way down
Billing is the other consumer of priced segments. For a monthly policy, the payment schedule is generated by taking the current priced segments and spreading costs across billing periods. Each month’s invoice is proportional to the annualized rate of the segments covering that month:
func allocations(segments: [PricedSegment], dates: [Date]) -> [Decimal] {
dates.map { startOfMonth in
let endOfMonth = startOfMonth.addingMonths(1)
let daysInMonth = endOfMonth.timeIntervalSince(startOfMonth)
return segments.filter { !$0.segment.replaced }
.reduce(Decimal.zero) { total, segment in
let overlapStart = max(segment.start, startOfMonth)
let overlapEnd = min(segment.end, endOfMonth)
guard overlapStart < overlapEnd else { return total }
let fraction = (overlapEnd - overlapStart) / daysInMonth
return total + fraction * annualized(segment)
}
}
}
Segments with different annual rates produce different monthly amounts. If a policy has a $5,000/year segment for the first half and a $10,000/year segment for the second, the monthly invoices aren’t flat — they reflect the actual rate in force during each month.
The first two invoices are collapsed together into a deposit paid at purchase. After that, each month gets its own invoice.
Endorsements: spreading the difference
When an endorsement happens mid-term, the billing system doesn’t create a lump-sum charge. Instead, it rebuilds the ideal payment schedule from the new segments — what the invoices would have looked like if the endorsement had been in place from day one. Then it compares what the policyholder actually paid against what they should have paid under the new schedule:
func reconcile(
paidInvoices: [Invoice],
expectedInvoices: [Invoice]
) -> [LineItem] {
// Compare expected premium to paid premium per invoice:
// expected amount - actually paid amount
// Non-zero differences become reconciliation line items
// on the first future invoice.
zip(paidInvoices, expectedInvoices).compactMap { paid, expected in
let diff = expected.total - paid.total
guard diff != 0 else { return nil }
return LineItem(
amount: diff,
description: "Reconciliation adjustment"
)
}
}
The result: past (already-paid) invoices stay locked. Future invoices are naturally at the new rate. And a small reconciliation line item on the first future invoice trues up the difference between what was paid and what should have been paid. For an upgrade, this is a small additional charge; for a downgrade, it’s a credit.
Try it below — toggle between monthly and annual billing, choose an upgrade or downgrade, and drag the effective date. With monthly billing, notice how past invoices (grey) stay unchanged, future invoices shift to the new rate, and the first unpaid invoice picks up a reconciliation adjustment. With annual billing, the full premium is paid upfront, so any endorsement produces a standalone adjustment invoice for the difference.
The downgrade case is worth walking through. When a policyholder reduces their limit, the ideal schedule shows lower amounts for every month. The past invoices were paid at the higher rate — so the reconciliation on the first future invoice is a credit, reducing that invoice. From there, every future invoice is simply at the new, lower rate. The billing system doesn’t need special “credit” or “refund” logic; it falls out naturally from diffing the old and new schedules.
A cancellation works the same way: the replaced segments produce a schedule with fewer active months, and the reconciliation handles any overpayment.
Out-of-sequence events
Real-world insurance is messy. An endorsement might be effective February 1st but not entered into the system until April. Another endorsement from March is already in the event log. The events arrive out of order relative to their effective dates.
The projection engine handles this by sorting events by publishedAt (transaction time) and letting later events overlay earlier ones:
// Events sorted by published_at, then effective_at
let sorted = events.sorted { a, b in
if a.publishedAt != b.publishedAt {
return a.publishedAt < b.publishedAt
}
return a.effectiveAt < b.effectiveAt
}
When we replay the February endorsement after the March one, the February change gets applied first (since it’s effective earlier), and the March endorsement’s parameters are preserved for the period after March.
Consider this scenario:
- Policy created Jan 1 with
limit: 1 - Endorsement on Mar 1 (published Day 1):
limit: 2 - Endorsement on Jan 1 (published Day 2):
zip_code: 90210, limit: 1.1
Step through it below — notice how the out-of-sequence endorsement’s zip_code carries forward through all subsequent segments, while the March endorsement’s limit: 2 still takes precedence for its period.
What you get from this
A mutable-state model makes most of these properties hard. The event-sourced approach gets them almost for free.
Every change to a policy is recorded. You can answer “who changed what, when, and what did it cost?” for any point in the policy’s history.
Billing is derived from the same segment projection as everything else, so the billing system can’t disagree with the policy state. There’s no sync problem because there’s only one source of truth.
Previewing a change uses the same code as applying one. The preview matches the actual result because it is the actual calculation, just with an unpersisted event.
You can ask “what does this policy look like now?”, “what did we think it looked like on March 3rd?”, and “if we apply this change effective February 1st, what happens to the price?” These aren’t special features. They fall out of the event model.
The projection engine is a pure function: events in, segments out. No database, no side effects. The test suite constructs event lists in memory and asserts on the resulting segments, including out-of-sequence endorsements, cancel-then-reinstate, and overlapping changes.
All of this comes from one decision: model a policy as an ordered event log projected into time-ranged segments.
Consuming events from Kafka
In production, policy events don’t arrive through a function call. They come off a Kafka topic.
Topics and partitioning
A Kafka topic is an ordered, append-only log, which maps naturally to the event model we’ve been building. Each message on the topic is a policy event, serialized as JSON or Avro, with the policy ID as the message key.
The message key matters because Kafka uses it to determine which partition a message lands in. A topic is split into partitions, and Kafka guarantees ordering within a partition but not across partitions. By keying on policy ID, all events for a single policy are routed to the same partition, so they arrive in the order they were produced. Different policies can land in different partitions, which is fine — their event streams are independent.
topic: policy-events (6 partitions)
partition 0: [pol-7291: Created] [pol-7291: Endorsement] [pol-7291: Cancelled] ...
partition 1: [pol-3044: Created] [pol-3044: Endorsement] ...
partition 2: [pol-8812: Created] ...
partition 3: [pol-5190: Created] [pol-5190: Endorsement] [pol-5190: Endorsement] ...
partition 4: (empty)
partition 5: [pol-1127: Created] ...
Each partition is consumed independently. A consumer group with N consumers can process up to N partitions in parallel — one consumer per partition. This means the system scales horizontally: more partitions, more consumers, more throughput, without sacrificing per-policy ordering.
The consumer reads messages sequentially, deserializes them, and feeds them into the same projection engine:
func consume(
topic: String,
group: String
) async throws {
let consumer = try KafkaConsumer(
topic: topic,
groupId: group,
config: .init(autoOffsetReset: .earliest)
)
for try await message in consumer.messages {
let event = try decoder.decode(
PolicyEvent.self, from: message.value
)
let policyId = String(data: message.key, encoding: .utf8)!
var eventList = store.events(for: policyId)
eventList.append(event)
store.save(policyId, events: eventList)
let segments = eventList.segmentProjection()
try await materializedView.update(
policyId: policyId, segments: segments
)
try await consumer.commit(message)
}
}
The projection is stateless; it replays the full event list every time. The consumer can be restarted, reprocessed from an earlier offset, or run in parallel across partitions without risk of state corruption. The Kafka offset tracks where we are in the log, and the event list tracks what we know about the policy. These are independent concerns.
Step through the demo below to see events being consumed from a Kafka topic. Each message is read in offset order, decoded, and projected into the segment timeline on the right.
Because the projection is a pure function, replaying the topic from offset 0 produces the exact same result. Disaster recovery is boring: drop the materialized view, reset the consumer group offset, and replay. The event log on Kafka is the source of truth.