Skip to content

fix(export): #669 Phase D4 — Rich export trust boundary (F-05/F-08) - #692

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

jnhu76 merged 4 commits into
masterfrom
fix/669-rich-phase-d4-export-trust

Conversation

@jnhu76

@jnhu76 jnhu76 commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Phase D4 — Rich export trust boundary (#669)

Implements Phase D4 only. D4 is driven by the post-D2 Authority ↔ Implementation adversarial audit.

F-05 is a frozen-contract violation, not a new export feature request. CSV export decided Rich validity from the runtime shape (typeof value === "string"), so a Rich slot holding a corrupt/unprovenanced string was silently exported as normal human-readable Plain answer text — violating the frozen §7/§14 rule that invalid/corrupt Rich must never be silently exported as a valid Plain projection. D4 also resolves the adjacent traceability gap F-08 (the seven-state classifier lived only in apps/web).

What changed

One shared classifier (D4-A / D4-K, F-08)

  • classifyPersistedRichAnswer / resolveRichAnswerDocument moved to @exam/contracts (packages/contracts/src/persistedRichAnswer.ts); the web module is deleted and RichTextAnswerInput + AttemptDetailPage / GradingDetailPage / ResultPage import it. Classifier bodies are byte-identical to the D3 implementation (verified by diff); the moved D3 suite passes unchanged.
  • contentDocumentsEqual moved to @exam/domain (the §18 equivalence authority) so the classifier and the editor share one comparator; its tests moved with it.
  • Narrow structural ownership guard (persistedRichAnswerOwnership.structural.test.ts): exactly one definition repo-wide; the export seam must not parse documents itself.

Trust-aware export (D4-B…D4-I)

  • New API policy apps/api/src/lib/attemptExportAnswer.ts: each candidate answer classifies through the shared classifier against the frozen snapshot answerMode (never the live question bank). text_response slots classify into the seven §7 states; typed objective slots keep their existing display rule (not Rich slots — the classifier cannot mislabel arrays/booleans as corrupt).
  • JSON export: candidateAnswer stays the untouched raw evidence; additive candidateAnswerMode / candidateAnswerIntegrity / candidateAnswerProjection. OpenAPI regenerated.
  • CSV export: appended 考生答案模式 / 考生答案状态 after the frozen columns (existing positions unchanged → positional consumers preserved). The 考生答案 cell holds the classified projection only — unsupported_version / corrupt render the not-applicable marker, never stored text; raw evidence stays in the JSON export route.
  • Export stays read-only: no normalize/canonicalize/repair/write-back; per-state policy documented in the contract (§14 as-built).

Regressions (real seam, not only the classifier)

R1/F-05 counterexample (string in frozen rich slot → corrupt, never CSV text) · R2 plain + typed objective controls · R4 rich_valid raw+labeled projection · R5 noncanonical projected & unrepaired (DB re-read) · R6 unsupported_version no fake Plain · R7 malformed envelope corrupt · D4-G live-question mutation proves frozen-mode authority · empty state · extraction-equivalence (moved D3 corpus + component fail-closed tests).

Non-goals

No D5 prompt renderer / KaTeX work; F-06 stays → D5-A. No new Rich grammar or ContentDocument version; no SaveAnswer replay / receipt / lifecycle changes; no candidate UX redesign (import paths only); no capability composition work.

Verification

pnpm test (16/16 tasks, API 2801 passed) · pnpm verify:static · pnpm verify (coverage + build) · exact-head CI pending on this head.

Refs #669 #673 · Phase D2 (#691) · Phase D3 (#690) · F-05 · F-08

jnhu76 added 4 commits October 3, 2026 16:06
Move classifyPersistedRichAnswer / RichAnswerReadState /
resolveRichAnswerDocument from apps/web into @exam/contracts so the web read
paths and the API export boundary consume ONE semantic implementation
(#669 Phase D4, F-08). The classifier bodies are byte-identical to the D3
web implementation; only the module location changed.

contentDocumentsEqual moves to @exam/domain (the §18 equivalence authority)
because the shared classifier's canonical-trust comparison needs it and a
second implementation is forbidden; the editor keeps importing the same
function from its new home, and its tests moved with it.

The web richAnswer module is deleted; consumers import from
@exam/contracts. No behavior change intended: the moved classifier suite and
the component-level fail-closed tests pass unchanged (readTrust, editor
ownership, re-pointed pages).
F-05 is a frozen-contract violation, not a new export feature: CSV export
decided Rich validity from the runtime shape (typeof value === "string"),
so a Rich slot holding a corrupt/unprovenanced string was silently exported
as normal Plain answer text (rich-content-semantic-contract §14).

The export boundary now classifies every candidate answer through the one
shared persisted-answer classifier against the FROZEN snapshot answerMode
(never the live question bank):

- text_response slots classify into the seven §7 read states; unsupported /
  corrupt values get NO semantic projection (CSV marker, never stored text);
  rich_valid / rich_noncanonical expose the same plain projection the read
  paths display; legacy_plain stays provenance-gated (unreachable until a
  proven producer exists);
- typed objective slots keep their existing display rule (not Rich slots,
  so the classifier cannot mislabel arrays/booleans as corrupt).

JSON keeps candidateAnswer as the untouched raw evidence and adds
candidateAnswerMode / candidateAnswerIntegrity / candidateAnswerProjection
(OpenAPI regenerated). CSV appends 考生答案模式 / 考生答案状态 after its
frozen columns so positional consumers keep their offsets; the answer cell
is the classified projection only. Export stays read-only: no normalization,
repair, or write-back.
Exercise the real export seams (both routes through the frozen attempt
snapshot), not only the classifier:

- R1/F-05 counterexample: a string in a frozen rich slot stays raw evidence
  in JSON, classifies corrupt, and never reaches the CSV as answer text;
- R2: plain and typed objective answers still export normally;
- R4: canonical Rich preserves raw evidence and labels its derived
  projection rich_valid;
- R5: noncanonical Rich is projected but never relabeled or repair-written;
- R6/R7: unsupported docVersion and malformed envelopes keep raw evidence
  with no fabricated Plain projection;
- D4-G: exporting after mutating the LIVE question row proves the frozen
  snapshot answerMode is the classification source;
- empty: an absent answer exports as empty with no projection.

The structural ownership guard pins D4-A/D4-K: classifyPersistedRichAnswer
is defined exactly once (in @exam/contracts), and the export seam does not
parse documents itself (no shape oracle).
Record the as-built export shape in §14 (JSON raw + integrity/projection
companions; CSV appended mode/state columns with the classified answer cell)
and point §18 at the single shared classifier in
packages/contracts/src/persistedRichAnswer.ts. The frozen §7/§14 semantics
are unchanged: corrupt Rich must never be exported as a valid Plain
projection, and this change repairs the implementation to match.
@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: 811fb138-9286-4ed9-a406-820b3621bf80
  • 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.

jnhu76 commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Focused independent D4 review: PASS.

Verified at exact head e675fcbbf68bef31df5f2fe5b53c49bc5c2dc460:

  • F-05 closed at the real export seam. Rich-capable text_response candidate answers are interpreted through the shared persisted-Rich classifier using frozen questionSnapshot.answerMode; an unprovenanced string in a Rich slot becomes corrupt, raw evidence remains in JSON, and CSV cannot present it as normal Plain answer text.
  • F-08 resolved structurally. classifyPersistedRichAnswer now has one production definition in @exam/contracts; Web read paths and API/export consume that authority rather than reimplementing it.
  • Export policy remains downstream of classification. resolveExportAnswerView owns representation only; corrupt / unsupported_version produce no semantic projection, while rich_valid / rich_noncanonical use the existing read-only projection without repair.
  • D3 semantics remain intact: rich_noncanonical remains displayable but not editable; unsupported/corrupt remain fail-closed; the classifier's semantic body is preserved by extraction.
  • Frozen-attempt authority is used rather than live question state; the real-route D4-G regression directly exercises that distinction.
  • Raw evidence is not replaced: JSON retains candidateAnswer verbatim and adds mode/integrity/projection. CSV intentionally exports the classified projection plus explicit mode/integrity labels.
  • The structural ownership guard appropriately prevents a second classifier and direct Rich document parsing at the export seam without turning into a broad repo linter.
  • Exact-head CI run 37108677426 completed success on e675fcbb, including E2E shards plus coverage/build/transport checks.

Verdict:

F05_CSV_EXPORT_TRUST          = CLOSED
F08_CLASSIFIER_PLACEMENT      = CLOSED
DUPLICATE_SEMANTIC_ORACLE     = NONE on audited Rich export seam
SHADOW_SPECIFICATION          = NONE on audited Rich export seam
PHASE_D4                      = PASS
READY_FOR_D5                  = YES_AFTER_MERGE
GATE_1                        = NOT_EVALUATED

No D5/F-06 work should be bundled into this PR.

Note: GitHub does not allow the authenticated PR author to approve their own PR, so this is recorded as the independent review disposition rather than an APPROVE review event.

@jnhu76
jnhu76 merged commit 4b9bed3 into master Oct 3, 2026
11 checks passed
@jnhu76
jnhu76 deleted the fix/669-rich-phase-d4-export-trust branch October 3, 2026 09:15
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