Skip to main content

Application Logic

:::info Source Sourced from services/assessment-service/APPLICATION_LOGIC.md in the documentation repo. :::

Layer: Application (Use-Cases, Command/Query Handlers, Orchestration) Companion: DOMAIN_MODEL.md · API_CONTRACTS.md · EVENT_SCHEMAS.md

This doc describes the use-cases — the orchestration that sits between the HTTP/NATS inbound edge and the pure domain. Use-cases own transactions, outbox writes, port invocations, and observability spans.


1. Use-Case catalogue

IDUse-caseInvoked byAggregate touched
UC-01CreateQuizBankHTTP POST /quiz-banksQuizBank
UC-02UpdateQuizBankHTTP PATCH /quiz-banks/{id}QuizBank
UC-03AddQuestionHTTP POST /quiz-banks/{id}/questionsQuizBank
UC-04UpdateQuestionHTTP PATCH /questions/{id}QuizBank
UC-05PublishQuizBankHTTP POST /quiz-banks/{id}/publishQuizBank
UC-06ArchiveQuizBankHTTP POST /quiz-banks/{id}/archiveQuizBank
UC-07ServePresentationHTTP GET /quiz-banks/{id}/questionsQuizBank (read)
UC-08SubmitResponseHTTP POST /attempts/{id}/submit-response(buffered, no aggregate write)
UC-09ScoreAttemptHTTP POST /attempts/{id}/scoreAttemptResult
UC-10ReconcileOfflineAttemptNATS sync.offline_artifact.received.v1AttemptResult
UC-11CreateScenarioHTTP POST /branching-scenariosBranchingScenario
UC-12UpdateScenarioNodeHTTP PATCH /branching-scenarios/{id}/nodes/{nid}BranchingScenario
UC-13PublishScenarioHTTP POST /branching-scenarios/{id}/publishBranchingScenario
UC-14NavigateHTTP POST /branching-scenarios/{id}/navigate(read)
UC-15RequestAIQuizGenerationHTTP POST /quiz-banks/{id}/ai/generateQuizBank (draft)
UC-16RequestAIBranchingGenerationHTTP POST /branching-scenarios/{id}/ai/generateBranchingScenario (draft)
UC-17GradeShortAnswerWithAIInternal (async, triggered by UC-09)AttemptResult
UC-18HumanReviewShortAnswerHTTP POST /attempts/{id}/responses/{qid}/human-gradeAttemptResult
UC-19RegradeAttemptHTTP POST /attempts/{id}/regradeAttemptResult (new)
UC-20ProjectBlockAddedNATS authoring.block.added.v1QuizBank (link)
UC-21HandleGDPRSubjectRequestNATS gdpr.subject_request.received.v1all attempts (redact)
UC-22PreloadQuestionsForPlaySessionNATS delivery.play_session.started.v1QuizBank (warm cache)

2. Canonical use-case shape

Every use-case follows this shape (NestJS-style, but framework-agnostic):

@UseCase()
class CreateQuizBank {
constructor(
private readonly repo: QuizBankRepo, // port
private readonly outbox: OutboxPort, // port
private readonly tenants: TenantScopePort, // port
private readonly clock: ClockPort, // port
private readonly tracer: Tracer, // OTel
private readonly policies: PolicyEnginePort, // ABAC
) {}

async execute(
cmd: CreateQuizBankCommand,
ctx: RequestContext,
): Promise<Result<QuizBankId, DomainError>> {
return this.tracer.span('assessment.create_quiz_bank', async (span) => {
// 1. authorize
const decision = await this.policies.check(ctx.principal, 'quiz_bank:create', { tenantId: ctx.tenantId });
if (!decision.allow) return err(new Forbidden(decision.reasonCode));

// 2. domain validation & construction
const bank = createQuizBank({
id: ULID.generate(),
tenantId: ctx.tenantId,
title: cmd.title,
questions: [],
gradingRule: cmd.gradingRule,
createdBy: ctx.principal.userId,
now: this.clock.now(),
});

// 3. transactional write: aggregate + outbox, single Postgres tx
await this.repo.withTx(async (tx) => {
await this.repo.save(bank, tx);
await this.outbox.enqueue(tx, {
topic: 'assessment.quiz_bank.created.v1',
payload: serializeQuizBankCreated(bank),
partitionKey: bank.id,
correlationId: ctx.correlationId,
tenantId: bank.tenantId,
actor: ctx.principal,
});
});

// 4. telemetry
span.setAttributes({ 'quiz_bank.id': bank.id, 'tenant.id': bank.tenantId });
return ok(bank.id);
});
}
}

Rules every use-case follows:

  1. One Postgres transaction per write use-case.
  2. Outbox write in same tx as aggregate write (no separate "publish" step).
  3. Authorization at use-case entry, not only at route layer (defense in depth).
  4. Clock via port (testable).
  5. ULID generation via port (seedable for tests).
  6. Returns Result<T, DomainError> — never throws domain errors.

3. High-signal use-cases explained

3.1 UC-07 ServePresentation (GET questions for a learner)

learner ─▶ delivery-service ─▶ assessment-service (this UC)

Steps:

  1. Resolve QuizBank by ID (with tenantId filter from RLS).
  2. Assert state === 'published'.
  3. Determine seed:
    • seedStrategy = 'attemptId'seed = attemptId
    • seedStrategy = 'userIdAndAttemptId'seed = hash(userId + attemptId)
    • seedStrategy = 'random'seed = ULID()
  4. Apply PoolConfig sampling (deterministic given seed) → shortlist of questions.
  5. Invoke QuestionSerializer.toPresentationForm(q, seed) for each → answer keys stripped, options shuffled if configured.
  6. Return PresentationPayload with locale-resolved prompts.
  7. Emit span assessment.serve_presentation with quizBank.id, questions.count, seed.hash.

Security invariants:

  • PresentationPayload is asserted free of isCorrect, correctIndex, acceptedAnswers, expected fields via a content-shape linter in unit tests (AssertNoCorrectAnswer).
  • If ctx.principal is a learner and state !== 'published' → 403.

Caching: Per-(quizBankId, version, locale) the stripped, unshuffled payload is cached in Redis (15 min TTL); the shuffle is applied per-request from the seed.


3.2 UC-09 ScoreAttempt

player ─▶ POST /attempts/{id}/score { responses: [...] }

Steps:

  1. Load QuizBank (or BranchingScenario for scenario attempts).
  2. Call scoreAttempt(qb, responses, scoringContext) pure function.
  3. Inspect result:
    • All deterministicstate = 'final'.
    • Any short-answer with rubric + AI grading enabledstate = 'pending_human_review' for those questions; queue GradeShortAnswerWithAI (UC-17) async per response.
  4. Persist AttemptResult + outbox assessment.attempt_result.scored.v1 in one tx.
  5. If pending_human_review, also emit assessment.attempt.pending_human_review.v1.
  6. Return summary DTO to caller.

Offline reconciliation path: if offlineScored === true the client claimed score is also included; ScoreReconciliation is computed. If mismatch > configured tolerance (default 0.001), emit assessment.score_mismatch_detected.v1 but still use server score as authoritative.

Latency target: p99 < 350 ms deterministic, < 2.5 s if AI-graded response scheduled.


3.3 UC-15 RequestAIQuizGeneration

author ─▶ POST /quiz-banks/{id}/ai/generate
{ lessonId, questionCount, types, difficulty }

Steps:

  1. Fetch lesson content via AuthoringReadPort.getLesson(lessonId) (read-only, idempotent).
  2. Call AIClient.generate({ promptId: 'quiz_generation/v3', context, constraints }).
  3. Validate structured output against JSON Schema QuizGenerationOutput/v3.json. Reject if invalid.
  4. Produce draft Question[] with aiProvenance stamped (model, prompt version, traceId).
  5. Return draft to caller — does not persist yet; authoring UI shows side-by-side with Accept/Edit/Reject actions.
  6. On accept: author posts PATCH /quiz-banks/{id} which runs UC-02 (normal path) with the approved questions. HITL enforced: aiProvenance.reviewedBy and reviewedAt set by UC-02.

Cost guard: per-tenant token budget checked before call. Exceeded budget → 402 with costBudget.exceeded error code.

Safety eval: The prompt quiz_generation/v3 has a locked eval set (300 items, multiple domains); any prompt change requires ≥ 95% pass on the eval before promotion per doc 15 §P5.


3.4 UC-17 GradeShortAnswerWithAI

Runs asynchronously after UC-09 schedules it.

Steps:

  1. Fetch AttemptResult (state = pending_human_review).
  2. For each response marked gradedBy: 'ai':
    • Build grading context: question prompt, rubric, learner response.
    • Call AIClient.grade({ promptId: 'rubric_grading/v2', rubric, response }).
    • Parse AIGradeOutcome (pointsPerCriterion, confidence, rationale).
    • If confidence < rubric.humanReviewThreshold → keep humanReviewRequired: true, do not promote to final.
    • If confidence >= threshold → mark response with gradedBy: 'ai', points, aiConfidence, rationale stored.
  3. After all responses processed:
    • If all AI-graded responses meet threshold and no human override pending → recompute scaledScore, set state = 'final', emit attempt_result.scored.v1.
    • Else → stay in pending_human_review, emit assessment.short_answer.ai_graded.v1 for telemetry.
  4. Record full aiProvenance on AttemptResult.

Anti-gaming: Learner-submitted text is sanitized before being sent to AI (strip control sequences, nested prompts). The system prompt is isolated at the gateway — assessment-service never puts learner text directly in the system message.


3.5 UC-10 ReconcileOfflineAttempt

Triggered when sync-service delivers a learner's offline attempt.

Steps:

  1. Deduplicate on (attemptId, clientMutationId) via inbox table.
  2. Extract ClaimedAttempt payload: responses[], clientScaledScore, device signature, timestamps.
  3. Verify device signature via identity-service device pubkey cache.
  4. Load QuizBank at the quizBankVersion the client used; if version is archived, prefer the version the client bundled.
  5. Call UC-09 ScoreAttempt internally with scoringMode='deterministic', marking offlineScored = true.
  6. Compare client vs server scaled score → ScoreReconciliation.
  7. Persist AttemptResult, emit appropriate events.
  8. If mismatch > tolerance, also trigger notification via notification-service to compliance officer queue.

Idempotency: Re-delivery of the same (attemptId, clientMutationId) returns the previously scored result (200 OK, same payload).


3.6 UC-22 PreloadQuestionsForPlaySession

Reactive to delivery.play_session.started.v1.

Steps:

  1. Parse event; extract courseVersionId, userId.
  2. For each quiz block in the session:
    • Prewarm Redis cache with the PresentationPayload (unshuffled variant) for that bank + locale.
  3. Emit telemetry metric assessment.preload.hit_rate.

Non-blocking — failures only log warnings.


4. Ports (abstractions the use-cases depend on)

interface QuizBankRepo {
findById(id: QuizBankId): Promise<QuizBank | null>;
findByIds(ids: QuizBankId[]): Promise<QuizBank[]>;
save(qb: QuizBank, tx: Tx): Promise<void>;
listByTenant(tenantId: TenantId, page: Pagination): Promise<Page<QuizBank>>;
withTx<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
}

interface BranchingScenarioRepo { /* analogous */ }

interface AttemptResultRepo {
findByAttemptId(id: AttemptId): Promise<AttemptResult | null>;
save(ar: AttemptResult, tx: Tx): Promise<void>;
withTx<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
}

interface OutboxPort {
enqueue(tx: Tx, entry: OutboxEntry): Promise<void>;
}

interface AIClient {
generate(req: AIGenerationRequest): Promise<AIGenerationResponse>;
grade(req: AIGradingRequest): Promise<AIGradingResponse>;
}

interface MediaClient {
resolveAssetRef(ref: MediaRef): Promise<MediaDescriptor>;
}

interface AuthoringReadPort {
getLesson(id: LessonId): Promise<LessonReadModel>;
getBlockRef(ref: BlockRef): Promise<BlockReadModel>;
}

interface PolicyEnginePort {
check(principal: Principal, action: string, resource: Resource): Promise<Decision>;
}

interface TenantScopePort {
currentTenantId(): TenantId;
}

interface ClockPort { now(): ISODate; }

interface IdempotencyStore {
claim(key: string): Promise<'new' | 'duplicate' | { cached: any }>;
commit(key: string, result: any): Promise<void>;
}

interface ULIDGenerator { next(): ULID; }

interface NotificationClient {
notify(req: NotifyRequest): Promise<void>;
}

5. Transactional boundaries

ConcernMechanism
Aggregate write + event emissionSingle Postgres transaction via withTx; outbox row is inserted in same tx
Outbox → NATS publicationSeparate background worker (OutboxPublisher) with SELECT … FOR UPDATE SKIP LOCKED
Cross-service callsOnly authoring-read and AI-grading are cross-service; both use Saga-light compensations (best-effort)
AI grading retriesExponential backoff, max 3 attempts, DLQ on final failure
Consumer idempotencyEvery inbound event checked against inbox_events unique (eventId)

6. Error taxonomy (application layer)

CodeReasonHTTPExample
quiz_bank.not_foundQuizBank not found or wrong tenant404Serve presentation for missing bank
quiz_bank.draft_not_servableAttempted to serve a draft409Learner tries to take unpublished quiz
quiz_bank.invariant_violationDomain invariant failed422No correct answer on MCQ
attempt.already_scoredAttempt already scored409Duplicate score call
attempt.expiredTime limit elapsed before submit422Late submission
ai.budget_exceededTenant AI budget hit402Request blocked
ai.output_invalidAI returned schema-invalid data502Retry or reject
policy.forbiddenABAC denied403Learner POSTs create-bank
idempotency.replay_mismatchSame Idempotency-Key, different body409Client bug
concurrency.stale_versionIf-Match mismatch412Two authors editing

All errors conform to doc 05 §8 problem+json.


7. Concurrency & locking

  • Optimistic concurrency on QuizBank / BranchingScenario via version column + If-Match header.
  • AttemptResult uses attemptId as primary key — collisions resolved by idempotency (same ID returns cached result).
  • Outbox publisher uses row-level lock SELECT … FOR UPDATE SKIP LOCKED LIMIT 100 to allow N parallel publishers.

8. Observability hooks (application layer)

Every use-case emits:

  • Span: assessment.<use_case_name> with attributes tenant.id, aggregate ID, actor type.
  • Metric counter: assessment.usecase.invocations_total{name, outcome}.
  • Metric histogram: assessment.usecase.duration_ms{name}.
  • Structured log: input hash (not plaintext), result outcome, error code if any.

AI use-cases additionally emit:

  • ai.prompt.id, ai.model, ai.tokens.in, ai.tokens.out, ai.cost.microUSD, ai.confidence, ai.decision.accepted.

See OBSERVABILITY.md for the full taxonomy.


9. Policy decisions encoded at application layer

DecisionRule
Can learner see correct answers?Per gradingRule.showCorrectAnswers
Can learner retake?Per gradingRule.maxAttempts and enrollment policy (delivery-service owns attempt count)
Is AI grading allowed for this tenant?tenant.ai.rubric_grading.enabled flag via tenant-service
Is prompt injection suspected?AI gateway's classifier returns suspected: true → reject with ai.prompt_injection_suspected

10. Saga summary

Assessment-service participates in one multi-step workflow:

"AI-assisted quiz authoring" saga (orchestrated by authoring-service)

1. Author clicks "Generate quiz" ─▶ authoring-service
2. authoring-service ─▶ assessment-service UC-15 (RequestAIQuizGeneration)
3. assessment-service ─▶ ai-gateway-service (grade)
4. assessment-service returns draft ─▶ authoring-service
5. Author reviews; on approval ─▶ authoring-service publishes block
6. authoring.block.added.v1 ─▶ assessment-service UC-20 (ProjectBlockAdded) links bank to block

No compensations needed — UC-15 does not persist; the draft is ephemeral until UC-20 links it.


11. References