Domain Model
:::info Source
Sourced from services/assessment-service/DOMAIN_MODEL.md in the documentation repo.
:::
Bounded context: Assessment (Supporting) Companion: SERVICE_OVERVIEW.md · APPLICATION_LOGIC.md · DATA_MODEL.md
This is the pure domain — no framework imports, no infrastructure leakage. It describes aggregates, entities, value objects, invariants, and domain events.
1. Domain map
┌─────────────────────────────────────────────────────────────────┐
│ Assessment Context │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ │
│ │ QuizBank │ │ BranchingScenario│ │ AttemptResult│ │
│ │ (aggregate) │ │ (aggregate) │ │ (aggregate) │ │
│ ├─────────────┤ ├──────────────────┤ ├─────────────┤ │
│ │ Question[] │ │ ScenarioNode[] │ │ Response[] │ │
│ │ GradingRule │ │ ScenarioScoring │ │ Scoring │ │
│ └─────────────┘ └──────────────────┘ └─────────────┘ │
│ │
│ ◄── references via BlockRef from authoring ── │
└─────────────────────────────────────────────────────────────────┘
Three aggregate roots: QuizBank, BranchingScenario, AttemptResult. All entities and value objects are immutable TS types; state transitions construct new instances.
2. Common primitives
// Branded identifiers (nominal typing)
type QuizBankId = Branded<string, 'QuizBankId'>; // ULID
type QuestionId = Branded<string, 'QuestionId'>; // ULID
type ScenarioId = Branded<string, 'ScenarioId'>; // ULID
type AttemptId = Branded<string, 'AttemptId'>; // ULID (owned by delivery-service; referenced here)
type TenantId = Branded<string, 'TenantId'>;
type UserId = Branded<string, 'UserId'>;
type CourseVersionId = Branded<string, 'CourseVersionId'>;
type I18nString = Record<Locale, string>; // e.g., { 'en-US': 'What is…', 'ar-SA': 'ما هو…' }
type ULID = string;
type ISODate = string;
interface AIProvenance {
model: string;
version?: string;
promptId?: string;
promptVersion?: SemVer;
traceId: string;
decisionId?: string; // HITL acceptance record
local: boolean; // false — AI always remote via gateway
generatedAt: ISODate;
reviewedBy?: UserId;
reviewedAt?: ISODate;
confidence?: number; // 0..1 — for graded responses
cost?: { microUSD: number; tokens: { in: number; out: number } };
}
3. Aggregate: QuizBank
3.1 Structure
interface QuizBank {
readonly id: QuizBankId;
readonly tenantId: TenantId;
readonly title: I18nString;
readonly description?: I18nString;
readonly questions: ReadonlyArray<Question>;
readonly gradingRule: GradingRule;
readonly poolConfig?: PoolConfig; // how to sample for serving
readonly timeLimit?: DurationSeconds; // optional attempt-wide limit
readonly state: 'draft' | 'published' | 'archived';
readonly version: number; // bumped on every mutation
readonly createdBy: UserId;
readonly createdAt: ISODate;
readonly updatedAt: ISODate;
readonly aiProvenance?: AIProvenance; // if bank was AI-drafted
}
type DurationSeconds = number; // positive integer
3.2 Invariants (enforced in constructor / with* methods)
| # | Invariant |
|---|---|
| QB-I1 | tenantId is required and equals every nested question's implicit tenant scope |
| QB-I2 | questions.length ≥ 1 when state === 'published' |
| QB-I3 | Every QuestionId is unique within the bank |
| QB-I4 | gradingRule.passThreshold ∈ [0, 1] |
| QB-I5 | Sum of question.weight > 0 |
| QB-I6 | If poolConfig.sampleSize set, it ≤ questions.length |
| QB-I7 | Only questions marked correct can have isCorrect = true; at least one correct answer per MCQ/MultiSelect |
| QB-I8 | Published banks are append-only: you may add questions or mark them inactive, never mutate existing question semantics without bumping courseVersionId usage |
| QB-I9 | If aiProvenance is set and state === 'published', aiProvenance.reviewedBy must be set (HITL enforced) |
3.3 Questions (entity discriminated union)
type Question =
| MCQ
| MultiSelect
| TrueFalse
| ShortAnswer
| NumericAnswer
| OrderingQuestion
| MatchingQuestion
| Hotspot
| DragDropClassify
| Likert;
interface QuestionBase {
readonly id: QuestionId;
readonly kind: Question['kind'];
readonly prompt: I18nString;
readonly explanation?: I18nString;
readonly media?: ReadonlyArray<MediaRef>;
readonly tags: ReadonlyArray<string>;
readonly weight: number; // relative weight in bank (must be > 0)
readonly difficulty?: 'easy' | 'medium' | 'hard';
readonly active: boolean; // soft-delete
readonly aiProvenance?: AIProvenance;
}
interface MCQ extends QuestionBase {
kind: 'mcq';
options: ReadonlyArray<MCQOption>;
shuffle: boolean;
}
interface MCQOption { id: string; text: I18nString; isCorrect: boolean; feedback?: I18nString; }
interface MultiSelect extends QuestionBase {
kind: 'multi_select';
options: ReadonlyArray<MCQOption>;
shuffle: boolean;
minCorrect: number; maxCorrect: number;
partialCredit: 'none' | 'proportional' | 'all_or_nothing';
}
interface TrueFalse extends QuestionBase { kind: 'true_false'; correct: boolean; }
interface ShortAnswer extends QuestionBase {
kind: 'short_answer';
acceptedAnswers: ReadonlyArray<string>; // case-insensitive match list (deterministic)
regex?: string; // optional regex match
rubric?: Rubric; // enables AI grading
maxLength: number;
}
interface NumericAnswer extends QuestionBase {
kind: 'numeric';
expected: number;
tolerance: number; // absolute
unit?: string;
}
interface OrderingQuestion extends QuestionBase {
kind: 'ordering';
items: ReadonlyArray<{ id: string; label: I18nString; correctIndex: number }>;
partialCredit: 'none' | 'kendall_tau';
}
interface MatchingQuestion extends QuestionBase {
kind: 'matching';
pairs: ReadonlyArray<{ leftId: string; left: I18nString; rightId: string; right: I18nString }>;
distractors?: ReadonlyArray<{ id: string; label: I18nString }>;
partialCredit: 'proportional' | 'all_or_nothing';
}
interface Hotspot extends QuestionBase {
kind: 'hotspot';
imageAssetId: string;
targets: ReadonlyArray<{ id: string; polygon: Array<[number, number]>; isCorrect: boolean }>;
toleranceRadius: number; // in image-relative units
}
interface DragDropClassify extends QuestionBase {
kind: 'drag_drop_classify';
items: ReadonlyArray<{ id: string; label: I18nString; correctBucketId: string }>;
buckets: ReadonlyArray<{ id: string; label: I18nString }>;
partialCredit: 'proportional' | 'all_or_nothing';
}
interface Likert extends QuestionBase {
kind: 'likert';
scale: ReadonlyArray<{ id: string; label: I18nString; value: number }>;
reverseCoded: boolean;
// No "correct" — used for surveys; weight = 0 or scored via rubric externally.
}
interface Rubric {
criteria: ReadonlyArray<{
id: string;
label: I18nString;
description: I18nString;
maxPoints: number;
anchors?: ReadonlyArray<{ points: number; descriptor: I18nString }>;
}>;
totalPoints: number;
aiGradingEnabled: boolean;
humanReviewThreshold: number; // confidence below this → HITL
}
3.4 GradingRule
interface GradingRule {
readonly passThreshold: number; // 0..1; e.g., 0.8
readonly wrongPenalty?: number; // 0..1; fraction of weight subtracted per wrong answer
readonly partialCreditDefault: 'none' | 'proportional' | 'all_or_nothing';
readonly lateSubmissionPenalty?: number; // 0..1
readonly perQuestionTimeLimit?: DurationSeconds;
readonly showCorrectAnswers: 'never' | 'after_attempt' | 'after_close';
readonly maxAttempts?: number; // optional cap
}
3.5 PoolConfig (randomization)
interface PoolConfig {
readonly strategy: 'all' | 'sample' | 'stratified';
readonly sampleSize?: number;
readonly strata?: ReadonlyArray<{ tag: string; count: number }>; // for stratified
readonly seedStrategy: 'attemptId' | 'userIdAndAttemptId' | 'random';
readonly shuffleOptions: boolean;
}
3.6 Domain operations (pure functions on aggregate)
function createQuizBank(input: NewQuizBankInput): QuizBank; // enforces QB-I*
function addQuestion(qb: QuizBank, q: Question): QuizBank; // returns new qb
function updateQuestion(qb: QuizBank, id: QuestionId, patch: QuestionPatch): QuizBank;
function deactivateQuestion(qb: QuizBank, id: QuestionId): QuizBank;
function publishQuizBank(qb: QuizBank): QuizBank; // state=draft→published
function archiveQuizBank(qb: QuizBank): QuizBank; // state=*→archived
function servePresentationForm(qb: QuizBank, seed: string, locale: Locale): PresentationPayload;
3.7 Domain events emitted
QuizBankCreatedQuizBankPublishedQuizBankUpdated(summary of changes)QuestionAddedQuestionUpdatedQuestionDeactivatedQuizBankArchived
4. Aggregate: BranchingScenario
4.1 Structure
interface BranchingScenario {
readonly id: ScenarioId;
readonly tenantId: TenantId;
readonly title: I18nString;
readonly description?: I18nString;
readonly rootNodeId: string;
readonly nodes: ReadonlyArray<ScenarioNode>;
readonly scoring: ScenarioScoring;
readonly state: 'draft' | 'published' | 'archived';
readonly version: number;
readonly createdBy: UserId;
readonly createdAt: ISODate;
readonly updatedAt: ISODate;
readonly aiProvenance?: AIProvenance;
}
interface ScenarioNode {
readonly id: string;
readonly prompt: I18nString;
readonly media?: ReadonlyArray<MediaRef>;
readonly choices: ReadonlyArray<ScenarioChoice>;
readonly isTerminal: boolean;
readonly terminalOutcome?: TerminalOutcome;
readonly timeLimitSeconds?: number;
}
interface ScenarioChoice {
readonly id: string;
readonly label: I18nString;
readonly feedback?: I18nString;
readonly nextNodeId?: string; // required unless leading to terminal
readonly outcomeWeight: number; // can be negative
readonly tags?: ReadonlyArray<string>;
}
interface TerminalOutcome {
readonly classification: 'pass' | 'fail' | 'conditional';
readonly scaledScore: number; // 0..1
readonly message: I18nString;
}
interface ScenarioScoring {
readonly method: 'terminal' | 'path_weighted' | 'hybrid';
readonly passThreshold: number; // 0..1
readonly maxPathLength?: number;
readonly penaltyPerBackTrack?: number;
}
4.2 Invariants
| # | Invariant |
|---|---|
| BS-I1 | rootNodeId must exist in nodes |
| BS-I2 | Every nextNodeId on a non-terminal choice must reference an existing node |
| BS-I3 | Graph is a DAG (no cycles — cycle detection on publish) |
| BS-I4 | Every leaf node must have isTerminal = true and a terminalOutcome |
| BS-I5 | At least one path from rootNodeId reaches a terminal with classification = 'pass' |
| BS-I6 | Node IDs are unique within scenario |
| BS-I7 | Published scenarios are append-only (new nodes/edges OK; existing semantics locked) |
| BS-I8 | scoring.passThreshold ∈ [0, 1] |
| BS-I9 | If AI-drafted, published state requires aiProvenance.reviewedBy (HITL) |
4.3 Domain operations
function createScenario(input: NewScenarioInput): BranchingScenario;
function addNode(s: BranchingScenario, n: ScenarioNode): BranchingScenario;
function updateNode(s: BranchingScenario, id: string, patch: NodePatch): BranchingScenario;
function publishScenario(s: BranchingScenario): BranchingScenario; // runs BS-I1..I9 including DAG check
function navigate(s: BranchingScenario, path: ReadonlyArray<string>, choiceId: string): NavigationStep;
function scorePath(s: BranchingScenario, path: TraversedPath): ScenarioResult;
4.4 Domain events
ScenarioCreatedScenarioPublishedScenarioNodeAddedScenarioNodeUpdatedScenarioArchived
5. Aggregate: AttemptResult
5.1 Structure
interface AttemptResult {
readonly attemptId: AttemptId;
readonly tenantId: TenantId;
readonly userId: UserId;
readonly quizBankId?: QuizBankId;
readonly scenarioId?: ScenarioId;
readonly courseVersionId?: CourseVersionId;
readonly responses: ReadonlyArray<Response>;
readonly rawScore: number; // sum of weighted points earned
readonly maxScore: number; // sum of weighted points possible
readonly scaledScore: number; // rawScore / maxScore ∈ [0, 1]
readonly passed: boolean;
readonly durationSeconds: number;
readonly startedAt: ISODate;
readonly scoredAt: ISODate;
readonly scoringMode: 'deterministic' | 'ai_graded' | 'mixed';
readonly offlineScored: boolean; // true if client scored and server re-validated
readonly scoreReconciliation?: ScoreReconciliation;
readonly regradeOf?: AttemptId; // links to previous attempt result
readonly aiProvenance?: AIProvenance;
readonly state: 'final' | 'pending_human_review' | 'superseded';
}
type Response =
| MCQResponse
| MultiSelectResponse
| TrueFalseResponse
| ShortAnswerResponse
| NumericResponse
| OrderingResponse
| MatchingResponse
| HotspotResponse
| DragDropResponse
| LikertResponse
| ScenarioPathResponse;
interface ResponseBase {
questionId?: QuestionId; // absent for scenario responses
answeredAt: ISODate;
durationSeconds: number;
pointsEarned: number;
pointsPossible: number;
correct: boolean | 'partial' | 'pending';
}
interface ShortAnswerResponse extends ResponseBase {
kind: 'short_answer';
text: string;
gradedBy: 'deterministic' | 'ai' | 'human';
rubricBreakdown?: Record<string, number>;
aiConfidence?: number;
humanReviewRequired: boolean;
}
interface ScoreReconciliation {
clientScaledScore: number;
serverScaledScore: number;
diffAbs: number;
mismatch: boolean;
resolution: 'server_wins' | 'equal';
}
5.2 Invariants
| # | Invariant |
|---|---|
| AR-I1 | Exactly one of quizBankId or scenarioId must be set |
| AR-I2 | scaledScore = rawScore / maxScore, rounded to 4 decimals |
| AR-I3 | passed = scaledScore >= gradingRule.passThreshold |
| AR-I4 | Once state = 'final', aggregate is immutable (no further mutations permitted) |
| AR-I5 | If any response has correct === 'pending', state must be 'pending_human_review' |
| AR-I6 | regradeOf must reference a different AttemptId in same tenant |
| AR-I7 | durationSeconds >= Σ responses[i].durationSeconds within ±1s tolerance (clock skew) |
| AR-I8 | scoringMode === 'ai_graded' or 'mixed' ⇒ aiProvenance set |
5.3 Domain operations
function scoreAttempt(
qb: QuizBank,
responses: ReadonlyArray<Response>,
context: ScoringContext
): AttemptResult; // pure function; deterministic scoring only
function scoreScenarioPath(
s: BranchingScenario,
path: TraversedPath,
context: ScoringContext
): AttemptResult;
function recordAIGradingOutcome(
partial: AttemptResult,
qId: QuestionId,
grade: AIGradeOutcome
): AttemptResult; // transitions 'pending_human_review' → 'final' when all resolved
function reconcileOfflineScore(
serverResult: AttemptResult,
clientClaimedScaledScore: number
): AttemptResult; // produces ScoreReconciliation; server wins
5.4 Domain events
AttemptScored— when reachesstate = 'final'AttemptPendingHumanReview— when entering'pending_human_review'AttemptSuperseded— when a regrade supersedesScoreMismatchDetected— whenScoreReconciliation.mismatch === true
6. Value objects
interface TraversedPath { nodeIds: ReadonlyArray<string>; choiceIds: ReadonlyArray<string>; }
interface ScoringContext {
now: ISODate;
locale: Locale;
scoringMode: 'deterministic' | 'ai_graded' | 'mixed';
deviceFingerprint?: string;
integrityFlags?: ReadonlyArray<'nav_lock_bypassed' | 'tab_switched' | 'dev_tools_open'>;
}
interface PresentationPayload {
quizBankId: QuizBankId;
presentedQuestions: ReadonlyArray<PresentedQuestion>; // answer keys stripped
seed: string;
servedAt: ISODate;
timeLimit?: DurationSeconds;
}
7. Domain services (stateless helpers)
QuestionSerializer.toPresentationForm(q, seed)— strips correct flags, shuffles options if configured.ScoringEngine.grade(question, response, context)— returns{ pointsEarned, correct }; pure per question kind.ScenarioPathValidator.validate(scenario, path)— checks traversal integrity before scoring.AnswerKeyCipher.encrypt / .decrypt— envelope-encrypts correct flags usingtenantKey.AIRubricBroker— ports AI grading requests to ai-gateway and maps results back toResponseentities.
8. Relationship to other bounded contexts
| Other context | Relationship | Mechanism |
|---|---|---|
| Authoring | Customer-Supplier (authoring is upstream) | authoring.block.added.v1 with kind: 'quiz' triggers quiz-bank creation/linkage |
| Delivery | Customer | /quiz-banks/{id}/questions (read) + /attempts/{id}/submit-response (write) |
| Progress (LRS) | Supplier | publishes attempt_result.scored.v1 for xAPI statement generation |
| Certification | Supplier (indirect) | via Progress → completion.recorded.v1 |
| AI Gateway | Conformist (we conform to its port) | AIClient adapter |
| Sync | Partner | Registers QuizBank and AttemptResult for offline replication |
9. Aggregate boundaries & transactional rules
- A single command operates on exactly one aggregate.
- A
ScoreAttemptcommand creates oneAttemptResult; it may read aQuizBankorBranchingScenariobut never modifies them. - Cross-aggregate references are by ID; never by embedded object.
- Outbox events are written in the same Postgres transaction as the aggregate mutation.
10. Versioning strategy
- Aggregates carry
version: numberincremented on every mutation; used for optimistic concurrency (If-Match). - Published
QuizBankandBranchingScenariobind to aCourseVersionId; a new course version creates new bindings but does not mutate history. AttemptResultrecordscourseVersionIdso a later re-grade against a newer bank version is always traceable.
11. References
- Canonical spec: docs/03-microservices/assessment-service.md
- Shared primitives: docs/12-data-models.md §0
- Event envelope: docs/04-event-driven-architecture.md §4