diff --git a/apps/e2e/e2e/rich-content.spec.ts b/apps/e2e/e2e/rich-content.spec.ts
index a04a67f20..21f502633 100644
--- a/apps/e2e/e2e/rich-content.spec.ts
+++ b/apps/e2e/e2e/rich-content.spec.ts
@@ -1194,3 +1194,104 @@ test.describe("editor identity, reconciliation, grading closure", () => {
expect(JSON.stringify(finalAnswer)).not.toContain("丁作答");
});
});
+
+test.describe("#669 D5 math render security (browser evidence)", () => {
+ /**
+ * jsdom cannot prove "no network fetch is initiated by math rendering"
+ * (D5-B M5): this test renders trust-disallowed / remote-referencing /
+ * HTML-like latex through the real static read path in a real browser and
+ * asserts the page initiates zero cross-origin requests and mounts no
+ * active/remote element for the adversarial payload. Complements the
+ * library-level characterization in
+ * apps/web/src/components/shared/content/MathRenderer.evidence.test.tsx.
+ */
+ test("adversarial math renders inert with zero external network fetches", async ({
+ page,
+ request,
+ }) => {
+ const adminToken = await adminApiToken(request);
+ const courseId = await seedCourseId(request, adminToken);
+ const createRes = await adminPost(request, adminToken, "/api/questions", {
+ courseId,
+ score: 5,
+ difficulty: 1,
+ type: "single_choice",
+ contentDocument: {
+ docVersion: 1,
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: `D5B安全-${STAMP}:` },
+ {
+ type: "inlineMath",
+ latex:
+ "\\includegraphics[width=5em]{https://evil.example/x.png}",
+ },
+ ],
+ },
+ {
+ type: "blockMath",
+ latex:
+ "\\href{https://evil.example}{click}
",
+ },
+ ],
+ },
+ options: [
+ {
+ id: "opt-a",
+ content: "选项A",
+ contentDocument: null,
+ isCorrect: true,
+ },
+ {
+ id: "opt-b",
+ content: "选项B",
+ contentDocument: null,
+ isCorrect: false,
+ },
+ ],
+ standardAnswer: "opt-a",
+ rubric: null,
+ });
+ expect(createRes.status(), await createRes.text()).toBe(201);
+ const { id: questionId } = (await createRes.json()) as { id: string };
+
+ // Record every http(s) request the real browser issues while the
+ // adversarial prompt renders; anything not aimed at the app origin is a
+ // violation of the no-remote-content invariant.
+ const externalRequests: string[] = [];
+ page.on("request", (req) => {
+ const url = new URL(req.url());
+ if (
+ (url.protocol === "http:" || url.protocol === "https:") &&
+ url.origin !== BASE_URL
+ ) {
+ externalRequests.push(req.url());
+ }
+ });
+
+ await loginAsAdmin(page);
+ await page.goto(`${BASE_URL}/admin/questions/${questionId}/edit`);
+
+ // The adversarial math renders its inert projection on the real read
+ // path (the trust-disallowed command stays as visible token text).
+ await expect(page.locator(".katex").first()).toBeVisible();
+ await expect(
+ page.getByText("\\includegraphics", { exact: false }).first(),
+ ).toBeVisible();
+
+ // No active/remote node may reference the adversarial payload anywhere
+ // on the page.
+ await expect(page.locator("img[src*='evil.example']")).toHaveCount(0);
+ await expect(page.locator("a[href*='evil.example']")).toHaveCount(0);
+ await expect(
+ page.locator("iframe, frame, object, embed, applet"),
+ ).toHaveCount(0);
+
+ // The strongest form of the no-remote-content property: the whole page
+ // loaded without a single cross-origin request.
+ expect(externalRequests, `${externalRequests.join("\n")}`).toEqual([]);
+ });
+});
diff --git a/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx b/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx
index b2aeefd59..4824099a1 100644
--- a/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx
+++ b/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx
@@ -5,6 +5,7 @@ import { render } from "@testing-library/react";
import type { ContentBlock, ContentDocumentV1 } from "@exam/domain";
import { describe, expect, it } from "vitest";
import { ContentRenderer } from "./ContentRenderer";
+import { ContentDocumentRenderer } from "./ContentDocumentRenderer";
import { MathRenderer } from "./MathRenderer";
/**
@@ -20,6 +21,13 @@ import { MathRenderer } from "./MathRenderer";
* These assertions are structural (DOM shape), not behavioral: jsdom never
* executes injected handlers anyway, so "no on* attribute / no script element"
* is the actual invariant we can prove here.
+ *
+ * Layering (#669 Phase D5-A): schema/limit-offending documents are rejected
+ * by the ContentRenderer trust boundary before rendering (see
+ * ContentRenderer.trust.test.tsx). The per-node fail-safes below are
+ * defense in depth: they pin ContentDocumentRenderer's own behavior for
+ * documents it would receive only if the boundary were bypassed, so a
+ * boundary regression can never silently turn into raw-HTML or crash output.
*/
function doc(blocks: ContentBlock[]): ContentDocumentV1 {
@@ -102,6 +110,9 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", ()
});
it("rich text runs render hostile strings as escaped text, including inside marks", () => {
+ // Marks are a valid combination (the grammar forbids inlineCode + other
+ // marks; that off-grammar case fails closed at the trust boundary — see
+ // ContentRenderer.trust.test.tsx D5A-R5).
const hostileDoc = doc([
para(''),
{
@@ -110,7 +121,7 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", ()
{
type: "text",
text: "
",
- marks: ["bold", "italic", "underline", "inlineCode"],
+ marks: ["bold", "italic", "underline"],
},
],
},
@@ -192,7 +203,7 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", ()
});
});
-describe("ContentRenderer — corrupt-data fail-safes", () => {
+describe("ContentDocumentRenderer — defense-in-depth fail-safes (boundary bypassed)", () => {
const UNSUPPORTED = "此内容包含当前版本不支持的元素";
it("replaces an unknown block node with the controlled placeholder", () => {
@@ -201,7 +212,7 @@ describe("ContentRenderer — corrupt-data fail-safes", () => {
para("after"),
] as unknown as ContentBlock[]);
const { container } = render(
- ,
+ ,
);
expect(container.textContent).toContain(UNSUPPORTED);
expect(container.textContent).toContain("after");
@@ -210,8 +221,7 @@ describe("ContentRenderer — corrupt-data fail-safes", () => {
it("drops an unknown inline node and ignores an unknown mark while keeping sibling content", () => {
const { container } = render(
- {
expect(container.querySelector("strong")?.textContent).toBe("styled");
});
- it("survives corrupt oversize input (deep tree, huge text run) without crashing", () => {
+ it("renders an oversize text run as escaped verbatim text if it is ever reached", () => {
+ const huge = "",
+ "
",
+ "\\text{h}",
+ ];
+ for (const latex of corpus) {
+ const html = katexRenderToHtml(latex, false);
+ assertInertHtml(html);
+ const dom = new DOMParser().parseFromString(html, "text/html");
+ expect(dom.body.textContent).not.toBe("");
+ }
+ });
+
+ // M6 — bounded rendering: explicit configuration, structural assertions
+ // only (no timing thresholds).
+ it("D5B M6/R7: expansion abuse fails bounded by maxExpand with the source preserved", () => {
+ const latex = "\\def\\a{\\a\\a}\\a";
+ const html = katexRenderToHtml(latex, false);
+ // maxExpand: 1000 stops the self-expansion as a controlled parse error
+ // that still carries the source evidence.
+ expect(html).toContain("katex-error");
+ const dom = new DOMParser().parseFromString(html, "text/html");
+ expect(dom.body.textContent).toContain(latex);
+ // Structural bound on the output: a failed expansion never produces an
+ // unbounded render.
+ expect(html.length).toBeLessThan(5000);
+ assertInertHtml(html);
+ });
+
+ it("D5B M6: dimension abuse is capped by maxSize", () => {
+ const html = katexRenderToHtml("\\rule{99999em}{99999em}", true);
+ expect(html).not.toContain("99999");
+ expect(html.length).toBeLessThan(2000);
+ assertInertHtml(html);
+ });
+});
+
+describe("MathRenderer — real React seam", () => {
+ it("D5B-R1: normal inline math renders through the lazy production seam", async () => {
+ const { container } = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(container.querySelector(".katex")).not.toBeNull();
+ });
+ });
+
+ it("D5B-R2: normal block math renders in display mode", async () => {
+ const { container } = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(container.querySelector(".katex")).not.toBeNull();
+ });
+ });
+
+ it("D5B-R3: malformed math never crashes the seam and the source stays visible", async () => {
+ const { container } = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(
+ container.querySelector(".katex-error") ??
+ container.querySelector("code"),
+ ).not.toBeNull();
+ });
+ expect(container.textContent).toContain("\\frac{1}{2");
+ });
+
+ it("D5B-R5: HTML-like math input is inert in the live DOM — no elements, escaped source only", async () => {
+ const { container } = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(container.textContent).not.toBe("");
+ });
+ expect(container.querySelector("script, img, iframe")).toBeNull();
+ for (const el of Array.from(container.querySelectorAll("*"))) {
+ for (const attr of Array.from(el.attributes)) {
+ expect(/^on/i.test(attr.name)).toBe(false);
+ }
+ }
+ });
+});
+
+describe("ContentRenderer → ContentDocumentRenderer → MathRenderer composition", () => {
+ function doc(blocks: ContentBlock[]): ContentDocumentV1 {
+ return { docVersion: 1, type: "doc", content: blocks };
+ }
+
+ it("D5B-R8: a real supported document with text, inline math, and block math composes into inert static output", async () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain("质点动能");
+ await waitFor(() => {
+ expect(container.querySelectorAll(".katex").length).toBe(2);
+ });
+ // Inert output sweep over the composed DOM.
+ expect(
+ container.querySelectorAll(
+ "script, iframe, img, video, audio, object, embed, link, a",
+ ),
+ ).toHaveLength(0);
+ });
+
+ it("D5B-R9: the real editor math path — Tiptap JSON → canonical document → static read seam → rendered math — preserves source semantics", async () => {
+ // Editor-side Tiptap JSON (toolbar inline math + block math), the same
+ // shape the editor emits on every update (canonical by construction —
+ // contentAdapter normalizes).
+ const editorJson: JSONContent = {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "动能定理:" },
+ { type: "inlineMath", attrs: { latex: "E=mc^2" } },
+ ],
+ },
+ { type: "blockMath", attrs: { latex: "\\frac{1}{2" } },
+ ],
+ };
+ const canonical = tiptapToContentDocument(editorJson);
+ // The editor document passes the D5-A static read trust boundary.
+ const trusted = resolvePersistedQuestionDocument(canonical);
+ expect(trusted).not.toBeNull();
+ const { container } = render(
+ ,
+ );
+ // The valid math renders its projection; the malformed block renders the
+ // controlled katex-error projection carrying the source.
+ await waitFor(() => {
+ expect(container.querySelector(".katex")).not.toBeNull();
+ expect(container.querySelector(".katex-error")).not.toBeNull();
+ });
+ // The rendered valid math is a projection, not the raw source text…
+ expect(container.textContent).not.toContain("E=mc^2");
+ // …while the malformed block's source evidence stays visible.
+ expect(container.textContent).toContain("\\frac{1}{2");
+ });
+});
diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts
index 41c2b77b3..28611d063 100644
--- a/apps/web/src/i18n/locales/zh-CN.ts
+++ b/apps/web/src/i18n/locales/zh-CN.ts
@@ -132,6 +132,7 @@ const zhCN = {
unsupportedBlock: "此内容包含当前版本不支持的元素",
unsupportedAnswer: "此作答内容无法以富文本安全显示",
unsafeEditableAnswer: "此作答内容无法安全加载编辑",
+ unsafeDocument: "此内容无法安全显示",
mathLoading: "公式加载中",
mode: {
label: "内容模式",
diff --git a/docs/architecture/rich-content-semantic-contract.md b/docs/architecture/rich-content-semantic-contract.md
index 8a1cb1ff5..107d3df2e 100644
--- a/docs/architecture/rich-content-semantic-contract.md
+++ b/docs/architecture/rich-content-semantic-contract.md
@@ -456,6 +456,21 @@ Phase-E acceptance requires permanent executable evidence for:
- bounded expansion / resource usage;
- no active / remote content path.
+As-built (Phase D5): every static prompt / option read path classifies a
+non-null `contentDocument` through the shared static read authority
+(`packages/contracts/src/persistedQuestionContent.ts`) inside
+`ContentRenderer` before any document rendering — only `rich_valid` /
+`rich_noncanonical` (read-only DISPLAY) reach the document renderer;
+`unsupported_version` / `corrupt` fail closed to a controlled integrity
+notice and never fall back to the plain `content` projection. Math
+rendering goes through one encapsulated KaTeX seam with trust disabled and
+explicit expansion / size bounds (implementation parameters, not frozen
+vocabulary). The permanent executable evidence lives at the library /
+React-seam / composition layers in
+`apps/web/src/components/shared/content/MathRenderer.evidence.test.tsx`
+and at the browser network level in `apps/e2e/e2e/rich-content.spec.ts`
+(D5 math render security).
+
## 16. Audit / telemetry
Audit / telemetry must not become a second answer-persistence surface. Raw Rich
@@ -499,6 +514,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) |
| 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/docs/standards/test-flakes.md b/docs/standards/test-flakes.md
index 2435833da..eebc26304 100644
--- a/docs/standards/test-flakes.md
+++ b/docs/standards/test-flakes.md
@@ -981,6 +981,7 @@ Error: Test timed out in 5000ms.
- 2026-07-25:P5-N1 review 修复阶段,`pnpm verify` 全量 coverage 下单次出现(1/1598),standalone 立即 3/3 PASS(1.2s)。
- 2026-09-19:#550 corrective-1 campaign 门禁(`pnpm test`,plain turbo 无 coverage)单次出现——同机数分钟前刚结束 95-min production-mode soak 测量,turbo 15 包并行负载击穿 5s 默认 testTimeout;standalone `npx vitest run tests/concurrency/ea-lock-order.test.ts` 立即 3/3 PASS(tests 1.8s),全量 `pnpm test` 复跑 EXIT=0(2,813 passed / 12 skipped)。与 2026-08-31 条目同机制(宿主负载型,操作背景引入),无代码改动、不调 timeout、不 skip。
+- 2026-10-03:#669 Phase D5 门禁(`pnpm verify`,coverage + `API_TEST_MAX_WORKERS=4`)单次出现,错误与 2026-07-25 首次登记完全一致(`Test timed out in 5000ms` @ `tests/concurrency/ea-lock-order.test.ts:292`);standalone 立即 3/3 PASS(tests 1.5s)。与 D5 改动(Rich 静态读信任、KaTeX 证据、文档)无因果,机制同前两条(coverage 插桩 + 并行负载),无代码改动、不调 timeout、不 skip;全量 verify 复跑通过。
---
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index ca1878a44..86025391e 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -10,6 +10,7 @@ export * from "./course.js";
export * from "./question.js";
export * from "./contentDocument.js";
export * from "./persistedRichAnswer.js";
+export * from "./persistedQuestionContent.js";
export * from "./exam.js";
export * from "./attempt.js";
export * from "./score.js";
diff --git a/packages/contracts/src/persistedQuestionContent.test.ts b/packages/contracts/src/persistedQuestionContent.test.ts
new file mode 100644
index 000000000..7f3454f25
--- /dev/null
+++ b/packages/contracts/src/persistedQuestionContent.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it } from "vitest";
+import {
+ classifyPersistedQuestionContent,
+ resolvePersistedQuestionDocument,
+} from "./persistedQuestionContent.js";
+
+const validDoc = {
+ docVersion: 1,
+ type: "doc",
+ content: [{ type: "paragraph", content: [{ type: "text", text: "题干" }] }],
+};
+
+/** Schema-valid but noncanonical: unsorted marks, split same-mark runs. */
+const noncanonicalDoc = {
+ docVersion: 1,
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "题", marks: ["italic", "bold"] },
+ { type: "text", text: "干", marks: ["italic", "bold"] },
+ ],
+ },
+ ],
+};
+
+/** Envelope-shaped but out-of-grammar (unknown inline node). */
+const corruptEnvelope = {
+ docVersion: 1,
+ type: "doc",
+ content: [
+ { type: "paragraph", content: [{ type: "mysteryInline", text: "x" }] },
+ ],
+};
+
+const unsupportedVersion = {
+ docVersion: 2,
+ type: "doc",
+ content: [{ type: "paragraph", content: [{ type: "text", text: "v2" }] }],
+};
+
+/**
+ * classifyPersistedQuestionContent is the static/prompt read-trust authority
+ * (#669 Phase D5-A, F-06): question contentDocument is Rich-authoritative
+ * whenever it is non-null (ADR-019 B′ — contentMode is derived from slot
+ * nullness), so the classifier distinguishes plain / rich_valid /
+ * rich_noncanonical / unsupported_version / corrupt. There is no
+ * legacy_plain: the document slot never carried a legacy plain-string
+ * population, so a string in the slot is an inconsistent stored shape.
+ */
+describe("classifyPersistedQuestionContent — §7 static read states", () => {
+ it("classifies a null document slot as plain (the content string is the authority)", () => {
+ expect(classifyPersistedQuestionContent(null)).toEqual({ kind: "plain" });
+ expect(classifyPersistedQuestionContent(undefined)).toEqual({
+ kind: "plain",
+ });
+ });
+
+ it("classifies a canonical document as rich_valid", () => {
+ const read = classifyPersistedQuestionContent(validDoc);
+ expect(read.kind).toBe("rich_valid");
+ expect(read.kind === "rich_valid" && read.document).toEqual(validDoc);
+ });
+
+ it("D5A-R2: a non-current docVersion is unsupported_version — never interpreted as V1, never plain", () => {
+ expect(classifyPersistedQuestionContent(unsupportedVersion)).toEqual({
+ kind: "unsupported_version",
+ raw: unsupportedVersion,
+ });
+ // The version signal wins even where the rest of the envelope is odd.
+ expect(classifyPersistedQuestionContent({ docVersion: 3 })).toEqual({
+ kind: "unsupported_version",
+ raw: { docVersion: 3 },
+ });
+ });
+
+ it("D5A-R3: a non-envelope value is corrupt, and a string in the document slot is never adopted as plain", () => {
+ for (const value of [
+ "遗留字符串",
+ 42,
+ [validDoc],
+ { type: "doc" },
+ { docVersion: 1, type: "doc", content: "not an array" },
+ ]) {
+ expect(classifyPersistedQuestionContent(value)).toEqual({
+ kind: "corrupt",
+ raw: value,
+ });
+ }
+ });
+
+ it("D5A-R3: an out-of-grammar envelope is corrupt (deep gate, not the shallow shape)", () => {
+ expect(classifyPersistedQuestionContent(corruptEnvelope)).toEqual({
+ kind: "corrupt",
+ raw: corruptEnvelope,
+ });
+ });
+
+ it("D5A-R4: a hostile deep document is corrupt (bounded preflight before the recursive parse)", () => {
+ let content: unknown = [{ type: "text", text: "leaf" }];
+ for (let i = 0; i < 500; i++) content = [content];
+ const hostile = { docVersion: 1, type: "doc", content };
+ expect(classifyPersistedQuestionContent(hostile)).toEqual({
+ kind: "corrupt",
+ raw: hostile,
+ });
+ });
+
+ it("D5A-R6: a schema-valid noncanonical document is rich_noncanonical and returned UNREPAIRED (read != repair)", () => {
+ const read = classifyPersistedQuestionContent(noncanonicalDoc);
+ expect(read.kind).toBe("rich_noncanonical");
+ expect(read.kind === "rich_noncanonical" && read.document).toEqual(
+ noncanonicalDoc,
+ );
+ });
+});
+
+/**
+ * resolvePersistedQuestionDocument is the binary projection of the classifier
+ * for static display — DISPLAY only, never canonicality, editability, or
+ * repair authority. ContentDocumentRenderer must receive only documents this
+ * projection has accepted (#669 Phase D5-A).
+ */
+describe("resolvePersistedQuestionDocument — static render authority", () => {
+ it("accepts a valid canonical document", () => {
+ expect(resolvePersistedQuestionDocument(validDoc)).toEqual(validDoc);
+ });
+
+ it("accepts a noncanonical historical document for read-only display", () => {
+ expect(resolvePersistedQuestionDocument(noncanonicalDoc)).toEqual(
+ noncanonicalDoc,
+ );
+ });
+
+ it("refuses unsupported-version, corrupt, and absent values", () => {
+ expect(resolvePersistedQuestionDocument(unsupportedVersion)).toBeNull();
+ expect(resolvePersistedQuestionDocument(corruptEnvelope)).toBeNull();
+ expect(resolvePersistedQuestionDocument({ docVersion: 1 })).toBeNull();
+ expect(resolvePersistedQuestionDocument("plain string")).toBeNull();
+ expect(resolvePersistedQuestionDocument(null)).toBeNull();
+ });
+
+ it("returns the parsed canonical document, not the raw payload", () => {
+ const resolved = resolvePersistedQuestionDocument(validDoc);
+ expect(resolved).not.toBeNull();
+ expect(resolved!.content[0]).toEqual(validDoc.content[0]);
+ });
+
+ it("stays binary-consistent with the classifier: valid/noncanonical render, everything else rejects", () => {
+ const corpus = [
+ validDoc,
+ noncanonicalDoc,
+ unsupportedVersion,
+ corruptEnvelope,
+ { docVersion: 1 },
+ "legacy-looking string",
+ null,
+ ];
+ for (const value of corpus) {
+ const read = classifyPersistedQuestionContent(value);
+ const resolved = resolvePersistedQuestionDocument(value);
+ const interpretable =
+ read.kind === "rich_valid" || read.kind === "rich_noncanonical";
+ expect(interpretable).toBe(resolved !== null);
+ }
+ });
+});
diff --git a/packages/contracts/src/persistedQuestionContent.ts b/packages/contracts/src/persistedQuestionContent.ts
new file mode 100644
index 000000000..4ecbe0f7a
--- /dev/null
+++ b/packages/contracts/src/persistedQuestionContent.ts
@@ -0,0 +1,126 @@
+import {
+ CONTENT_DOC_VERSION,
+ contentDocumentsEqual,
+ isContentDocumentV1,
+ preflightContentDocumentStructure,
+ type ContentDocumentV1,
+} from "@exam/domain";
+import {
+ ContentDocumentV1Schema,
+ canonicalizeContentDocument,
+} from "./contentDocument.js";
+
+/**
+ * FROZEN-SEMANTICS read authority for persisted QUESTION content
+ * (rich-content-semantic-contract.md §7; #669 Phase D5-A, F-06). Every static
+ * render path that interprets a question prompt / option `contentDocument` —
+ * take-exam runtime, grading view, candidate result, authoring preview,
+ * choice inputs — must classify through `classifyPersistedQuestionContent`
+ * (or its binary projection `resolvePersistedQuestionDocument`) and must not
+ * keep a private read oracle.
+ *
+ * PROMPT PROVENANCE (differs from the persisted-answer classifier): a
+ * question is Rich exactly when its `contentDocument` slot is non-null
+ * (ADR-019 B′ — `contentMode` is derived from slot nullness, never stored),
+ * so no answer-mode context is needed and there is no `legacy_plain` state:
+ * the document slot never carried a legacy plain-string population, so a
+ * string in the slot is an inconsistent stored shape (`corrupt`). A null
+ * slot is `plain`: the `content` column is the sole authority. `content` on
+ * a Rich question is a server-derived projection (search/display text), NOT
+ * a fallback authority — a corrupt document must never degrade into
+ * rendering it as though the question were Plain.
+ *
+ * The Rich interpretation below is the same primitive sequence the
+ * persisted-answer classifier uses (version gate → envelope gate → bounded
+ * preflight → schema+limits → canonical identity), so prompt and answer reads
+ * share one definition of valid Rich (§7). Read classification never mutates
+ * or repairs persisted data: a noncanonical document is returned as persisted
+ * (`rich_noncanonical`), and `unsupported_version` / `corrupt` carry the raw
+ * value so callers can fail closed without losing track of the source truth.
+ */
+
+/** The §7 static read states as a runtime vocabulary (export/DTO consumers). */
+export const PERSISTED_QUESTION_CONTENT_STATES = [
+ "plain",
+ "rich_valid",
+ "rich_noncanonical",
+ "unsupported_version",
+ "corrupt",
+] as const;
+
+/** One of the §7 static read states, as a bare token. */
+export type PersistedQuestionContentState =
+ (typeof PERSISTED_QUESTION_CONTENT_STATES)[number];
+
+export type PersistedQuestionContentReadState =
+ | { kind: "plain" }
+ | { kind: "rich_valid"; document: ContentDocumentV1 }
+ | { kind: "rich_noncanonical"; document: ContentDocumentV1 }
+ | { kind: "unsupported_version"; raw: unknown }
+ | { kind: "corrupt"; raw: unknown };
+
+/** Compile-time guard: the state vocabulary and the classified union agree. */
+type AssertExact = [A] extends [B]
+ ? [B] extends [A]
+ ? true
+ : false
+ : false;
+const _stateVocabularyIdentity: AssertExact<
+ PersistedQuestionContentReadState["kind"],
+ PersistedQuestionContentState
+> = true;
+void _stateVocabularyIdentity;
+
+export function classifyPersistedQuestionContent(
+ value: unknown,
+): PersistedQuestionContentReadState {
+ if (value == null) return { kind: "plain" };
+ // A numeric docVersion the system does not interpret is a forward-compat
+ // signal and must win over any shape reasoning: never reinterpret a
+ // future-version envelope as V1 content or plain.
+ if (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as { docVersion?: unknown }).docVersion === "number" &&
+ (value as { docVersion?: unknown }).docVersion !== CONTENT_DOC_VERSION
+ ) {
+ return { kind: "unsupported_version", raw: value };
+ }
+ if (!isContentDocumentV1(value)) return { kind: "corrupt", raw: value };
+ if (preflightContentDocumentStructure(value).length > 0) {
+ return { kind: "corrupt", raw: value };
+ }
+ const parsed = ContentDocumentV1Schema.safeParse(value);
+ if (!parsed.success) return { kind: "corrupt", raw: value };
+ // Canonical trust: a persisted value is rich_valid only when the same
+ // canonicalization the write seam uses reproduces it (RC-03). Schema-valid
+ // but non-canonical values stay interpretable for read-only DISPLAY and
+ // are never silently normalized at read time (read != repair).
+ const canonical = canonicalizeContentDocument(parsed.data);
+ if (!canonical.ok)
+ return { kind: "rich_noncanonical", document: parsed.data };
+ return contentDocumentsEqual(parsed.data, canonical.value)
+ ? { kind: "rich_valid", document: parsed.data }
+ : { kind: "rich_noncanonical", document: parsed.data };
+}
+
+/**
+ * Binary projection of `classifyPersistedQuestionContent` for static
+ * rendering: the canonical document when the value is interpretable Rich
+ * (`rich_valid` or `rich_noncanonical`), null otherwise. Callers render
+ * their controlled integrity fallback for null — a corrupt/unsupported
+ * prompt must never fall through to the plain `content` projection and must
+ * never reach `ContentDocumentRenderer` unvalidated.
+ *
+ * This projection grants DISPLAY, never canonicality, editability, or repair
+ * authority: nothing in the static read path may normalize and persist a
+ * `rich_noncanonical` document.
+ */
+export function resolvePersistedQuestionDocument(
+ value: unknown,
+): ContentDocumentV1 | null {
+ const read = classifyPersistedQuestionContent(value);
+ return read.kind === "rich_valid" || read.kind === "rich_noncanonical"
+ ? read.document
+ : null;
+}