Skip to main content

Domain Model

:::info Source Sourced from services/assignment-service/DOMAIN_MODEL.md in the documentation repo. :::

Companion: SERVICE_OVERVIEW · 12 Data Models · 02 DDD & Bounded Contexts


1. Aggregate Map

Assignment (aggregate root)
├── id: AssignmentId
├── tenantId: TenantId
├── createdBy: UserId
├── title: I18nString
├── description?: I18nString
├── courseId: CourseId
├── courseVersionPolicy: 'pin' | 'latest'
├── pinnedVersionId?: CourseVersionId // required iff policy='pin'
├── targets: AssignmentTarget[] // union of users, org units, dynamic groups
├── rrule?: RRULEString // null = one-shot assignment
├── startDate: ISODate
├── dueOffset: ISODuration // e.g. "P30D" — 30 days from occurrenceStart
├── gracePeriod: ISODuration // e.g. "P7D" — 7 days after due
├── escalation: EscalationPolicy
├── reminderPolicy: ReminderPolicy
├── state: AssignmentState // draft|active|paused|archived
├── aiSuggested?: boolean
├── aiProvenance?: AIProvenance // if aiSuggested=true
├── createdAt: ISODate
├── updatedAt: ISODate
└── activatedAt?: ISODate

ComplianceWindow (aggregate root)
├── id: ULID
├── tenantId: TenantId
├── assignmentId: AssignmentId
├── userId: UserId
├── occurrenceStart: ISODate
├── dueAt: ISODate
├── graceUntil: ISODate
├── state: WindowState // open|in_progress|completed|overdue|closed_missed
├── enrollmentId?: EnrollmentId
├── completedAt?: ISODate
├── overdueAt?: ISODate
├── closedAt?: ISODate
├── escalationLevel: number // 0 = none fired yet
├── remindersSent: number
└── lastReminderAt?: ISODate

2. Branded Types

type AssignmentId = Branded<string, 'AssignmentId'>;
type ComplianceWindowId = Branded<string, 'ComplianceWindowId'>; // ULID
type EscalationPolicyId = Branded<string, 'EscalationPolicyId'>;

3. Value Objects

3.1 AssignmentTarget

type AssignmentTarget =
| { kind: 'user'; userId: UserId }
| { kind: 'org_unit'; orgUnitId: OrgUnitId; includeDescendants: boolean }
| { kind: 'dynamic_group'; groupId: DynamicGroupId };

Invariants:

  • At least one target required for active state.
  • dynamic_group targets require a known group at activation time (validated against tenant-service projection).

3.2 EscalationPolicy

interface EscalationPolicy {
steps: EscalationStep[]; // evaluated in order, idempotent
maxLevel: number; // upper bound (safety)
}

interface EscalationStep {
level: number; // 1,2,3...
trigger: 'on_overdue' | { afterDueOffset: ISODuration };
actions: EscalationAction[];
}

type EscalationAction =
| { kind: 'notify_user'; channel: NotificationChannel }
| { kind: 'notify_manager'; channel: NotificationChannel }
| { kind: 'notify_role'; roleId: RoleId; channel: NotificationChannel }
| { kind: 'notify_webhook'; webhookId: string }
| { kind: 'flag_compliance'; severity: 'low'|'medium'|'high'|'critical' };

3.3 ReminderPolicy

interface ReminderPolicy {
enabled: boolean;
schedule: ReminderTrigger[]; // e.g. "P-7D" (7 days before due), "P-1D", "on_due"
channel: NotificationChannel;
suppressIfInProgress: boolean; // skip if window already in_progress
}

type ReminderTrigger =
| { kind: 'relative_to_due'; offset: ISODuration } // negative offset = before
| { kind: 'on_due' }
| { kind: 'relative_to_overdue'; offset: ISODuration };

3.4 AIProvenance (shared kernel)

Reused from @ghasi/domain-primitives. Populated for aiSuggested=true assignments with prompt id, model, traceId, decisionId, and cost.

4. Entities and Aggregates Lifecycle

4.1 Assignment state machine

create()


┌────────┐ activate() ┌────────┐
│ draft ├──────────────────▶│ active │
└────────┘ └────┬───┘
│ │
│ archive() pause() │ resume()
▼ ▼ │
┌────────┐ ┌────────┐◀─┘
│archived│ │ paused │
└────────┘ └────────┘

│ archive()

┌────────┐
│archived│
└────────┘

Invariants per transition:

  • draft → active requires: ≥1 target, valid RRULE (or null), courseVersionPolicy resolvable, escalation.steps non-empty OR escalation.steps=[]+reminderPolicy.enabled=true.
  • active → paused halts new window materialization but preserves existing windows.
  • Cannot transition archived back to any other state.

4.2 ComplianceWindow state machine (saga)

RRULE materializer


┌──────────┐ enrollment.created.v1 ┌─────────────┐
│ open ├─────────────────────────────▶│ in_progress │
└────┬─────┘ └──────┬──────┘
│ │
│ time > dueAt │ progress.completion
▼ │ .recorded.v1 (pass)
┌──────────┐ ▼
│ overdue │ ┌─────────────┐
└────┬─────┘ │ completed │
│ └─────────────┘
│ time > graceUntil
▼ ▲
┌──────────────┐ │
│closed_missed │ │
└──────────────┘ progress.completion │
.recorded.v1 (pass) on │
overdue within grace ───────┘

Transition rules (exhaustive):

FromToTriggerSide effect
openRRULE materializer cronpublish assignment.window.opened.v1
openin_progressenrollment.created.v1 matchesattach enrollmentId
in_progresscompletedprogress.completion.recorded.v1 w/ passpublish .completed.v1
open/in_progressoverduewall-clock > dueAtfire escalation step(s), publish .overdue.v1
overduecompletedprogress.completion.recorded.v1 w/ pass within gracepublish .completed.v1 (late flag)
overdueclosed_missedwall-clock > graceUntilpublish .closed_missed.v1

Terminal states: completed, closed_missed. They never leave.

5. Invariants

5.1 Aggregate invariants (enforced on write)

  • Assignment.rrule — if present, must be RFC 5545-conformant and cap at 365 days or 200 occurrences (safety).
  • Assignment.dueOffset > PT0S (must be strictly positive).
  • Assignment.gracePeriod >= PT0S.
  • Assignment.pinnedVersionIdcourseVersionPolicy === 'pin'.
  • ComplianceWindow.graceUntil >= dueAt >= occurrenceStart.
  • Per (assignmentId, userId, occurrenceStart) uniqueness — prevents duplicate windows.

5.2 Business invariants (enforced by sagas and handlers)

  • A completed window never reopens.
  • Grace-period completions set completed but carry late=true in the published event.
  • Windows only materialise for users who are active members of the target at occurrenceStart time.
  • When Assignment.state moves to paused, existing windows continue their lifecycle but no new windows are materialised.
  • When Assignment.state moves to archived, all open windows are force-closed with reason assignment_archived.

6. Domain Events (produced)

See EVENT_SCHEMAS.md for wire contracts.

EventEmitted when
assignment.created.v1Assignment aggregate persisted (state=draft)
assignment.activated.v1State → active
assignment.paused.v1State → paused
assignment.archived.v1State → archived
assignment.window.opened.v1Window materialised (new)
assignment.window.in_progress.v1Window → in_progress
assignment.window.overdue.v1Window → overdue
assignment.window.completed.v1Window → completed
assignment.window.closed_missed.v1Window → closed_missed
assignment.escalation.triggered.v1EscalationStep fired (per action)

7. Repository interfaces

interface AssignmentRepository {
findById(id: AssignmentId, tenantId: TenantId): Promise<Assignment | null>;
save(a: Assignment): Promise<void>;
listByTenant(tenantId: TenantId, filter: AssignmentFilter): Promise<Page<Assignment>>;
listActiveWithRRULE(now: Date): AsyncIterable<Assignment>; // for materializer
}

interface ComplianceWindowRepository {
findById(id: ComplianceWindowId, tenantId: TenantId): Promise<ComplianceWindow | null>;
findBy(assignmentId: AssignmentId, userId: UserId, occ: ISODate): Promise<ComplianceWindow | null>;
save(w: ComplianceWindow): Promise<void>;
insertMany(ws: ComplianceWindow[]): Promise<void>; // bulk materialize
listOpen(now: Date, limit: number): AsyncIterable<ComplianceWindow>; // overdue sweeper
listByAssignment(assignmentId: AssignmentId, filter: WindowFilter): Promise<Page<ComplianceWindow>>;
}

8. Domain Services

ServiceResponsibility
RRULEEngineCompute next occurrences within horizon; handles DST, TZ (tenant-scoped TZ)
TargetResolverExpand targets → concrete list of UserIds at a given effective time
EscalationEvaluatorGiven window + elapsed time, compute pending escalation steps
ReminderPlannerProduce reminder dispatch plan for a window
AIAssignmentSuggesterCall AI Gateway to propose assignments from tenant context (S5)

9. Anti-Corruption Layers

  • From TenantDynamicGroupSnapshot VO is our stable projection of tenant-service's group state. We never query live; we consume tenant.dynamic_group.evaluated.v1.
  • From Progress — only progress.completion.recorded.v1 is consumed. Partial progress, started, etc. are ignored.
  • From CatalogCoursePublishedSnapshot projection (courseId → latestVersionId) used for courseVersionPolicy='latest'.

10. Explicit Non-Model

  • EnrollmentRecord — lives in enrollment-service. We only know its EnrollmentId.
  • LearningProgress — not our concern.
  • Notification channels / templates — defined in notification-service.