Policy Admin
Policy Admin is the system of record for insurance product configuration: which products exist, how they are versioned, what underwriting rules apply at each version, and which market-segment profiles override behaviour. It also holds the party and account master (members, employers, brokers, providers) that other services reference by locator. Owned by the policy-admin service, schema policy_admin.
Field reference: full columns, types and nullability live in the catalog: glossary terms
Party,PartyRole,Account,Product,ProductVersion,RuleSet,Rule,MarketProfile,MarketProfileVersion,FieldDefinition. This page is the narrative.
Rating and boundary subsystems are not in the running service
The rating engine (rating factors, rate tables, rate evaluations) and the boundary/PostGIS service (boundary levels, boundaries, spatial lookups) are built on unmerged feature branches and are not wired into the deployed service today. They are called out as Planned below. A reader checking out the active branch and running policy-admin gets none of that functionality: main.go wires only party, account, product, rule-set, config, market-profile and field-definition services.
What it owns
- The party master (individuals, organisations, providers) and their role assignments.
- Accounts that group parties for billing and relationship management, plus account contacts.
- Versioned insurance products with an
ACTIVE/INACTIVElifecycle. - Rule sets and individual rules per product version, consumed by the Enrollment rule engine.
- Config bundles: validate then deploy a packaged product release (product + versions + rule sets) in one operation.
- Market profiles and their versioned, market-segment configurations.
- Field definitions: configurable per-version product attributes without schema changes.
Planned (unmerged branch): per-version rating factors and rate tables for the pricing engine; hierarchy-agnostic boundary definitions with PostGIS spatial lookups. See Planned subsystems.
Data model
policy_admin schema, 13 migrations (0001-0013). No spatial or rating tables exist in the running schema.
| Table | Purpose |
|---|---|
parties | Person and organisation records. type is INDIVIDUAL/ORGANISATION/PROVIDER. |
party_roles | Links a party to an entity. entity_type is ACCOUNT/POLICY/QUOTE; role is ACCOUNT_HOLDER/INSURED/BENEFICIARY/DEPENDENT. |
accounts | Account records grouping parties for billing and management. |
products | Product definitions. status is ACTIVE/INACTIVE. |
product_versions | Versioned product configuration. status is DRAFT/PUBLISHED; published_at records the transition. |
rule_sets | Named collections of rules per product version. |
rules | Individual rule conditions and outcomes. |
rule_evaluations | Audit log of rule evaluation results. |
market_profiles | Market segment header, with a current_version pointer. |
market_profile_versions | Versioned market config (jurisdiction, regulatory regime, feature flags, billing rules, mandatory fields, document templates). Published state is published_at being non-null. |
field_definitions | Configurable field specifications per product version. |
Statuses above are enforced by Postgres CHECK constraints on the listed columns.
API routes
JWT routes require a bearer token; internal routes are cluster-internal with no JWT. Every collection GET uses an explicit /list suffix.
Parties and accounts
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /parties | JWT | Create a party |
GET | /parties/list | JWT | List parties (filter by type, search) |
GET | /parties/{locator} | JWT | Get party by locator |
PATCH | /parties/{locator} | JWT | Update party details |
GET | /parties/{locator}/roles/list | JWT | List roles for a party |
POST | /accounts | JWT | Create an account |
GET | /accounts/list | JWT | List accounts |
GET | /accounts/{locator} | JWT | Get account by locator |
PATCH | /accounts/{locator} | JWT | Update account |
PATCH | /accounts/{locator}/billingLevel | JWT | Set billing level |
POST | /accounts/{locator}/contacts | JWT | Add a contact |
GET | /accounts/{locator}/contacts/list | JWT | List contacts |
PATCH | /accounts/{locator}/contacts/{contactLocator} | JWT | Update a contact |
DELETE | /accounts/{locator}/contacts/{contactLocator} | JWT | Remove a contact |
GET | /accounts/{locator}/policies/list | JWT | List account policies (stub: returns empty) |
GET | /accounts/{locator}/quotes/list | JWT | List account quotes (stub: returns empty) |
POST | /accounts/{locator}/number/generate | JWT | Generate account display number |
POST | /accounts/{locator}/number/set | JWT | Set account display number explicitly |
The policies/list and quotes/list handlers currently return empty arrays. They do not read enrollment data, so they do not create a runtime dependency on Enrollment.
Products, versions and rules
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /products | JWT | Create a product |
GET | /products/list | JWT | List products (filter by status) |
GET | /products/{locator} | JWT | Get product |
PATCH | /products/{locator} | JWT | Update product |
PATCH | /products/{locator}/activate | JWT | Activate a product |
PATCH | /products/{locator}/deactivate | JWT | Deactivate a product |
POST | /products/{locator}/versions | JWT | Create a product version |
GET | /products/{locator}/versions/list | JWT | List product versions |
GET | /products/{locator}/versions/{version} | JWT | Get a specific version |
PATCH | /products/{locator}/versions/{version} | JWT | Update a version (409 if published) |
PATCH | /products/{locator}/versions/{version}/publish | JWT | Publish a version |
POST | /products/{locator}/versions/{version}/rulesets/ | JWT | Create a rule set |
GET | /products/{locator}/versions/{version}/rulesets/list | JWT | List rule sets for a version |
GET | /products/{locator}/versions/{version}/rulesets/{name} | JWT | Get a named rule set with its rules |
PUT | /products/{locator}/versions/{version}/rulesets/{name}/rules | JWT | Replace all rules in a rule set |
PATCH | /products/{locator}/versions/{version}/rulesets/{name}/rules/{ruleLocator} | JWT | Update a single rule |
POST | /products/{productLocator}/versions/{version}/fields | JWT | Create a field definition |
GET | /products/{productLocator}/versions/{version}/fields | JWT | List field definitions |
DELETE | /products/{productLocator}/versions/{version}/fields/{fieldLocator} | JWT | Delete a field definition |
Rule sets are mounted under .../rulesets (one word). There is no top-level /rules/{locator} route: single-rule updates are nested under the owning rule set.
Config bundles
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /config/validate | JWT | Validate a config bundle (422 with failures if invalid) |
POST | /config/deploy | JWT | Deploy a config bundle, returning the new version |
GET | /config/{locator}/download | JWT | Download the latest published bundle |
GET | /config/{locator}/download/{version} | JWT | Download a specific version's bundle |
Market profiles
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /market-profiles | JWT | Create a market profile |
GET | /market-profiles/list | JWT | List market profiles |
GET | /market-profiles/{code} | JWT | Get market profile by code |
GET | /market-profiles/{code}/current | JWT | Get the current published version |
POST | /market-profiles/{code}/versions | JWT | Create a profile version |
GET | /market-profiles/{code}/versions/list | JWT | List versions |
GET | /market-profiles/{code}/versions/{version} | JWT | Get a specific version |
PATCH | /market-profiles/{code}/versions/{version} | JWT | Update a version (409 if published) |
PATCH | /market-profiles/{code}/versions/{version}/publish | JWT | Publish a version |
Internal routes
No JWT; cluster-internal callers only.
| Method | Path | Description |
|---|---|---|
GET | /internal/products/{productLocator}/versions/{version}/rulesets/{name} | Fetch a rule set with rules for Enrollment |
GET | /internal/parties/{locator} | Fetch a party (e.g. for Notifications) |
GET | /internal/market-profiles/{code}/current | Fetch the current market profile version |
GET | /internal/product-versions/{id}/fields | Fetch field definitions by product-version UUID |
Events
Policy Admin publishes directly to Kafka, with no outbox: internal/kafka/producer.go builds the canonical platform envelope (eventId, eventType, occurredAt, payload, optional state; see the Kafka Event Catalog) and writes it to policy-admin.events. Every emit is fire-and-forget, so a publish failure never blocks the REST write it describes. The producer stamps no correlationId or session lineage yet; the OTel trace context travels only in the Kafka message headers. Each event freezes its complete subject entities at emit time under the envelope's state key, built by the stateOf* helpers in internal/service/state.go, because a locator in the payload points at a mutable row.
eventType | Emitted when | State subjects |
|---|---|---|
member.party_created | a party row is first created (public POST /parties or the internal idempotent create; reuse by email does not re-emit) | party |
party.updated | a party's mutable details (names, email, phone) change | party |
account.created | an employer/broker account record is first created | account |
product.published | a product version goes live (immutable from then on) | product, productVersion |
It consumes nothing: no Kafka consumer runs, and other state changes (deactivate, market-profile publish, config deploy) emit no events today. Contracts (payload schema, required state subjects, lineage, producers/consumers as code refs, golden examples) live in the event registry, one directory per type under packages/go/domain/eventregistry/registry/.
Dependencies
Policy Admin has no runtime service dependencies. It is a dependency of Enrollment, Billing, Claims, Provider, Notifications, Broker API, and Member Portal API, each of which holds a POLICY_ADMIN_URL client and reads parties, products, rule sets, market profiles or field definitions over the internal routes above.
Invariants
- A published
product_versionis immutable: updating its rules, fields, or attributes returns409 Conflict. Enrollment references a specific version, so in-force policies are always adjudicated against the rules in effect when underwritten. - Publishing a market profile version is one-way; re-publishing or editing a published version returns
409. - Config deploy is atomic over product + versions + rule sets;
validateis the dry-run that returns the same failure set without writing. parties.type,products.status, andproduct_versions.statusareCHECK-constrained enums, not free text (unlike the billing domain).
Key design decisions
Config bundle deployment. Rather than editing rules through individual calls, an operator assembles a full bundle (product, versions, rule sets), validates it with POST /config/validate, then deploys it atomically with POST /config/deploy. Product releases stay reproducible and auditable.
Versioned, immutable products. Configuration freezes on publish. This is what lets Enrollment pin a policy to the exact ruleset it was underwritten under.
Planned subsystems
These are built on unmerged feature branches (feat-rating-engine, feat-boundary-service) and are not registered in the running service. They are documented here so the roadmap is not lost; do not treat them as live.
Rating engine (Planned, not implemented). Adds per-product-version rating factors and rate tables so the pricing path can compute premiums from configuration. Planned tables: rating_factors, rate_tables, rate_evaluations. Planned routes, all under /products/{locator}/versions/{version}:
| Method | Path | Description |
|---|---|---|
POST | /rating-factors | Create a rating factor |
GET | /rating-factors | List rating factors (no /list suffix on this branch) |
DELETE | /rating-factors/{locator} | Delete a rating factor |
PUT | /rate-tables/base | Set base rates |
GET | /rate-tables/base | Get base rates |
PUT | /rate-tables/{factorLocator} | Replace rate table entries for a factor |
GET | /rate-tables/{factorLocator} | List rate table entries |
GET | /rate-evaluations/{entityType}/{entityId} | Rating audit trail |
Rate table rows are loaded by the PUT replace endpoints. There is no bulk rate-tables/import route.
Boundary service / PostGIS (Planned, not implemented). A hierarchy-agnostic boundary model: expanding to a new country means defining boundary levels and importing GeoJSON rather than changing code. Planned tables: boundary_levels, boundaries, boundary_groups, boundary_group_members. The boundaries.boundary_type CHECK enum is ADMINISTRATIVE, SERVICE_AREA, RATING_ZONE, SCHEME_REGION, EXCLUSION_ZONE. Planned routes:
| Method | Path | Description |
|---|---|---|
POST | /boundary-levels | Define hierarchy levels for a market |
GET | /boundary-levels/{marketCode} | List levels for a market |
POST | /boundaries | Create a boundary |
POST | /boundaries/import | Bulk import GeoJSON boundaries |
GET | /boundaries/list | List boundaries (filter by market, level, type) |
GET | /boundaries/{locator} | Get boundary metadata |
GET | /boundaries/{locator}/geometry | Get geometry as GeoJSON |
GET | /boundaries/{locator}/children | List child boundaries |
GET | /boundaries/{locator}/ancestors | List ancestor boundaries |
DELETE | /boundaries/{locator} | Soft-delete a boundary |
POST | /boundary-groups | Create a boundary group |
GET | /boundary-groups/list | List boundary groups |
GET | /boundary-groups/{code} | Get a group |
PUT | /boundary-groups/{code}/members | Set group members |
GET | /boundary-groups/{code}/members | List group members |
POST | /spatial/contains | Check if a point is in a boundary |
POST | /spatial/lookup | Find all boundaries containing a point |
POST | /spatial/serviceability | Serviceability check (in a service area, not excluded) |
