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
| Object | Table (group_scheme schema) | Description |
|---|---|---|
| Scheme | schemes | Employer group scheme definition |
| Scheme member | scheme_members | Member roster per scheme (one row per member_party_locator) |
| Bulk enrollment job | bulk_enrollment_jobs | Async 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
| Context | Requirement |
|---|---|
All /api/v1/* routes | JWT (validated against Keycloak JWKS when KEYCLOAK_JWKS_URI is set), the employer-admin role, and org_locator tenancy |
/onboarding/* funnel routes | X-Onboarding-Session header, verified against the identity service |
/internal/* routes | X-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
| Method | Path | Request body | Success | Errors |
|---|---|---|---|---|
POST | /schemes | {code, name, employerPartyLocator} | 201 scheme object (status: "ACTIVE") | 422 missing required field · 500 |
GET | /schemes | query employerPartyLocator (optional filter) | 200 array of schemes | 500 |
GET | /schemes/list | same as GET /schemes | 200 array | 500 |
GET | /schemes/{locator} | - | 200 scheme object | 404 · 500 |
PATCH | /schemes/{locator} | {name?, status?} (both optional) | 200 updated scheme | 404 · 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
| Method | Path | Request body | Success | Errors |
|---|---|---|---|---|
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 | - | 200 | 404 · 500 |
DELETE | /schemes/{locator}/members/{memberPartyLocator} | - | 204 No Content | 404 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)
| Method | Path | Status |
|---|---|---|
GET | /schemes/{locator}/billing | Stub. Returns 200 {schemeLocator, invoices: [], note}; billing aggregation is not wired |
GET | /schemes/{locator}/eligibility | Stub. 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
| Method | Path | Request body | Success | Errors |
|---|---|---|---|---|
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 object | 404 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).
| Method | Path | Request body | Success | Errors |
|---|---|---|---|---|
POST | /onboarding/schemes | {code?, name, ...} | 201 scheme; 200 existing scheme if the session already created one | 401 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:
FAILEDif the members list is empty, or if every member failed.COMPLETEDin 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.
| Object | Values the code actually sets |
|---|---|
| Scheme | ACTIVE (set on create) |
| Scheme member | ACTIVE (set on add) |
| Bulk job | PENDING → RUNNING → COMPLETED | 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.
eventType | Emitted when | Key | state subjects |
|---|---|---|---|
scheme.created | CreateScheme lands an ACTIVE scheme row | scheme locator | scheme |
scheme.updated | PATCH /schemes/{locator} persists name/status | scheme locator | scheme |
member.added | legacy AddMember (bare memberPartyLocator) inserts an ACTIVE roster row | member party locator | member, scheme |
scheme.member_removed | DELETE member (row hard-deleted before the event fires) | member party locator | scheme |
member.dispatched | SetMemberStatus PENDING to DISPATCHED (service seam; no HTTP caller wired yet) | member party locator | scheme |
member.activated | SetMemberStatus DISPATCHED to ACTIVATED (service seam; no HTTP caller wired yet) | member party locator | scheme |
bulk_enrollment.completed | RunJob terminal status COMPLETED (includes partial failure) | job locator | none |
bulk_enrollment.failed | RunJob terminal status FAILED (all members failed, or empty roster) | job locator | none |
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 onemployer_party_locatorandstatus.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 inerrors.
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
| Dependency | Env var | Purpose | Failure mode |
|---|---|---|---|
Postgres (group_scheme schema) | DATABASE_URL | Persistence; migrations run on boot | Hard fail |
| Enrollment (REST) | ENROLLMENT_URL | POST /internal/policies/issue per bulk-job member | Degraded: per-member failures isolated, job continues |
| Keycloak (JWKS) | KEYCLOAK_JWKS_URI | JWT validation + employer-admin role check | Required to start |
| Policy Admin / Identity (REST) | POLICY_ADMIN_URL | Mint party + identity with activation PIN (mint-with-pin) on add-member; party directory reads for roster enrichment | Add-member fails; roster degrades to bare locators |
| Identity (REST) | IDENTITY_URL | Verify X-Onboarding-Session for funnel routes | Funnel routes 503 |
| Notifications (REST) | NOTIFICATIONS_URL | Send member activation email | Best-effort |
| Document Service (REST) | DOCUMENT_SERVICE_URL | Scheme document listing/downloads | Degraded |
| OTel collector | OTEL_ENDPOINT | Tracing | Degraded |
| Billing (REST) | BILLING_URL | Reserved for the /billing stub | None (unused) |
| Kafka | KAFKA_BROKERS | Direct producer to group-scheme.events (see Events) | Best-effort: publishes are fire-and-forget, events dropped |
| Eligibility (REST) | ELIGIBILITY_URL | Reserved for the /eligibility stub | None (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.codeandbulk_enrollment_jobs.locatorare unique;(scheme_id, member_party_locator)is unique per scheme.- A bulk job is
FAILEDonly when its members list is empty or every member failed; otherwise it isCOMPLETED. Partial failures are recorded inerrors, not in a distinct status. - One member's enrollment failure never aborts the rest of the job.
employer-adminis enforced at the middleware before any DB access; a missing role returns403.DELETEon a member is a hard delete; the row is gone, not flagged.
Non-goals
| Not this service | Owner |
|---|---|
| Premium computation / underwriting | Enrollment |
| Payment collection and invoicing | Billing |
| Eligibility enforcement at claim time | Eligibility |
| Party / account creation | Party / 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 nomember.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
/billingand/eligibilityroutes are stubs returning empty arrays. - Dependents and employee IDs on the roster; demographics jsonb plus policy-admin identity joins cover names/emails today.
