# 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:

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

Current matches:

```text
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:

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

Important chain:

```text
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:

```bash
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:

```java
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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`:

```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:

```bash
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`:

```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`:

```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`:

```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:

```bash
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`:

```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:

```bash
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`:

```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:

```bash
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:

```java
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:

```java
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:

```java
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:

```bash
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:

```java
import com.fasterxml.jackson.databind.JsonNode;
```

Change the record field and factory parameter to:

```java
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:

```java
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:

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

with:

```java
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:

```xml
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>
```

- [ ] **Step 5: Verify domain source no longer references Jackson**

Run:

```bash
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:

```java
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:

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

with:

```java
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:

```java
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:

```java
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:

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

to:

```java
public String render(ReportContent content, Instant generatedAt) {
```

For `PreviewTestRenderer`, if the title is needed, use:

```java
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:

```java
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:

```bash
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:

```bash
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:

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

Change constructors to:

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

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

Change render entry point to:

```java
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:

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

Change render entry point to:

```java
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:

```java
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:

```java
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:

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

Change calls from:

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

to:

```java
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:

```bash
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:

```bash
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:

```java
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:

```java
@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:

```java
@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:

```bash
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:

```markdown
> 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:

```markdown
> 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:

```markdown
| `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:

```markdown
| 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:

```markdown
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```text
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:

```text
- 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:

```text
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.
