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_durationfor 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¶
compute_eligibility_timelines(eligibilities.py) β when evaluating a module's eligibility for a member, look up the override timeline for anyEligibilityOverridematching(scope_path, name, module). Splice the forced result (perforce_true/force_false) into the per-(member, module)Timeline[DetailedEligibilityResult]before the engine consumes it.apply_constraints_to_enrollment_periods(constraints/loop.py) β before invokingmodule_constraint_handlerfor a(constraint, version)pair, look up the override timeline for matchingConstraintOverridedisables. 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
ModuleConstraintsubclass withcross_member: ClassVar[bool](class-level, not an instance field β not serialized when constraints are dumped). ConstraintOverrideshape 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=Truecombined with non-emptymembers_with_membership_types/target_membership_types.- Within-group
MemberScopeintersection on the same target over overlapping windows (Β§4.4). MemberScope.target_membership_typesnon-empty orall_members=Trueon a cross-member constraint (Β§5.1.1).- Target unresolved at
start_date: each override's identifier (constraint_nameor(scope_path, name)+ module filters) must resolve in the contract version covering itsstart_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_dateversions 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
removeover 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:
- Run all submit-time validation (Β§6). On failure, return
400; no row written. - Persist the
OverrideGrouprow. - Publish
OverrideGroupCreatedto the events pipeline. - 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=...):
- Run the same submit-time validation (Β§6) against the proposed
OverrideGroup. On failure, return400β exactly like submit. - Load the current
EnrollmentGroupand all storedOverrideGroups for the(contract, primary). - Construct the would-be
OverrideGroupin memory (no DB write). - Build override timelines from
[stored groups + proposed group]viabuild_override_timelines(Β§4.2). - Run
compute_eligibility_timelinesandrun_engineexactly as the recompute handler would (Β§5). - Diff the engine's output periods against the current
EnrollmentGrouprevision β that's the simulation result. - Persist the
EngineRunas audit, viaREQUIRES_NEWso it survives any outer rollback. The audit row carries the full trace (input_timelines, eligibility_timelines, period diffs) but no FK back to anOverrideGroup(none was stored). Same pattern asEngineRunAuditWriterin change-request simulate. - Return the diff + the audit
engine_run_idso 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
OverrideGroupCreatedevent published. - No
EnrollmentGroupRecomputeRequestenqueued. - No
OverrideGrouprow persisted. - No new
EnrollmentGrouprevision 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_grouprelationship isviewonly=True. - No
status/rejection_reason/latest_engine_run_id/last_seen_revision_numbercolumns. Submit either persists the row cleanly or returns400with 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 storedOverrideGroups.Expression.nameandExpression.scopeβ eligibility overrides reference sub-expressions by(scope, name).stable_plan_module_slugβ already documented as cross-revision stable; add an "also: referenced byOverrideGroup.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
OverrideGroupis 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 isOverrideGroupTriggerDetails, the row resolvesoverride_group_idand shows the correspondingOverrideGroup. - 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;OverrideGroupTriggerDetailsis one more variant. UI-wise it's a new card type β same shape as the existing employment/contract trigger cards, populated from theOverrideGrouplookup.
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.