Skip to main content

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-I1tenantId is required and equals every nested question's implicit tenant scope
QB-I2questions.length ≥ 1 when state === 'published'
QB-I3Every QuestionId is unique within the bank
QB-I4gradingRule.passThreshold ∈ [0, 1]
QB-I5Sum of question.weight > 0
QB-I6If poolConfig.sampleSize set, it ≤ questions.length
QB-I7Only questions marked correct can have isCorrect = true; at least one correct answer per MCQ/MultiSelect
QB-I8Published banks are append-only: you may add questions or mark them inactive, never mutate existing question semantics without bumping courseVersionId usage
QB-I9If 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

  • QuizBankCreated
  • QuizBankPublished
  • QuizBankUpdated (summary of changes)
  • QuestionAdded
  • QuestionUpdated
  • QuestionDeactivated
  • QuizBankArchived

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-I1rootNodeId must exist in nodes
BS-I2Every nextNodeId on a non-terminal choice must reference an existing node
BS-I3Graph is a DAG (no cycles — cycle detection on publish)
BS-I4Every leaf node must have isTerminal = true and a terminalOutcome
BS-I5At least one path from rootNodeId reaches a terminal with classification = 'pass'
BS-I6Node IDs are unique within scenario
BS-I7Published scenarios are append-only (new nodes/edges OK; existing semantics locked)
BS-I8scoring.passThreshold ∈ [0, 1]
BS-I9If 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

  • ScenarioCreated
  • ScenarioPublished
  • ScenarioNodeAdded
  • ScenarioNodeUpdated
  • ScenarioArchived

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-I1Exactly one of quizBankId or scenarioId must be set
AR-I2scaledScore = rawScore / maxScore, rounded to 4 decimals
AR-I3passed = scaledScore >= gradingRule.passThreshold
AR-I4Once state = 'final', aggregate is immutable (no further mutations permitted)
AR-I5If any response has correct === 'pending', state must be 'pending_human_review'
AR-I6regradeOf must reference a different AttemptId in same tenant
AR-I7durationSeconds >= Σ responses[i].durationSeconds within ±1s tolerance (clock skew)
AR-I8scoringMode === '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 reaches state = 'final'
  • AttemptPendingHumanReview — when entering 'pending_human_review'
  • AttemptSuperseded — when a regrade supersedes
  • ScoreMismatchDetected — when ScoreReconciliation.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 using tenantKey.
  • AIRubricBroker — ports AI grading requests to ai-gateway and maps results back to Response entities.

8. Relationship to other bounded contexts

Other contextRelationshipMechanism
AuthoringCustomer-Supplier (authoring is upstream)authoring.block.added.v1 with kind: 'quiz' triggers quiz-bank creation/linkage
DeliveryCustomer/quiz-banks/{id}/questions (read) + /attempts/{id}/submit-response (write)
Progress (LRS)Supplierpublishes attempt_result.scored.v1 for xAPI statement generation
CertificationSupplier (indirect)via Progress → completion.recorded.v1
AI GatewayConformist (we conform to its port)AIClient adapter
SyncPartnerRegisters QuizBank and AttemptResult for offline replication

9. Aggregate boundaries & transactional rules

  • A single command operates on exactly one aggregate.
  • A ScoreAttempt command creates one AttemptResult; it may read a QuizBank or BranchingScenario but 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: number incremented on every mutation; used for optimistic concurrency (If-Match).
  • Published QuizBank and BranchingScenario bind to a CourseVersionId; a new course version creates new bindings but does not mutate history.
  • AttemptResult records courseVersionId so a later re-grade against a newer bank version is always traceable.

11. References