Repository navigation
fix(rich): D5.1 publish-boundary Rich trust closure - #694
Conversation
publishExam projected repository-loaded question/option contentDocument straight through plainTextProjection, so a corrupt historical/bypassed row (the canonical write seam can never emit one) crashed the freeze gate with a TypeError instead of a controlled publish rejection. The gate now classifies every repository-loaded document through the shared Phase D5 read authority (classifyPersistedQuestionContent): rich_valid proceeds to the unchanged projection invariant; rich_noncanonical / unsupported_version / corrupt fail closed as typed ValidationErrors, and the frozen snapshot is built only after all per-question trust checks pass (validation-before-freeze). Canonicality at publish follows from frozen authority (ADR-019 single-write-seam; semantic contract §2/§8): publish creates a new frozen commitment, and only canonical Rich may be frozen. As-built references recorded in the semantic contract (§6/§18) and ADR-019 compliance notes; no authority semantics changed. #669 (Phase D5.1, follows #693 / D5)
R1 canonical rich publish positive control; R2/R4 corrupt question and
option documents (fabricated bypassed rows via the established
as-unknown cast pattern) reject as ValidationError, not the pre-fix
TypeError ('inlines is not iterable') that would surface as an HTTP
500; R3/R5 unsupported docVersion parity for question and option; R6
proves no fallback to the stored content projection field; R8 proves
the noncanonical rejection is the canonicality policy itself — the
fixture's content matches the raw document's projection, so only the
freeze-gate policy can reject it. The pre-existing projection-mismatch
regressions (R7) are unchanged and still green.
#669 (Phase D5.1)
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
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. Comment |
Independent focused review — D5.1 publish-boundary Rich trustVerdict: PASS / MERGE RECOMMENDED. I independently checked the current head Findings
Non-blocking observationMoving DispositionExact-head GitHub Actions are green and CodeRabbit is success. No further D5.1 correction requested. |
|
Re the review observation (snapshot-move changing multi-defect error precedence): adjudicated with an independent evidence pass — no fix needed, mergeable as-is.
So the observation is accepted as correct-but-inconsequential; no rework. (The dangling-reference scrub on question delete is a separate pre-existing issue, not introduced or expanded here.) |
This closes a latent publish-boundary trust gap found during D5.
It does not change Rich authority or publication semantics.
Refs: #669 (Phase D5.1), #693 (D5), ADR-019, rich-content-semantic-contract.md
Root cause
publishExamprojected repository-loadedquestion.contentDocument/option.contentDocumentstraight throughplainTextProjection, assuming theTypeScript
ContentDocumentV1shape was already trustworthy. A corrupthistorical/bypassed row (the canonical write seam can never emit one) reached
the projection and raised
TypeError("inlines is not iterable") → HTTP 500instead of a controlled publish rejection.
Classification:
P3,PUBLISH_BOUNDARY_TRUST_GAP, supported-writerreachability NO, corrupt-historical reachability YES.
Change
The publish freeze gate now consumes the existing Phase D5 semantic
classification (
classifyPersistedQuestionContentin@exam/contracts) beforeany projection — no second Rich validity oracle was introduced:
rich_valid→ unchangedcontent == plainTextProjection(document)invariant(question and option messages byte-identical).
rich_noncanonical→ValidationError(canonicality-at-publish derived fromfrozen authority: ADR-019 single-write-seam rule + contract §2/§8 — publish
creates a new frozen commitment, and only canonical Rich may be frozen; the
D5 DISPLAY grant never conferred canonicality).
unsupported_version/corrupt→ typedValidationError; no projection,no fallback to the stored
contentcompatibility field, no raw payloads inmessages.
contentDocument == null) branch untouched.buildQuestionSnapshotnow runs only after everyper-question trust/invariant check passes, so a corrupt row is never
materialized into a provisional snapshot.
No repair writes: publish validates then freezes; a noncanonical row is never
normalized (repair is a separate explicit migration).
Call-site classification (publish-time
plainTextProjectionon repository rows)examCommands.tsquestion pathexamCommands.tsoption pathcontracts/src/question.ts(×2)ContentSlotSchemain the same zod pipeline)apps/api/src/lib/attemptExportAnswer.tsread.document)apps/api/src/routes/questionContent.ts(×2)canonicalizeContentDocumentsuccess value)apps/web/.../contentAdapter.ts(×2)Package dependency decision
@exam/exam-enginegains@exam/contracts(acyclic: contracts → domain only;lint:arch forbids neither edge). One Rich validity authority preserved — the
engine imports the D5 classifier directly instead of duplicating it.
Regressions (engine level; exact error class asserted)
ValidationError, not the pre-fixTypeError(red run captured"inlines is not iterable")docVersion: 2→ typed rejectionValidationError(mandatory option path)docVersionparitycontentstring → still rejected (no Plainfallback; B′ ownership)
green
projection → rejected by the canonicality policy itself
Route-level mapping
ValidationError → 400 VALIDATION_ERRORfor the publishsurface is already permanently covered by
apps/api/src/routes/examPolicyValidation.test.ts; the central handler sendsany non-
AppError(the oldTypeError) to 500, which is exactly the channelthis gate closes.
Verification
pnpm --filter @exam/exam-engine test762 passedpnpm verifyPASS (verify:static incl. lint:arch, eslint, typecheck,openapi; full workspace coverage on real PostgreSQL; build)