Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/adr/ADR-019-content-document-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion docs/architecture/rich-content-semantic-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions packages/exam-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@exam/authz": "workspace:*",
"@exam/contracts": "workspace:*",
"@exam/domain": "workspace:*"
},
"devDependencies": {
Expand Down
167 changes: 166 additions & 1 deletion packages/exam-engine/src/examCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
75 changes: 69 additions & 6 deletions packages/exam-engine/src/examCommands.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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`,
Expand All @@ -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`,
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading