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
activestate. dynamic_grouptargets 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 → activerequires: ≥1 target, valid RRULE (ornull), courseVersionPolicy resolvable, escalation.steps non-empty OR escalation.steps=[]+reminderPolicy.enabled=true.active → pausedhalts new window materialization but preserves existing windows.- Cannot transition
archivedback 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):
| From | To | Trigger | Side effect |
|---|---|---|---|
| — | open | RRULE materializer cron | publish assignment.window.opened.v1 |
open | in_progress | enrollment.created.v1 matches | attach enrollmentId |
in_progress | completed | progress.completion.recorded.v1 w/ pass | publish .completed.v1 |
open/in_progress | overdue | wall-clock > dueAt | fire escalation step(s), publish .overdue.v1 |
overdue | completed | progress.completion.recorded.v1 w/ pass within grace | publish .completed.v1 (late flag) |
overdue | closed_missed | wall-clock > graceUntil | publish .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.pinnedVersionId↔courseVersionPolicy === 'pin'.ComplianceWindow.graceUntil >= dueAt >= occurrenceStart.- Per (assignmentId, userId, occurrenceStart) uniqueness — prevents duplicate windows.
5.2 Business invariants (enforced by sagas and handlers)
- A
completedwindow never reopens. - Grace-period completions set
completedbut carrylate=truein the published event. - Windows only materialise for users who are active members of the target at
occurrenceStarttime. - When
Assignment.statemoves topaused, existing windows continue their lifecycle but no new windows are materialised. - When
Assignment.statemoves toarchived, allopenwindows are force-closed with reasonassignment_archived.
6. Domain Events (produced)
See EVENT_SCHEMAS.md for wire contracts.
| Event | Emitted when |
|---|---|
assignment.created.v1 | Assignment aggregate persisted (state=draft) |
assignment.activated.v1 | State → active |
assignment.paused.v1 | State → paused |
assignment.archived.v1 | State → archived |
assignment.window.opened.v1 | Window materialised (new) |
assignment.window.in_progress.v1 | Window → in_progress |
assignment.window.overdue.v1 | Window → overdue |
assignment.window.completed.v1 | Window → completed |
assignment.window.closed_missed.v1 | Window → closed_missed |
assignment.escalation.triggered.v1 | EscalationStep 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
| Service | Responsibility |
|---|---|
RRULEEngine | Compute next occurrences within horizon; handles DST, TZ (tenant-scoped TZ) |
TargetResolver | Expand targets → concrete list of UserIds at a given effective time |
EscalationEvaluator | Given window + elapsed time, compute pending escalation steps |
ReminderPlanner | Produce reminder dispatch plan for a window |
AIAssignmentSuggester | Call AI Gateway to propose assignments from tenant context (S5) |
9. Anti-Corruption Layers
- From Tenant —
DynamicGroupSnapshotVO is our stable projection of tenant-service's group state. We never query live; we consumetenant.dynamic_group.evaluated.v1. - From Progress — only
progress.completion.recorded.v1is consumed. Partial progress,started, etc. are ignored. - From Catalog —
CoursePublishedSnapshotprojection (courseId → latestVersionId) used forcourseVersionPolicy='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.