Transaction Model
Every change to a policy is recorded as an immutable transaction. Transactions are the single source of truth — policy state is always derived from them, never authored directly.1
Configure
Define your fields, option sets, and exposure types via the Configuration API.
2
Create
POST /transaction/new-business creates a policy with initial state covering the full term.3
Endorse
POST /{policyId}/transaction/endorse modifies the policy — add exposures, change field values, adjust coverage.4
Cancel
POST /{policyId}/transaction/cancel cancels the policy from a specified date.5
Reinstate
POST /{policyId}/transaction/reinstate reinstates a cancelled policy.6
Renew
POST /transaction/renew starts a new policy term linked to the previous one.Transaction Types
NEW_BUSINESS
Creates the policy. The effective date is the policy start date. Produces one segment covering the full term.
ENDORSE
Modifies policy state from a given effective date. Carries one or more of five channels (see Endorsement Channels). May split existing segments or merge them if the change aligns state across periods.
CANCEL
Flips segment-scoped
policyStatus to "cancelled" from the cancellation date through end of term, and records a single cancellationEffectiveOnDate (uniform across the term). policyStatus alone marks which side of the boundary a segment is on. Optionally accepts whole-object fullTermPricingInfo (e.g., short-rate penalties), fullTermBillingInfo and fullTermPolicyRatingResult.REINSTATE
Flips segment-scoped
policyStatus back to "active" from the reinstatement date and clears cancellationEffectiveOnDate (no reinstatement date field is added). May not leave a coverage gap. Optionally accepts whole-object fullTermPricingInfo (e.g., reinstatement fees), fullTermBillingInfo and fullTermPolicyRatingResult.RENEW
Creates a new policy term linked to the previous via the root
previousPolicy field (a required uuid). Accepts a full data payload — the caller provides the complete initial state for the new term. The new term’s policyStartDate must be on or after the previous policy’s policyEndDate (no backward overlap with the term being renewed).Effective Date vs Transaction Timestamp
Each transaction carries two dates on independent axes: aneffectiveDate (where on the policy term the change lands — it must fall within [policyStartDate, policyEndDate]) and a transactionTimestamp (the audit / booking axis — when the decision was recorded). effectiveDate may backdate or post-date freely within the term; transactionTimestamp only moves forward. The full temporal model — the one rule binding a delta’s startDate to the effectiveDate, why there is no third “take-effect” axis, worked backdate / book-ahead examples, and precedence + monotonicity — lives on its own page: Effective Dates & the Policy Timeline.
Endorsement Channels
An endorsement carries one or more of five channels. Full-term-ness is membership in a reserved-name container —fullTermPricingInfo, fullTermBillingInfo, fullTermPolicyRatingResult, and crossSegmentRatingOutputs.
Input channel:
deltas— changes to policy field data. Each delta carries its ownstartDateandendDatewithin the policy term. The path must not address a reserved full-term container. Every delta’sstartDatemust equal the transaction’seffectiveDate— the change starts applying exactly when the endorsement takes effect — so a single transaction cannot mix deltas with differentstartDates (split that into separate transactions). This binding, and why there is no separate “take-effect” axis, is covered on Effective Dates & the Policy Timeline. The whole-term root fields are the one exemption from that binding: they always state the whole term. This is also the channel that amends a policy term — see Amending the Term.
deltas:
fullTermPricingInfo— a whole object that overwrites the policy-root pricing contract (pricingComponents; the four rollups are computed by the platform from the components).fullTermBillingInfo— a whole object that overwrites the policy-root Billing Aggregate:{ lines: [{ invoiceType, lineItem, direction, amount }] }. You author it, except on acancelorreinstatewhere the platform derives it when you omit it; see The Billing Aggregate.fullTermPolicyRatingResult— a whole object that overwrites the policy-root canonical rating result (the twin of billing).crossSegmentRatingOutputs— element-level rating output,[{ path, value }]. Each path terminates at acrossSegmentRatingOutputscontainer on a list element (or the policy); the server derives the write range from the host’s presence across segments, so it works on part-term hosts. Not offered on cancel/reinstate.
deltas, fullTermPricingInfo, fullTermBillingInfo, fullTermPolicyRatingResult or crossSegmentRatingOutputs. An endorsement that restates only the Billing Aggregate is therefore a legal transaction on its own.
Delta Structure
Per-segment delta (deltas):
Amending the Term
Shortening a policy term is adeltas write to the ROOT policy.policyEndDate (or policy.policyStartDate), stating the whole term as its window:
policyEndDate later or policyStartDate earlier claims days that no endorsement can create, so it is rejected (400, problem code TermLengtheningNotSupported) — at whatever path depth it is written. Shortening is allowed. A genuine term extension is not yet supported; issue the longer term as a new policy.
Whole-Term Root Fields
Four policy-root fields hold values that are invariant across the term by definition:
Because a part-term value would be meaningless for them, any delta that touches one of these paths — exactly, at any depth (
policy.policyEndDate.year), or via an ancestor such as a whole-policy Overwrite — is held to two rules:
- The window must be the whole term.
startDatemust equal the policy start date andendDatethe policy end date, or it is rejected (400,InvalidDelta):Delta path "policy.policyEndDate" is invariant across the policy term, so its window must be the whole term […] — got […]. Because such a delta always states the whole term, these paths are exempt from the “deltastartDatemust equaleffectiveDate” rule. - A term bound may not move outward (
400, problem codeTermLengtheningNotSupported) — see Amending the Term.
These four root fields are the single source for the policy number, the term bounds and the renewal pointer — on the write side and on the read side alike. A whole-term delta on a root field is what moves the term. There is no second copy anywhere on a policy.
Delta Actions
Overwrite
Replace a scalar value or an entire object. This is the most common action.Add
Append to a collection. Uses set semantics — objects are matched byid, primitives by equality. If the value already exists, the delta is a no-op.
Remove
Remove from a collection. Same matching rules as Add. If the value is not present, the delta is a no-op.Path Notation
Paths target fields at any depth. Index into a list by a predicate on any field —key[field = 'value'] — which must resolve to exactly one element (the API throws on zero or multiple matches). This uniqueness rule is the cross-segment identity guarantee, and it removes the need for a dedicated id field on embedded custom objects.
Overwrite on an indexed path (e.g.,
policy.additionalExposures[id = 'exp-1']) replaces the entire exposure object. Add on a collection path (e.g., policy.additionalExposures) appends an exposure. These are different operations targeting different levels.fullTermPricingInfo, fullTermBillingInfo, fullTermPolicyRatingResult, crossSegmentRatingOutputs) must not appear in a deltas path, at any depth — each has its own channel. A container path in deltas is rejected (400, InvalidDelta): Invalid deltas path "…" — full-term container paths cannot be written by a caller.
Example: Endorsement with Deltas
An endorsement toPOST /v1/policies/{policyId}/transaction/endorse effective April 1 that adds a new exposure (per-segment deltas) and updates pricing (the fullTermPricingInfo channel):
fullTermPricingInfo is a whole-object channel applied uniformly across the full policy term — no explicit dates, because it must be identical in every segment. (It is additive on deltas here, but is never mixed into the deltas array itself.)
Segments
A segment is a maximal contiguous date range where the policy state is identical.Segment Properties
Every version’s segments satisfy three invariants:- No overlaps — segments never share a date
- Full coverage — segments span the entire policy term with no gaps
- No adjacent duplicates — adjacent segments with identical state are automatically merged
How Segments Change
- Create
- Endorse (split)
- Correction (merge)
A new policy starts with one segment covering the full term.
Versions
Each transaction produces a new policy version. A version is a complete snapshot — it contains the full set of segments representing the policy at that point in the transaction history.
You can query any historical version to see what the policy looked like after a specific transaction.
How It Works
When you submit a transaction, the system:- Loads the previous version — gets the current segments
- Applies deltas — applies each delta to all segments whose date range overlaps the delta’s date range
- Normalizes — produces deterministic JSON and computes a hash for each resulting segment
- Merges — collapses adjacent segments with identical hashes into one
- Checks term invariance — refuses the write if it leaves any term-invariant policy field differing between the segments of one term
- Persists — stores the new version with its segments
No-op deltas are safe. Adding a value that already exists or removing one that’s already gone has no effect. This means a delta that spans a wide date range may change some segments and leave others untouched — the system handles it correctly.
Transaction Validation Rules
Every write transaction is validated before any version is persisted. A violation returns400 with a descriptive message and no new version is created. The rules below are in addition to field-level configuration validation.
Delta Date Ranges
Each per-segment delta carries its ownstartDate / endDate. Two constraints apply (400, InvalidDelta):
startDate <= endDate— a delta whose range is inverted is rejected:Delta startDate (…) must be <= endDate (…).[startDate, endDate]⊆[policyStartDate, policyEndDate]— a delta range that starts before the policy term or ends after it is rejected:Delta date range [start, end] falls outside policy period [start, end]. A delta cannot apply state to dates the policy does not cover.
Within-Transaction Path Conflicts
Because newest-wins precedence only orders deltas across transactions, two deltas in one transaction that touch the same place have no ordering between them and are rejected up front (400, InvalidDelta). This is checked independently within each partition (per-segment deltas and whole-term deltas):
- Duplicate path — two deltas sharing the exact same
pathin one transaction:Two deltas in this transaction share the path "…" — within-transaction conflicts cannot be resolved by insertion order. Collapse them into the single intended write. - Path-prefix conflict — one delta targets an object and another targets a descendant of it in the same transaction (e.g.
policy.coveragestogether withpolicy.coverages[0].limit, orpolicy.additionalExposures[id = 'exp-1']together withpolicy.additionalExposures[id = 'exp-1'].bedCount):Delta paths "…" and "…" overlap — a delta cannot target both an object and one of its descendants in the same transaction. The parent would replace the whole subtree while the child mutates one node inside it — ambiguous, so it is rejected. Sibling predicates on the same collection (e.g. two differentadditionalExposures[…]elements) are not a conflict.
This is the within-transaction counterpart to the collection-vs-element distinction: operating on a collection and on one of its elements are different operations at different levels, and they may not be combined in a single transaction.
Transaction Timestamp Monotonicity
When you settransactionTimestamp explicitly, it must be >= the largest transactionTimestamp already recorded on that policy (the audit axis only moves forward); effective dates may still backdate freely. An out-of-order timestamp is rejected (400, InvalidRequest): transactionTimestamp (…) is earlier than the latest existing transaction on this policy (…). This rule, the cross-transaction precedence model, and the two-axis model it constrains are explained in full on the Effective Dates & the Policy Timeline page.
Premiums and Rating
The API does not calculate premiums. When you submit a transaction, you supply field values and billing totals yourself. The system stores what you send — it does not rate, pro-rate, or re-aggregate. Policy financial data lives at two levels:Full-Term Pricing, Billing and Rating
fullTermPricingInfo is a cross-segment invariant — identical across every segment in a version. It is the pricing contract for the entire policy term: pricingComponents plus four server-computed, read-only rollups (caller-supplied rollup values are ignored and recomputed). Every endorsement that changes the price should include a fullTermPricingInfo channel to keep pricing current.
total is the grand total of what the insured owes. Every registry entry names the rollup its classification reports into — one of the three — so total accounts for every component, with nothing escaping into an unmapped bucket. Like the other three, total is read-only and always present on a response; a value you send is ignored and recomputed.
The former brokerCommission and programCommission rollups are retired: commission is not part of the policy’s price, so its money belongs to the Billing Aggregate’s payable lines. The platform no longer computes the two keys (they were never writable); a policy version stored before the retirement may still return them until the per-tenant data sweeps.
fullTermPolicyRatingResult is its twin — an optional whole-object, policy-level canonical rating result, also invariant across segments. Both are derived (a rating byproduct) but caller-supplied — the transaction API never rates. Beyond the billing totals, fullTermPolicyRatingResult may carry rating factors and more granular pricing detail worth exposing to underwriters.
Pricing Component Identity
Every pricing component you submit — anywhere on the API, for every company — must carry aclassification naming a live entry in your company’s classification registry, a per-company financials configuration surface. Registry keys are lower-kebab-case (^[a-z0-9]+(?:-[a-z0-9]+)*$, at most 64 characters) — e.g. surplus-lines-tax. A component without a classification is rejected with a 400 that says so.
A component is {label, value, classification[, qualifier, earningSchedule]}:
label(display text) andvalue(a plain number, USD) are required.classificationis the stable identity half. Required on every component.qualifieris the structured qualifier that completes the identity, such as a taxing jurisdiction. Defaults to"".earningSchedule(pro-rataorimmediate) says how the charge earns. Omitted meanspro-rata.
<classification, qualifier>, unique within one contract. The point of this identity is that it is stable and structured: the jurisdiction stops being baked into a display string, and because label carries no identity, renaming a label is a purely cosmetic edit rather than the creation of a new charge.
The Retired Legacy Vocabulary
The legacy component fields —group, kind and earningBasis — are retired as input. Stated values are ignored and discarded: never validated, never stored on a newly written component. There are no dual-agreement checks; a legacy field cannot conflict with anything.
- Echo tolerance. A client that GETs a stored component still carrying the legacy keys and POSTs it back keeps working — the retired fields are simply dropped on write.
- Reads. A component stored before the retirement keeps echoing
kind/group/earningBasisuntil the per-tenant data sweeps remove them. Treat them as historical output, never as identity, and do not rely on their presence.classification/qualifier/earningScheduleare the real identity and schedule.
Refusals
Each of these is rejected with a400 (InvalidPolicyData on new-business / renew; InvalidFieldModelV1Data on endorse / cancel / reinstate and the entity and rating doors):
- A component with no
classification. The error lists your registry’s live keys. - A
classificationthat names no registry entry, or names a deprecated one — it must be a live entry. - An unrecognised
earningSchedule. - Two components in one contract sharing the same
<classification, qualifier>pair. - Any component sent while your company’s classification registry has no entries at all. Configure the classification registry first, then state a classification on every component.
The Billing Aggregate
fullTermBillingInfo is the policy’s term-level billing channel: what is owed, per line item, free of invoices. It is a reserved full-term container exactly like its pricing sibling — invariant across every segment, banned from a deltas path, and hoisted onto responses next to fullTermPricingInfo.
A line’s identity is the pair
<invoiceType, lineItem>, unique within one container; a duplicate pair is a 400.
It deliberately carries no classification and no rollup, exactly as fullTermPricingInfo carries no invoice type and no line item: pricing and billing keep disjoint vocabularies, and per-rollup cash stays a naming convention over line items rather than schema.
It is accepted everywhere fullTermPricingInfo is accepted — inside data on new business and renew, and as a top-level sibling channel on endorse, cancel and reinstate — and returned everywhere fullTermPricingInfo is returned.
The Two Laws
Structure — shape,direction membership, finite amounts, and <invoiceType, lineItem> uniqueness — is validated on every request. Two further laws apply unconditionally, on every persisted version that states an aggregate:
The checksum is also the endorsement restate-or-refuse gate: a transaction that moves the price without restating the lines fails that exact comparison. Paid invoices are never edited — a delta lands as new signed invoices.
payable lines sit outside the checksum by direction, which is why the direction you send is a checked assertion rather than an input.
An aggregate you do not state is exempt from both laws. A policy with no billing configuration still prices and earns.
Derivation on Cancel and Reinstate
On acancel or reinstate the platform derives the aggregate when you omit it, because the pricing side is derived too and an untouched aggregate beside a floored contract would break the checksum:
- Cancel scales the
receivablelines down to the earned floor, allocating cents by largest remainder so they conserve exactly, and leavespayablelines exactly as they are — commission clawback is contractual, so there is no defensible default to apply. - Reinstate restores the pre-cancel lines verbatim, payable lines included.
Per-Segment and Element-Level Rating
Each segment can carry its own rating data. Element-level rating output attaches to its host via acrossSegmentRatingOutputs container — on an exposure (policy.additionalExposures[id = '…'].crossSegmentRatingOutputs), a coverage, or the policy. These typically include:
annualPremium— the premium as if that segment’s state applied for the full yeardailyProratedPremium— the daily premium rate for that segment’s risk profile
crossSegmentRatingOutputs container is uniform across exactly the segments its host spans.
fullTermPricingInfo is not necessarily derivable from per-segment rates. Full-term pricing can include flat premium minimums, surplus lines taxes, policy fees, or other adjustments that are independent of element-level rating. fullTermPolicyRatingResult captures the aggregate policy-level rating detail.Worked Example
A medical facility policy (Greenfield Medical Center) for Jan 1 – Dec 31 with one exposure. This shows the core segment behaviors — splitting, maintaining, and merging — with the actual payloads. Each endorsement also includes afullTermPricingInfo update (omitted from the segment tables since it’s the same in every segment).
1. NEW_BUSINESS — 1 segment
1. NEW_BUSINESS — 1 segment
Create the policy with initial state spanning the full term. Grand total: 89,750.Segments:
2. ENDORSE Apr 1: add satellite clinic — 2 segments
2. ENDORSE Apr 1: add satellite clinic — 2 segments
A satellite clinic opens. The Segments:
Add action appends a new exposure to the collection. Grand total increases to 103,400 (+13,650) — the additional exposure adds risk for the remaining 9 months.The original segment split at April — different exposure count on each side.
3. ENDORSE Jun 1: add physician + specialty — 3 segments
3. ENDORSE Jun 1: add physician + specialty — 3 segments
A new surgeon joins the main campus. Neurology added as a covered specialty. Grand total increases to 111,800 (+8,400) — the additional physician and expanded specialty coverage increase risk.Segments:
The Apr–Dec segment from version 2 split at the June boundary.
4. Backdated corrections — merge to 2 segments
4. Backdated corrections — merge to 2 segments
Internal audit reveals the physician change, bed reduction, and Neurology addition should all have been effective April 1, not June 1. A single endorsement corrects everything retroactively. Grand total decreases to 106,550 (-5,250) — the physician departure and bed reduction reduce risk, partially offset by Neurology covering a longer period.All four per-segment deltas span Apr 1 – Dec 31. The Jun–Dec segment already had beds=110, Nguyen removed, Okafor present, and Neurology — those deltas are no-ops there. Only Apr–May changes. After applying, Apr–May and Jun–Dec have converged to identical state. The system merges them:Segments:
Four transactions, two segments. The Apr–May / Jun–Dec boundary vanished — not because a transaction was reversed, but because the correction converged the per-segment state on both sides.
fullTermPricingInfo didn’t affect the merge — it’s the same in every segment.Cancellation and Reinstatement
Cancel and reinstate are simpler than endorsements but follow the same segment mechanics. The system automatically expands the cancellation/reinstatement date into per-segment status deltas — you only supply the date (and optional billing/rating).- Cancel flips segment-scoped
policyStatusto"cancelled"from the cancellation date through end of term, splitting the existing segment at the boundary. It records a singlecancellationEffectiveOnDate, written uniformly across the whole term — the same value on both sides of the boundary.policyStatusalone tells you which side a segment is on. The date field is technically derivable from the active→cancelled boundary, but it is kept explicit because it makes list/query filtering by cancel date intuitive (you read the date directly instead of reconstructing it from segment boundaries). - Reinstate flips
policyStatusback to"active"from the reinstatement date and clearscancellationEffectiveOnDateacross the term — it removes the cancellation marker rather than recording a parallel reinstatement marker. There is no reinstatement date field. - A reinstate may not leave a coverage gap. A reinstate that would leave a cancelled window between two active periods (e.g. cancel Jun 15, reinstate Jul 1, leaving Jun 15–Jun 30 cancelled) is not allowed — the domain models that as a new policy, not a reinstatement, so it is rejected with a
400pointing at new-business / renew. A valid reinstate restores continuous coverage and fully clears the cancellation. - Both optionally accept whole-object
fullTermPricingInfo(e.g., short-rate penalties or reinstatement fees),fullTermBillingInfoandfullTermPolicyRatingResult— never per-segment or element-level rating output.
A cancel followed by a reinstate is invisible in the final segments —
cancellationEffectiveOnDate is removed and policyStatus returns to "active" everywhere, so the derived segments are identical to the pre-cancellation version and merge back together. Both transactions are preserved in the audit trail.Renewal
POST /transaction/renew starts a fresh policy term as its own policy (its own policyId), linked to the term it renews. It takes the same whole-state data payload as new business, with two extra runtime checks (400, InvalidRequest):
- The root
previousPolicyfield is required and must be a validuuid. Omitting it or sending a malformed value is rejected:previousPolicy is required for RENEW (uuid). It is stamped onto every segment of the new term and written to thePolicy<N:1:previousPolicy>Policyrelationship — the only record of the renewal chain. - The new term must not run backward into the term it renews. The new term’s bounds are read from the root
policyStartDate/policyEndDate, exactly as on new business. The newpolicyStartDatemust be>=the previous policy’spolicyEndDate; otherwise:policyStartDate (…) must be >= previous policy end date (…). The two terms may meet at a shared boundary date but may not overlap.
Transaction Deletion
Only the most recent transaction on a policy can be deleted. Deleting a transaction:- Rolls back to the prior version — the deleted transaction’s segments are removed, and the previous version becomes current (no new version is created)
- Preserves the audit trail — the deleted transaction is archived, not erased
- Is irreversible through the API once deleted (the transaction can be re-created manually)
Merging and Time-Dependent Fields
Segment merging compares the entire per-segment state, including rating. If any per-segment field’s value depends on the segment’s duration, two segments with identical risk profiles but different durations will never merge.The Problem
Suppose you store atotalProratedPremium that represents the premium for each segment’s time slice. After a backdated correction converges two segments’ structural data, they still can’t merge:
This is a chicken-and-egg problem: you can’t compute the merged segment’s prorated premium without knowing the merge will happen, but the merge can’t happen while the values differ.
The Solution Today
Use time-independent per-segment values.annualPremium and dailyProratedPremium describe the risk profile, not the duration. Two segments with the same risk produce the same values regardless of how many days each covers, so merging works naturally.
If you need to know the total premium for a specific segment’s time span, derive it from the daily rate and the segment’s date range after reading the policy — don’t store it as a per-segment field.
Looking Ahead
We are working on support for calculated fields — per-segment fields whose values are automatically derived after segment computation. This will allow fields liketotalProratedPremium to be stored on segments without blocking merges, because the system will exclude them from the merge comparison and recompute them based on each segment’s final date range.