From 58e26b85a717807277d38f2d690a2d8dd9174266 Mon Sep 17 00:00:00 2001 From: JnHu Date: Sat, 3 Oct 2026 19:10:30 +0800 Subject: [PATCH 1/2] fix(exam): validate persisted Rich before publish projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publishExam projected repository-loaded question/option contentDocument straight through plainTextProjection, so a corrupt historical/bypassed row (the canonical write seam can never emit one) crashed the freeze gate with a TypeError instead of a controlled publish rejection. The gate now classifies every repository-loaded document through the shared Phase D5 read authority (classifyPersistedQuestionContent): rich_valid proceeds to the unchanged projection invariant; rich_noncanonical / unsupported_version / corrupt fail closed as typed ValidationErrors, and the frozen snapshot is built only after all per-question trust checks pass (validation-before-freeze). Canonicality at publish follows from frozen authority (ADR-019 single-write-seam; semantic contract §2/§8): publish creates a new frozen commitment, and only canonical Rich may be frozen. As-built references recorded in the semantic contract (§6/§18) and ADR-019 compliance notes; no authority semantics changed. #669 (Phase D5.1, follows #693 / D5) --- docs/adr/ADR-019-content-document-model.md | 6 +- .../rich-content-semantic-contract.md | 14 +++- packages/exam-engine/package.json | 1 + packages/exam-engine/src/examCommands.ts | 75 +++++++++++++++++-- pnpm-lock.yaml | 3 + 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/docs/adr/ADR-019-content-document-model.md b/docs/adr/ADR-019-content-document-model.md index 37779bfe9..4788ea3a4 100644 --- a/docs/adr/ADR-019-content-document-model.md +++ b/docs/adr/ADR-019-content-document-model.md @@ -135,7 +135,11 @@ entirely (KaTeX is self-contained and meets the offline constraint). ## Compliance notes - Publish gates reject fill_blank+rich, `answerMode` outside text_response, - and rich questions whose `content` diverges from the derived projection. + rich questions whose `content` diverges from the derived projection, and + persisted question/option Rich that fails the shared §7 read + classification (noncanonical / unsupported-version / corrupt) before any + projection (Phase D5.1; canonicality at publish follows from the + single-write-seam rule above). - Snapshot evolution is additive (`contentDocument`/`answerMode` default to null for legacy rows); migration is append-only. - Audit metadata must never embed raw rich answer payloads (ADR-010 diff --git a/docs/architecture/rich-content-semantic-contract.md b/docs/architecture/rich-content-semantic-contract.md index 107d3df2e..9576defb5 100644 --- a/docs/architecture/rich-content-semantic-contract.md +++ b/docs/architecture/rich-content-semantic-contract.md @@ -191,6 +191,18 @@ editor / external input - Rich validation may produce typed semantic failures internally, but outer protocols (SaveAnswer, route validation) own their wire / error mapping. +As-built (Phase D5.1): the exam publish/freeze gate is one such protocol owner +and classifies every repository-loaded question / option `contentDocument` +through the same shared static read authority (§7, +`classifyPersistedQuestionContent`) before its projection invariant — +`rich_valid` proceeds to the `content == plainTextProjection(document)` freeze +check, while `rich_noncanonical` / `unsupported_version` / `corrupt` are typed +publish rejections (`ValidationError`), never a projection crash and never a +fallback to the stored `content` string. Publication freezes only canonical +Rich (a noncanonical row bypassed the single write seam and may not become a +new frozen commitment); publish validates then freezes and never normalizes a +historical row — repair is a separate explicit migration. + Rich does **not** emit the following lifecycle / authz errors: - `STALE_VERSION` @@ -514,7 +526,7 @@ are not moved into this semantic contract. | Rich V1 grammar, limits, normalization, equivalence | This document + [`packages/domain/src/content/contentDocument.ts`](../../packages/domain/src/content/contentDocument.ts) implementation | | Wire schema / type identity | [`packages/contracts/src/contentDocument.ts`](../../packages/contracts/src/contentDocument.ts) | | Persisted-answer read classification (§7) | [`packages/contracts/src/persistedRichAnswer.ts`](../../packages/contracts/src/persistedRichAnswer.ts) — the single shared classifier consumed by web read paths and API export (Phase D4) | -| Static question-content read classification (§7) | [`packages/contracts/src/persistedQuestionContent.ts`](../../packages/contracts/src/persistedQuestionContent.ts) — consumed by the `ContentRenderer` render trust boundary (Phase D5) | +| Static question-content read classification (§7) | [`packages/contracts/src/persistedQuestionContent.ts`](../../packages/contracts/src/persistedQuestionContent.ts) — consumed by the `ContentRenderer` render trust boundary (Phase D5) and the exam publish freeze gate (Phase D5.1) | | Cross-boundary Exam semantics | [`exam-semantic-boundaries.md`](exam-semantic-boundaries.md) / ADR-021 | | Product capability composition | [`product-capability-composition.md`](product-capability-composition.md) / ADR-022 | | Attempt lifecycle / SaveAnswer / submit / grading / result | [`exam-runtime.md`](exam-runtime.md), ADR-005, ADR-006, ADR-008, ADR-012 | diff --git a/packages/exam-engine/package.json b/packages/exam-engine/package.json index 9f51b452c..2fa7d4d5a 100644 --- a/packages/exam-engine/package.json +++ b/packages/exam-engine/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@exam/authz": "workspace:*", + "@exam/contracts": "workspace:*", "@exam/domain": "workspace:*" }, "devDependencies": { diff --git a/packages/exam-engine/src/examCommands.ts b/packages/exam-engine/src/examCommands.ts index 687cf2c47..bfd940b12 100644 --- a/packages/exam-engine/src/examCommands.ts +++ b/packages/exam-engine/src/examCommands.ts @@ -1,6 +1,11 @@ import type { Exam, Question, QuestionSnapshot } from "@exam/domain"; -import { InvalidStateTransitionError, ValidationError } from "@exam/domain"; -import { plainTextProjection } from "@exam/domain"; +import { + InvalidStateTransitionError, + ValidationError, + plainTextProjection, + type ContentDocumentV1, +} from "@exam/domain"; +import { classifyPersistedQuestionContent } from "@exam/contracts"; import { assertTransition } from "./examStateMachine.js"; import { assertExamPolicyValid } from "./examPolicy.js"; @@ -94,6 +99,48 @@ export function buildQuestionSnapshot( }); } +/** + * Publish freeze gate for repository-loaded Rich content (#669 D5.1). The + * document is classified through the single shared persisted-question read + * authority (@exam/contracts, Phase D5) BEFORE any projection: `rich_valid` + * returns the trusted document, while `rich_noncanonical` / + * `unsupported_version` / `corrupt` fail closed as typed ValidationErrors so + * a historical/bypassed row can never explode inside `plainTextProjection`. + * INVARIANT: publish validates then freezes — a noncanonical row is never + * normalized here (repair is a separate migration), and a corrupt document + * never degrades into the stored `content` projection field. + */ +function assertPublishableRichDocument( + subject: string, + contentDocument: unknown, +): ContentDocumentV1 { + const read = classifyPersistedQuestionContent(contentDocument); + switch (read.kind) { + case "rich_valid": + return read.document; + case "rich_noncanonical": + // Publish creates a new frozen commitment, and every durable Rich write + // boundary must persist only the canonical write seam's output + // (ADR-019; rich-content-semantic-contract.md §2/§8): a noncanonical + // row bypassed that seam, so it may not be frozen. + throw new ValidationError( + `${subject} contentDocument is not canonical Rich; publish freezes only canonical Rich documents`, + ); + case "unsupported_version": + throw new ValidationError( + `${subject} contentDocument carries an unsupported docVersion`, + ); + case "corrupt": + throw new ValidationError( + `${subject} contentDocument is corrupt or not valid Rich content`, + ); + case "plain": + // Unreachable: every caller gates on contentDocument != null, and only + // null classifies as plain. Fail closed rather than widen the contract. + throw new ValidationError(`${subject} contentDocument is missing`); + } +} + /** * Publishes an exam: validates all preconditions (questions, scores, timing, policies), * builds the question snapshot, and transitions the exam to published status. @@ -150,7 +197,6 @@ export async function publishExam( // remain here because they need DB-loaded question facts. assertExamPolicyValid(exam); - const questionSnapshot = buildQuestionSnapshot(exam.questionIds, questions); if (questions.some((question) => question.courseId !== exam.courseId)) { throw new ValidationError("Exam questions must belong to its course"); } @@ -192,8 +238,16 @@ export async function publishExam( // #301 B′ projection invariant: for Rich questions the stored `content` // must be exactly the deterministic projection of the frozen document. // A mismatch means a writer bypassed the server-side derivation seam. + // D5.1: the repository-loaded document is classified through the shared + // persisted-Rich read authority first — corrupt/unsupported/noncanonical + // rows are typed publish rejections, never a TypeError escaping + // plainTextProjection. if (question.contentDocument != null) { - const projection = plainTextProjection(question.contentDocument); + const document = assertPublishableRichDocument( + `rich question ${question.id}`, + question.contentDocument, + ); + const projection = plainTextProjection(document); if (question.content !== projection) { throw new ValidationError( `rich question ${question.id} content must equal plainTextProjection(contentDocument) at publish`, @@ -203,10 +257,15 @@ export async function publishExam( // #301 corrective pass: the SAME projection invariant holds for rich // OPTIONS — a divergent frozen option would show candidates one text // (plain projection) while the rich renderer draws another. Publish is - // the freeze gate: fail closed, never auto-repair. + // the freeze gate: fail closed, never auto-repair. D5.1 applies the same + // persisted-Rich trust gate as the question-level path above. for (const option of question.options) { if (option.contentDocument != null) { - const optionProjection = plainTextProjection(option.contentDocument); + const document = assertPublishableRichDocument( + `rich option ${option.id} of question ${question.id}`, + option.contentDocument, + ); + const optionProjection = plainTextProjection(document); if (option.content !== optionProjection) { throw new ValidationError( `rich option ${option.id} of question ${question.id} content must equal plainTextProjection(contentDocument) at publish`, @@ -230,6 +289,10 @@ export async function publishExam( // Any other type: publish validation is intentionally permissive here; // future subjective types will add their own rubric-style guard as needed. } + // INVARIANT (D5.1 validation-before-freeze): the frozen snapshot is built + // only after every per-question trust/invariant check above passes — a + // corrupt Rich row is never materialized into a provisional snapshot. + const questionSnapshot = buildQuestionSnapshot(exam.questionIds, questions); const totalScore = questionSnapshot.reduce( (sum, question) => sum + question.score, 0, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a602c566..f6624c11f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -440,6 +440,9 @@ importers: '@exam/authz': specifier: workspace:* version: link:../authz + '@exam/contracts': + specifier: workspace:* + version: link:../contracts '@exam/domain': specifier: workspace:* version: link:../domain From 2994c10e7ee29eb5f5948822973877e6fd034be4 Mon Sep 17 00:00:00 2001 From: JnHu Date: Sat, 3 Oct 2026 19:10:32 +0800 Subject: [PATCH 2/2] test(exam): add corrupt Rich publish-boundary regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 canonical rich publish positive control; R2/R4 corrupt question and option documents (fabricated bypassed rows via the established as-unknown cast pattern) reject as ValidationError, not the pre-fix TypeError ('inlines is not iterable') that would surface as an HTTP 500; R3/R5 unsupported docVersion parity for question and option; R6 proves no fallback to the stored content projection field; R8 proves the noncanonical rejection is the canonicality policy itself — the fixture's content matches the raw document's projection, so only the freeze-gate policy can reject it. The pre-existing projection-mismatch regressions (R7) are unchanged and still green. #669 (Phase D5.1) --- packages/exam-engine/src/examCommands.test.ts | 167 +++++++++++++++++- 1 file changed, 166 insertions(+), 1 deletion(-) diff --git a/packages/exam-engine/src/examCommands.test.ts b/packages/exam-engine/src/examCommands.test.ts index 27c40579f..bea54b9e6 100644 --- a/packages/exam-engine/src/examCommands.test.ts +++ b/packages/exam-engine/src/examCommands.test.ts @@ -12,7 +12,7 @@ import { publishResults, type ExamRepository, } from "./examCommands.js"; -import type { Exam, Question } from "@exam/domain"; +import type { Exam, Question, ContentDocumentV1 } from "@exam/domain"; import { InvalidStateTransitionError, ValidationError, @@ -259,6 +259,171 @@ describe("examCommands", () => { ).rejects.toThrow(/rich option b .*plainTextProjection/s); }); + // D5.1: a repository-loaded contentDocument must pass the shared persisted- + // Rich read authority BEFORE any projection. These fixtures fabricate + // historical/bypassed rows (the supported write seam canonicalizes, so it + // can never emit them) and require controlled ValidationErrors, never a + // TypeError escaping plainTextProjection. + describe("persisted Rich trust at the publish boundary (#669 D5.1)", () => { + function publishRepo(question: Question) { + return makeRepo( + makeExam({ + questionIds: [question.id], + totalScore: question.score, + passingScore: 0, + }), + ); + } + + // Both fixtures deliberately violate the ContentDocumentV1 shape: the + // cast fabricates what a bypassed/historical DB row looks like — the + // supported write seam can never emit either. + const CORRUPT_DOC = { + docVersion: 1, + type: "doc", + // Paragraph missing its inline list: schema-off-grammar, and exactly + // the shape that used to explode inside plainTextProjection. + content: [{ type: "paragraph" }], + } as unknown as ContentDocumentV1; + + const UNSUPPORTED_DOC = { + docVersion: 2, + type: "doc", + content: [], + } as unknown as ContentDocumentV1; + + // Schema-valid but unnormalized: adjacent unmarked text runs must have + // merged. Projection is identical pre/post normalization ("ab"), so the + // rejection below is the canonicality policy itself, not a projection + // mismatch. + const NONCANONICAL_DOC = { + docVersion: 1 as const, + type: "doc" as const, + content: [ + { + type: "paragraph" as const, + content: [ + { type: "text" as const, text: "a" }, + { type: "text" as const, text: "b" }, + ], + }, + ], + }; + + it("publishes a canonical rich question whose content matches its projection (R1)", async () => { + const doc = makeRichDoc(); + const richQuestion = makeQuestion("q-rich-ok", { + type: "text_response", + content: plainTextProjection(doc), + contentDocument: doc, + answerMode: "rich", + options: [], + standardAnswer: null, + rubric: "按要点给分", + }); + const repo = publishRepo(richQuestion); + const result = await publishExam(repo, "exam-1", [richQuestion]); + expect(result.status).toBe("published"); + }); + + it("rejects a corrupt persisted question document with ValidationError, not TypeError (R2)", async () => { + const corrupt = makeQuestion("q-corrupt", { + type: "text_response", + content: "Solve ", + contentDocument: CORRUPT_DOC, + answerMode: "rich", + options: [], + standardAnswer: null, + }); + await expect( + publishExam(publishRepo(corrupt), "exam-1", [corrupt]), + ).rejects.toThrow(/corrupt/); + await expect( + publishExam(publishRepo(corrupt), "exam-1", [corrupt]), + ).rejects.toThrow(ValidationError); + }); + + it("rejects an unsupported persisted question docVersion with ValidationError (R3)", async () => { + const future = makeQuestion("q-v2", { + type: "text_response", + content: "Solve ", + contentDocument: UNSUPPORTED_DOC, + answerMode: "rich", + options: [], + standardAnswer: null, + }); + await expect( + publishExam(publishRepo(future), "exam-1", [future]), + ).rejects.toThrow(/unsupported docVersion/); + }); + + it("rejects a corrupt persisted OPTION document with ValidationError (R4)", async () => { + const corruptOption = makeQuestion("q-opt-corrupt", { + type: "single_choice", + content: "plain prompt", + contentDocument: null, + options: [ + { id: "a", content: "A", contentDocument: null }, + { id: "b", content: "B", contentDocument: CORRUPT_DOC }, + ], + }); + await expect( + publishExam(publishRepo(corruptOption), "exam-1", [corruptOption]), + ).rejects.toThrow(ValidationError); + await expect( + publishExam(publishRepo(corruptOption), "exam-1", [corruptOption]), + ).rejects.toThrow(/corrupt/); + }); + + it("rejects an unsupported persisted OPTION docVersion with ValidationError (R5)", async () => { + const futureOption = makeQuestion("q-opt-v2", { + type: "single_choice", + content: "plain prompt", + contentDocument: null, + options: [ + { id: "a", content: "A", contentDocument: null }, + { id: "b", content: "B", contentDocument: UNSUPPORTED_DOC }, + ], + }); + await expect( + publishExam(publishRepo(futureOption), "exam-1", [futureOption]), + ).rejects.toThrow(/unsupported docVersion/); + }); + + it("never falls back to the stored content string when the document is corrupt (R6)", async () => { + const fallback = makeQuestion("q-fallback", { + type: "text_response", + // A plausible plain projection that a Plain fallback would accept. + content: "apparently valid fallback text", + contentDocument: CORRUPT_DOC, + answerMode: "rich", + options: [], + standardAnswer: null, + }); + await expect( + publishExam(publishRepo(fallback), "exam-1", [fallback]), + ).rejects.toThrow(/corrupt/); + }); + + it("rejects a schema-valid but noncanonical document at the freeze gate even when its projection matches (R8)", async () => { + const noncanonical = makeQuestion("q-noncanon", { + type: "text_response", + // Deliberately the projection of the RAW document: the rejection + // must come from the canonicality policy, not the projection + // invariant. + content: plainTextProjection(NONCANONICAL_DOC), + contentDocument: NONCANONICAL_DOC, + answerMode: "rich", + options: [], + standardAnswer: null, + rubric: "按要点给分", + }); + await expect( + publishExam(publishRepo(noncanonical), "exam-1", [noncanonical]), + ).rejects.toThrow(/not canonical Rich/); + }); + }); + it("transitions draft → published", async () => { const repo = makeRepo(makeExam()); const result = await publishExam(repo, "exam-1", testQuestions);