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:
- Records and enforces Enrollment Periods as the source of truth.
- Exposes read APIs (CQRS views) and event streams describing enrollment state + state changes.
- 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_idorpet_id) - is enrolled to a Module (
module_idβ the cross-revisionContractEnrollmentModule.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_dateandend_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_typeacross 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):
- Change requests β A member (or admin) asks to switch modules starting on a date, with an optional
end_dateto bound the enrollment to[switch_date, end_date]. The engine checks eligibility, applies the change, runs constraints, and approves or rejects. - 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_idget_enrollment_group_view_by_contract_and_primarylist_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:
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:
- Keep the Capabilities API as the front door for yes/no service-access checks. Callers stay stable.
- Migrate the inside of each capability check to consume
core_enrollmentviews once M1 is wired in for that country. - Use this dichotomy when migrating ES backend callers off
EsEnrollment*: callers needing only a boolean should switch to capabilities (avoid touchingcore_enrollmentat 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_apimodules to interact withcore_enrollment. Never import frominternal/. - You MUST NOT add cross-component fields (module name,
service_type, plan-catalog data) toEnrollmentGroupView/EnrollmentPeriodView. Callers resolve those separately viaget_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 toEnrollmentGroupRepositoryβ its contract is "load aggregate for mutation". Reads go onEnrollmentGroupQueryService.
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-stackSQLAlchemyEnrollmentGroupRepository.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βEnrollmentGroupQueryServiceABC + core-stack impl + per-country legacy registry.internal/infrastructure/es_legacy_enrollment_group_query_service.pyβ Spain legacy query service (own SQL + view projection viaes_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, legacyRead*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).