Skip to content

Core Enrollment β€” Overrides

Status: DRAFT β€” for iteration with the team

1. Goals

Two admin-driven knobs that bend how the engine evaluates a given (contract_id, primary_member_id) over a date window for a subset of members:

  • Disable a module constraint by name (e.g. waive minimum_duration for child Alice for [Jan, Mar]).
  • Override a named scope inside an eligibility function β€” addressed via the Expression layer's (scope, name) pair. Forces the sub-expression's result; the rest of the function is unchanged.

Overrides flow through the regular engine on the next recompute, producing the usual EnrollmentGroup revision + EngineRun. Downstream consumers (billing, capabilities, events) see them as any other change.

2. Non-goals

  • No input-value overrides. If the issue is bad input data, fix the data source β€” don't paper over it from core_enrollment.
  • No contract-level eligibility override β€” per the existing comment in core_plan.py:73-74.
  • No contract-wide overrides. Always (contract, primary)-scoped. If you want it for the whole company, cut a new contract version.
  • No EnrollmentPeriod-shape overrides (force-add a period etc.). Those are admin change requests; keep the audit story on one rail.

3. Aggregate shape

One row = one atomic batch of overrides β€” the batch materializes the operator's intent and carries the audit metadata (who, when, why).

class OverrideGroup(BaseModel):
    id: UUID

    # Target β€” same either-or rule as EnrollmentGroupChangeRequest.
    enrollment_group_id: UUID | None
    contract_id: ContractId | None
    primary_member_id: GlobalUserId | None

    overrides: list[ConstraintOverride | EligibilityOverride]  # min_length=1

    requested_by_global_user_id: GlobalUserId
    requested_at: datetime
    requester_comment: str   # required, non-empty

No status, no latest_engine_run_id, no last_seen_revision_number, no rejection_reason. Submit is all-or-nothing: validation passes β†’ row persists, fails β†’ 400, row never written (Β§6). The engine runs asynchronously via the events pipeline (Β§7).

To undo an existing override, submit a new OverrideGroup whose overrides paint action=remove over the windows in question (Β§7.2). The old group stays on the timeline; painting nullifies it.

3.1 ConstraintOverride and EligibilityOverride

Two override shapes β€” same MemberScope + window, different identifier fields, different action union (per Diaoul's typing suggestion):

class MemberScope(BaseModel):
    """Which members an override applies to."""
    members_with_membership_types: list[MemberWithMembershipType] = []
    target_membership_types: list[MembershipType] = []
    all_members: bool = False

    @model_validator(mode="after")
    def _validate(self) -> Self:
        # At least one of the three forms is set.
        # all_members=True precludes non-empty
        # members_with_membership_types / target_membership_types.
        ...


class ConstraintAction(StrEnum):
    """apply  = the constraint is disabled inside the window.
    remove = remove any older override on this target/window
    (default engine behavior restored)."""
    apply = "apply"
    remove = "remove"


class EligibilityAction(StrEnum):
    """force_true / force_false = the named sub-expression evaluates
    to True / False inside the window. remove = remove any older
    override on this target/window."""
    force_true = "force_true"
    force_false = "force_false"
    remove = "remove"


class ConstraintOverride(BaseModel):
    kind: Literal["constraint"] = "constraint"

    member_scope: MemberScope

    start_date: date
    end_date: date | None    # inclusive; None = open-ended

    # What constraint is being overridden.
    constraint_name: str
    service_types: list[BaseServiceType] = []
    module_slugs: list[str] = []    # = ContractEnrollmentModule.stable_plan_module_slug

    action: ConstraintAction


class EligibilityOverride(BaseModel):
    kind: Literal["eligibility"] = "eligibility"

    member_scope: MemberScope

    start_date: date
    end_date: date | None

    # What eligibility sub-expression is being overridden.
    scope_path: str | None    # e.g. "cadre_only" or "child_path.age_check"
    name: str                 # the Expression.name to match
    service_types: list[BaseServiceType] = []
    module_slugs: list[str] = []

    action: EligibilityAction

Why two override classes (and two action enums) rather than one with a discriminated body: makes the action set per-kind type-correct. force_true/force_false only make sense for eligibility; apply (=disable) only makes sense for constraints. Both share MemberScope and the window. Note that remove appears in both enums (same string value, different enum types) β€” that's intentional, painting checks action == "remove" which works on both.

MemberScope β€” targeting semantics

Empty list / False = "nothing for this field", not "everything."

Shape Example Meaning
Explicit list only members_with_membership_types=[Alice] Just Alice.
Membership-type wildcard target_membership_types=[child] Every child in the group.
Explicit + wildcard (union) both non-empty Alice + every child.
Everyone all_members=True Every member in the group. The other two fields must be empty.

The MemberScope model_validator enforces: at least one of the three forms is set; all_members=True precludes non-empty members_with_membership_types / target_membership_types.

Target identifiers β€” weak references by name

Constraint and eligibility-scope identifiers are names, not stable IDs. ConstraintOverride.constraint_name matches ModuleConstraint.name; EligibilityOverride.(scope_path, name) matches Expression.(scope, name) inside the module's EligibilityFunction. These names are version-scoped and can change across contract amendments. Same goes for module_slugs = stable_plan_module_slug.

We accept the soft coupling. Mitigation:

  • Add comments on the source-code definitions (ModuleConstraint.name, Expression.name/Expression.scope, stable_plan_module_slug) noting that overrides reference these by name β€” anyone renaming needs to know they're a load-bearing identifier downstream.
  • Submit-time check verifies that every override's identifiers resolve in the contract version covering its start_date (Β§6).
  • Engine-time is permissive: silent no-op in versions where the identifier doesn't resolve, with a structured log entry (Β§5.2).
Field Stability
ConstraintOverride.constraint_name Per-version. Strict at submit (must resolve at start_date); silent no-op in other versions at engine time.
EligibilityOverride.(scope_path, name) Per-version. Same handling.
module_slugs[*] = stable_plan_module_slug Cross-revision stable by design (but still a weak reference by string).
service_types[*] Stable enum.

4. Building the override timeline

Per-(member, target) interval painting, same general approach as _build_change_request_timeline (engine_core.py:189-226) β€” newer requested_at wins, older fills gaps. The override implementation chooses an oldest-first chronological direction (Β§4.2) which is easier to read given the asymmetry between inserting actions and Remove, but the semantic is identical to the change-request timeline: more recent intent wins.

4.1 Timeline key

The natural timeline key is (member, target_tuple), where target_tuple is:

  • Constraint: ("constraint", constraint_name, service_types, module_slugs)
  • Eligibility: ("eligibility", scope_path, name, service_types, module_slugs)

Per-(member, target_tuple), you paint intervals. Newer requested_at wins where overrides overlap; older fills gaps.

4.2 Painting pseudocode

Oldest-first chronological with overwrite. Two rules per override:

  • Inserting action (apply, force_true, force_false) β€” trim existing segments inside the override's window, then insert (window, override).
  • remove β€” trim existing segments inside the window. Nothing inserted.

The result is a non-overlapping timeline; a date with no segment means "no override in effect on this (member, target)".

OverrideWithParent = tuple[OverrideGroup, ConstraintOverride | EligibilityOverride]


def build_override_timelines(
    override_groups: list[OverrideGroup],
    enrollment_group: EnrollmentGroup,
) -> dict[
    tuple[MemberWithMembershipType, TargetTuple],
    Timeline[OverrideWithParent],
]:
    """Build per-(member, target) override timelines, oldest-first."""
    # 1. Fan out overrides to per-member entries.
    entries: list[tuple[
        MemberWithMembershipType, TargetTuple,
        ConstraintOverride | EligibilityOverride, OverrideGroup,
    ]] = []
    for group in override_groups:
        for override in group.overrides:
            for member in _resolve_members(override.member_scope, enrollment_group):
                entries.append((member, _target_tuple(override), override, group))

    # 2. Group by (member, target).
    grouped: dict[
        tuple[MemberWithMembershipType, TargetTuple],
        list[OverrideWithParent],
    ] = defaultdict(list)
    for member, target, override, group in entries:
        grouped[(member, target)].append((group, override))

    # 3. Paint per group.
    return {key: _paint(items) for key, items in grouped.items()}


def _paint(
    items: list[OverrideWithParent],
) -> Timeline[OverrideWithParent]:
    """Chronological paint. Inserting actions add a segment; Remove only trims."""
    sorted_asc = sorted(items, key=lambda item: item[0].requested_at)

    segments: list[EffectiveValue[DateInterval, OverrideWithParent]] = []

    for group, override in sorted_asc:
        interval = DateInterval.from_dates(
            start_date=override.start_date, end_date=override.end_date,
        )

        # Trim every existing segment by this override's interval.
        new_segments: list[EffectiveValue[DateInterval, OverrideWithParent]] = []
        for existing in segments:
            for remaining in existing.interval.subtract(interval):
                new_segments.append(
                    EffectiveValue(interval=remaining, value=existing.value)
                )
        segments = new_segments

        # Inserting actions add their own segment. Remove just trimmed.
        if override.action != "remove":
            segments.append(
                EffectiveValue(interval=interval, value=(group, override))
            )

    segments.sort(key=lambda ev: mandatory(ev.interval.start_date))
    return Timeline(values=tuple(segments))


def _resolve_members(
    scope: MemberScope,
    enrollment_group: EnrollmentGroup,
) -> set[MemberWithMembershipType]:
    """Fan out explicit + wildcard + all_members targeting. De-duped."""
    if scope.all_members:
        return set(enrollment_group.members_with_membership_types)
    resolved: set[MemberWithMembershipType] = set(
        scope.members_with_membership_types
    )
    if scope.target_membership_types:
        for member in enrollment_group.members_with_membership_types:
            if member.membership_type in scope.target_membership_types:
                resolved.add(member)
    return resolved

4.3 Engine-time fan-out

Fan-out happens at engine time (the group is already loaded), not at submit time.

  • Membership-type wildcards (target_membership_types=[child]) β†’ walk group members, filter by type, emit one entry per match. Cheap; groups are small.
  • Overrides with both explicit members and wildcards β†’ union, de-duped by (member, target).

Output: list[(member, target, override, parent_group)], ready for Β§4.2. Submit-time validation (Β§4.4) doesn't fan out β€” it checks structural overlap from the request alone, no group load.

4.4 Cross-group vs within-group overlaps

Mirrors the change-request rule (sibling-only check):

  • Within a group β€” any pair of overrides sharing a target tuple with overlapping windows and intersecting MemberScopes β‡’ reject the submit. Atomic; no partial accept.
  • Across groups β€” overlap is expected. Painting resolves it, newer wins.

MemberScope intersection check, structurally from the request (no enrollment-group load required):

MemberScope A MemberScope B Intersects when
explicit list explicit list A.members_with_membership_types ∩ B.members_with_membership_types non-empty.
explicit list wildcard [T,…] Any A explicit member's membership_type is in B's target_membership_types.
wildcard [T,…] wildcard [U,…] target_membership_types sets intersect.
all_members=True any other shape Always intersects.
all_members=True all_members=True Always intersects.

Example β€” target_membership_types=[partner] vs explicit MemberWithMembershipType(Alice, partner) collides on row 2. Alice is provably caught by the wildcard; reject without consulting the group.

Real fan-out (resolving [partner] into actual partner members) happens at engine time. New partners added later are naturally caught by the wildcard on the next recompute.

4.5 Other corner cases

Scenario Choice
Wildcard [child] + explicit Alice (child) in the same group, same target, overlapping window Reject. Not a correctness issue (painting would resolve it identically) but signals operator confusion within one atomic batch. Drop one of the two siblings, or split into two groups.
Two ConstraintOverrides with different further-scoping (service_types=[X] vs module_slugs=[Y]) that happen to hit the same engine constraint instance Different target tuples β†’ independent timelines. Engine sees "disabled" from either; idempotent.
Constraint name absent in some contract versions in the window Silent no-op per version + structured log entry. (Submit-time still requires resolution at start_date β€” see Β§6.)
Eligibility scope (scope_path, name) matches multiple modules and no module_slugs filter Wildcard by design. One log entry per matched module.

4.6 Why (member, target) is enough

Change-request timelining works because each enrollment period belongs to one (member, service_type) β€” painting can't double-claim. For overrides, the analog: an override's engine effect is fully determined by (member, target). Different targets for the same member are independent levers (disabling constraint X is orthogonal to forcing eligibility scope Y). Same-target same-member reconciles via painting.

service_type and module_slug aren't separate timeline axes β€” they live inside the target tuple.

5. Engine integration

Override timelines are built once in the recompute handler and threaded through both compute_eligibility_timelines and run_engine:

# In the recompute handler:
override_groups = override_group_repository.get_for_contract_and_primary(
    contract_id=contract_id, primary_member_id=primary_member_id,
)
override_timelines = build_override_timelines(
    override_groups=override_groups,
    enrollment_group=enrollment_group,
)

eligibility_timelines = compute_eligibility_timelines(
    ...,
    override_timelines=override_timelines,   # NEW β€” for EligibilityOverride
)

converged, final = run_engine(
    ...,
    override_timelines=override_timelines,   # NEW β€” for ConstraintOverride
)

Why the build happens outside run_engine (unlike _build_change_request_timeline which lives inside it): eligibility overrides have to splice into the eligibility result before compute_eligibility_timelines runs, which is before run_engine. Building once in the handler keeps eligibility and the engine consuming the same precomputed object.

5.1 Two integration sites

  1. compute_eligibility_timelines (eligibilities.py) β€” when evaluating a module's eligibility for a member, look up the override timeline for any EligibilityOverride matching (scope_path, name, module). Splice the forced result (per force_true / force_false) into the per- (member, module) Timeline[DetailedEligibilityResult] before the engine consumes it.
  2. apply_constraints_to_enrollment_periods (constraints/loop.py) β€” before invoking module_constraint_handler for a (constraint, version) pair, look up the override timeline for matching ConstraintOverride disables. Apply per Β§5.1.1 below.

5.1.1 Per-member vs cross-member constraints

Most constraints (AtMostOne, MinimumDuration, MaximumFirstEnrollmentAge, MembershipTypeMax, CoverByDefault, MutuallyRequired) act per-member. Two (FamilyDependencyModuleConstraint, PrimaryDependencyModuleConstraint) act across a family/primary group, making per-member disable ambiguous.

Rule:

  • Tag each ModuleConstraint subclass with cross_member: ClassVar[bool] (class-level, not an instance field β€” not serialized when constraints are dumped).
  • ConstraintOverride shape doesn't change. Engine dispatches per contract version, so amendments that flip the type keep stored overrides working.

Submit-time β€” disallow wildcard targeting on cross-member constraints. For each version overlapping the override's window (loaded at submit to do this check, see Β§6): - Constraint absent in this version β†’ no constraint to check (still required to resolve in the start_date version per Β§6). - Per-member there β†’ accept regardless of targeting shape. - Cross-member there β†’ reject if the override's member_scope has non-empty target_membership_types or all_members=True. Must use explicit members_with_membership_types.

Why explicit-only: cross-member constraints affect a whole family group; membership-type wildcards or all_members don't carry the right semantic β€” they'd mean "every member of these types across all families." For a single operator gesture on a cross-member constraint, the natural shape is "disable this for the family group containing member X" β€” and naming X explicitly is the cleanest input.

Engine-time β€” per-version dispatch.

Version's type Targeting Behavior
Per-member any (explicit, wildcard, or all_members) Splice the resolved members' periods out of the handler input, run, splice back.
Cross-member explicit list Disable the constraint for any family group containing any listed member.
Cross-member wildcard / all_members (only possible if the constraint became cross-member after the override was stored, via amendment) Fallback: expand to family-wide disable for the family containing any resolved member. Emit structured log (outcome="cross_member_expanded_post_amendment").
Cross-member listed member doesn't belong to the constraint's family types No-op for this version; log.

Net: no non-convergence β€” the constraint always sees a coherent input. Submit-time keeps cross-member operations explicit; engine-time stays robust to amendments.

5.2 Trace β€” overrides are upstream, attribution is comparative

Overrides modify the engine's inputs (effective eligibility timeline, the set of in-scope constraints). The engine's diff reasons (eligibility:fallback:<module>, constraint:<name>, …) are post-override; they don't carry an "override:" tag (would be misleading).

Attribution = comparison. "What did this override change?" β†’ the simulate_* APIs (Β§7.4) run with vs. without, then diff.

No new fields on EngineRun. The override ↔ engine_run linkage flows through the recompute trigger (Β§7.1).

Per-application diagnostics ("target absent in version v2026Q1", "cross-member expansion post-amendment", etc.) go to structured logging via current_logger.info(...) with override_group_id, override_index, member_id, outcome. Datadog catches them. Promote to a JSONB field on EngineRun if cross-run SQL becomes a need β€” not now.

6. Submit-time validation (all-or-nothing)

There is no status on the OverrideGroup row β€” submit either succeeds (row written, event published) or returns 400 (nothing persisted). All validation is synchronous at submit; the engine doesn't get a veto.

Submit returns 400 and writes nothing if any check fails:

  • Empty targeting (no explicit members, no membership-type wildcard, all_members=False).
  • MemberScope.all_members=True combined with non-empty members_with_membership_types / target_membership_types.
  • Within-group MemberScope intersection on the same target over overlapping windows (Β§4.4).
  • MemberScope.target_membership_types non-empty or all_members=True on a cross-member constraint (Β§5.1.1).
  • Target unresolved at start_date: each override's identifier (constraint_name or (scope_path, name) + module filters) must resolve in the contract version covering its start_date. The endpoint loads the contract to do this check.
  • Authorization denial (out of scope; IAM).

Notably NOT a 400:

  • Cross-group overlap with an existing OverrideGroup β€” painting resolves it (Β§7.2).
  • Target absent in non-start_date versions inside the override's window β€” silent no-op per version at engine time (Β§5.2).
  • "No engine effect" β€” a valid no-op.

6.1 Action semantics within a winning window

In the segment where one override wins the (member, target) timeline, the engine sees only that override's action β€” no merging with painted-over overrides:

  • Newer inserting action over older inserting action β†’ engine sees the newer action's effect.
  • Newer remove over older inserting action β†’ engine sees no override.

Older overrides still apply outside the newer one's window. "Newer" = greater requested_at; painting direction (Β§4.2) is implementation, semantic is always "most recent intent wins."

6.2 No engine-time rejection of OverrideGroups

The engine runs asynchronously (Β§7). It doesn't reject anything β€” once a group is stored, it's authoritative. If a recompute fails (engine non-convergence triggered by some other change, or an exception), the recompute request row flips to EnrollmentGroupRecomputeRequestStatus.failed. The OverrideGroup row stays untouched: intent (the override) is decoupled from execution outcome (the recompute). Same separation we already have for employment/contract/profile triggers.

7. Submit, modify, remove

OverrideGroups are immutable rows; every change is a new group. Modify and remove ride on the painting algorithm from Β§4.

7.1 Submit

OverrideService.submit(...) is the only write path:

  1. Run all submit-time validation (Β§6). On failure, return 400; no row written.
  2. Persist the OverrideGroup row.
  3. Publish OverrideGroupCreated to the events pipeline.
  4. Return the created group to the caller.

That's it β€” the endpoint does not run the engine. The OverrideGroupCreated event is picked up by a core_enrollment consumer, which creates an EnrollmentGroupRecomputeRequest with trigger.details = OverrideGroupTriggerDetails(override_group_id=…). The recompute worker then runs the engine. Mirrors the way profile / employment / contract updates trigger recomputes today.

Concurrency: no last_seen_revision_number check on submit. Since the endpoint never runs the engine, there's no revision to check against. Multiple operators submitting concurrent OverrideGroups is fine β€” painting resolves at engine time, newest wins.

7.2 Modify and remove via the timeline

No modify API. Every change is a new OverrideGroup whose overrides share a (member, target) with a newer requested_at. Painting (Β§4) makes the newer one win wherever it overlaps; the older stays on the timeline and fills gaps.

Examples (same target for Alice):

Older New OverrideGroup submitted now Net engine behavior
apply Jan–Dec apply Jul–Dec (or force_true / force_false for an eligibility override) Older Jan–Jun; newer Jul–Dec.
apply Jan–Dec remove Apr–May Apply Jan–Mar + Jun–Dec; default Apr–May.
apply Jan–Dec remove Jul–Dec Apply Jan–Jun only (shrunk tail).
apply Jan–Dec remove Jan–Dec Default Jan–Dec (older fully painted-over).

The last row makes a prior group stop having any effect: one override with action=remove per target. Multi-override originals need multi-override cancellations.

Older groups stay in the table forever β€” true to history. Marmot's view computes "fully painted-over" as a derived filter.

7.3 Read

Engine read API: load all OverrideGroups for the (contract, primary) pair. No status filter (there is no status). Date and painted-over reasoning happens on the timeline (Β§4.2), not via a SQL today filter.

7.4 Simulate

Now that submit is fire-and-forget (no synchronous engine), simulate has its own ephemeral engine path β€” it does NOT go through submit or the events pipeline.

Flow for simulate_addition(overrides=...):

  1. Run the same submit-time validation (Β§6) against the proposed OverrideGroup. On failure, return 400 β€” exactly like submit.
  2. Load the current EnrollmentGroup and all stored OverrideGroups for the (contract, primary).
  3. Construct the would-be OverrideGroup in memory (no DB write).
  4. Build override timelines from [stored groups + proposed group] via build_override_timelines (Β§4.2).
  5. Run compute_eligibility_timelines and run_engine exactly as the recompute handler would (Β§5).
  6. Diff the engine's output periods against the current EnrollmentGroup revision β†’ that's the simulation result.
  7. Persist the EngineRun as audit, via REQUIRES_NEW so it survives any outer rollback. The audit row carries the full trace (input_timelines, eligibility_timelines, period diffs) but no FK back to an OverrideGroup (none was stored). Same pattern as EngineRunAuditWriter in change-request simulate.
  8. Return the diff + the audit engine_run_id so Marmot can deep-link to the engine-runs inspector.

simulate_removal(override_group_ids=...) is symmetric: load the stored groups, omit the requested IDs from the input list to build_override_timelines, run the same engine path, diff, persist audit, return.

What's NOT done in simulate:

  • No OverrideGroupCreated event published.
  • No EnrollmentGroupRecomputeRequest enqueued.
  • No OverrideGroup row persisted.
  • No new EnrollmentGroup revision produced.

The audit EngineRun is the only durable artifact. Everything else is in-memory and discarded after the response.

Concurrency: no last_seen_revision_number check on simulate (same as submit β€” there's no revision to check against). Two operators can simulate concurrently against the same group; each gets its own audit row.

8. Public API sketch

class OverrideService:
    def submit(self, *, enrollment_group_id | (contract_id, primary_member_id),
               overrides: list[ConstraintOverride | EligibilityOverride],
               requester_comment: str,
               requested_by_global_user_id: GlobalUserId,
              ) -> OverrideGroupView: ...

    # No modify, no retract, no OCC. To change an existing override,
    # submit a new OverrideGroup on the same (member, target) with
    # newer requested_at; use action=remove to nullify or carve out
    # (Β§7.2).

    # Dry-runs (Β§7.4) β€” in-memory engine run; no DB row, no event, no
    # recompute. Persists an audit EngineRun (REQUIRES_NEW) so Marmot
    # can deep-link to the trace.
    def simulate_addition(self, *, ..., overrides) -> OverrideSimulationView: ...
    def simulate_removal(self, *, override_group_ids: list[UUID]) -> OverrideSimulationView: ...

    # OverrideSimulationView: { diff: list[PeriodDiff], audit_engine_run_id: UUID }

    # Reads
    def list_for_enrollment_group(self, *, enrollment_group_id) -> list[OverrideGroupView]: ...
    def get(self, *, override_group_id) -> OverrideGroupView: ...

9. Domain, repository, storage

One new table; overrides serialized as JSONB on that row. No child table for individual overrides, no join table to EngineRun, no new field on EngineRun.

9.1 Domain entity

Specified in Β§3. Lives in internal/domain/entities.py. Discriminated unions on the overrides list and on action serialize cleanly into JSONB.

9.2 Domain repository protocol

# internal/domain/repository.py

class OverrideGroupRepository(Protocol):
    def get(self, id: UUID) -> OverrideGroup | None: ...
    def save(self, entity: OverrideGroup) -> None: ...
    def get_for_contract_and_primary(
        self,
        contract_id: ContractId,
        primary_member_id: GlobalUserId,
    ) -> list[OverrideGroup]: ...
    def flush_events(self) -> list[DomainEvent]: ...

Recompute handler calls get_for_contract_and_primary to load the override set fed into Β§4.2's painting. No status filter β€” every stored group is authoritative.

9.3 ORM model β€” one new table

# internal/models/override_group.py

class OverrideGroup(BaseModel):
    __tablename__ = "enrollment_group_override"

    contract_id: Mapped[uuid.UUID]
    primary_global_user_id: Mapped[str]

    overrides: Mapped[list[ConstraintOverride | EligibilityOverride]] = mapped_column(
        PydanticJSONB(list[ConstraintOverride | EligibilityOverride]),
        nullable=False,
    )

    requested_at: Mapped[datetime]
    requested_by_global_user_id: Mapped[str]
    requester_comment: Mapped[str]   # required, non-empty (see Β§3)

    enrollment_group: Mapped["EnrollmentGroup"] = relationship(
        "EnrollmentGroup",
        primaryjoin=(
            "and_(OverrideGroup.contract_id == EnrollmentGroup.contract_id, "
            "OverrideGroup.primary_global_user_id == EnrollmentGroup.primary_global_user_id)"
        ),
        foreign_keys=[contract_id, primary_global_user_id],
        viewonly=True,
    )

    __table_args__ = (
        Index(
            "override_group_enrollment_lookup_idx",
            "contract_id", "primary_global_user_id",
            unique=False,
        ),
        {"schema": CORE_ENROLLMENT_SCHEMA_NAME},
    )

Notes:

  • No FK to EnrollmentGroup.id β€” an override group can predate the enrollment group. Lookup via (contract_id, primary_global_user_id); enrollment_group relationship is viewonly=True.
  • No status / rejection_reason / latest_engine_run_id / last_seen_revision_number columns. Submit either persists the row cleanly or returns 400 with no row.

9.4 No join table to EngineRun

The recompute handler that runs the engine for an override knows which OverrideGroup triggered it via OverrideGroupTriggerDetails on the recompute request. From a revision row in the history view, we walk revision β†’ engine_run β†’ recompute_request β†’ trigger.details .override_group_id β†’ OverrideGroup. Same pattern as employment/contract triggers.

9.5 Events pipeline

A new stdlib event schema and a core_enrollment consumer:

# stdlib/python/src/events_pipeline_common/schemas/enrollment_group_overrides.py
@events_pipeline_schema
class OverrideGroupCreated(EventSchema):
    override_group_id: UUID
    contract_id: ContractId
    primary_global_user_id: str

# stream identifier β€” added to StreamName StrEnum.
# StreamName.ENROLLMENT_GROUP_OVERRIDES
# internal/events_pipeline_consumers.py
@events_pipeline_consumer(
    StreamName.ENROLLMENT_GROUP_OVERRIDES,
    name="core_enrollment:override_group_recompute",
    event_types=[OverrideGroupCreated],
)
def handle_override_group_created(
    event: OverrideGroupCreated,
    context: EventContext,
    session: Session,
) -> None:
    # Build a recompute request with OverrideGroupTriggerDetails;
    # the existing recompute worker picks it up and runs the engine.
    ...

A new variant on RecomputeTriggerDetails:

class OverrideGroupTriggerDetails(BaseModel):
    trigger_type: Literal["override_group"] = "override_group"
    override_group_id: UUID

9.6 Infrastructure repository

# internal/infrastructure/override_group_repository.py

class OverrideGroupMapper:
    @staticmethod
    def to_entity(model: OverrideGroupModel, *, enrollment_group_id: UUID | None) -> OverrideGroup: ...
    @staticmethod
    def to_model(entity: OverrideGroup, *, resolver_fn: ...) -> OverrideGroupModel: ...


class SQLAlchemyOverrideGroupRepository(OverrideGroupRepository):
    def __init__(self, session: Session) -> None: ...
    def get(self, id: UUID) -> OverrideGroup | None: ...
    def save(self, entity: OverrideGroup) -> None: ...
    def get_for_contract_and_primary(
        self, contract_id: ContractId, primary_member_id: GlobalUserId,
    ) -> list[OverrideGroup]: ...
    def flush_events(self) -> list[DomainEvent]: ...

save uses session.merge() + session.flush().

9.7 UnitOfWork wiring

Add override_group_repository_factory to internal/infrastructure/unit_of_work.py alongside the existing factories.

9.8 Source-code annotations for soft-coupled identifiers

Add comments on the identifiers overrides reference by name, so future maintainers know they're load-bearing:

  • ModuleConstraint.name β€” overrides reference constraints by this string; renaming requires coordinating with stored OverrideGroups.
  • Expression.name and Expression.scope β€” eligibility overrides reference sub-expressions by (scope, name).
  • stable_plan_module_slug β€” already documented as cross-revision stable; add an "also: referenced by OverrideGroup.module_slugs" line.

9.9 Summary β€” what's new

Object Kind Notes
OverrideGroup Domain entity Pydantic, in internal/domain/entities.py. No status.
MemberScope, ConstraintOverride, EligibilityOverride Pydantic value objects Same file; serialize into JSONB.
ConstraintAction, EligibilityAction StrEnums Parameterless action tags; same file.
OverrideGroupRepository Domain Protocol internal/domain/repository.py.
OverrideGroup ORM One new table enrollment_group_override, JSONB list of overrides.
OverrideGroupCreated event + consumer Events pipeline Async trigger of recompute.
OverrideGroupTriggerDetails New variant of RecomputeTriggerDetails Mirrors existing trigger details types.
SQLAlchemyOverrideGroupRepository + mapper Infrastructure One mapper + one repo.
Comments on ModuleConstraint.name, Expression.name/scope, stable_plan_module_slug Source-code annotation Flags weak references.

No new schema beyond the single table, no child table, no join table, no new field on EngineRun.

10. Resolved with team

# Question Decision
1 Override kinds Constraint-disable + eligibility-scope-override only.
2 Input-value overrides Dropped.
3 Targeting shape Explicit members AND/OR membership-type wildcard; union semantics.
4 Module selector module_slug (= stable_plan_module_slug); no module_id.
5 Constraint scoping constraint_name always; optional service_types + module_slugs.
6 Eligibility scope addressing (scope_path, name) from the Expression layer.
7 Lifecycle No status on the row. Submit either persists (and publishes the OverrideGroupCreated event) or returns 400. Engine runs asynchronously via the events pipeline + recompute, like profile/employment/contract updates.
7b Modify / shrink / remove / replace No modify or retract API. Submit a new OverrideGroup on the same (member, target) with newer requested_at; painting resolves (Β§4); action=remove nullifies windows.
7c Override typing (Diaoul's shape) Two override classes β€” ConstraintOverride (action: ConstraintAction = apply / remove) and EligibilityOverride (action: EligibilityAction = force_true / force_false / remove). Both share MemberScope and window.
7d Audit / diagnostics Submit-time validation failures = 400 (no row). Engine-time diagnostics (target name not present in some versions, post-amendment expansion, etc.) go to structured logging β€” not a new table or column.
7e OCC / last_seen_revision_number Dropped. Endpoint doesn't touch revisions β€” no concurrent-revision concern.
8 Per-member vs cross-member constraints Tag each ModuleConstraint subclass with cross_member: ClassVar[bool]. Submit-time: disallow wildcard / all_members targeting on cross-member constraints β€” explicit member listing required. Engine-time: per-version dispatch, robust to amendments that flip type (Β§5.1.1).
9 Trace attribution Overrides are upstream of engine. Attribution = simulate-diff. Engine-time diagnostics go to structured logging, not a stored audit field.
10 Auto-expiry Dropped; timeline arithmetic at read time is enough β€” no "today" reasoning.
11 Component placement Inside core_enrollment.
12 Contract-wide Never. Cut a new contract version instead.
13 Weak references by name Accept. constraint.name, Expression.scope/name, stable_plan_module_slug referenced by overrides. Mitigation: source-code comments on those fields + submit-time resolution check at start_date.

11. Marmot UX

11.1 Operator intents β†’ OverrideGroup shapes

The operator never picks an action variant directly. UI maps intents to OverrideGroup shapes.

Intent Affordance What the system submits
Cancel a stored group entirely "Cancel" button + required reason A new OverrideGroup with one action=remove override per override of the original.
Change anything else (window, action, members) on an existing override "Edit" button on the stored group β†’ pre-filled form, target locked A new OverrideGroup whose overrides are computed by diffing the operator's edits against the effective current state (Β§11.3). 1..N overrides.
Create from scratch "Add new override" β†’ empty form. Operator can stack multiple overrides ("+ Add override") for an atomic submit. A new OverrideGroup with 1..N overrides, each an inserting action (apply / force_true / force_false). action=remove from scratch is allowed but unusual.
Dry-run before submit "Simulate" on either form Engine dry-run; shows the enrollment-period diff the engine would produce. Available on both Add and Edit.
See the paint-over impact on the override timeline before submit Timeline preview panel Edit form only. Shows (member, target) coverage before vs after. Add doesn't need it.

11.1.1 Add vs Edit β€” what differs

Add new override Edit existing override
Target Editable Locked (changing target = a different override; do that via Cancel + Add)
Members / window / action Editable Editable
Action picker Visible (Remove rare for a fresh group) Hidden β€” derived from interval diff
Multiple overrides Operator stacks via "+ Add override" Generated automatically by the diff
Timeline preview panel Not shown β€” nothing to diff against Shown
Engine simulate Available Available
Comment Required Required

11.2 Form layout

[Start date] [End date β€” optional, defaults to open-ended]
                    β”‚
                    β–Ό
[Override type]  Constraint / Eligibility
                    β”‚
                    β–Ό   (picker scoped to contract version at start_date)
[Pick a target]
  - Constraint: dropdown of constraint names in that version.
  - Eligibility: flat list of selectable nodes, grouped by module,
    sorted by (scope_path, name). Includes the root, scope-only
    nodes, and (scope, name) nodes.
  - Annotation next to each: "Effective Jan 1 – Mar 31" if it
    doesn't span the full window (display only, not an input).
                    β”‚
                    β–Ό
[Optional further-scoping: service_types / module_slugs]
                    β”‚
                    β–Ό
[Members]
  - Cross-member constraint β†’ only explicit member listing is allowed
    (Β§5.1.1 submit-time rule). Wildcard / "all members" controls are
    disabled. Listing one family member is enough; the engine derives
    the family at run time.
  - Otherwise: explicit list AND/OR membership-type wildcards
    AND/OR "all members" β€” see Β§3.1 targeting table.
                    β”‚
                    β–Ό
[Action]
  - Constraint override β†’ `apply` / `remove`
  - Eligibility override β†’ `force_true` / `force_false` / `remove`
  (skipped for "Edit dates" flow β€” derived from interval diff;
   see Β§11.3)
                    β”‚
                    β–Ό
[Comment β€” required]
                    β–Ό
[Simulate]   [Submit]

11.3 The "Edit dates" flow

Behind the scenes, narrowing or extending a window may require both inserting and Remove overrides in one group. The operator never sees this β€” they edit a date range, the backend diffs.

Let current = effective intervals on (member, target) timeline, new = operator's input intervals. Then:

to_insert = new βˆ’ current   # newly covered β‡’ action ∈ {apply, force_true, force_false}
to_remove = current βˆ’ new   # no longer covered β‡’ action = remove

Each interval-diff segment becomes one override in the new OverrideGroup. By construction they don't overlap each other, so the within-group overlap check passes.

The form shows a preview panel before submit:

Currently effective:  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ        β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
                      Jan---Mar      Jun------Dec

New effective range:  [start β–Ό] [end β–Ό]

Preview: β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
         Jan---------------------------------Dec
This will:
 β€’ Cover Apr-May (was previously cancelled)
 β€’ No coverage removed

[Simulate]  [Submit]

11.4 List view and painted-over filter

The existing Marmot overrides toggle, adapted to the new shape. Shows every stored OverrideGroup on the enrollment group. Painted-over status is derived from the timeline:

An OverrideGroup is fully painted-over when, for every override it contains, none of its (member, target) segments wins any portion of the painted timeline.

Default: hide fully painted-over groups; toggle to include them. When shown, each painted-over row links to the newer groups covering its overrides. No "today" reasoning β€” pure interval-set arithmetic.

11.5 Revision history view

Overrides show up in Marmot's revision history through the recompute trigger path β€” same as employment changes, contract amendments, and profile updates. They are NOT siblings of change requests in the history view; they're just another RecomputeTriggerDetails variant.

How the history view picks them up:

  • Revision β†’ engine_run β†’ recompute_request β†’ trigger.details. When the trigger is OverrideGroupTriggerDetails, the row resolves override_group_id and shows the corresponding OverrideGroup.
  • The row displays the override group's requester_comment, requested-by, requested-at, and a summary of the overrides inside. Click-through to the group detail view.
  • A revision caused by a paint-over group (a cancellation or shrink) shows the same way β€” operators see "this revision happened because override group #4521 cancelled #4498."
  • No new infra needed: the history view already discriminates on RecomputeTriggerDetails; OverrideGroupTriggerDetails is one more variant. UI-wise it's a new card type β€” same shape as the existing employment/contract trigger cards, populated from the OverrideGroup lookup.

11.6 Audit / "why was this cancelled"

Cancellations are new OverrideGroups whose overrides have action=remove; their required requester_comment carries the "why." Linking a cancelled group to its canceller(s) = "newest override painting over each of this group's overrides." Computed at read time; surfaced in list-view "show painted-over" mode and the detail view.

12. Open

All earlier open questions have been resolved through team review.