Skip to content

fix(rich): Phase D5 render trust — fail closed on untrusted static prompts + KaTeX evidence - #693

Merged
jnhu76 merged 4 commits into
masterfrom
fix/669-rich-phase-d5-render-trust
Oct 3, 2026
Merged

jnhu76 merged 4 commits into
masterfrom
fix/669-rich-phase-d5-render-trust

Conversation

@jnhu76

@jnhu76 jnhu76 commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Closes the render-trust subphase of #669 (Phase D5, the final corrective phase before Phase E). Builds on the Phase-B authority (#669 Phase-B authority pass), D1 (#687 write truth), D3 (#690 read/edit trust), D2 (#691 replay truth), and D4 (#692 export truth).

F-06 is a read-trust defect.
PC-F08 is primarily a rendering/fidelity evidence class; D5 does not reclassify it as a semantic defect without new evidence. It is reproduced and classified EXPECTED_KATEX_POLICY below.

D5-A — static / prompt Rich read trust (closes F-06)

Every static prompt/option render path funnels through ContentRenderer, which previously handed a non-null contentDocument straight to ContentDocumentRenderer on a TypeScript annotation alone — a corrupt or future-version persisted value could partially render inconsistently or throw a render-time TypeError (document.content.map / inlines.map), reproduced verbatim by the new regressions before the fix.

Call-graph audit (all production static Rich entry points):

Entry point Source Classified
TakeExamPage prompt frozen attempt snapshot through ContentRenderer
ResultPage prompt / answer candidate result DTO / frozen answer ContentRenderer / resolveRichAnswerDocument (D4)
GradingDetailPage prompt / answer frozen grading wire ContentRenderer / resolveRichAnswerDocument (D4)
QuestionPreview prompt + options authoring (live editor docs — canonical by construction — and persisted values) through ContentRenderer
SingleChoiceInput / MultipleChoiceInput options frozen runtime views through ContentRenderer
AttemptDetailPage projection frozen answer resolveRichAnswerDocument (D4)
API export persisted answer D4 classifier (untouched)

Changes:

  • New shared static read authority packages/contracts/src/persistedQuestionContent.ts: classifyPersistedQuestionContent / resolvePersistedQuestionDocument, distinguishing plain / rich_valid / rich_noncanonical / unsupported_version / corrupt (§7). It reuses the exact primitive sequence of the D4 answer classifier (version gate → envelope gate → bounded preflight → schema+limits → canonical identity) so prompt and answer reads keep one definition of valid Rich — but it is a separate entry, because prompt provenance differs (contentMode is derived from slot nullness per ADR-019 B′; no legacy_plain state exists: the document slot never carried a legacy plain-string population). The answer classifier's semantics are untouched (§28).
  • ContentRenderer becomes the render trust boundary: only rich_valid / rich_noncanonical reach ContentDocumentRenderer. rich_noncanonical grants read-only DISPLAY, never canonicality or repair (read ≠ repair). unsupported_version / corrupt fail closed to a controlled integrity notice (content.unsafeDocument) — never a TypeError, never a silent fallback to the plain content projection (on a Rich question content is a server-derived search/display text, not an authority).
  • ContentDocumentRenderer's per-node fail-safes remain as defense in depth, pinned by tests that drive the renderer directly (unreachable through the boundary); no second grammar/parser/oracle introduced.

Regressions: D5A-R1 (canonical prompt, real render), R2 (docVersion 2 fail-closed, never V1-interpreted, never plain fallback), R3 (corrupt envelopes: missing/non-array/wrong-type content, string in slot), R4 (nested structures that crash .map()), R5 (off-grammar nodes rejected before rendering, not partially rendered), R6 (historical noncanonical: explicit classification, displays, zero mutation).

D5-B — rendering / KaTeX permanent executable evidence

Characterized empirically against the pinned KaTeX 0.18.4 with the exact production options (throwOnError: false, trust: false, strict: "ignore", output: "html", maxSize: 50, maxExpand: 1000) — no remembered defaults. MathRenderer.evidence.test.tsx now carries the permanent evidence at the library seam, the React seams, and the composition layer:

  • M1/R1/R2: normal inline/block math renders structured output through the lazy production seam.
  • M2/R3: parse-level malformed math preserves the source verbatim (KaTeX error projection); unknown commands keep tokens and arguments visible — no crash, no silent disappearance.
  • M3/R4: trust-disallowed commands produce no anchor/image/attribute capability (DOM-level assertions, not string presence).
  • PC-F08 (reproduced, classified EXPECTED_KATEX_POLICY): \includegraphics[...]{https://…} renders the command token as inert text while the argument is not faithfully represented — source evidence remains, no active content, degradation bounded, and §3.1 freezes no rendered-fidelity promise for trust-disallowed constructs. Not a semantic defect; no trust enabling, no grammar change.
  • M4/R5: HTML-like / event-handler-like source becomes escaped text only.
  • M6/R7: expansion abuse fails bounded by maxExpand with the source preserved; dimension abuse is capped by maxSize — structural assertions only, no timing thresholds. Grammar-layer bounds (CONTENT_LIMITS) are separate and unchanged.
  • R8: real ContentRenderer → ContentDocumentRenderer → MathRenderer composition renders inert static output.
  • R9: real editor math path — Tiptap JSON → canonical document (adapter normalizes) → static read trust boundary → rendered math — with source semantics preserved (composes the existing adapter/C12–C15 pins; no selection-settlement work reopened).
  • Browser-level no-remote-content proof (jsdom cannot prove network behavior): new E2E in rich-content.spec.ts renders an adversarial prompt (\includegraphics remote URL, \href, HTML-like math) in a real browser — zero cross-origin requests, no active/remote node, inert token text visible.
  • §24/§25 sweeps re-verified: the only active dangerouslySetInnerHTML is the encapsulated KaTeX seam (pinned by test); the V1 grammar has no image/iframe/video/audio/link node types — URLs can only exist as inert text/latex.

Explicitly out of scope / follow-up found during audit

  • The publish gate (examCommands.ts projection invariant) calls plainTextProjection(question.contentDocument) directly on raw repository rows. A corrupt envelope row would surface as a TypeError 500 instead of a typed ValidationError — latent (the write seam canonicalizes; import is Plain-only), server-side, non-render. Not bundled (D4/publish semantics must not move in D5); recommend a focused follow-up issue.
  • Phase E is not started; Gate-1 not evaluated.

Verification

  • pnpm verify:static — PASS (local)
  • pnpm verify (coverage + build) — PASS (local)
  • bash scripts/e2e/run.sh rich-content — PASS (2/2 shards, including the new adversarial-math browser test)
  • Exact-head CI — pending on this head

Closes F-06. #669 #673

jnhu76 added 3 commits October 3, 2026 17:56
Static prompt/option render paths (take-exam runtime, grading, result,
preview, choice inputs) handed a non-null contentDocument straight to
ContentDocumentRenderer on a TypeScript annotation alone (#669 F-06): a
corrupt or future-version persisted value could partially render
inconsistently or throw a render-time TypeError on malformed nested
structure.

Add the shared static read authority classifyPersistedQuestionContent /
resolvePersistedQuestionDocument (@exam/contracts, §7 read contract) and
resolve trust inside ContentRenderer before rendering: only rich_valid /
rich_noncanonical reach the document renderer (noncanonical = read-only
DISPLAY, never repair); unsupported_version / corrupt fail closed to a
controlled integrity notice — never a TypeError, never a silent fallback
to the plain content projection (a derived search/display text on Rich
questions, not an authority). The answer-side classifier is untouched:
the prompt seam reuses the same domain/contracts primitives, so prompt
and answer reads keep one definition of valid Rich.
Permanent executable evidence for the §15 rendering/security contract
(#669 Phase D5-B), characterized empirically against the pinned KaTeX
(0.18.4) with the exact production options — no remembered defaults:

- M1: normal inline/block math renders structured output
- M2/R3: malformed math never crashes; parse errors preserve the source
  verbatim (katex-error projection), unknown commands keep tokens and
  arguments visible — no silent disappearance
- M3/R4 + PC-F08: trust-disallowed commands produce no anchor/image/
  attribute capability; the Phase-C fidelity observation is reproduced
  exactly (command token remains as inert text, argument not faithfully
  represented) and classified EXPECTED_KATEX_POLICY — source evidence
  stays, no active content, and §3.1 freezes no rendered-fidelity
  promise for trust-disallowed constructs
- M4/R5: HTML-like / event-handler-like source becomes escaped text only
- M6/R7: expansion abuse fails bounded by maxExpand with source
  preserved; dimension abuse is capped by maxSize (structural
  assertions, no timing thresholds)
- R8: real ContentRenderer → ContentDocumentRenderer → MathRenderer
  composition renders inert static output
- R9: real editor math path — Tiptap JSON → canonical document → static
  read trust boundary → rendered math — preserves source semantics
- browser-level no-remote-content proof (jsdom cannot prove network
  behavior): the adversarial prompt renders in a real browser with zero
  cross-origin requests and no active/remote node
As-built references only (§33): the static question-content read
classification authority joins the §18 authority map, and §15 records
the Phase D5 implementation pointers (render trust boundary in
ContentRenderer, encapsulated KaTeX seam with explicit expansion/size
bounds as implementation parameters, permanent evidence locations).
No normative semantics changed.
@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c21c89b5-63c2-4719-9419-44b20b67b09f
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Second recurrence of the registered 2026-07-25 host-load flake
(coverage instrumentation + parallel workers breach the 5s default
testTimeout); standalone passes immediately, full verify rerun is
green, and the D5 changes have no causal connection to exam-attempt
lock ordering. Bookkeeping per the ledger's own recurrence protocol —
no timeout, no skip, no code change.

jnhu76 commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Independent D5 focused review — PASS.

I verified the D5-A trust boundary, D5-B KaTeX evidence, and exact-head CI at c6263701759d73d03eff1f6fb6dc14c7a82ae1a0.

Verdict:

  • F-06: CLOSED
  • static prompt trust: PASS
  • corrupt / unsupported prompt fail-closed behavior: PASS
  • noncanonical prompt remains display-only / no repair: PASS
  • KaTeX production options and bounded/inert evidence: PASS
  • PC-F08: reproduced and correctly classified as EXPECTED_KATEX_POLICY / fidelity observation, not a frozen-semantic defect
  • browser no-remote-content evidence: PASS
  • exact-head CI: green

No D5 blocker found. #693 is mergeable after review.

One follow-up should be closed before Phase E rather than bundled into D5: publishExam still calls plainTextProjection() directly on raw repository question.contentDocument and rich option.contentDocument. A corrupt historical/bypassed row can therefore throw a raw TypeError instead of failing through the Rich trust boundary / typed ValidationError. This is latent because supported writers canonicalize, so it does not block #693, but Phase E should not begin with this known publish-boundary trust gap still open.

@jnhu76
jnhu76 merged commit 01412bb into master Oct 3, 2026
11 checks passed
@jnhu76
jnhu76 deleted the fix/669-rich-phase-d5-render-trust branch October 3, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant