Skip to content
Updated Aug 22, 2026

Terms, transactions & elements

Schema deep-dive · living document · #5 in the reading sequence

Tablesenrollment.policy_terms, enrollment.policy_transactions, enrollment.policy_elements[1][2][3]
Owner serviceenrollment (sole writer)
LocatorsTRM- (terms) · TXN- (transactions) · ELM- (elements)
Last updated2026-08-19
CompanionERD story, slide 5 · real policy JSON · previous: Quote & Policy

1. Scope and usage

These three tables are the effective-dated versioning core - the machinery that lets the policy row stay almost immutable while everything about the cover can change over time:

  • A PolicyTerm (TRM-) is one annual window of the contract - the adjudication anchor. Accumulators are scoped to a term_id, so "5 GP sessions a year" means per term, and renewal (a new term) is what resets them.
  • A PolicyTransaction (TXN-) is the unit of change: issuance, endorsement, renewal, cancellation, reinstatement. Nothing about cover changes except through one.
  • A PolicyElement (ELM-) is one line of insured cover - live, always an employee element carrying the member's catalogue snapshot in coverage_terms. Elements are versioned, never mutated: a change closes the current row and inserts a new one.

Mid-term change is a normal fact of insurance - "During the term of a general (non-life) policy, it may be necessary to change the terms of the policy. The insured may wish to increase the sum insured, change the description of the property or add another driver to a motor policy."

- and this trio is the Socotra-shaped

answer: the change is a first-class, statused, effective-dated record, not an UPDATE.

The one concept to internalise from this page is static_id: the stable identity of an element across its versions. Every re-versioning path copies it forward[7][8], and it is the id other services hold: eligibility.member_coverage.element_static_id keys the projection on it[11], and claims.claim_lines.element_id is designed to name it (§7 for the live truth). Row ids churn on every endorsement; static_id never does.

2. Boundaries and relationships

This trio is not…That concern lives inJoin
the policyenrollment.policies - status, structure and the locator hub live there (previous page); all three tables FK to itpolicy_id (real FK)
the product cataloguepolicy_admin.product_versions.element_schema; coverage_terms is a snapshot of it taken at issue or upgrade - the product can change without touching in-force covercopied bytes
what adjudication readseligibility.member_coverage + coverage_accumulators - a projection built from the events these tables emit, one flat row per covered element[12]element_static_id
an audit logthe transaction is the change, with its own status funnel; quote_events / claim events are the append-only logs-
a personparty_id on an element is a uuid soft ref (Flow-0 mints it as uuidv5("party:"+partyLocator), backed by no row)[6]; the real person join is the policy's party_locatoruuid soft ref
a custom-field storequote_field_values / policy_field_values / transaction_field_values - the EAV second ring, reserved for product-defined fields, FK-ing their parent here and soft-referencing policy_admin.field_definitions cross-DB[13]in-service FK

3. Structure

DDL[1][2][3] · Go models[4]

policy_terms

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE TRM-
policy_iduuidReal FK to policies; indexed
term_numberint1, 2, 3… - the human "year N of the contract"
effective_from / effective_todate✓/✓Both NOT NULL - a term is always a closed window (issue writes from → from+1y)
statustextDefault 'ACTIVE'; convention, no CHECK. Live: all 502 ACTIVE

policy_transactions

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE TXN-
policy_iduuidReal FK; indexed
term_iduuidReal FK to terms, nullable - only ISSUANCE and RENEWAL link a term; live endorsement rows carry NULL
categorytextFree text. Model comment declares ISSUANCE | ENDORSEMENT | CANCELLATION | REINSTATEMENT | RENEWAL | REVERSAL[4]; live data holds ENDORSEMENT (9), ADD_ELEMENT (7), RENEWAL (7), REMOVE_ELEMENT (7), REINSTATEMENT (7) - see §9
statustextDefault 'DRAFT'. Vocabulary DRAFT | PRICED | UNDERWRITTEN | APPLIED | DECLINED | DISCARDED | REVERSED[4]; live: APPLIED (30), DRAFT (7)
effective_datedateWhen the change takes effect
documentjsonbChange payload; feeds rule evaluation at price/underwrite[7]

policy_elements

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE ELM-. New on every version
policy_iduuidReal FK; indexed
transaction_iduuidReal FK - which change created this version; NULL on issuance-created rows
static_iduuidThe identity. Same value across all versions of one element; indexed (migration 0009)[5]; model comment "stable across versions"[4]
element_typetextDeclared employee | dependent[4]; live: employee on all 518 rows
statustextACTIVE | REMOVED. Live: ACTIVE (511), REMOVED (7)
party_iduuidThe insured party - uuid soft ref (§2)
datajsonbPer-element data (quote-funnel capture); NULL on Flow-0 rows
coverage_termsjsonbThe snapshot: the product version's element_schema copied at issue/upgrade - see the real policy JSON
effective_from / effective_todate✓/-Half-open window: effective_to NULL = current version[4]
rated_premium, premium_currencynumeric(14,4) / textMigration 0022[14]; written by the rating pass[15]. NULL on all 518 live rows: the writer switches on once rating factors are configured, and premium today comes from the catalogue

Field-by-field: what and why

term_number + the term window - issuance writes term 1 spanning one year[6]; renewal counts existing terms and appends term N+1 with the caller's window plus a RENEWAL transaction, atomically[9]. Why a table and not dates on the policy? Because the term is what adjudication anchors to: eligibility's accumulators carry term_id, so consumption resets by construction when a new term starts - no reset job, a new scope. The internal policy response resolves "the ACTIVE term" (falling back to the first) precisely because "the eligibility projection scopes accumulators to termId"[10].

category + status on transactions - the category says what kind of change, the status runs the same funnel a quote does (DRAFT → PRICED → UNDERWRITTEN → APPLIED, with DECLINED/DISCARDED/REVERSED exits)[7]. Two writing styles coexist: lifecycle actions (cancel, reinstate) write an already-APPLIED transaction as a side effect[9], while endorse and renew open a DRAFT for the price/underwrite/apply walk[9] - which is why 7 DRAFT rows sit live: endorsements and renewals opened and not yet applied, each resumable from where it stopped.

static_id - allocated once, when the element is genuinely new (uuid.New() at issue[6]); copied forward on every re-versioning: Apply closes each active element and creates the successor "with the same static_id"[7], and UpgradeCoverage does the same with the comment that names the principle: "identity survives the version bump"[8]. Why two ids? id answers "which version"; static_id answers "which cover". Anything that must survive an endorsement (a coverage projection, a claim line, an accumulator) holds the static id.

coverage_terms - the same catalogue bytes walked on the Product & catalogue page, frozen per member. Two paths write it: issue snapshots the resolved product version's element_schema[6], and upgrade swaps in the new version's - the only path on which cover changes, because plain Apply deliberately copies terms forward unchanged[8]. Why snapshot rather than join? So the question "what was this member covered for on date X" is answered by this table alone, whatever the product did since.

The one time that guarantee was set aside. On 2026-08-18 the catalogue's shape changed (one key set per module, typed waiting periods, the contractual blocks) and every snapshot on this table was rewritten in place rather than versioned, 483 rows of policy_elements and 479 of member_coverage. Rewriting an in-force snapshot is precisely what effective-dated versioning exists to prevent. It was done because the alternative, a compatibility layer across six readers, costs more than a development estate's history is worth, and because the change was to the shape rather than to what anyone was sold. Against real policies the answer would be different: version, do not convert. The conversion is infra/local/reconcile/catalogue_to_v2.py, and it skipped 56 pre-catalogue rows whose modules is a list of coverage names rather than a module map, because turning one into modules would invent a product nobody sold.

effective_from / effective_to half-open windows - "current" is defined as effective_to IS NULL AND status = 'ACTIVE' - exactly the predicate ListActiveByPolicy runs[16]; closing a version is a single UPDATE … SET effective_to[16] (the one sanctioned mutation on this table). Live: 502 of 518 element rows are open-ended. Terms differ on purpose: a term is always a closed window, an element version is open until superseded.

4. Invariants

InvariantEnforced by
All three locators uniqueDB unique constraints[1][2][3]
Every row belongs to a real policy; txn→term and element→txn resolveDB FKs (in-service, so real ones)[2][3]
Element versioning is atomic (close + create + events in one txn)Application: Apply and UpgradeCoverage run in one DB transaction[7][8]
static_id stable across versionsApplication only - every writer copies it; nothing in the DB stops an UPDATE or a fresh uuid
Transaction status transitionsApplication: from-state guards per transition[7]
category, all status vocabulariesNothing - free text, comment-documented[4]; live data already exceeds the declared category list (§9)
At most one open version per static_idNothing - upheld by the writers' close-then-create discipline, not by a partial unique index
Term windows do not overlap; term_number gaplessNothing - renewal derives N+1 from a count[9]
Every applied change reaches downstreamApplication: per-element + per-category events enqueued in the same DB txn, drained to enrollment.events[7]
Row changes captured to CDCDebezium publication dbz_enrollment (live \d)

5. Lifecycle

The canonical walk - an endorsement re-versioning an element:

Steps 4-7 are one DB transaction[7]. The event chosen per element is category-aware (elementEvent: REMOVED status → element.removed; ENDORSEMENT/RENEWAL → element.updated; anything else → element.added)[17], and the category maps to its policy-level event (policy.endorsed, policy.renewed, …)[17]. On the consumer side, element.updated terminates the old coverage row and upserts the new one keyed on the same element_static_id[12] - the projection mirrors the versioning without ever reading these tables.

The upgrade variant (POST /internal/policies/{locator}/upgrade[18]) compresses the walk: it writes the ENDORSEMENT transaction already APPLIED, swaps coverage_terms to the new product version's catalogue, repoints the policy's product_version_id, and emits element.updated with the payload shape the projection dictates - the code warns that "emitting the wrong keys leaves member_coverage on the old terms"[8].

6. Populated example

Two live exhibits: the worked example's steady state, and a real endorsement chain showing static_id doing its job.

The steady state: POL-2026-001500's term and element

The previous page's policy owns exactly one of each (full JSON):

json
{
  "term":    { "locator": "TRM-2026-001502", "term_number": 1,
               "effective_from": "2026-08-17", "effective_to": "2027-08-17",
               "status": "ACTIVE" },
  "element": { "locator": "ELM-2026-001518",
               "static_id": "f6c127ac-ab8d-4ae5-af50-2d6bdee54dab",
               "element_type": "employee", "status": "ACTIVE",
               "transaction_id": null,
               "effective_from": "2026-08-17", "effective_to": null,
               "rated_premium": null,
               "coverage_terms": { "tier": "STANDARD", "modules": { "…": "…" } } }
}
KeyRead byWhat it drives
term.effective_from/toeligibility (via the internal policy response[10])the window the member's six accumulators are scoped to. Verified live: every accumulator row for this policy carries this term's id
element.static_id: f6c127ac…eligibility projectionmember_coverage.element_static_id = this exact value live - the cross-service identity join, confirmed row-for-row
element.transaction_id: null-issuance-created: version 1 of an element has no transaction behind it on the Flow-0 path
coverage_termseligibility accumulator seeding, claims adjudication contextthe OHC-2026 v2 catalogue verbatim - gp_video 5 sessions, diagnostics £250, tier STANDARD. The snapshot that makes the cover self-describing
rated_premium: nullthe rating pass, once factors existno rating factors are configured yet; premium truth lives in the catalogue's premium_per_member_monthly, and the column pair is ready for the day a product is priced per element

The endorsement chain: one static_id, four versions

POL-2026-001012 (PTY-2026-000068, now UNLIMITED) was upgraded three times on 2026-07-10 via the upgrade path. All four element rows share static_id = c899d42a-20bd-49cb-aa7f-c6d6f39bd7f0:

VersionElementTier in coverage_termstransaction_ideffective_to
v1 (issue)ELM-2026-001005(none - pre-fix issuance, no snapshot)NULL2026-07-10
v2ELM-2026-001009UNLIMITEDTXN-2026-001002 (ENDORSEMENT, APPLIED)2026-07-10
v3ELM-2026-001012STANDARDTXN-2026-001005 (ENDORSEMENT, APPLIED)2026-07-10
v4 (current)ELM-2026-001015UNLIMITEDTXN-2026-001008 (ENDORSEMENT, APPLIED)NULL

Everything on this page is visible in one exhibit: each change is a transaction; each transaction closed a version and minted its successor; identity survived (static_id constant while four ELM- locators churned); exactly one row is open-ended; the policy's plan_tier ended UNLIMITED in lockstep because upgrade repoints tier and version on the policy row[8]. The outbox holds the matching published element.updated events for these ELM- keys (verified live). And v1's empty snapshot is the fossil of the pre-#1164-fix issuance that the "refuse to issue without a product version" guard now prevents (previous page §3).

7. Who references the trio

WhereColumn / mechanismNature
eligibility.member_coverageelement_static_id (in UNIQUE (party_id, element_static_id, term_id, effective_from))[11] + term_id, policy_element_idthe projection's spine - verified populated live (503 rows, static ids match)
eligibility.coverage_accumulatorsterm_idterm-scoped metering - renewal resets by scope change[12]
claims.claim_lineselement_id[19], adjudicator keyed ElementID → PolicyElement[20]the version-precise identity chain, reserved for line-level attribution; live adjudication joins cover by coverage_term_key, which needs no element id (§9)
billing.installment_schedules / ledger_entriespolicy_idmoney references the policy, never a term or element - premium is per-member-per-month from the catalogue
enrollment.*_field_valuesquote_id / policy_id / transaction_id FKs[13]the EAV second ring: 0 rows in all three, because the config layer they read defines no fields yet; field_definition_id is a cross-DB soft ref to policy_admin.field_definitions, so a new question is a row there rather than a column here

8. Design determinations

  1. Nothing mutates: change is a record - endorsement writes new rows and end-dates old ones; the transaction is the unit of change with its own funnel. (Socotra-shaped model; ERD story slide 5.)
  2. static_id is the cross-service identity - projections and claim lines hold it; version ids are private to enrollment. §3.
  3. The term is the adjudication anchor - accumulators scope to term_id; renewal is a new term, not a reset job. Interacts with PMI's annual-renewal convention (Product & catalogue plan-terms companions).
  4. Apply copies cover forward; only upgrade changes it - a deliberate split so a generic endorsement can never silently rewrite what a member is covered for[8].
  5. Coverage snapshots at issue - coverage_terms freezes the catalogue per element; product changes never touch in-force cover. D-07 lineage (#1010) - cover is real at enrol, self-contained thereafter.
  6. Events mirror versioning downstream - per-element events (element.added/updated/removed) + per-category policy events, outbox-first; eligibility rebuilds rather than joins. (Data architecture)
  7. Custom fields are EAV against product-defined definitions - modelled end to end (definitions, values, required-field gate at issue), so a new underwriting question is a field_definitions row and a value, not a migration. Reserved for the first product that asks one. #219/#225 context.
  8. Capability ahead of the products that need it - the versioning core was built to absorb the full general-insurance change vocabulary (endorsement, renewal, reinstatement, reversal, dependants, group grain, per-element rating, custom fields) before any product exercises all of it. Adding those products is configuration and event payloads rather than schema change: §9 names the surface for each.

9. Caveats and extensibility

Group and individual. The trio is structure-agnostic: elements are the per-insured grain either way. Today every policy holds one employee element; a group-master policy would hold many (one per certificate holder), and a family policy adds dependent elements - the type is already declared in the model[4]. No schema change either way.

Where to extend. Each row is a change the model already absorbs; the "where" is the surface someone touches when the need lands.

When we need …What to addWhere
A mid-term change to cover (add cover, correct a detail, adjust a member)Open a transaction, price/underwrite it, apply it. Apply closes the current element version and mints a successor carrying the same static_id, in one DB txn with its events. Nothing is mutated, no migrationPATCH /policies/{POL}/endorse then /transactions/{TXN}/apply · services/enrollment/internal/service/transaction.go
A change that alters what the member is covered forRoute it through upgrade, which swaps coverage_terms to the target product version and repoints the policy - the one path allowed to change cover[8]POST /internal/policies/{locator}/upgrade · services/enrollment/internal/service/upgrade.go
Renewal into policy year N+1Call renew: term N+1 plus a RENEWAL transaction, atomically. Accumulators reseed under the new term_id because they scope to it, so the annual reset needs no jobRenew, services/enrollment/internal/service/policy.go
A new benefit to reach an existing memberPublish the product version carrying the module; upgrade puts the new catalogue bytes into that member's coverage_terms and eligibility seeds an accumulator per module key. No enrollment schema changepolicy_admin.product_versions.element_schemapolicy_elements.coverage_terms
Dependants or a family policyWrite dependent elements beside the employee one; the type is declared in the model (above) and every versioning path copies it forwardelement_type on policy_elements; element writers in transaction.go / upgrade.go
A group-master policy with many certificate holdersOne element per insured on the same policy - the trio is already per-insured grainpolicy_elements, no DDL change
Per-element premium instead of catalogue priceConfigure rating factors and rate tables; rateTransactionElements then fills rated_premium / premium_currency on each version[15]policy_admin migrations 0022_create_rating_factors.sql / 0023_create_rate_tables.sql
Underwriting questions or bespoke data on a changeDefine the field once in config; values land against the transaction and the issue-time required-field gate enforces them - see Enrollment: dynamic fields & underwritingpolicy_admin.field_definitionsenrollment.transaction_field_values (migration 0015)
To back out an applied changeReverse moves an APPLIED transaction to REVERSED behind a from-state guard[7]; pair it with the compensating element versionservices/enrollment/internal/service/transaction.go
A new change category (an element-level add/remove surface, say)Write the category; elementEvent classifies anything outside ENDORSEMENT/RENEWAL as element.added, so downstream stays correct without a mapping change[17]categoryToEvent / elementEvent, transaction.go
A new downstream reader of coverConsume the per-element events and key on static_id; that is what the eligibility projection does, and it never reads these tablesKafka enrollment.events

Known warts, stated:

  • claim_lines.element_id carries the zero uuid on all 53 live rows - the column is NOT NULL by DDL, and the sentinel satisfies it without binding the line to a version. Adjudication is unaffected because it joins cover by coverage_term_key. The fix, if line-level attribution is wanted: resolve the member's active coverage at line creation and store its element_static_id, in services/claims/internal/service/claim.go.
  • category has outgrown its comment - live data holds ADD_ELEMENT and REMOVE_ELEMENT (14 rows, 2026-08-12), written by an element-mutation surface that is not part of the pinned commit; the pinned elementEvent mapping nevertheless classifies them correctly (non-ENDORSEMENT/RENEWAL → element.added)[17]. Free-text category made the drift silent. The fix: widen the declared list in packages/go/domain/policies.go and add the CHECK in a new services/enrollment/migrations/00NN_*.sql.
  • element_type vocabulary drift in the projection - enrollment's live rows are uniformly employee, but eligibility.member_coverage still holds fossil rows typed OUTPATIENT, INPATIENT, DENTAL and health (one each) from earlier seed vocabularies. Consumers grouping by element type must expect both dialects. The fix: normalise the fossil rows in eligibility.member_coverage and settle the vocabulary on the model comment in packages/go/domain/policies.go.
  • Endorsement transactions carry NULL term_id - only ISSUANCE and RENEWAL link their term, so "all changes within term 2" needs date math against the term window rather than the FK. The fix: resolve and set term_id when the DRAFT is opened, in Endorse, services/enrollment/internal/service/policy.go.
  • No DB guard on the one-open-version rule - the close-then-create discipline lives only in the writers, so a buggy writer could fork an element's timeline silently. The fix: a partial unique index on (static_id) WHERE effective_to IS NULL, as a new services/enrollment/migrations/00NN_*.sql.
  • v1 elements from before the product-wiring fix have empty coverage_terms - visible in the worked chain (§6); harmless now, and a trap for anyone replaying history. The fix if history matters: backfill from the policy's product version, the way infra/local/reconcile/catalogue_to_v2.py walks the same rows.

References

Code links are pinned to commit 8329d7b on main (2026-08-19); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.

  1. enrollment/migrations/0005_create_policy_terms.sql - terms DDL: both dates NOT NULL L7-8
  2. enrollment/migrations/0006_create_policy_transactions.sql - transactions DDL: nullable term FK L6, free-text category L7
  3. enrollment/migrations/0007_create_policy_elements.sql - elements DDL: static_id NOT NULL L7, nullable effective_to L14
  4. packages/go/domain/policies.go#L66 - the three models; category/status comments L89-90, static_id comment L109, element_type L110, effective_to L116
  5. 0009_idx_policy_elements_static_id.sql - the static_id index
  6. enrollment/internal/service/policy.go#L590 - issue writes term 1 (+1y) and the element with fresh static_id + snapshot
  7. enrollment/internal/service/transaction.go#L204 - Apply: close + recreate same static_id L233-267, per-element events L257-266, categoryToEvent L292-305, elementEvent L308-316; status funnel L93-199; from-state guard helper L444-461
  8. enrollment/internal/service/upgrade.go#L24 - UpgradeCoverage: Apply-copies-terms-forward rationale L24-34, APPLIED ENDORSEMENT L96-103, "identity survives the version bump" L129, coverage swap L134, payload-shape warning L159-161, policy repoint L171-173
  9. enrollment/internal/service/policy.go#L239 - Renew (term N+1 from count L258-264, DRAFT RENEWAL L280-289) and Endorse (DRAFT ENDORSEMENT L332-340); Cancel/Reinstate write APPLIED txns L134-225
  10. enrollment/internal/handler/internal.go#L70 - resolveActiveTerm: why the term rides the internal policy response
  11. eligibility/migrations/0002_create_member_coverage.sql - element_static_id NOT NULL L7, in the natural key L19
  12. eligibility/internal/projection/handlers.go#L146 - element.added/updated handlers keyed on staticId; createAccumulators term-scoping L97-118
  13. 0014_create_policy_field_values.sql - EAV shape (0013 quotes / 0015 transactions identical); field_definition_id cross-DB soft ref
  14. 0022_add_premium_columns.sql - rated_premium numeric(14,4) + premium_currency
  15. enrollment/internal/service/transaction.go#L320 - rateTransactionElements: the rated_premium writer (no-ops while factors are empty)
  16. enrollment/internal/repository/gorm_elements.go#L53 - ListActiveByPolicy (effective_to IS NULL AND ACTIVE) and CloseVersion
  17. enrollment/internal/service/transaction.go#L292 - category → policy event and element event mappings
  18. enrollment/internal/handler/internal.go#L47 - the upgrade route, idempotent on (policy, target version)
  19. claims/migrations/0004_create_claim_lines.sql - element_id UUID NOT NULL L6 + coverage_term_key L7
  20. claims/internal/adjudication/adjudicator.go#L33 - elements map[uuid]… lookup per claim line

Live-schema facts (constraint and index lists, Debezium publication, row counts, category/status/element_type distributions, the four-version chain, the zero-uuid claim-line observation, member_coverage join verification) come from PGPASSWORD=olly psql -h 10.0.1.2 -U olly -d enrollment · \d enrollment.policy_terms, \d enrollment.policy_transactions, \d enrollment.policy_elements, plus -d eligibility / -d claims for the cross-service checks, 2026-08-18.

Olly Health Insurance Platform