Skip to content

fix(rich): Phase-D3 persisted-read trust and fail-closed editable adoption (#669) - #690

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

jnhu76 merged 4 commits into
masterfrom
fix/669-rich-phase-d3-read-trust

Conversation

@jnhu76

@jnhu76 jnhu76 commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

变更说明

#669 Phase D3:修复 Phase-C 发现 PC-F03 / PC-F04 / PC-F05 / PC-F07(候选可编辑路径的类型化读信任缺失 + adapter 静默降级)。基线 = live master 1d8bbb6a(PR #687 / Phase D1 merge)。

根因与修复

发现 as-built 根因(1d8bbb6a) 修复
PC-F03 RichTextAnswerInput 以 typeof value === "string" 无出处采用字符串为 legacy 内容 §7 provenance 规则进分类器:plain slot 的字符串 = plain(冻结 answerMode 即出处);rich slot 字符串仅在显式 legacyPlainProvenance 证据下 = legacy_plain,否则 corrupt
PC-F04 docVersion:2 信封掉进 plainTextToDocument("") → 可编辑空编辑器 → 首个 onChange/autosave 覆写原值 typed unsupported_version:渲染完整性提示,不挂编辑器 ⇒ 无任何 onChange/autosave 路径
PC-F05 浅信封门 isContentDocumentV1 放深度非法值进编辑器 深读门(preflight → schema → canonicalization 比较)收进单一分类器;浅门契约注释冻结为 shape hint,退出一切信任决策
PC-F07 blockToTiptap/inlineToTiptap 无 default 分支:未知节点 → undefined 条目 → 编辑器带残缺文档静默挂载 显式 typed throw(unmappable block/inline node);分类器使其实际不可达,throw 是纵深防御边界

读状态模型(§7 单一权威)

classifyPersistedRichAnswer({ value, answerMode, legacyPlainProvenance? }) → empty | plain | legacy_plain | rich_valid | rich_noncanonical | unsupported_version | corrupt(richAnswer.ts,由既有 resolveRichAnswerDocument 演化而来;后者保留为其二进制投影,无第二读 oracle)。

  • rich_valid = 写缝同一 canonicalizeContentDocument 精确复现持久值(RC-03);schema 合法但非 canonical(含 PC-F01 闭包类,fixture 化回归)→ rich_noncanonical,按持久化形态返回,读取期不修复、不改写。
  • provenance:answerMode 来自冻结快照(nullish → plain 合同变换)。legacy_plain 当前无生产者——rich 写缝与 answerMode 同期落地(Content/WYSIWYG V1 — Plain/Rich content model, math/table/code, asset-ready, performance-first #301),受支持的写者从未产出 rich-slot 字符串(Phase-C 台账 L1:SUPPORTED_CURRENT_WRITER = NO);状态为未来可证明出处的迁移保留。
  • 回答读取分类不改变服务端行为;take/submit/grading 仍按设计透传 raw evidence,信任决策在客户端读权威单点完成。

采纳策略(所有路径同一信任语义)

initial load / reload retry / EXAM-519 reconcile / REC-I3 restore 全部经 applySnapshot,STALE_VERSION 经 setAnswers——都存 raw 值,由 RichTextAnswerInput 单点解释(§10):

  • rich_valid → 按持久化文档挂载编辑器
  • empty → 显式空编辑器;plain / legacy_plain → 内核授权升级映射(plainTextToDocument)
  • rich_noncanonical / unsupported_version / corrupt → 完整性提示(content.unsafeEditableAnswer),无编辑器 ⇒ 无合成 onChange ⇒ 无 autosave 可覆写源真值

rich_noncanonical 可解释但不可编辑(F1,focused review 修复 e47c1107):编辑器 onUpdate 经 canonicalization 再序列化,挂载 noncanonical 值即把读分类变成对未编辑内容的静默修复写;PC-F01 闭包类 canonical 化后超 CONTENT_LIMITS.textRun,会得到一个产出永远无法保存的值的可编辑表面。此类值的修复属未来显式 repair policy,绝不发生在读取期。只读渲染(结果/批改页)仍按持久化形态原样显示。

只读消费者(ResultPage / GradingDetailPage / AttemptDetailPage)经同一权威:rich slot 不可解释值显示 content.unsupportedAnswer,不再漏渲染原始字符串、不再以浅门做信任决策(§13:editable 与静态显示无弱差异)。

回归证据(R1–R5 + F1,最小化 fixture)

  • R1 rich slot + 字符串 → corrupt + 组件完整性态(原 QuestionRenderer 的 PC-F03 行为测试改写为 fail-closed 断言);正控:plain slot 字符串仍升级挂载;分类器级正控证明显式 provenance 下 legacy_plain 可达
  • R2 docVersion:2 → unsupported_version;组件断言无 .ProseMirror、onChange 零发射(覆盖 mount → onChange → autosave 全危险边)
  • R3 corrupt envelope(未知 inline / 未知 block)→ corrupt;生产代码中 isContentDocumentV1 调用仅剩分类器内部深门控前置
  • R4 adapter 对未知 block/inline 显式 throw(/unmappable block node: script/);正控:canonical 文档无损往返
  • R5 valid 挂载 → STALE_VERSION 采纳 corrupt/unsupported serverAnswer → 编辑器替换为完整性态且零新增发射;正控:valid → valid 照常权威采纳
  • F1-A 可 canonical 化的短 noncanonical(乱序 marks + 同 mark 分裂 run)→ 完整性态,零发射(mount → 规范化 onChange → autosave 全危险边被挡)
  • F1-B PC-F01 闭包类 noncanonical(10001+10001 同 mark run)→ 完整性态,不暴露普通可编辑表面

验证

  • 分类器/adapter/组件/相关页面 targeted tests:PASS(richAnswer 18 + contentAdapter 31 + readTrust/QuestionRenderer/ownership/reality 74 + ResultPage/TakeExamPage/examComponents 80)
  • F1 修复后定向复跑(e47c1107):richAnswer + contentAdapter + readTrust(9,含 F1-A/F1-B) + QuestionRenderer 66/66 PASS;pnpm verify:static PASS
  • pnpm verify:static:PASS
  • pnpm verify:PASS @ 017cf87c(API coverage 显式重跑:210 files / 2783 tests passed | 4 files skipped,含 server.shutdown——首跑 EADDRINUSE 为宿主遗留 12h tsx watch dev server 占用 :3000 的环境性冲突,清理后复跑全绿,与本 diff 无关);e47c1107 全量等价证据由 exact-head CI 提供
  • E2E rich-content slice(bash scripts/e2e/run.sh rich-content):9/9 passed @ 017cf87c(含 STALE_VERSION 真浏览器 reconciliation 流),runner exit 0
  • exact-head CI:见 checks

非目标

无 D2(receipt)/ D4(export 契约)/ D5(KaTeX)工作;未改 Rich 文法、CONTENT_LIMITS、canonicalization 语义、Attempt 生命周期、SaveAnswer 错误优先级;未重开 C12–C15(编辑器→canonical 方向的 C14 冻结 cell/list 映射保持原样);无 #678 capability 工作。

关联 #669 #673 #686;基线 #687 merge 1d8bbb6a。

jnhu76 added 3 commits October 3, 2026 02:23
classifyPersistedRichAnswer is the single §7 read authority for persisted
candidate answers (empty / plain / legacy_plain / rich_valid /
rich_noncanonical / unsupported_version / corrupt). A string in a rich slot
is corrupt unless explicit legacyPlainProvenance evidence is supplied —
runtime shape never establishes legacy_plain (PC-F03). rich_valid requires
the write seam's canonicalization to reproduce the persisted value (RC-03);
schema-valid noncanonical values (incl. the PC-F01 closure class) are
returned unrepaired. resolveRichAnswerDocument becomes a binary projection
of the classifier; isContentDocumentV1's contract comment now states it is
a shape hint, never trust proof.
RichTextAnswerInput now adopts through the typed read authority: only
rich_valid / rich_noncanonical / empty (and plain-mode strings via the
authorized kernel upgrade) mount the editor; unsupported_version and
corrupt render an integrity notice with NO editor — no initial
onChange/autosave can ever replace the persisted source with synthetic
empty content (PC-F04, CORRUPT_PERSISTED_RICH != EMPTY_DOCUMENT). The
frozen snapshot answerMode is threaded from QuestionRenderer so the
classification has slot context.

Initial load, reload, EXAM-519 reconcile, REC-I3 restore (all through
applySnapshot) and STALE_VERSION serverAnswer adoption share these trust
semantics at the single component seam. Read-only consumers
(ResultPage / GradingDetailPage / AttemptDetailPage) route the same
authority: an uninterpretable rich-mode answer keeps the integrity notice
instead of the raw payload or the shallow-gate branch (PC-F05 read side).
inlineToTiptap / blockToTiptap gain explicit default branches that throw
on nodes outside the closed grammar. Previously an unknown block produced
a silent undefined entry in the Tiptap content array and the editor
mounted with the remaining fragments (PC-F07); conversion now fails
explicitly. Classifier-gated schema-valid documents make the throw
unreachable in practice — it is the defense-in-depth boundary, matching
the reverse direction's existing unmappable-node throws.
@coderabbitai

coderabbitai Bot commented Oct 2, 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: 30ce7e40-fb36-4cac-b02f-c04cfcdd2b20

  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

@jnhu76 jnhu76 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focused D3 review: CHANGE_REQUIRED — one blocker remains.

F1 — rich_noncanonical is currently treated as editable exactly like rich_valid, which violates the frozen §7 rule that read classification must not become a silent repair write.

Evidence:

  • classifyPersistedRichAnswer deliberately returns the persisted, unrepaired document as rich_noncanonical.
  • RichTextAnswerInput mounts rich_noncanonical directly into RichContentEditorLazy with the normal onChange callback.
  • RichContentEditor maps every onUpdate through tiptapToContentDocument(), which normalizes before onChange(next).
  • The new read-trust test itself notes that the normal editor mount may emit transactions/callbacks (callsAtValidMount).

Therefore a schema-valid but noncanonical persisted value can be mounted, normalized by the editor representation, and emitted into autosave without a user semantic edit. For ordinary noncanonical values this is an implicit repair write; for the PC-F01 closure-class fixture, the editor can hold/emit the merged >textRun representation that the D1 server boundary rejects, leaving an apparently editable answer that cannot be saved. Both contradict the D3 requirement: persisted content must earn editability, and read must not silently repair persisted truth.

Required focused correction (do not broaden D3):

  1. Define an explicit editable policy for rich_noncanonical that cannot silently mutate persisted truth. The simplest safe policy is fail-closed/read-integrity state for all rich_noncanonical until an explicit migration/repair path exists. A more permissive policy is acceptable only if it proves mount emits zero repair write and handles canonicalization-failing PC-F01-class values safely.
  2. Add regressions for both:
    • canonicalizable noncanonical fixture (split/unsorted marks): mount/adoption must not silently emit a repaired value;
    • PC-F01 closure-class noncanonical fixture: must not expose an editor that immediately/inevitably produces an unsaveable canonical value.
  3. Keep rich_valid, explicit empty, plain-slot mapping, corrupt/unsupported, STALE_VERSION, and PC-F07 behavior unchanged.

Everything else I reviewed is good: provenance rule closes PC-F03; unsupported/corrupt fail closed and block the destructive empty-editor path; resolveRichAnswerDocument is a projection of the typed classifier rather than a second oracle; adapter unknown block/inline now throws; exact-head CI is green.

Verdict:
PC-F03 = PASS
PC-F04 = PASS
PC-F05 = PASS
PC-F07 = PASS
F1_NONCANONICAL_EDIT_ADOPTION = BLOCKER
PHASE_D3 = CHANGE_REQUIRED
BROAD_RERUN_REQUIRED = NO

Only a focused correction + focused re-review is needed.

Focused-review repair (#669 Phase D3 F1): RichTextAnswerInput mounted
rich_noncanonical with the ordinary editor, whose onUpdate path
re-serializes through canonicalization — turning read classification
into a silent repair write of never-edited content, and, for the PC-F01
closure class, producing a canonical form the write seam always rejects.
Noncanonical persisted values now join the integrity state; repair of
such values remains a future explicit policy, never read-time behavior.
@jnhu76

jnhu76 commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

F1 处置(e47c1107)

接受 BLOCKER 裁决,已按最小修法修复:rich_noncanonical 移入 fail-closed 完整性态,不再获得任何 editability。

采纳策略(修复后)

  • rich_valid → 编辑器(唯一可编辑的 Rich 态)
  • empty → 显式空编辑器
  • plain / legacy_plain → 内核授权升级映射(plainTextToDocument)
  • rich_noncanonical / unsupported_version / corrupt → 完整性提示,无编辑器 ⇒ 无 onChange ⇒ 无 autosave 路径

不采用"部分 noncanonical 可编辑"方案:证明 mount 零发射、部分重写留痕、PC-F01 不可保存表面三条都满足,超出 D3 范围;noncanonical 修复属未来显式 repair policy,绝不发生在读取期。分类器、provenance、STALE_VERSION、adapter 均未触碰;resolveRichAnswerDocument 保持 noncanonical 只读渲染(注释已冻结:DISPLAY, never editability)。

新增 focused regression(RichTextAnswerInput.readTrust.test.tsx)

  • F1-A 可 canonical 化的短 noncanonical(乱序 marks + 同 mark 分裂 run)→ 完整性态,onChange 零发射 —— mount → 规范化 onChange → autosave 全危险边被挡
  • F1-B PC-F01 闭包类(10001 + 10001 同 mark run)→ 完整性态,不暴露普通可编辑表面

证据

  • 定向复跑:richAnswer + contentAdapter + readTrust(9) + QuestionRenderer 66/66 PASS
  • pnpm verify:static:PASS(EXIT=0)
  • exact-head CI @ e47c1107:10/10 全绿(Static / Build / API coverage 4m37s / Web coverage / Package coverage / Production transport / E2E ×4)
  • 按裁决未做 broad rerun;pnpm verify 与 E2E slice 既有证据标注在 head 017cf87c,e47c1107 的全量等价覆盖由本次 CI 提供

PHASE_D3 状态变更待你 focused re-review 复裁。

@jnhu76 jnhu76 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focused re-review — PASS.

F1_NONCANONICAL_EDIT_ADOPTION = CLOSED.

Verified at head e47c110:

  • RichTextAnswerInput grants editability only to rich_valid, explicit empty, and authorized plain / legacy_plain mappings.
  • rich_noncanonical, unsupported_version, and corrupt all fail closed to the same integrity state; no editor surface means no mount/update/autosave repair path.
  • F1-A guards a canonicalizable noncanonical value (split same-mark runs + unsorted marks) with zero onChange emission.
  • F1-B guards the PC-F01 closure-class noncanonical value from exposing any editable surface.
  • resolveRichAnswerDocument remains explicitly read-only DISPLAY projection for rich_valid / rich_noncanonical; this does not confer editability.
  • Classifier/provenance/STALE_VERSION/adapter semantics from the prior review are unchanged.
  • exact-head CI run 37058097355 completed SUCCESS (10 jobs).

Final D3 verdict:
PC-F03 = PASS
PC-F04 = PASS
PC-F05 = PASS
PC-F07 = PASS
F1 = CLOSED
PHASE_D3 = PASS
READY_FOR_D2 = YES_AFTER_MERGE
GATE_1 = NOT_EVALUATED

No further D3 code changes requested.

@jnhu76
jnhu76 merged commit a2a691c into master Oct 3, 2026
11 checks passed
@jnhu76
jnhu76 deleted the fix/669-rich-phase-d3-read-trust branch October 3, 2026 01:05
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