Skip to content
Updated Jul 12, 2026

Group Scheme Service

A group scheme is an employer's container for its members: one Scheme row per employer programme, with a SchemeMember roster underneath it. The service also runs bulk enrollment jobs that walk a roster and issue a policy per member through the Enrollment service. Owned by the group-scheme-service Go service, used by the employer portal (employer.dev.hiolly.com) and web-admin.

As-built status

The service stores schemes, members and bulk-job rows and drives Enrollment over REST. Since the v0.2 release it also enforces org_locator tenancy, enriches the roster with identity fields from policy-admin, stores enrolment demographics, runs the pre-account onboarding funnel (session-gated scheme creation and bulk enrolment), and exposes X-Internal-Service-gated internal routes. Since the event-contract work it publishes lifecycle events to Kafka (see Events). There is still no Temporal: bulk jobs run as an in-process goroutine whose pending roster lives in memory (lost on restart). See Planned, not implemented.

Field reference: column types and nullability live in the catalog. This page is the narrative.

What it owns

ObjectTable (group_scheme schema)Description
SchemeschemesEmployer group scheme definition
Scheme memberscheme_membersMember roster per scheme (one row per member_party_locator)
Bulk enrollment jobbulk_enrollment_jobsAsync job tracking with inline per-member failure capture

All three tables have UUID primary keys and live in a Postgres schema named group_scheme on the shared Postgres instance (10.0.1.2:5432 in dev). There is no dedicated database, and no outbox, projection-checkpoint or job-member tables.

Scheme.locator is SCH-{year}-{seq}, member rows are keyed by the caller-supplied member_party_locator, and bulk jobs get a BMJ-{year}-{seq} locator. The sequence counters are in-process (internal/service/scheme.go, bulk_enrollment.go), not DB-backed.

Auth model

ContextRequirement
All /api/v1/* routesJWT (validated against Keycloak JWKS when KEYCLOAK_JWKS_URI is set), the employer-admin role, and org_locator tenancy
/onboarding/* funnel routesX-Onboarding-Session header, verified against the identity service
/internal/* routesX-Internal-Service shared secret (fail-closed when unset); also 404-blocked at the APISIX edge
Health (/healthz, /readyz)None

The employer-admin role is checked at the middleware layer (requireEmployerAdmin, handler/handler.go) in both realm_access.roles and any resource_access.<client>.roles; a missing role returns 403 before any DB access. When KEYCLOAK_JWKS_URI is unset (test mode) the JWT and role checks are skipped and routes mount directly.

Tenancy: enforceSchemeOwnership compares the JWT org_locator claim against the scheme's employer_party_locator on create, get, and every member/billing/eligibility/document route, returning 403 on a cross-employer access; list results are filtered to the caller's org.

API routes

All paths are under /api/v1 and require JWT + employer-admin (above). Locator path params are scheme locators (SCH-...) and job locators (BMJ-...).

Schemes

MethodPathRequest bodySuccessErrors
POST/schemes{code, name, employerPartyLocator}201 scheme object (status: "ACTIVE")422 missing required field · 500
GET/schemesquery employerPartyLocator (optional filter)200 array of schemes500
GET/schemes/listsame as GET /schemes200 array500
GET/schemes/{locator}-200 scheme object404 · 500
PATCH/schemes/{locator}{name?, status?} (both optional)200 updated scheme404 · 500

GET /schemes/list is an alias of GET /schemes reached by web-admin through APISIX. PATCH writes status verbatim with no transition validation (see Statuses).

Scheme members

MethodPathRequest bodySuccessErrors
POST/schemes/{locator}/members{memberPartyLocator} (legacy) or {firstName, lastName, email, age, sexAtBirth, postcode, planTier?}204 (legacy) / 201 (demographics shape)422 validation · 404 scheme not found · 500
GET/schemes/{locator}/members-200 array of members (only ACTIVE)404 · 500
POST/schemes/{locator}/members/{memberPartyLocator}/resend-activation-200404 · 500
DELETE/schemes/{locator}/members/{memberPartyLocator}-204 No Content404 scheme not found · 500

The demographics shape (used by the employer portal's Add Member) mints the party and identity via policy-admin/identity (mint-with-pin), stores a demographics jsonb blob, and is validated by validateFunnelMember: firstName and email required, age 16 to 100, sexAtBirth in {male, female}, UK-format postcode; failures return 422. Add-member is idempotency-key protected.

Roster enrichment: GET /members rows are a SchemeMember plus firstName, lastName, email fetched live from policy-admin's party directory (bounded fan-out, best-effort: an unreachable party leaves the row with just its locator). Rows include the stored demographics field, which is a jsonb column mapped to Go []byte, so it arrives base64-encoded on the wire; clients must base64-decode before JSON-parsing it.

DELETE is a hard delete (GORM Delete, repository/gorm_scheme.go), not a soft-delete to a REMOVED status, and there is no member PATCH. GET /members returns only rows with status = 'ACTIVE'.

Employer aggregates (stubs)

MethodPathStatus
GET/schemes/{locator}/billingStub. Returns 200 {schemeLocator, invoices: [], note}; billing aggregation is not wired
GET/schemes/{locator}/eligibilityStub. Returns 200 {schemeLocator, members: [], note}; roster check is not wired

The Billing and Eligibility clients are constructed in main.go and then discarded (_ =); these routes exist so callers and tests have a stable shape, but return empty aggregates.

Bulk enrollment

MethodPathRequest bodySuccessErrors
POST/schemes/{locator}/bulk-enrollments{members: [{memberPartyLocator, productVersionId, planTier?, document?}]}202 job object (status: "PENDING")422 empty members list · 404 scheme not found · 500
GET/bulk-enrollments/{jobLocator}/status-200 job object404 job not found · 500

The status route is not nested under the scheme and the path ends in /status. POST returns the raw job struct (locator, status, total, processed, failedCount, errors, ...): there is no member_count, estimated_eta_seconds, dry_run, oversized-payload cap, or cancel endpoint. The only request validation is rejecting an empty members list (422).

Onboarding funnel (pre-account)

Mounted outside the JWT group; gated by the X-Onboarding-Session header, verified against the identity service's cluster-only POST /internal/onboarding-sessions/verify (see Identity).

MethodPathRequest bodySuccessErrors
POST/onboarding/schemes{code?, name, ...}201 scheme; 200 existing scheme if the session already created one401 missing/unknown/expired session · 409 session bound to another scheme · 502 verification failed · 503 not configured
POST/onboarding/schemes/{locator}/bulk-enrollments{members: [{firstName, lastName, email, age, sexAtBirth, postcode, planTier?}]}202 {accepted, schemeLocator, status: "processing"}422 validation (see below) · 401/409 session errors

A session creates at most one scheme: after create the service re-verifies with bindSchemeLocator to bind the session, and a repeat call returns the already-bound scheme with 200 instead of minting another.

Funnel bulk enrolment validates every member with validateFunnelMember: firstName and email (with @) required, age 16 to 100, sexAtBirth in {male, female} case-insensitive, UK-format postcode. Any invalid member fails the whole request with 422 {error: "members failed validation", details: [{email, error}]} listing every failure. Valid rosters are processed in a background goroutine and each member's demographics are persisted as jsonb.

Bulk enrollment behaviour

StartBulkEnrollmentAsync creates the PENDING job row, stashes the roster in an in-memory pendingMembers map keyed by job UUID, and launches RunJob in a goroutine (internal/service/bulk_enrollment.go). There is no Temporal, no durable queue, and no signal-based cancellation. A restart loses any roster still pending in memory.

RunJob marks the job RUNNING, then iterates members sequentially (not parallel). For each member it calls the Enrollment chain. The final status is:

  • FAILED if the members list is empty, or if every member failed.
  • COMPLETED in all other cases, including jobs where some members failed.

There is no PARTIAL_FAILURE bucket: a job with partial failures reports COMPLETED, with the failures captured in the job's errors JSONB column as [{member, error}]. Successful members are not individually recorded, and failures are not auto-retried.

Per member, RunJob issues the policy through Enrollment's internal one-shot endpoint: POST /internal/policies/issue (internal/client/enrollment_client.go), one call per member. (The legacy four-step quote chain client in internal/client/enrollment.go still exists but is no longer what bulk jobs use.) Any non-2xx is a member failure; one member's failure does not abort the job.

Statuses

These are free TEXT columns with no DB CHECK and no entries in packages/go/domain/enums.go.

ObjectValues the code actually sets
SchemeACTIVE (set on create)
Scheme memberACTIVE (set on add)
Bulk jobPENDINGRUNNINGCOMPLETED | FAILED

PATCH /schemes/{locator} will persist any status string the caller sends with no validation, so ACTIVE is the only value the service itself ever writes for schemes and members. There is no SUSPENDED/TERMINATED/ELIGIBLE/ENROLLED/REMOVED/DECLINED/IN_PROGRESS/PARTIAL_FAILURE/CANCELLED anywhere in the code.

Events

The service produces only; it runs no Kafka consumer. Events are published directly by an in-process producer (internal/kafka/producer.go), not via an outbox: publishes are fire-and-forget (_ =) after the DB write, so a broker outage drops events rather than failing the request, and there is no retry or replay. All events go to the group-scheme.events topic (KAFKA_BROKERS, default kafka:9092; wired in cmd/server/main.go).

Each message is the canonical platform envelope (see the event catalog): eventId, eventType, occurredAt, client lineage (sessionId / activityId / activityName, lifted from W3C baggage on the request context), payload, and state (event-carried state: the subject entities frozen at emit time). The direct producer stamps no correlationId; trace context rides in the Kafka message headers instead. Bulk-job events are published from a detached goroutine and carry no session lineage.

eventTypeEmitted whenKeystate subjects
scheme.createdCreateScheme lands an ACTIVE scheme rowscheme locatorscheme
scheme.updatedPATCH /schemes/{locator} persists name/statusscheme locatorscheme
member.addedlegacy AddMember (bare memberPartyLocator) inserts an ACTIVE roster rowmember party locatormember, scheme
scheme.member_removedDELETE member (row hard-deleted before the event fires)member party locatorscheme
member.dispatchedSetMemberStatus PENDING to DISPATCHED (service seam; no HTTP caller wired yet)member party locatorscheme
member.activatedSetMemberStatus DISPATCHED to ACTIVATED (service seam; no HTTP caller wired yet)member party locatorscheme
bulk_enrollment.completedRunJob terminal status COMPLETED (includes partial failure)job locatornone
bulk_enrollment.failedRunJob terminal status FAILED (all members failed, or empty roster)job locatornone

Honest gaps: the Flow 0 compose add-member path and bulk enrollment's own roster inserts write scheme_members rows without emitting member.added; scheme.member_removed is the one member-lifecycle type not named member.*; and member.dispatched / member.activated have no reachable HTTP surface today.

Events consumed: none. Payload schemas, lineage/state contracts and golden examples live in the event registry (packages/go/domain/eventregistry/registry/<eventType>/).

Database

Three tables in the group_scheme schema (migrations/0001_create_schema.sql). Full columns are in the catalog; the load-bearing shape:

  • schemes: id (UUID PK), locator (unique), code (unique), name, employer_party_locator, status, created_at, updated_at. Indexed on employer_party_locator and status.
  • scheme_members: id (UUID PK), scheme_id (FK → schemes.id), member_party_locator, policy_locator (nullable), status, demographics (jsonb), created_at. Unique on (scheme_id, member_party_locator). Names/emails are not stored here; they live on the party in policy-admin and are joined at read time.
  • bulk_enrollment_jobs: id (UUID PK), locator (unique), scheme_id (FK), status, total, processed, failed_count, errors (JSONB, default []), created_at, updated_at. Per-member failures live inline in errors.

The only uniqueness constraints are schemes.locator, schemes.code, bulk_enrollment_jobs.locator, and scheme_members(scheme_id, member_party_locator). There is no (org_locator, scheme_name) or (scheme_id, idempotency_key) constraint.

Dependencies

DependencyEnv varPurposeFailure mode
Postgres (group_scheme schema)DATABASE_URLPersistence; migrations run on bootHard fail
Enrollment (REST)ENROLLMENT_URLPOST /internal/policies/issue per bulk-job memberDegraded: per-member failures isolated, job continues
Keycloak (JWKS)KEYCLOAK_JWKS_URIJWT validation + employer-admin role checkRequired to start
Policy Admin / Identity (REST)POLICY_ADMIN_URLMint party + identity with activation PIN (mint-with-pin) on add-member; party directory reads for roster enrichmentAdd-member fails; roster degrades to bare locators
Identity (REST)IDENTITY_URLVerify X-Onboarding-Session for funnel routesFunnel routes 503
Notifications (REST)NOTIFICATIONS_URLSend member activation emailBest-effort
Document Service (REST)DOCUMENT_SERVICE_URLScheme document listing/downloadsDegraded
OTel collectorOTEL_ENDPOINTTracingDegraded
Billing (REST)BILLING_URLReserved for the /billing stubNone (unused)
KafkaKAFKA_BROKERSDirect producer to group-scheme.events (see Events)Best-effort: publishes are fire-and-forget, events dropped
Eligibility (REST)ELIGIBILITY_URLReserved for the /eligibility stubNone (unused)

There is still no Temporal, and Kafka is produce-only (no consumer, no outbox).

Runtime

The container listens on port 8080 internally (the code default for PORT is 4010, but docker-compose.yml sets PORT=8080 and the Dockerfile EXPOSEs 8080). 4010 is only the host-side mapping (127.0.0.1:4010:8080 in docker-compose.yml); other services reach it at http://group-scheme-service:8080. There are no GKE/Cloud-SQL artifacts in this repo.

Invariants

  • schemes.locator, schemes.code and bulk_enrollment_jobs.locator are unique; (scheme_id, member_party_locator) is unique per scheme.
  • A bulk job is FAILED only when its members list is empty or every member failed; otherwise it is COMPLETED. Partial failures are recorded in errors, not in a distinct status.
  • One member's enrollment failure never aborts the rest of the job.
  • employer-admin is enforced at the middleware before any DB access; a missing role returns 403.
  • DELETE on a member is a hard delete; the row is gone, not flagged.

Non-goals

Not this serviceOwner
Premium computation / underwritingEnrollment
Payment collection and invoicingBilling
Eligibility enforcement at claim timeEligibility
Party / account creationParty / Policy Admin

Planned, not implemented

These appeared in earlier drafts as live behaviour but have no code today. They are kept here as roadmap, not as contract:

  • Durable event publishing. Events now flow (see Events) but through a direct fire-and-forget producer: no outbox, no retry, and events are silently dropped when the broker is down. There is still no consumption of enrollment.policy.*, and the Flow 0 compose and bulk-insert roster paths emit no member.added.
  • Durable bulk workflow (Temporal). Bulk enrollment is an in-process goroutine with an in-memory roster. There is no Temporal workflow, no temporal_workflow_id, and no signal-based cancellation.
  • Dry-run and roster caps on bulk submission (add-member does carry an idempotency key now; bulk does not).
  • Billing and eligibility aggregation. The /billing and /eligibility routes are stubs returning empty arrays.
  • Dependents and employee IDs on the roster; demographics jsonb plus policy-admin identity joins cover names/emails today.

Olly Health Insurance Platform