2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md 54 KB

DDD Naming Governance JsonNode Payload Loop Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Complete a small third DDD naming governance loop by introducing AchievementReportContent as a domain-owned content concept for the ACHIEVEMENT report path and removing direct Jackson / JsonNode usage from exam-sprint-domain main source without changing external HTTP contracts.

Architecture: Keep public request/response DTOs, HTTP JSON fields, paths, status codes, and public enum literals unchanged. The application boundary still accepts external JsonNode, validates contract payload shape, and converts the selected ACHIEVEMENT payload into domain AchievementReportContent; the legacy OUTLOOK path remains explicitly wrapped as transitional unmodeled content with an exit condition. Domain owns the ReportContent abstraction and AchievementReportContent, while infrastructure renderers adapt domain content into HTML without moving Storage / Renderer / PdfGenerator or splitting DefaultExamSprintReportApplicationService.

Tech Stack: Java 17 source level, Maven multi-module build, Spring Boot 3.3.5, JUnit 5, AssertJ, ArchUnit, Jackson at application/infrastructure/runtime boundaries only.


Execution note: Do not create git commits unless the user explicitly asks for commits. Treat each “commit checkpoint” below as a local verification checkpoint until commit permission is given.

Scope guard: This loop only models the ACHIEVEMENT report content. Do not migrate OUTLOOK payload records, do not rename ExamSprintReport, do not move Storage / Renderer / PdfGenerator, do not split DefaultExamSprintReportApplicationService, do not change ReportGenerationStatus / ReportType, and do not edit public contract enum names or DTO field names.

Initial worktree baseline: The isolated worktree is /Users/exiao/Codes/dcjxb.microservice/.worktrees/refactor-ddd-jsonnode-payload-loop on branch refactor/ddd命名治理三轮-jsonnode-payload. Before writing this plan, git status --short was empty in the source worktree, a new worktree was created from master, and mvn -q test was run in the new worktree without a tool-reported failure; Maven output was truncated because of normal test logs.

Current Research Findings

Current domain Jackson / JsonNode usage

Command:

rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"

Current matches:

abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java:import com.fasterxml.jackson.databind.JsonNode;
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java:    String render(JsonNode payload, Instant generatedAt);
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:import com.fasterxml.jackson.databind.JsonNode;
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:        JsonNode payload,
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:            JsonNode payload,
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:    public JsonNode payload() {
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:    private static JsonNode copyPayload(JsonNode payload) {

Active domain Jackson debt is exactly two domain classes:

  • ExamSprintReport
  • ExamSprintReportRenderer

Current payload / render conversion chain

Command:

rg "payload\(|render\(|treeToValue|valueToTree|JsonNode" "abilities/exam-sprint"

Important chain:

runtime HTTP controller -> ExamSprintReportApplicationService accepts JsonNode
DefaultExamSprintReportApplicationService.validateAchievementPayload(JsonNode)
DefaultExamSprintReportApplicationService.readPayload(JsonNode, AchievementExamSprintReportPayload.class)
ExamSprintReport.pending(..., JsonNode payload, ...)
ExamSprintReportGenerationPipeline.generate(...)
ExamSprintReportGenerationPipeline calls renderer.render(processingReport.payload(), startedAt)
ClasspathAchievementExamSprintReportRenderer.render(JsonNode, Instant)
ClasspathAchievementExamSprintReportRenderer objectMapper.treeToValue(payload, AchievementExamSprintReportPayload.class)

The same pattern exists for OUTLOOK, but OUTLOOK has a larger payload and renderer surface.

Current ArchUnit Jackson allowlist

Command:

rg "CURRENT_DOMAIN_JACKSON_DEBT|domain_should_not_depend_on_jackson|new_domain_classes_should_not_depend_on_jackson" "ability-center-runtime/src/test/java"

Current allowlist:

private static final Set<String> CURRENT_DOMAIN_JACKSON_DEBT = Set.of(
        "ExamSprintReport",
        "ExamSprintReportRenderer");

@ArchTest
static final ArchRule new_domain_classes_should_not_depend_on_jackson = noClasses()
        .that().resideInAPackage("..domain..")
        .and(areNotNamed(CURRENT_DOMAIN_JACKSON_DEBT))
        .should().dependOnClassesThat().resideInAnyPackage(
                "com.fasterxml.jackson.."
        );

Trial report type selection

Use ACHIEVEMENT as the trial report type.

Reasons:

  1. AchievementExamSprintReportPayload is smaller than OutlookExamSprintReportPayload: 4 top-level text fields plus 4 nested content groups, compared with the much larger OUTLOOK payload with charts, frequency plans, case studies, color handling, and more renderer-specific edge cases.
  2. ClasspathAchievementExamSprintReportRenderer already uses a simple placeholder map and a separate SVG chart builder, so replacing contract payload DTO usage with domain content has a smaller blast radius.
  3. Existing application and runtime tests already exercise ACHIEVEMENT sync generation and public JSON compatibility.
  4. This slice can remove domain compile-time Jackson debt while leaving OUTLOOK JSON conversion as an explicitly named transitional compatibility path.

Target File Structure

Create

  • abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ReportContent.java
    • Domain-owned report content marker with ReportType reportType(); no Jackson imports.
  • abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java
    • Strongly named domain content for the ACHIEVEMENT report; fields mirror only what the current achievement renderer needs.
  • abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/UnmodeledReportContent.java
    • Transitional wrapper for report types not modeled in this loop; stores an opaque boundary-prepared object and has an explicit exit condition in this plan.
  • abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java
    • Package-private application boundary mapper from contract AchievementExamSprintReportPayload to domain AchievementReportContent.
  • abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java
    • TDD test for mapping every renderer-used field and null handling.

Modify

  • abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java
    • Replace JsonNode payload with ReportContent content; keep reportType and lifecycle methods unchanged.
  • abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java
    • Change render(JsonNode payload, Instant generatedAt) to render(ReportContent content, Instant generatedAt).
  • abilities/exam-sprint/domain/pom.xml
    • Remove jackson-databind after domain source no longer imports Jackson.
  • abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
    • Convert ACHIEVEMENT JSON/contracts payload to AchievementReportContent; wrap OUTLOOK JSON as UnmodeledReportContent for this loop.
  • abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java
    • Pass processingReport.content() into the renderer.
  • abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
    • Assert domain AchievementReportContent for achievement saves; keep public response assertions on contracts enums/DTOs.
  • abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
    • Update test renderers and report construction to ReportContent.
  • abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java
    • Render from AchievementReportContent; remove contract payload DTO and ObjectMapper.treeToValue(...) from the selected path.
  • abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
    • Continue using OutlookExamSprintReportPayload and Jackson inside infrastructure by unwrapping UnmodeledReportContent for OUTLOOK only.
  • abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java
    • Convert sample JSON to domain content in the test boundary and call render(AchievementReportContent, Instant).
  • abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java
    • Wrap sample JsonNode as UnmodeledReportContent before rendering.
  • abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java
    • Wrap sample content when rendering before PDF generation.
  • ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java
    • Remove the Jackson allowlist and make domain -> Jackson a hard rule.
  • docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md
    • Record the third-loop result and remaining content modeling debt.

Keep unchanged

  • abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java
    • Public payload record name and JSON field names remain unchanged.
  • abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java
    • Not migrated in this loop.
  • abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java
    • Public enum remains OUTLOOK, ACHIEVEMENT.
  • abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java
    • Public application boundary still accepts JsonNode because runtime HTTP JSON remains unchanged.
  • abilities/exam-sprint/infrastructure/pom.xml
    • Keep exam-sprint-contracts and Jackson dependencies because infrastructure still renders legacy OUTLOOK from contract DTOs.
  • DefaultExamSprintReportApplicationService, Storage, Renderer, and PdfGenerator locations and names.

Task 1: Establish current baseline and active debt

Files:

  • Verify: abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java
  • Verify: abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java
  • Verify: ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java

  • [ ] Step 1: Confirm branch and clean worktree

Run:

git status --short
git status -sb

Expected: branch is refactor/ddd命名治理三轮-jsonnode-payload; before implementation edits, git status --short shows only this plan document as untracked or modified.

  • Step 2: Search current domain Jackson usage

Run:

rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"

Expected current matches are only ExamSprintReport and ExamSprintReportRenderer.

  • Step 3: Search payload/render conversion chain

Run:

rg "payload\(|render\(|treeToValue|valueToTree|JsonNode" "abilities/exam-sprint"

Expected: application accepts JsonNode; application validates using contract payload classes; pipeline currently renders processingReport.payload(); infrastructure renderers currently deserialize JsonNode with treeToValue(...).

  • Step 4: Check current ArchUnit Jackson allowlist

Run:

rg "CURRENT_DOMAIN_JACKSON_DEBT|domain_should_not_depend_on_jackson|new_domain_classes_should_not_depend_on_jackson" "ability-center-runtime/src/test/java"

Expected: allowlist contains ExamSprintReport and ExamSprintReportRenderer.

  • Step 5: Run full baseline tests

Run:

mvn -q test

Expected: PASS in this worktree. If this fails before implementation, stop and record the exact failing module/test before continuing.

Task 2: Add domain report content concepts with tests first

Files:

  • Create: abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ReportContent.java
  • Create: abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java
  • Create: abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/UnmodeledReportContent.java
  • Create: abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java

  • [ ] Step 1: Write the failing domain content test

Create AchievementReportContentTest.java:

package cn.yunzhixue.ability.center.examsprint.domain.report;

import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class AchievementReportContentTest {

    @Test
    void reportsAchievementTypeAndCopiesHitWords() {
        List<String> hitWords = new java.util.ArrayList<>(List.of("number", "bear"));
        AchievementReportContent content = sampleContent(hitWords);

        hitWords.add("mutated");

        assertThat(content.reportType()).isEqualTo(ReportType.ACHIEVEMENT);
        assertThat(content.examUnknownWordsHitStatus().hitWords())
                .containsExactly("number", "bear");
    }

    @Test
    void rejectsNullRequiredGroups() {
        assertThatThrownBy(() -> new AchievementReportContent(
                "title",
                "subtitle",
                "completion title",
                "completion subtitle",
                null,
                comparison(),
                comparison(),
                hitStatus(List.of("number"))))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("summaryMetrics");
    }

    private AchievementReportContent sampleContent(List<String> hitWords) {
        return new AchievementReportContent(
                "高考英语临考突击学习成果报告",
                "2024真题 · 两周专项训练 · 真实提分效果",
                "恭喜完成两周考前突击专项训练",
                "基于2024英语真题试卷 · 真实学习效果分析",
                new AchievementReportContent.SummaryMetrics("+19", "+4", "1.93%", "0.48倍"),
                comparison(),
                comparison(),
                hitStatus(hitWords));
    }

    private AchievementReportContent.Comparison comparison() {
        return new AchievementReportContent.Comparison(2328.0, 2347.0, "2328 词", "2347 词", "+19 词");
    }

    private AchievementReportContent.ExamUnknownWordsHitStatus hitStatus(List<String> hitWords) {
        return new AchievementReportContent.ExamUnknownWordsHitStatus(
                "1.93%",
                "0.48倍",
                "207 个",
                "203 个",
                "4 个",
                hitWords);
    }
}
  • Step 2: Run the domain test and verify RED

Run:

mvn -q -pl abilities/exam-sprint/domain -am -Dtest=AchievementReportContentTest test

Expected: FAIL because AchievementReportContent does not exist.

  • Step 3: Add ReportContent

Create ReportContent.java:

package cn.yunzhixue.ability.center.examsprint.domain.report;

public sealed interface ReportContent permits AchievementReportContent, UnmodeledReportContent {

    ReportType reportType();
}
  • Step 4: Add AchievementReportContent

Create AchievementReportContent.java:

package cn.yunzhixue.ability.center.examsprint.domain.report;

import java.util.List;
import java.util.Objects;

public record AchievementReportContent(
        String reportTitle,
        String reportSubtitle,
        String completionTitle,
        String completionSubtitle,
        SummaryMetrics summaryMetrics,
        Comparison vocabularyComparison,
        Comparison paperKnownWordsComparison,
        ExamUnknownWordsHitStatus examUnknownWordsHitStatus) implements ReportContent {

    public AchievementReportContent {
        Objects.requireNonNull(summaryMetrics, "summaryMetrics");
        Objects.requireNonNull(vocabularyComparison, "vocabularyComparison");
        Objects.requireNonNull(paperKnownWordsComparison, "paperKnownWordsComparison");
        Objects.requireNonNull(examUnknownWordsHitStatus, "examUnknownWordsHitStatus");
    }

    @Override
    public ReportType reportType() {
        return ReportType.ACHIEVEMENT;
    }

    public record SummaryMetrics(
            String vocabularyGrowthText,
            String paperKnownWordsGrowthText,
            String unknownWordHitRateText,
            String learningEfficiencyText) {
    }

    public record Comparison(
            Double beforeValue,
            Double afterValue,
            String beforeText,
            String afterText,
            String growthText) {
    }

    public record ExamUnknownWordsHitStatus(
            String unknownWordHitRateText,
            String learningEfficiencyText,
            String unknownWordsBeforeText,
            String unknownWordsAfterText,
            String reducedUnknownWordsText,
            List<String> hitWords) {

        public ExamUnknownWordsHitStatus {
            hitWords = hitWords == null ? null : List.copyOf(hitWords);
        }
    }
}
  • Step 5: Add transitional UnmodeledReportContent

Create UnmodeledReportContent.java:

package cn.yunzhixue.ability.center.examsprint.domain.report;

import java.util.Objects;

public record UnmodeledReportContent(ReportType reportType, Object source) implements ReportContent {

    public UnmodeledReportContent {
        Objects.requireNonNull(reportType, "reportType");
        Objects.requireNonNull(source, "source");
    }
}

Exit condition: remove UnmodeledReportContent after a later loop introduces OutlookReportContent and the OUTLOOK renderer no longer needs JSON-backed content.

  • Step 6: Run domain content tests

Run:

mvn -q -pl abilities/exam-sprint/domain -am -Dtest=AchievementReportContentTest test

Expected: PASS.

Task 3: Convert application boundary from contract payload to domain content

Files:

  • Create: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java
  • Create: abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java
  • Modify: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java

  • [ ] Step 1: Write mapper test first

Create AchievementReportContentMapperTest.java:

package cn.yunzhixue.ability.center.examsprint.application.report;

import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class AchievementReportContentMapperTest {

    @Test
    void mapsEveryRendererUsedAchievementFieldToDomainContent() {
        AchievementReportContent content = AchievementReportContentMapper.toDomainContent(payload());

        assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
        assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
        assertThat(content.vocabularyComparison().beforeValue()).isEqualTo(2328.0);
        assertThat(content.vocabularyComparison().afterText()).isEqualTo("2347 词");
        assertThat(content.paperKnownWordsComparison().growthText()).isEqualTo("+4 个");
        assertThat(content.examUnknownWordsHitStatus().hitWords())
                .containsExactly("number", "bear", "popular", "importance");
    }

    @Test
    void rejectsNullPayload() {
        assertThatThrownBy(() -> AchievementReportContentMapper.toDomainContent(null))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("payload");
    }

    private AchievementExamSprintReportPayload payload() {
        return new AchievementExamSprintReportPayload(
                "高考英语临考突击学习成果报告",
                "2024真题 · 两周专项训练 · 真实提分效果",
                "恭喜完成两周考前突击专项训练",
                "基于2024英语真题试卷 · 真实学习效果分析",
                new AchievementExamSprintReportPayload.SummaryMetrics("+19", "+4", "1.93%", "0.48倍"),
                new AchievementExamSprintReportPayload.Comparison(2328.0, 2347.0, "2328 词", "2347 词", "+19 词"),
                new AchievementExamSprintReportPayload.Comparison(650.0, 654.0, "650 个", "654 个", "+4 个"),
                new AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus(
                        "1.93%",
                        "0.48倍",
                        "207 个",
                        "203 个",
                        "4 个",
                        List.of("number", "bear", "popular", "importance")));
    }
}
  • Step 2: Run mapper test and verify RED

Run:

mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test

Expected: FAIL because AchievementReportContentMapper does not exist.

  • Step 3: Implement mapper

Create AchievementReportContentMapper.java:

package cn.yunzhixue.ability.center.examsprint.application.report;

import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;

import java.util.Objects;

final class AchievementReportContentMapper {

    private AchievementReportContentMapper() {
    }

    static AchievementReportContent toDomainContent(AchievementExamSprintReportPayload payload) {
        Objects.requireNonNull(payload, "payload");
        return new AchievementReportContent(
                payload.reportTitle(),
                payload.reportSubtitle(),
                payload.completionTitle(),
                payload.completionSubtitle(),
                new AchievementReportContent.SummaryMetrics(
                        payload.summaryMetrics().vocabularyGrowthText(),
                        payload.summaryMetrics().paperKnownWordsGrowthText(),
                        payload.summaryMetrics().unknownWordHitRateText(),
                        payload.summaryMetrics().learningEfficiencyText()),
                comparison(payload.vocabularyComparison()),
                comparison(payload.paperKnownWordsComparison()),
                new AchievementReportContent.ExamUnknownWordsHitStatus(
                        payload.examUnknownWordsHitStatus().unknownWordHitRateText(),
                        payload.examUnknownWordsHitStatus().learningEfficiencyText(),
                        payload.examUnknownWordsHitStatus().unknownWordsBeforeText(),
                        payload.examUnknownWordsHitStatus().unknownWordsAfterText(),
                        payload.examUnknownWordsHitStatus().reducedUnknownWordsText(),
                        payload.examUnknownWordsHitStatus().hitWords()));
    }

    private static AchievementReportContent.Comparison comparison(AchievementExamSprintReportPayload.Comparison comparison) {
        return new AchievementReportContent.Comparison(
                comparison.beforeValue(),
                comparison.afterValue(),
                comparison.beforeText(),
                comparison.afterText(),
                comparison.growthText());
    }
}
  • Step 4: Run mapper test GREEN

Run:

mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test

Expected: PASS.

  • Step 5: Change achievement validation to return domain content

In DefaultExamSprintReportApplicationService, change achievement entry methods to preserve validation and conversion at the application boundary:

public CreateExamSprintReportResponse createAchievementReport(JsonNode payload) {
    AchievementReportContent content = validateAchievementPayload(payload);
    return submitReportGeneration(ReportType.ACHIEVEMENT, content);
}

public CreateExamSprintReportWithUrlResponse createAchievementReportSync(JsonNode payload) {
    AchievementReportContent content = validateAchievementPayload(payload);
    return submitReportGenerationSync(ReportType.ACHIEVEMENT, content);
}

Change outlook entry methods to wrap boundary-prepared JSON without modeling it in this loop:

public CreateExamSprintReportResponse createOutlookReport(JsonNode payload) {
    validateOutlookPayload(payload);
    return submitReportGeneration(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy()));
}

public CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload) {
    validateOutlookPayload(payload);
    return submitReportGenerationSync(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy()));
}

Change private submit methods from JsonNode payload to ReportContent content, and pass content to ExamSprintReport.pending(...).

Change validateAchievementPayload to:

private AchievementReportContent validateAchievementPayload(JsonNode payload) {
    validateAchievementPayloadShape(payload);
    AchievementExamSprintReportPayload reportPayload = readPayload(payload, AchievementExamSprintReportPayload.class);

    Set<ConstraintViolation<AchievementExamSprintReportPayload>> violations = validator.validate(reportPayload);
    if (!violations.isEmpty()) {
        throw new BusinessException(ErrorCode.VALIDATION_ERROR);
    }
    return AchievementReportContentMapper.toDomainContent(reportPayload);
}
  • Step 6: Run application tests to expose compile failures before domain model migration is complete

Run:

mvn -q -pl abilities/exam-sprint/application -am test

Expected: FAIL until ExamSprintReport accepts ReportContent and tests are updated. Record the first compile failure as the expected red state for the next task.

Task 4: Replace domain JsonNode with domain ReportContent

Files:

  • Modify: abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java
  • Modify: abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java
  • Modify: abilities/exam-sprint/domain/pom.xml
  • Modify: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java

  • [ ] Step 1: Update ExamSprintReport

Remove:

import com.fasterxml.jackson.databind.JsonNode;

Change the record field and factory parameter to:

public record ExamSprintReport(
        String reportId,
        ReportType reportType,
        ReportContent content,
        ReportGenerationStatus generationStatus,
        Instant createdAt,
        Instant updatedAt,
        Instant expiresAt,
        String storageObjectKey,
        String fileName,
        String failureReason) {

    public ExamSprintReport {
        if (content != null && reportType != content.reportType()) {
            throw new IllegalArgumentException("reportType must match content.reportType");
        }
    }

    public static ExamSprintReport pending(
            String reportId,
            ReportType reportType,
            ReportContent content,
            Instant createdAt,
            Instant expiresAt) {

Replace all constructor arguments currently passing payload with content. Delete the overridden payload() accessor and copyPayload(...) helper.

  • Step 2: Update renderer port

Change ExamSprintReportRenderer.java to:

package cn.yunzhixue.ability.center.examsprint.domain.report;

import java.time.Instant;

public interface ExamSprintReportRenderer {

    boolean supports(ReportType reportType);

    String render(ReportContent content, Instant generatedAt);
}
  • Step 3: Update pipeline render call

In ExamSprintReportGenerationPipeline, replace:

String html = renderer.render(processingReport.payload(), startedAt);

with:

String html = renderer.render(processingReport.content(), startedAt);
  • Step 4: Remove domain Jackson dependency

In abilities/exam-sprint/domain/pom.xml, remove only this dependency block:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>
  • Step 5: Verify domain source no longer references Jackson

Run:

mvn -q -pl abilities/exam-sprint/domain -am test
rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"

Expected: domain tests PASS; rg returns no matches in domain main source.

Task 5: Adapt application tests and selected application assertions

Files:

  • Modify: abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
  • Modify: abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java

  • [ ] Step 1: Update application service content assertions

In ExamSprintReportApplicationServiceTest, import:

import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;

Replace the current achievement payload assertion:

assertThat(saved.payload().path("reportTitle").asText()).isEqualTo("高考英语临考突击学习成果报告");

with:

assertThat(saved.content()).isInstanceOf(AchievementReportContent.class);
AchievementReportContent content = (AchievementReportContent) saved.content();
assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
assertThat(content.examUnknownWordsHitStatus().hitWords())
        .containsExactly("number", "bear", "popular", "importance");
  • Step 2: Replace raw ObjectNode report construction with transitional content helpers

Add helper methods near existing payload helpers:

private ReportContent unmodeledOutlookContent() {
    return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode());
}

private AchievementReportContent validAchievementContent() {
    return AchievementReportContentMapper.toDomainContent(
            OBJECT_MAPPER.convertValue(validAchievementPayload(), AchievementExamSprintReportPayload.class));
}

Replace ExamSprintReport.pending(..., OBJECT_MAPPER.createObjectNode(), ...) for OUTLOOK tests with unmodeledOutlookContent().

Replace ExamSprintReport.pending(..., validAchievementPayload(), ...) for ACHIEVEMENT tests with validAchievementContent().

  • Step 3: Preserve copy behavior at application boundary

Replace the old createOutlookReportCopiesPayloadBeforeSaving assertion with:

ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
UnmodeledReportContent content = (UnmodeledReportContent) saved.content();
JsonNode savedPayload = (JsonNode) content.source();
assertThat(savedPayload.path("reportMetadata").path("learnerName").asText()).isEqualTo("李同学");

This verifies the copy now happens in the application boundary for the legacy OUTLOOK path.

  • Step 4: Update test renderers

Change all ExamSprintReportRenderer test doubles from:

public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {

to:

public String render(ReportContent content, Instant generatedAt) {

For PreviewTestRenderer, if the title is needed, use:

String title = content instanceof AchievementReportContent achievementContent
        ? achievementContent.reportTitle()
        : "";
return "<html><body>preview:" + title + ":" + generatedAt + "</body></html>";
  • Step 5: Update worker tests

In ExamSprintReportGenerationWorkerTest, import ReportContent and UnmodeledReportContent, add:

private ReportContent unmodeledOutlookContent() {
    return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode());
}

private ReportContent unmodeledAchievementContent() {
    return new UnmodeledReportContent(ReportType.ACHIEVEMENT, OBJECT_MAPPER.createObjectNode());
}

Use unmodeledOutlookContent() for existing OUTLOOK worker tests and unmodeledAchievementContent() for the file-name-only ACHIEVEMENT worker test. Change test renderer signatures to render(ReportContent content, Instant generatedAt).

  • Step 6: Run application verification

Run:

mvn -q -pl abilities/exam-sprint/application -am test

Expected: PASS. Tests should now distinguish domain content assertions from public API/contract assertions.

Task 6: Adapt infrastructure renderers and renderer tests

Files:

  • Modify: abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java
  • Modify: abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
  • Modify: abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java
  • Modify: abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java
  • Modify: abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java

  • [ ] Step 1: Run infrastructure tests to expose port signature failures

Run:

mvn -q -pl abilities/exam-sprint/infrastructure -am test

Expected: FAIL until infrastructure renderers implement render(ReportContent, Instant).

  • Step 2: Change achievement renderer to domain content

In ClasspathAchievementExamSprintReportRenderer, remove imports for AchievementExamSprintReportPayload, JsonNode, ObjectMapper, and Autowired. Add imports:

import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;

Change constructors to:

public ClasspathAchievementExamSprintReportRenderer() {
    this(new AchievementExamSprintReportSvgChartBuilder());
}

ClasspathAchievementExamSprintReportRenderer(AchievementExamSprintReportSvgChartBuilder chartBuilder) {
    this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
}

Change render entry point to:

public String render(ReportContent content, Instant generatedAt) {
    if (!(content instanceof AchievementReportContent reportContent)) {
        throw new IllegalArgumentException("Achievement renderer requires AchievementReportContent");
    }
    try {
        AchievementReportContent.SummaryMetrics summary = reportContent.summaryMetrics();
        AchievementReportContent.Comparison vocabulary = reportContent.vocabularyComparison();
        AchievementReportContent.Comparison paperKnownWords = reportContent.paperKnownWordsComparison();
        AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = reportContent.examUnknownWordsHitStatus();

        return renderTemplate(loadTemplate(), placeholders(reportContent, summary, vocabulary, paperKnownWords, hitStatus));
    } catch (IOException exception) {
        throw new UncheckedIOException("Failed to load achievement exam sprint report template", exception);
    } catch (Exception exception) {
        throw new IllegalStateException("Failed to render achievement exam sprint report", exception);
    }
}

Change helper signatures from AchievementExamSprintReportPayload.* to AchievementReportContent.*.

  • Step 3: Change outlook renderer to unwrap transitional content in infrastructure

In ClasspathOutlookExamSprintReportRenderer, keep ObjectMapper, JsonNode, and OutlookExamSprintReportPayload because OUTLOOK remains unmodeled. Add:

import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;

Change render entry point to:

public String render(ReportContent content, Instant generatedAt) {
    if (!(content instanceof UnmodeledReportContent unmodeledContent)
            || unmodeledContent.reportType() != ReportType.OUTLOOK
            || !(unmodeledContent.source() instanceof JsonNode payload)) {
        throw new IllegalArgumentException("Outlook renderer requires unmodeled OUTLOOK JsonNode content");
    }
    try {
        OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
        return loadTemplate()
                .replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart()))
                .replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart()))
                .replace("{{highFrequencyVocabularyChart}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularyChart()))
                .replace("{{vocabularyFrequencyBandChart}}", renderVocabularyFrequencyBandChart(reportPayload.vocabularyFrequencyBandChart()))
                .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.frequencyPlan()))
                .replace("{{scoreImprovementCaseStudy}}", renderScoreImprovementCaseStudy(reportPayload.scoreImprovementCaseStudy()));
    } catch (IOException exception) {
        throw new UncheckedIOException("Failed to load outlook exam sprint report template", exception);
    } catch (Exception exception) {
        throw new IllegalStateException("Failed to render outlook exam sprint report", exception);
    }
}
  • Step 4: Update achievement renderer tests to pass domain content

In ClasspathAchievementExamSprintReportRendererTest, replace JsonNode samplePayload() with:

private AchievementReportContent sampleContent() throws Exception {
    AchievementExamSprintReportPayload payload = OBJECT_MAPPER.treeToValue(samplePayloadJson(), AchievementExamSprintReportPayload.class);
    return new AchievementReportContent(
            payload.reportTitle(),
            payload.reportSubtitle(),
            payload.completionTitle(),
            payload.completionSubtitle(),
            new AchievementReportContent.SummaryMetrics(
                    payload.summaryMetrics().vocabularyGrowthText(),
                    payload.summaryMetrics().paperKnownWordsGrowthText(),
                    payload.summaryMetrics().unknownWordHitRateText(),
                    payload.summaryMetrics().learningEfficiencyText()),
            new AchievementReportContent.Comparison(
                    payload.vocabularyComparison().beforeValue(),
                    payload.vocabularyComparison().afterValue(),
                    payload.vocabularyComparison().beforeText(),
                    payload.vocabularyComparison().afterText(),
                    payload.vocabularyComparison().growthText()),
            new AchievementReportContent.Comparison(
                    payload.paperKnownWordsComparison().beforeValue(),
                    payload.paperKnownWordsComparison().afterValue(),
                    payload.paperKnownWordsComparison().beforeText(),
                    payload.paperKnownWordsComparison().afterText(),
                    payload.paperKnownWordsComparison().growthText()),
            new AchievementReportContent.ExamUnknownWordsHitStatus(
                    payload.examUnknownWordsHitStatus().unknownWordHitRateText(),
                    payload.examUnknownWordsHitStatus().learningEfficiencyText(),
                    payload.examUnknownWordsHitStatus().unknownWordsBeforeText(),
                    payload.examUnknownWordsHitStatus().unknownWordsAfterText(),
                    payload.examUnknownWordsHitStatus().reducedUnknownWordsText(),
                    payload.examUnknownWordsHitStatus().hitWords()));
}

private JsonNode samplePayloadJson() throws Exception {
    return OBJECT_MAPPER.readTree("""
            {
              "reportTitle": "高考英语临考突击学习成果报告",
              "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
              "completionTitle": "恭喜完成两周考前突击专项训练",
              "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
              "summaryMetrics": {
                "vocabularyGrowthText": "+19",
                "paperKnownWordsGrowthText": "+4",
                "unknownWordHitRateText": "1.93%",
                "learningEfficiencyText": "0.48倍"
              },
              "vocabularyComparison": {
                "beforeValue": 2328,
                "afterValue": 2347,
                "beforeText": "2328 词",
                "afterText": "2347 词",
                "growthText": "+19 词"
              },
              "paperKnownWordsComparison": {
                "beforeValue": 650,
                "afterValue": 654,
                "beforeText": "650 个",
                "afterText": "654 个",
                "growthText": "+4 个"
              },
              "examUnknownWordsHitStatus": {
                "unknownWordHitRateText": "1.93%",
                "learningEfficiencyText": "0.48倍",
                "unknownWordsBeforeText": "207 个",
                "unknownWordsAfterText": "203 个",
                "reducedUnknownWordsText": "4 个",
                "hitWords": ["number", "bear", "popular", "importance"]
              }
            }
            """);
}

When mutating test content, create a small helper that rebuilds AchievementReportContent with the changed field. Example for hit words:

AchievementReportContent content = sampleContent();
AchievementReportContent mutated = new AchievementReportContent(
        "成果<script>alert(1)</script>",
        content.reportSubtitle(),
        content.completionTitle(),
        content.completionSubtitle(),
        content.summaryMetrics(),
        content.vocabularyComparison(),
        content.paperKnownWordsComparison(),
        new AchievementReportContent.ExamUnknownWordsHitStatus(
                content.examUnknownWordsHitStatus().unknownWordHitRateText(),
                content.examUnknownWordsHitStatus().learningEfficiencyText(),
                content.examUnknownWordsHitStatus().unknownWordsBeforeText(),
                content.examUnknownWordsHitStatus().unknownWordsAfterText(),
                content.examUnknownWordsHitStatus().reducedUnknownWordsText(),
                java.util.List.of("number", "bear<script>")));
String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));
  • Step 5: Update outlook renderer and PDF tests to wrap JSON content

Add helper in each affected test class:

private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
    return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
}

Change calls from:

renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));

to:

renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));

For achievement PDF rendering tests, use AchievementReportContent instead of JSON.

  • Step 6: Run infrastructure verification

Run:

mvn -q -pl abilities/exam-sprint/infrastructure -am test

Expected: PASS. Achievement renderer no longer depends on contract payload DTOs or Jackson for its render input; outlook renderer still does by explicit transitional design.

Task 7: Tighten ArchUnit Jackson rule and run runtime/API compatibility checks

Files:

  • Modify: ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java

  • [ ] Step 1: Verify no domain Jackson matches before tightening

Run:

rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"

Expected: no matches.

  • Step 2: Remove Jackson allowlist and make hard rule

In ExamSprintArchitectureTest, remove:

import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.base.DescribedPredicate;
import java.util.Set;

private static final Set<String> CURRENT_DOMAIN_JACKSON_DEBT = Set.of(
        "ExamSprintReport",
        "ExamSprintReportRenderer");

Replace:

@ArchTest
static final ArchRule new_domain_classes_should_not_depend_on_jackson = noClasses()
        .that().resideInAPackage("..domain..")
        .and(areNotNamed(CURRENT_DOMAIN_JACKSON_DEBT))
        .should().dependOnClassesThat().resideInAnyPackage(
                "com.fasterxml.jackson.."
        );

with:

@ArchTest
static final ArchRule domain_should_not_depend_on_jackson = noClasses()
        .that().resideInAPackage("..domain..")
        .should().dependOnClassesThat().resideInAnyPackage(
                "com.fasterxml.jackson.."
        );

Delete the areNotNamed(...) helper if it has no remaining caller.

  • Step 3: Run architecture/runtime verification

Run:

mvn -q -pl ability-center-runtime -Dtest=ExamSprintArchitectureTest test
mvn -q -pl ability-center-runtime -am test

Expected: PASS. Runtime HTTP tests continue to verify public JSON fields and enum literals such as OUTLOOK, ACHIEVEMENT, PENDING, PROCESSING, SUCCESS, FAILED, and EXPIRED.

Task 8: Update governance documentation

Files:

  • Modify: docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md
  • Verify: docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md

  • [ ] Step 1: Update design document status

Change the status line near the top to:

> Status: Third governance loop planned for Jackson / `JsonNode` payload governance. The intended slice introduces `AchievementReportContent` as a domain concept while keeping external contracts stable.

After implementation, change it to:

> Status: Third governance loop implemented for `AchievementReportContent`; `exam-sprint-domain` no longer depends on Jackson / `JsonNode`. Remaining content debt is modeling `OUTLOOK` and retiring transitional unmodeled content.
  • Step 2: Update technical debt register rows

Replace the domain -> jackson row with:

| `domain -> jackson` | Cleared at compile-time in the `AchievementReportContent` loop by replacing domain `JsonNode` with `ReportContent`; `OUTLOOK` still uses a transitional unmodeled content wrapper prepared at application boundary | introduce `OutlookReportContent`, remove `UnmodeledReportContent`, and keep ArchUnit hard rule |

Update the payload records row to:

| Payload records contain business behavior | `AchievementReportContent` now owns the selected achievement content shape; `OutlookExamSprintReportPayload` and richer invariants remain in contracts/infrastructure | migrate `OutlookReportContent` and move stable invariants into domain value objects incrementally |
  • Step 3: Add third-loop result and next-loop recommendation

Near the first/second loop summary, add:

Third loop result: `AchievementReportContent` is introduced as strongly named domain report content; the application boundary converts public achievement payload JSON/contracts DTOs into domain content; domain main source no longer imports Jackson / `JsonNode`; and the domain-to-Jackson architecture rule is tightened to a hard guardrail.

Remaining payload debt: `OUTLOOK` content remains unmodeled through a transitional `UnmodeledReportContent` wrapper so this loop does not migrate all payload records or rewrite the larger outlook renderer.

Next recommended loop: introduce `OutlookReportContent` and retire `UnmodeledReportContent`, then review whether rendering/file/PDF ports should move to application in a separate dedicated loop.
  • Step 4: Run documentation grep checks

Run:

rg "AchievementReportContent|domain -> jackson|JsonNode|UnmodeledReportContent|OutlookReportContent" "docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md" "docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md"

Expected: matches describe this loop, remaining debt, and next recommendation clearly.

Task 9: Final verification and review preparation

Files:

  • Verify: all modified files

  • [ ] Step 1: Run targeted module suites with -am

Run:

mvn -q -pl abilities/exam-sprint/application -am test
mvn -q -pl abilities/exam-sprint/infrastructure -am test
mvn -q -pl ability-center-runtime -am test

Expected: all PASS.

  • Step 2: Run full reactor tests

Run:

mvn -q test

Expected: PASS. If unrelated failures occur, record exact module/test names and do not widen the governance scope without review.

  • Step 3: Run required final searches

Run:

git status --short
rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
rg "contracts\.report" "abilities/exam-sprint/domain/src/main/java"
rg "exam-sprint-contracts" "abilities/exam-sprint/domain/pom.xml"

Expected:

git status --short
# only files intentionally touched by this plan

rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
# no matches

rg "contracts\.report" "abilities/exam-sprint/domain/src/main/java"
# no matches

rg "exam-sprint-contracts" "abilities/exam-sprint/domain/pom.xml"
# no matches
  • Step 4: Prepare code review checklist

Review these exact points before claiming completion:

- domain main source has no Jackson / JsonNode imports or types.
- domain main source still has no contracts.report imports.
- abilities/exam-sprint/domain/pom.xml has no exam-sprint-contracts and no jackson-databind dependency.
- ACHIEVEMENT path stores AchievementReportContent in ExamSprintReport.
- application boundary converts external JsonNode/contracts payload to AchievementReportContent.
- public contracts DTO fields and enum literals remain unchanged.
- runtime HTTP JSON fields, paths, status codes, and enum strings remain unchanged.
- OUTLOOK remains transitional and documented through UnmodeledReportContent; no full OUTLOOK payload migration happened in this loop.
- ArchUnit Jackson allowlist is removed only after the source grep confirms no domain Jackson usage.
- Storage / Renderer / PdfGenerator were not moved.
- DefaultExamSprintReportApplicationService was not split.
- ExamSprintReport was not renamed.
  • Commit checkpoint, only if explicitly requested

Suggested message:

refactor(exam-sprint): 推进DDD命名治理三轮内容建模

Implementation Handoff

Recommended execution mode:

  1. Use subagent-driven development, with one task per implementation subagent after this plan is reviewed.
  2. Review after each task for dependency direction, public API compatibility, and scope control.
  3. Use TDD for each production-code change: write or update the failing test first, run it to observe the expected failure, implement the smallest code change, and rerun targeted verification.
  4. Use systematic-debugging before fixing any unexpected test/build failure.
  5. Use verification-before-completion before claiming the implementation is complete.
  6. Use requesting-code-review after implementation and verification.