Skip to content

Core Enrollment Component

Source of truth for who is on what, when β€” under which contract.

Core Stack glossary on Notion β§‰

Mission

core_enrollment records and enforces that Member Enrollments conform to both what the Contract allows and what Members/Admins have chosen. It:

  1. Records and enforces Enrollment Periods as the source of truth.
  2. Exposes read APIs (CQRS views) and event streams describing enrollment state + state changes.
  3. Powers client interactions: available choices at a date, price simulations, admin (Marmot) tooling.

Key Concepts

EnrollmentPeriod

The basic unit of the system β€” an immutable value. Records that:

  • a Member (identified by global_user_id or pet_id)
  • is enrolled to a Module (module_id β€” the cross-revision ContractEnrollmentModule.identifier, not the row PK)
  • as a Membership Type (primary / partner / child / ascendant / pet)
  • of a Primary (identified by global_user_id)
  • under a Contract (contract_id)
  • over a period of time, delimited by start_date and end_date (inclusive, None = unbounded).

During constraint processing, a period is transiently wrapped with an is_committed flag indicating whether it has completed its minimum-duration commitment.

EnrollmentGroup

The consistency boundary: all EnrollmentPeriods sharing the same Primary under the same Contract. Keyed by (primary_member_id, contract_id).

Invariants enforced on every mutation:

  • No overlapping periods for same (member, module).
  • Each member has a single consistent membership_type across all their periods.
  • Maximize consistency with past decisions taken by members and admins.
  • Maximize satisfaction of the contract's rules.

Contract specifications

backend/shared/core_stack/protocols defines the contract / contract-version / module shape. A contract is a sequence of versions; each version has multiple modules.

Rules the EnrollmentGroup must satisfy live on ContractVersion and ContractEnrollmentModule. Rule scopes:

Scope Example
Isolated member Child must be under 25 years old
All members All members enrolled on module A together
Primary Module A available only if primary's salary is in a bracket
Cross module Module B available only when enrolled to module A
Commitment Enrollment to module A can only end after 6 months

Scopes combine.

Decisions

Members and admins emit decisions; the system updates the EnrollmentGroup accordingly. Admin overrides are just another decision type β€” same code path. See internal/domain/entities.py (change requests, scoped changes) for the domain shape.

Revisions

EnrollmentGroup is versioned: every mutation creates a new revision β€” a monotonically-numbered, point-in-time snapshot of the group. Enables point-in-time queries and optimistic concurrency.

Entry points

The engine has two flows, both sharing a stateless engine core (engine_core.py):

  1. Change requests β€” A member (or admin) asks to switch modules starting on a date, with an optional end_date to bound the enrollment to [switch_date, end_date]. The engine checks eligibility, applies the change, runs constraints, and approves or rejects.
  2. Recomputes β€” An external event (employment change, profile update, contract amendment) triggers a full re-evaluation of the enrollment group from inputs and eligibility, including wipe-and-replay of approved change requests.

See internal/engine/README.md β§‰.

Layer structure

public/          β†’ Component API surface
internal/
  domain/        β†’ Entities, repositories, exceptions (pure domain, no I/O)
  engine/        β†’ Enrollment computation engine (eligibility, constraints, diffs)
  infrastructure/β†’ SQLAlchemy repository + QueryService implementations, UnitOfWork
  models/        β†’ ORM models (bi-temporal enrollment_period table)
  controllers/   β†’ API endpoints (WIP)
  commands/      β†’ CLI commands
external/
  adapters/      β†’ Abstract + concrete adapters for contract, employment, profile data
  inputs/        β†’ Eligibility input resolvers (address, age, employment, etc.)
  event_consumers/ β†’ Event-driven recompute triggers (WIP)

Public API

Two surfaces β€” don't mix them. Pick based on what you need to do, not what feels familiar.

Reads β€” QueryService Views (CQRS)

Returns EnrollmentGroupView (identity-only, aggregate-rooted, no cross-component data like module name or service type).

from components.core_enrollment.public.python_api.enrollment_group import (
    list_enrollment_group_views_by_member,
    get_enrollment_group_view_by_contract_and_primary,
)

views = list_enrollment_group_views_by_member(member_id=user_id)

Available read methods (public/python_api/enrollment_group.py):

  • get_enrollment_group_view_by_id
  • get_enrollment_group_view_by_contract_and_primary
  • list_enrollment_group_views_by_{contract,primary,member}

All accept source_of_truth: SourceOfTruth = follow_migration β€” preserves migration validation modes (force_legacy / force_core_stack / follow_migration).

Need module name or service_type? Use contracting.public.queries.get_modules.get_modules_info({(contract_id, module_id), ...}) separately. Views deliberately don't carry cross-component data.

Writes β€” ChangeRequestService

from components.core_enrollment.public.python_api.change_request import ChangeRequestService

ChangeRequestService().submit(...)

The engine then checks eligibility, applies the change, runs constraints, and approves or rejects. See internal/engine/README.md β§‰ for the flow.

Events

The component emits domain + integration events. Subscribe to react to enrollment changes:

from components.core_enrollment.public.events_pipeline_events import RecomputeRequestRetried

Capabilities API vs Core Enrollment

Most code that today inspects enrollments only needs a yes/no service-access answer β€” can this user submit a reimbursement, book a therapy session, access the dashboard? That's what the Capabilities API is for.

[!IMPORTANT] Rule of thumb: - Yes/no can-user-access-X β†’ Capabilities API - What enrollment periods does this group have / what's the active module / list members β†’ Core Enrollment view API

Where capabilities live (per-country today)

Country Path Public surface
πŸ‡ͺπŸ‡Έ ES components/es/internal/business_logic/user_capability/ components/es/public/capability.py (user_has_product_capability, get_user_product_capabilities)
πŸ‡§πŸ‡ͺ BE components/be/internal/capabilities/ components/be/public/user.py re-exports get_user_capabilities
πŸ‡¨πŸ‡¦ CA components/ca/internal/capabilities/ GET /users/me controller; internal callers reach in directly
πŸ‡ΈπŸ‡¦ SA components/sa/internal/capabilities/ GET /users/me controller

There is no unified cross-country capabilities service yet. Each country exposes its own shape (BE/CA/SA: boolean dataclass; ES v2: ProductCapabilityType enum + ValidityPeriod).

Examples β€” what belongs where

# βœ… Capabilities β€” gating service access
if not user_has_product_capability(user_id, ProductCapabilityType.REIMBURSEMENTS):
    raise ReimbursementException(...)

# βœ… Capabilities β€” feature flags by country
capabilities = get_user_capabilities(user_id)
if capabilities.video_therapy:
    ...

# βœ… Core Enrollment β€” listing periods, finding active module, group membership
views = list_enrollment_group_views_by_member(member_id=user_id)
for view in views:
    for period in view.enrollment_periods:
        ...

# ❌ Don't load enrollments to derive a boolean -- use capabilities
enrollments = EsEnrollmentModelBroker.get_user_active_or_future_enrollment(user_id)
has_reimbursements = any(e.module.service_type == "outpatient" for e in enrollments)

Migration relationship

Today, capability checks read from each country's legacy enrollment tables (e.g. ES CapabilityLogic.get_capability_with_enrollments reads EsEnrollmentModelBroker). The Capabilities API is not yet plugged into M1 of the Spain migration.

Direction of travel:

  1. Keep the Capabilities API as the front door for yes/no service-access checks. Callers stay stable.
  2. Migrate the inside of each capability check to consume core_enrollment views once M1 is wired in for that country.
  3. Use this dichotomy when migrating ES backend callers off EsEnrollment*: callers needing only a boolean should switch to capabilities (avoid touching core_enrollment at all); callers needing actual enrollment data should switch to the view API.

If you're about to add a new caller that loads enrollments just to compute a boolean, add a capability instead.

DOs and DON'Ts

  • You MUST use the public python_api modules to interact with core_enrollment. Never import from internal/.
  • You MUST NOT add cross-component fields (module name, service_type, plan-catalog data) to EnrollmentGroupView / EnrollmentPeriodView. Callers resolve those separately via get_modules_info.
  • You MUST NOT use Flask Admin to edit enrollment data β€” it bypasses constraint validation and revision tracking.
  • You MUST NOT add new callers of the legacy Read* types (ReadEnrollmentGroup, ReadEnrollmentPeriod, ReadModule). They're being migrated to views.
  • You MUST NOT add new list_by_* read methods to EnrollmentGroupRepository β€” its contract is "load aggregate for mutation". Reads go on EnrollmentGroupQueryService.

Internal Structure

[!NOTE] You don't need to read this part if you're not planning to modify the component.

The component follows a CQRS-flavored DDD structure: a write side (aggregate + repository) and a permanent read side (QueryService + views).

Domain Layer

Directory: internal/domain/ Goal: All business logic encapsulated on entities; pure domain, no I/O. Contains: EnrollmentGroup, EnrollmentPeriod, value objects, domain events, exceptions, and the EnrollmentGroupRepository protocol.

See internal/domain/README.md β§‰.

Infrastructure Layer

Directories: internal/infrastructure/ (repository + query-service impls), internal/models/ (ORM models on the bi-temporal enrollment_period table).

Contains:

  • internal/infrastructure/repository.py β€” core-stack SQLAlchemyEnrollmentGroupRepository.
  • internal/infrastructure/es_legacy_repository.py β€” Spain legacy read-only repository.
  • internal/infrastructure/legacy_repositories.py β€” per-country legacy-repo registry.
  • internal/infrastructure/enrollment_group_query_services.py β€” EnrollmentGroupQueryService ABC + core-stack impl + per-country legacy registry.
  • internal/infrastructure/es_legacy_enrollment_group_query_service.py β€” Spain legacy query service (own SQL + view projection via es_legacy_helpers).

Engine

Directory: internal/engine/ Goal: Stateless enrollment computation β€” eligibility, constraints, diffs. See Entry points above for the two flows it serves.

External Layer

Directories: external/adapters/ (abstract + concrete adapters for contract, employment, profile data), external/inputs/ (eligibility input resolvers).

See external/adapters/README.md β§‰.

Application Layer

Public

Directory: public/ Goal: API to interact with the component.

  • public/python_api/enrollment_group.py β€” read API (views + repository fallbacks).
  • public/python_api/change_request.py β€” write API (ChangeRequestService).
  • public/entities.py β€” EnrollmentGroupView, EnrollmentPeriodView, legacy Read* types.
  • public/events_pipeline_events.py β€” emitted events.
  • public/errors.py β€” public exception types.

Private

Directory: internal/application/ Goal: Orchestrate use cases; communication between domain and infrastructure layers.

Reads vs Writes β€” decision rule

Read shape Goes on
Loads single aggregate for mutation Repository
Projection, check, cross-aggregate query (active members, etc.) QueryService

Putting list/check reads on EnrollmentGroupRepository is a category error β€” its contract is "load aggregate for mutation". Route reads through EnrollmentGroupQueryService.

The QueryService is permanent, not migration scaffold. The ABC stays stable; impl swaps behind the per-country registry. View shape is the swap contract.

Spain migration (2026 Q2)

Spain Health is migrating from legacy ES models (EsHealthContract, EsPolicy, EsEnrollment, …) to the Core Stack via 5 milestones (strangler-fig, dual-write before cutover). References: Linear project β§‰, planning discussion β§‰.

# Name What it means for core_enrollment
M1 Translate Read APIs serve EnrollmentGroupView from legacy ES tables via in-memory translation.
M2 Dual Write Every write touchpoint also creates a core-stack EnrollmentGroup.
M3 Read from Core APIs Spain callers migrate from legacy ORM lookups to the view API.
M4 Switch to persisted Reads flip from in-memory translation to the persisted core-stack tables.
M5 Remove obsolete Drop ES legacy repos/query-services, drop legacy tables.

Country bridge layers (e.g. components/es/external/core_stack/queries.py) consume the view API + get_modules_info and project to country-specific shapes (e.g. Spain's HealthEnrollment).

Running tests

# BDD tests (behave)
direnv exec backend uv run behave components/core_enrollment/features/

# Unit / integration tests (pytest)
direnv exec backend uv run pytest components/core_enrollment/