Build a Spring AI Mission Lab

Build a local Spring AI app with typed output, retries, and grounding checks.

Introduction

30 Second Summary

An AI response can look polished while quietly making up details. A dependable app checks every result before showing it to someone.

In this project, you will build a local ChristOS mission board that turns mocked wellness context into three grounded action cards. Spring AI will recover malformed JSON before your server verifies every reference without a paid model call.

What You'll Build

In Safari, your finished dashboard shows three mission cards beside a clear record of the local recovery that produced them.

By the end of this project, you'll have:

  • A working mission board that generates three micro-missions from mocked daily context.
  • A visible evidence strip showing LOCAL_DETERMINISTIC mode plus two model attempts.
  • Automated grounding proof that every returned scripture reference belongs to the server-owned allowlist.
  • Secret Mission: A negative test that proves the grounding guard rejects Invented 1:1.

Are there any prerequisites?

This project assumes JDK 21 plus IntelliJ IDEA are installed on macOS.

IntelliJ IDEA's bundled Maven integration handles the build. You do not need a separate Maven installation or any cloud credentials.

Before We Start

This checkpoint locks in the zero-cost local ChristOS mission lab you are about to build with Spring Boot. You will use a deterministic local Spring AI ChatModel as the first typed, testable safety boundary before adding any cloud infrastructure.

Set Up the Local Spring Application

A local Spring Boot shell gives your ChristOS lab a fast feedback loop. This first boundary needs no cloud credentials or provider configuration.

You will create the Maven project that carries the local app. Then you will run its first page from IntelliJ IDEA.

In this step, get ready to:
  • Create a local Maven project with JDK 21.
  • Pin the Spring Boot and Spring AI dependencies.
  • Run the shell application at http://localhost:8080.
Create the Maven project

A Maven project gives IntelliJ IDEA a standard place for source code and resources. IntelliJ IDEA includes Maven integration, so this project does not need a separate Maven installation.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ IDEA.
  • Press Enter to open IntelliJ IDEA.
  • Click New on the Home screen.
  • Click Project.
  • Select Java as the language.
  • Enter christos-local in the Name field.
  • Set the Location field to ~/Desktop/christos-local.

What are Maven coordinates?

Maven coordinates identify a project inside a dependency graph. The GroupId names its namespace.

The ArtifactId names this specific project. Together, com.christos:christos-local gives the app a stable identity.

  • Select Maven in the Build system list.
  • Select JDK 21 in the JDK list.
  • Expand Advanced Settings.
  • Enter com.christos in the GroupId field.
  • Enter christos-local in the ArtifactId field.
  • Click Create.

IntelliJ IDEA opens the new christos-local project. You will see pom.xml with the standard source folders in the Project tool window.

Your local project now has a stable home on your Desktop. That folder carries every artifact you build from this point.

Pin the local stack

The pom.xml file is Maven's source of truth for the build. It pins versions and declares the libraries available to your code.

  • Click pom.xml in the Project tool window.
  • Select the existing contents of pom.xml.
  • Replace the selected contents with the project coordinates and version properties below:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.2</version>
    </parent>

    <groupId>com.christos</groupId>
    <artifactId>christos-local</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>christos-local</name>
    <description>Zero-cost local ChristOS mission lab</description>

    <properties>
        <java.version>21</java.version>
        <spring-ai.version>2.0.2</spring-ai.version>
    </properties>
</project>

What does this configuration do?

  • The parent selects Spring Boot version 4.1.2 as the foundation for the build.
  • The project coordinates identify version 0.0.1-SNAPSHOT of the local application.
  • The properties pin Java to 21.
  • The spring-ai.version property pins Spring AI to version 2.0.2.
  • Save pom.xml.
  • Click the Sync Maven Changes notification icon in the editor.

The Maven tool window now links christos-local with the pinned Java and framework versions.

Does Maven reject the file?

Check that pom.xml starts with the XML declaration. Check that it ends with </project>.

Compare every opening tag with its closing tag. A missing angle bracket prevents Maven from reading the project.

Help me fix the XML structure in my pom.xml file.

  • Place your cursor directly above the closing </project> line in pom.xml.
  • Add the application dependencies by pasting this block:
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-client-chat</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

What do these dependencies provide?

  • The spring-boot-starter-webmvc dependency supplies the embedded web application foundation through Spring MVC.
  • The spring-ai-client-chat dependency supplies the ChatClient boundary used later in the project.
  • The spring-boot-starter-test dependency supplies the test support used to prove the mission contract.
  • Save pom.xml.
  • Click the Sync Maven Changes notification icon.

Expand Dependencies in the Maven tool window. You will see entries for spring-boot-starter-webmvc, spring-ai-client-chat, and spring-boot-starter-test.

Are dependencies still unresolved?

Open the Maven tool window. Right-click the linked christos-local project and select Sync Project.

Check your internet connection if the dependency names stay highlighted in red. Maven needs to download them during the first synchronization.

Help me resolve the Maven dependencies in IntelliJ IDEA.

  • Place your cursor directly above the closing </project> line.
  • Add the executable packaging plugin by pasting this block:
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

What does this plugin do?

The spring-boot-maven-plugin prepares the Spring Boot application for executable packaging. Maven inherits the compatible plugin version from the pinned parent.

  • Save pom.xml.
  • Click the Sync Maven Changes notification icon.

Expand Plugins in the Maven tool window. You will see spring-boot-maven-plugin in the imported build.

Is the plugin missing?

Confirm that the <build> block sits inside <project>.

Run another Maven synchronization after moving the block. The plugin appears when Maven can parse the complete file.

Help me place the Spring Boot Maven plugin correctly in pom.xml.

✔️ Awesome, I've got everything!

Your Maven build now pins the complete local stack. Double check that pom.xml is saved.

ⓧ I'd like to double check the full code

Compare your complete pom.xml file with this reference.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.2</version>
    </parent>

    <groupId>com.christos</groupId>
    <artifactId>christos-local</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>christos-local</name>
    <description>Zero-cost local ChristOS mission lab</description>

    <properties>
        <java.version>21</java.version>
        <spring-ai.version>2.0.2</spring-ai.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-client-chat</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
Start the shell application

The application class hands control to Spring Boot. The static welcome page gives the embedded server something visible to return at the local root URL.

  • Right-click src/main/java in the Project tool window.
  • Select New.
  • Select Package.
  • Enter com.christos.localmission.
  • Press Enter to create the package.

The com.christos.localmission package now appears beneath src/main/java.

  • Right-click com.christos.localmission in the Project tool window.
  • Select New.
  • Select Java Class.
  • Enter ChristosLocalApplication.
  • Press Enter to create the class.
  • Replace the generated class contents with this application entry point:
package com.christos.localmission;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ChristosLocalApplication {

    public static void main(String[] args) {
        SpringApplication.run(ChristosLocalApplication.class, args);
    }
}

What does this class do?

  • The @SpringBootApplication annotation enables the application configuration and component discovery.
  • The main(String[] args) method provides the Java entry point.
  • The SpringApplication.run(ChristosLocalApplication.class, args) call starts the embedded application server.
  • Save ChristosLocalApplication.java.
  • Click Build in the main menu.
  • Click Build Project.

The Build tool window completes without compilation failures. The gutter beside main() now shows the run icon.

Does the application class fail to compile?

Confirm that the file sits at src/main/java/com/christos/localmission/ChristosLocalApplication.java.

Check that the package declaration is com.christos.localmission. Check that Maven synchronization finished before rebuilding.

Help me fix the ChristosLocalApplication compilation failure.

✔️ Awesome, I've got everything!

The application entry point compiles. Keep ChristosLocalApplication.java saved.

ⓧ I'd like to double check the full code

Compare your complete ChristosLocalApplication.java file with this reference.

package com.christos.localmission;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ChristosLocalApplication {

    public static void main(String[] args) {
        SpringApplication.run(ChristosLocalApplication.class, args);
    }
}
  • Right-click src/main/resources in the Project tool window.
  • Select New.
  • Select Directory.
  • Enter static.
  • Press Enter to create the directory.

The static directory now appears beneath src/main/resources.

  • Right-click static in the Project tool window.
  • Select New.
  • Select File.
  • Enter index.html.
  • Press Enter to create the file.
  • Add the local shell page by pasting this HTML:
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>ChristOS Local Mission Lab</title>
</head>
<body>
        <h1>ChristOS Local Mission Lab</h1>
</body>
</html>

What does this page provide?

  • The metadata gives the page a UTF-8 character set and a responsive viewport.
  • The browser tab uses ChristOS Local Mission Lab as its title.
  • The heading gives you a visible response at the application root.
  • Save index.html.

✔️ Awesome, I've got everything!

Your static shell is ready. Double check that index.html is saved beneath src/main/resources/static.

ⓧ I'd like to double check the full code

Compare your complete index.html file with this reference.

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>ChristOS Local Mission Lab</title>
</head>
<body>
        <h1>ChristOS Local Mission Lab</h1>
</body>
</html>

The main method keeps the embedded server active in IntelliJ IDEA's Run tool window. Keep that process running while you check the page.

Before you run it, think about whether the local root URL will display the heading on its first request.

  • Click the gutter run icon beside main() in ChristosLocalApplication.java.
  • Select the Run action for ChristosLocalApplication.main().

The Run tool window stays active. Its startup output confirms that the embedded server is listening on port 8080.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Safari.
  • Press Enter to open Safari.
  • Click Safari's address field.
  • Enter http://localhost:8080.
  • Press Enter to load the local app.

Safari displays ChristOS Local Mission Lab at the root URL. Your Maven build, application entry point, embedded server, and static page now work together.

That is the first local loop complete. Your browser can now reach the Spring Boot application without an account or paid model call.

Does the page fail to load?

Return to IntelliJ IDEA and confirm that the Run tool window is still active. Restart ChristosLocalApplication.main() if the process stopped.

Check that index.html is inside src/main/resources/static. Check that Safari is using http://localhost:8080.

Help me troubleshoot why my local ChristOS page does not load.

Your local Spring application is running. Next, you will turn this shell into a browser-only mission preview with visible context and three action cards.

Build the Browser-Only Mission Preview

Your local Spring Boot application already serves its shell in Safari. This gives you a fast loop for turning the shell into a visible product interaction.

You will build the mission board with HTML, CSS, and JavaScript. The finished preview exposes the trust problem created when mission content stays inside the browser.

In this step, get ready to:
  • Shape the mission board around mocked daily context.
  • Add responsive styling and browser-only interaction.
  • Identify the trust gap in browser-owned mission data.
Shape the mission board

The mission board needs stable regions for context, controls, evidence, and results. JavaScript can target those regions through their element IDs.

  • In the Project panel in IntelliJ IDEA, expand src/main/resources/static.
  • Select the existing index.html file.
  • Press Cmd+A (macOS) or Ctrl+A (Windows) to select the existing markup.
  • Replace the selected markup with this complete hero shell:
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>ChristOS Local Mission Lab</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
    <header class="hero">
        <p class="eyebrow">Zero-cost local prototype</p>
        <h1>ChristOS Local Mission Lab</h1>
        <p>Turn mocked daily context into three small, grounded actions without calling a paid model.</p>
    </header>
</main>
<script src="/app.js"></script>
</body>
</html>

What Does This Markup Do?

  • The hero introduces the local prototype and its three-action goal.
  • The stylesheet link connects the page to styles.css.
  • The script element connects the page to app.js.
  • Save index.html.
  • Return to Safari.
  • Refresh http://localhost:8080.

You will see the ChristOS title beneath the zero-cost prototype label. The supporting text describes the three-action result.

Still Seeing the Old Shell?

Confirm that you edited src/main/resources/static/index.html. Check that its editor tab shows no unsaved-change indicator.

Help me find why my updated static page is missing in Safari.

The mocked context makes every preview predictable. The controls give the interaction a visible status and mode.

  • In index.html, place the cursor after the closing </header> tag.
  • Add the context panel and controls by pasting this code:
    <section class="context-panel" aria-label="Mocked user context">
        <div><span>Energy</span><strong id="energy">2/5</strong></div>
        <div><span>Stress</span><strong id="stress">4/5</strong></div>
        <div><span>Day part</span><strong id="dayPart">Evening</strong></div>
    </section>

    <section class="controls">
        <button id="generateButton" type="button">Generate My Missions</button>
        <p id="status" class="status" role="status" aria-live="polite" data-state="preview">
            Preview only. Generate to call the local backend.
        </p>
        <p id="evidence" class="evidence">LOCAL PREVIEW</p>
    </section>

How Is the Preview Connected?

  • The energy, stress, and dayPart IDs identify the displayed context values.
  • The generateButton ID gives JavaScript a target for the click listener.
  • The status and evidence regions expose the result of each interaction.
  • Save index.html.
  • Refresh the page in Safari.

You will see Energy 2/5, Stress 4/5, and Day part Evening. You will also see the Generate My Missions button.

Missing Context or Controls?

Confirm that both sections sit inside the main element. Check that every opening section tag has a matching closing tag.

Help me inspect the context and controls in index.html.

The results area previews the final response shape. Each card pairs one short action with its duration and reference label.

  • In index.html, place the cursor after the closing tag for the controls section.
  • Add the three preview mission cards by pasting this code:
    <section id="missions" class="mission-grid" aria-label="Mission results">
        <article class="mission-card">
            <p class="duration">1 min</p>
            <h2>Pause in stillness</h2>
            <p>Sit quietly and take five slow breaths.</p>
            <p class="reference">Psalm 46:10</p>
        </article>
        <article class="mission-card">
            <p class="duration">3 min</p>
            <h2>Name gratitude and concern</h2>
            <p>Write one gratitude and one concern in a notebook.</p>
            <p class="reference">Philippians 4:6-7</p>
        </article>
        <article class="mission-card">
            <p class="duration">5 min</p>
            <h2>Choose one wholehearted action</h2>
            <p>Pick one useful task and give it your full attention.</p>
            <p class="reference">Mark 12:30</p>
        </article>
    </section>

How Are the Missions Organized?

  • The missions element is the replaceable results area.
  • Each mission-card article contains one complete action.
  • The reference labels come from the demonstration data stored in this page.
  • Save index.html.
  • Refresh the page in Safari.

You will see three unstyled missions below the controls. Their reference labels are Psalm 46:10, Philippians 4:6-7, and Mark 12:30.

Seeing Fewer Than Three Missions?

Confirm that the missions section contains three article elements. Check that each article has its own closing tag.

Help me find a missing mission card in index.html.

✔️ Awesome, I've got everything!

Your mission-board structure is complete. Double-check that index.html is saved.

ⓧ I'd like to double check the full code

Compare your complete index.html file with this version.

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>ChristOS Local Mission Lab</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
    <header class="hero">
        <p class="eyebrow">Zero-cost local prototype</p>
        <h1>ChristOS Local Mission Lab</h1>
        <p>Turn mocked daily context into three small, grounded actions without calling a paid model.</p>
    </header>

    <section class="context-panel" aria-label="Mocked user context">
        <div><span>Energy</span><strong id="energy">2/5</strong></div>
        <div><span>Stress</span><strong id="stress">4/5</strong></div>
        <div><span>Day part</span><strong id="dayPart">Evening</strong></div>
    </section>

    <section class="controls">
        <button id="generateButton" type="button">Generate My Missions</button>
        <p id="status" class="status" role="status" aria-live="polite" data-state="preview">
            Preview only. Generate to call the local backend.
        </p>
        <p id="evidence" class="evidence">LOCAL PREVIEW</p>
    </section>

    <section id="missions" class="mission-grid" aria-label="Mission results">
        <article class="mission-card">
            <p class="duration">1 min</p>
            <h2>Pause in stillness</h2>
            <p>Sit quietly and take five slow breaths.</p>
            <p class="reference">Psalm 46:10</p>
        </article>
        <article class="mission-card">
            <p class="duration">3 min</p>
            <h2>Name gratitude and concern</h2>
            <p>Write one gratitude and one concern in a notebook.</p>
            <p class="reference">Philippians 4:6-7</p>
        </article>
        <article class="mission-card">
            <p class="duration">5 min</p>
            <h2>Choose one wholehearted action</h2>
            <p>Pick one useful task and give it your full attention.</p>
            <p class="reference">Mark 12:30</p>
        </article>
    </section>
</main>
<script src="/app.js"></script>
</body>
</html>
Add local interaction and styling

The markup now contains the full preview. Styling gives those regions a responsive dashboard layout before JavaScript rebuilds the cards.

  • In the Project panel, right-click src/main/resources/static.
  • Select New.
  • Select File.
  • Enter styles.css as the file name.
  • Add the dashboard foundation by pasting this code:
:root {
    color-scheme: dark;
    font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    background: #0d1117;
    color: #edf2f7;
}

* {
    box-sizing: border-box;
}

body {
    margin: 0;
    min-height: 100vh;
    background:
        radial-gradient(circle at top left, rgba(75, 121, 161, 0.28), transparent 34rem),
        #0d1117;
}

button {
    font: inherit;
}

.shell {
    width: min(1080px, calc(100% - 2rem));
    margin: 0 auto;
    padding: 4rem 0;
}

What Does the Foundation Style?

  • The root rules establish the dark palette and shared font stack.
  • The body adds a soft blue highlight behind the dashboard.
  • The shell centers the page within the available width.
  • Save styles.css.
  • Refresh the page in Safari.

You will see a dark page with a centered content column. A soft blue glow sits near the top-left corner.

Page Still Has a White Background?

Confirm that styles.css sits beside index.html. Check that the stylesheet path is /styles.css.

Help me troubleshoot why styles.css is missing from the page.

  • Add the hero styles at the end of styles.css by pasting this code:
.hero {
    max-width: 720px;
}

.eyebrow,
.duration,
.reference,
.evidence {
    color: #8bd3dd;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}

h1 {
    margin: 0.3rem 0 1rem;
    font-size: clamp(2.3rem, 7vw, 4.8rem);
    line-height: 0.98;
}

.hero > p:last-child {
    color: #b7c0ce;
    font-size: 1.1rem;
    line-height: 1.6;
}

How Does the Hero Gain Hierarchy?

  • The heading scales with the browser width.
  • The blue accent links the eyebrow to the mission labels.
  • The muted description separates supporting text from the title.
  • Save styles.css.
  • Refresh the page in Safari.

You will see a larger title with compact line spacing. The prototype label appears in blue uppercase text.

Hero Typography Unchanged?

Check that the hero rules appear after the closing brace for .shell. A missing brace can prevent later selectors from loading.

Help me inspect the hero selectors and CSS braces.

  • Add the context-panel styles at the end of styles.css by pasting this code:
.context-panel {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1rem;
    margin: 2.5rem 0 1.5rem;
}

.context-panel div,
.mission-card {
    border: 1px solid #2b3442;
    border-radius: 1rem;
    background: rgba(22, 27, 34, 0.9);
    box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.2);
}

.context-panel div {
    padding: 1rem;
}

.context-panel span {
    display: block;
    color: #8b98aa;
    font-size: 0.85rem;
}

How Does the Context Panel Work?

  • The grid gives each context value an equal column.
  • The shared container rules give the context cells and mission cards a consistent surface.
  • The smaller labels keep the readings prominent.
  • Save styles.css.
  • Refresh the page in Safari.

You will see Energy, Stress, and Day part inside three bordered panels.

Context Values Still Stacked?

Confirm that the HTML uses class="context-panel". Check that the matching CSS selector begins with a full stop.

Help me match the context-panel class to its CSS selector.

  • Add the context-value and button styles at the end of styles.css by pasting this code:
.context-panel strong {
    display: block;
    margin-top: 0.35rem;
    font-size: 1.35rem;
}

.controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.8rem 1rem;
    margin-bottom: 1.5rem;
}

button {
    border: 0;
    border-radius: 999px;
    padding: 0.85rem 1.25rem;
    background: #8bd3dd;
    color: #081014;
    cursor: pointer;
    font-weight: 800;
}

button:disabled {
    cursor: wait;
    opacity: 0.65;
}

What Changes in the Controls?

  • The larger values separate each reading from its label.
  • The flexible control row adapts to the available width.
  • The rounded button uses the dashboard accent color.
  • Save styles.css.
  • Refresh the page in Safari.

You will see larger context values. The Generate My Missions button becomes a rounded blue control.

Button Still Looks Plain?

Confirm that the HTML uses a button element. Check that the CSS selector has no leading full stop.

Help me find why the button style is missing.

  • Add the status and grid styles at the end of styles.css by pasting this code:
.status {
    margin: 0;
    color: #b7c0ce;
}

.status[data-state="error"] {
    color: #ff9b9b;
}

.status[data-state="success"] {
    color: #9be9a8;
}

.evidence {
    width: 100%;
    margin: 0;
    font-size: 0.78rem;
}

.mission-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1rem;
}

How Does the Board Show State?

  • The status color responds to its data-state value.
  • The evidence strip occupies a full row beneath the controls.
  • The mission grid gives each action an equal column.
  • Save styles.css.
  • Refresh the page in Safari.

You will see the three mission cards arranged across one row.

Missions Still Form One Column?

Confirm that the mission section uses class="mission-grid". Check that the matching selector sits outside the evidence rule.

Help me troubleshoot the mission grid layout.

  • Add the card-content styles at the end of styles.css by pasting this code:
.mission-card {
    min-height: 230px;
    padding: 1.35rem;
}

.mission-card h2 {
    margin: 0.75rem 0;
    font-size: 1.35rem;
}

.mission-card > p:not(.duration):not(.reference) {
    color: #b7c0ce;
    line-height: 1.55;
}

.duration,
.reference {
    margin: 0;
    font-size: 0.75rem;
}

.reference {
    margin-top: 1.4rem;
}

How Do the Cards Stay Readable?

  • The minimum height keeps the cards visually balanced.
  • The padding separates each action from its border.
  • The compact duration and reference labels frame the mission content.
  • Save styles.css.
  • Refresh the page in Safari.

You will see three balanced cards with blue duration and reference labels.

Card Text Looks Unbalanced?

Check that every duration uses class="duration". Confirm that every reference uses class="reference".

Help me match the card classes to their CSS selectors.

  • Add the responsive rule at the end of styles.css by pasting this code:
@media (max-width: 760px) {
    .context-panel,
    .mission-grid {
        grid-template-columns: 1fr;
    }

    .shell {
        padding-top: 2rem;
    }
}

What Happens on a Narrow Screen?

The media rule switches both grids to one column below 760px. The smaller top padding gives the stacked content more room.

  • Save styles.css.
  • Narrow the Safari window until the dashboard switches to one column.

You will see each context value and mission card span the available width. The page remains free of horizontal scrolling.

Layout Does Not Stack?

Confirm that the media rule sits outside the .reference block. Check that both grid selectors remain inside the media rule.

Help me fix the responsive rule for my mission board.

✔️ Awesome, I've got everything!

Your responsive dashboard styling is ready. Double-check that styles.css is saved.

ⓧ I'd like to double check the full code

Compare your complete styles.css file with this version.

:root {
    color-scheme: dark;
    font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    background: #0d1117;
    color: #edf2f7;
}

* {
    box-sizing: border-box;
}

body {
    margin: 0;
    min-height: 100vh;
    background:
        radial-gradient(circle at top left, rgba(75, 121, 161, 0.28), transparent 34rem),
        #0d1117;
}

button {
    font: inherit;
}

.shell {
    width: min(1080px, calc(100% - 2rem));
    margin: 0 auto;
    padding: 4rem 0;
}

.hero {
    max-width: 720px;
}

.eyebrow,
.duration,
.reference,
.evidence {
    color: #8bd3dd;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}

h1 {
    margin: 0.3rem 0 1rem;
    font-size: clamp(2.3rem, 7vw, 4.8rem);
    line-height: 0.98;
}

.hero > p:last-child {
    color: #b7c0ce;
    font-size: 1.1rem;
    line-height: 1.6;
}

.context-panel {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1rem;
    margin: 2.5rem 0 1.5rem;
}

.context-panel div,
.mission-card {
    border: 1px solid #2b3442;
    border-radius: 1rem;
    background: rgba(22, 27, 34, 0.9);
    box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.2);
}

.context-panel div {
    padding: 1rem;
}

.context-panel span {
    display: block;
    color: #8b98aa;
    font-size: 0.85rem;
}

.context-panel strong {
    display: block;
    margin-top: 0.35rem;
    font-size: 1.35rem;
}

.controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.8rem 1rem;
    margin-bottom: 1.5rem;
}

button {
    border: 0;
    border-radius: 999px;
    padding: 0.85rem 1.25rem;
    background: #8bd3dd;
    color: #081014;
    cursor: pointer;
    font-weight: 800;
}

button:disabled {
    cursor: wait;
    opacity: 0.65;
}

.status {
    margin: 0;
    color: #b7c0ce;
}

.status[data-state="error"] {
    color: #ff9b9b;
}

.status[data-state="success"] {
    color: #9be9a8;
}

.evidence {
    width: 100%;
    margin: 0;
    font-size: 0.78rem;
}

.mission-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1rem;
}

.mission-card {
    min-height: 230px;
    padding: 1.35rem;
}

.mission-card h2 {
    margin: 0.75rem 0;
    font-size: 1.35rem;
}

.mission-card > p:not(.duration):not(.reference) {
    color: #b7c0ce;
    line-height: 1.55;
}

.duration,
.reference {
    margin: 0;
    font-size: 0.75rem;
}

.reference {
    margin-top: 1.4rem;
}

@media (max-width: 760px) {
    .context-panel,
    .mission-grid {
        grid-template-columns: 1fr;
    }

    .shell {
        padding-top: 2rem;
    }
}

The board now looks complete. A local renderer gives the button a deterministic browser-only result.

  • In the Project panel, right-click src/main/resources/static.
  • Select New.
  • Select File.
  • Enter app.js as the file name.
  • Add the mission-card renderer by pasting this code:
function renderMissions(actions) {
    missions.replaceChildren();

    for (const action of actions) {
        const card = document.createElement("article");
        card.className = "mission-card";

        const duration = document.createElement("p");
        duration.className = "duration";
        duration.textContent = `${action.durationMinutes} min`;

        const title = document.createElement("h2");
        title.textContent = action.title;

        const instruction = document.createElement("p");
        instruction.textContent = action.instruction;

        const reference = document.createElement("p");
        reference.className = "reference";
        reference.textContent = action.scriptureReference;

        card.append(duration, title, instruction, reference);
        missions.append(card);
    }
}

How Does the Renderer Build Cards?

  • The renderer clears the current mission area.
  • The loop creates one article for every local action.
  • The completed article receives its duration, title, instruction, and reference.
  • Save app.js.
  • Confirm that app.js appears beside index.html in the Project panel.

Script File in the Wrong Place?

Confirm that all three static files share the src/main/resources/static directory. The script path in index.html expects app.js there.

Help me check the static file locations in my project.

The click handler supplies three hard-coded actions to the renderer. It also updates the page status with evidence that this is still a local preview.

  • Place the cursor at the first line of app.js.
  • Add the element references and preview handler before renderMissions() by pasting this code:
const generateButton = document.querySelector("#generateButton");
const status = document.querySelector("#status");
const evidence = document.querySelector("#evidence");
const missions = document.querySelector("#missions");
const energy = document.querySelector("#energy");
const stress = document.querySelector("#stress");
const dayPart = document.querySelector("#dayPart");

const previewActions = [
    { title: "Pause in stillness", durationMinutes: 1, instruction: "Sit quietly and take five slow breaths.", scriptureReference: "Psalm 46:10" },
    { title: "Name gratitude and concern", durationMinutes: 3, instruction: "Write one gratitude and one concern in a notebook.", scriptureReference: "Philippians 4:6-7" },
    { title: "Choose one wholehearted action", durationMinutes: 5, instruction: "Pick one useful task and give it your full attention.", scriptureReference: "Mark 12:30" }
];

generateButton.addEventListener("click", generateMissions);

function generateMissions() {
    renderMissions(previewActions);
    status.dataset.state = "success";
    status.textContent = "Preview only";
    evidence.textContent = "LOCAL PREVIEW";
}

What Does the Handler Own?

  • The constants connect JavaScript to the element IDs in index.html.
  • The previewActions array stores the three browser-owned missions.
  • The click listener runs generateMissions().
  • The handler sends the local actions to the renderer and updates the preview evidence.

Before you click, do you expect the board to produce new content or rebuild the same three missions?

  • Save app.js.
  • Return to Safari.
  • Refresh http://localhost:8080.
  • Click Generate My Missions.

You will see the same three mission cards rebuilt from previewActions. The status changes to Preview only in green.

Button Does Not Rebuild the Cards?

Confirm that each selector matches its corresponding ID in index.html. Check app.js for a missing quote, comma, parenthesis, or brace.

Help me debug the browser-only mission renderer.

✔️ Awesome, I've got everything!

Your browser-only interaction is working. Double-check that app.js is saved.

ⓧ I'd like to double check the full code

Compare your complete browser-only app.js file with this version.

const generateButton = document.querySelector("#generateButton");
const status = document.querySelector("#status");
const evidence = document.querySelector("#evidence");
const missions = document.querySelector("#missions");
const energy = document.querySelector("#energy");
const stress = document.querySelector("#stress");
const dayPart = document.querySelector("#dayPart");

const previewActions = [
    { title: "Pause in stillness", durationMinutes: 1, instruction: "Sit quietly and take five slow breaths.", scriptureReference: "Psalm 46:10" },
    { title: "Name gratitude and concern", durationMinutes: 3, instruction: "Write one gratitude and one concern in a notebook.", scriptureReference: "Philippians 4:6-7" },
    { title: "Choose one wholehearted action", durationMinutes: 5, instruction: "Pick one useful task and give it your full attention.", scriptureReference: "Mark 12:30" }
];

generateButton.addEventListener("click", generateMissions);

function generateMissions() {
    renderMissions(previewActions);
    status.dataset.state = "success";
    status.textContent = "Preview only";
    evidence.textContent = "LOCAL PREVIEW";
}

function renderMissions(actions) {
    missions.replaceChildren();

    for (const action of actions) {
        const card = document.createElement("article");
        card.className = "mission-card";

        const duration = document.createElement("p");
        duration.className = "duration";
        duration.textContent = `${action.durationMinutes} min`;

        const title = document.createElement("h2");
        title.textContent = action.title;

        const instruction = document.createElement("p");
        instruction.textContent = action.instruction;

        const reference = document.createElement("p");
        reference.className = "reference";
        reference.textContent = action.scriptureReference;

        card.append(duration, title, instruction, reference);
        missions.append(card);
    }
}
Observe the trust problem

The preview now behaves like a complete product interaction. Its visual polish provides no proof about who selected the missions or approved their reference labels.

Before the final check, do you think the page can prove that its references came from a trusted server?

  • Refresh http://localhost:8080 in Safari.
  • Click Generate My Missions.
  • Confirm that exactly three mission cards remain visible.
  • Confirm that the status reads Preview only.
  • Switch back to app.js in IntelliJ IDEA.
  • Locate the previewActions array.

The browser owns every mission and reference in previewActions. The client therefore has no server-controlled grounding boundary for those values.

What Did the Preview Prove?

The preview proves that the mission-board interaction is clear and responsive. The button reliably displays three short actions.

The source inspection exposes the trust gap. Mission content and reference labels both originate in browser JavaScript.

You now have the complete interaction and a clear trust gap. Next, you will move mission generation behind the local Spring AI boundary.

Move Mission Generation Behind Spring AI

Your browser preview proves that the mission board works. However, its JavaScript still controls the mission content and reference labels.

Now you will move those decisions into a server-owned Spring AI flow. The browser will receive typed results from a local endpoint while the backend enforces the trusted reference packet.

In this step, get ready to:
  • Define the server-owned context and typed mission records.
  • Route mission generation through a deterministic local ChatModel.
  • Connect the browser to the mission-generation endpoint.
Define server-owned context and output records

A typed boundary gives every generated mission a predictable Java shape. The server-owned context also limits valid references to a trusted allowlist.

What is a grounding boundary?

A grounding boundary restricts generated content to application-owned source material. Here, the backend accepts only the three references stored in its trusted context.

  • In IntelliJ IDEA's Project tool window, select the com.christos.localmission package under src/main/java.
  • Create ReflectionSource.java by choosing New followed by Java Class.
  • Replace the generated template with this record:
package com.christos.localmission;

public record ReflectionSource(String reference, String cue) {
}

What does this record do?

The record pairs an allowed reference with its application-owned reflection cue. Each pair becomes one trusted source available to mission generation.

  • Save ReflectionSource.java.
  • Create TrustedContext.java in the same package with this code:
package com.christos.localmission;

import java.util.List;

public record TrustedContext(
        String userId,
        int energy,
        int stress,
        String dayPart,
        List<ReflectionSource> sources) {
}

What does this record do?

The record keeps wellness metrics beside the trusted source packet. This makes the backend the owner of every input used for generation.

  • Save TrustedContext.java.
  • Restart ChristosLocalApplication.main() from IntelliJ's existing run controls.
  • Refresh the existing page in Safari to confirm the ChristOS dashboard still loads.

The successful restart proves that your first two server-owned context types compile inside the application.

Seeing a compilation error?

Check that both files begin with package com.christos.localmission;. Confirm that TrustedContext.java imports java.util.List.

Ask for help with the exact compiler message: help me fix my ReflectionSource or TrustedContext record

  • Create MissionAction.java in the com.christos.localmission package with this code:
package com.christos.localmission;

public record MissionAction(
        String title,
        int durationMinutes,
        String instruction,
        String scriptureReference) {
}

What does this record do?

Each mission action has a title and a duration. It also carries one instruction plus one reference that the guard can inspect.

  • Save MissionAction.java.
  • Create MissionPlan.java in the same package with this code:
package com.christos.localmission;

import java.util.List;

public record MissionPlan(String stateSummary, List<MissionAction> actions) {
}

What does this record do?

The plan groups the generated state summary with its mission actions. This is the type that ChatClient will create from model output.

  • Save MissionPlan.java.
  • Restart ChristosLocalApplication.main() from IntelliJ's run controls.
  • Refresh Safari to confirm the dashboard remains available at http://localhost:8080.

Your application now compiles with typed inputs and outputs. The browser still works because these records add a server contract without changing the existing page.

  • Create MissionEnvelope.java in the same package with this code:
package com.christos.localmission;

public record MissionEnvelope(
        String mode,
        int modelAttempts,
        String userId,
        int energy,
        int stress,
        String dayPart,
        MissionPlan mission) {
}

What does this record do?

The envelope combines the typed mission with evidence about how it was generated. It also returns the server-owned context that the browser displays.

  • Save MissionEnvelope.java.
  • Create DemoContextRepository.java in the same package with this code:
package com.christos.localmission;

import java.util.List;

public final class DemoContextRepository {

    public TrustedContext load() {
        return new TrustedContext(
                "demo-user",
                2,
                4,
                "EVENING",
                List.of(
                        new ReflectionSource("Psalm 46:10", "Take one minute of stillness."),
                        new ReflectionSource("Philippians 4:6-7", "Name one concern and one gratitude."),
                        new ReflectionSource("Mark 12:30", "Choose one wholehearted action.")));
    }
}

What does this repository own?

The repository returns the mocked user metrics from the browser preview. It also defines the only three references that generated actions may use.

  • Save DemoContextRepository.java.
  • Restart ChristosLocalApplication.main() from IntelliJ's run controls.
  • Refresh Safari to confirm the local dashboard still loads with Energy 2/5 and Stress 4/5.

The server now owns the same metrics shown by the dashboard. It also owns the trusted packet that the browser could not protect.

  • Create GroundingGuard.java in the same package with this code:
package com.christos.localmission;
import java.util.HashSet;
import java.util.Set;
public final class GroundingGuard {
    public void validate(MissionPlan plan, TrustedContext context) {
        if (plan == null || plan.actions() == null) {
            throw new IllegalStateException("Mission plan and actions are required.");
        }
        if (plan.actions().size() != 3) {
            throw new IllegalStateException("Mission plan must contain exactly three actions.");
        }
        Set<String> allowedReferences = new HashSet<>();
        for (ReflectionSource source : context.sources()) {
            allowedReferences.add(source.reference());
        }
        for (MissionAction action : plan.actions()) {
            if (action == null || action.scriptureReference() == null) {
                throw new IllegalStateException("Every action needs a scripture reference.");
            }
            if (!allowedReferences.contains(action.scriptureReference())) {
                throw new IllegalStateException(
                        "Unsupported scripture reference: " + action.scriptureReference());
            }
        }
    }
}

What does the guard enforce?

  • The first check rejects a missing plan or action list.
  • The second check requires exactly three mission actions.
  • The final checks require every action to use a reference from the server-owned packet.
  • Save GroundingGuard.java.
  • Restart ChristosLocalApplication.main() from IntelliJ's run controls.
  • Refresh Safari to confirm the mission board still loads without a server error.

Does GroundingGuard fail to compile?

Confirm that the file imports both java.util.HashSet and java.util.Set. Check every brace against the complete file later in this step.

Ask for focused help here: help me fix my GroundingGuard compiler error

Add the local Spring AI boundary

A custom ChatModel keeps this flow deterministic while exercising the real Spring AI client path. It returns fixed model text locally without an account or outbound model call.

Why a custom ChatModel?

A plain Java fake would bypass Spring AI's prompt and typed-conversion path. The custom model keeps that boundary active while remaining free.

A local Ollama model would add a separate runtime and model download. The deterministic model keeps this lesson focused on the application contract.

  • Create DeterministicChatModel.java in the com.christos.localmission package.
  • Add the valid local response inside the class with this code:
package com.christos.localmission;

public final class DeterministicChatModel {

    private static final String VALID_JSON = """
            {
              "stateSummary": "Low energy and high stress call for a gentle evening reset.",
              "actions": [
                {
                  "title": "Pause in stillness",
                  "durationMinutes": 1,
                  "instruction": "Sit quietly and take five slow breaths.",
                  "scriptureReference": "Psalm 46:10"
                },
                {
                  "title": "Name gratitude and concern",
                  "durationMinutes": 3,
                  "instruction": "Write one gratitude and one concern in a notebook.",
                  "scriptureReference": "Philippians 4:6-7"
                },
                {
                  "title": "Choose one wholehearted action",
                  "durationMinutes": 5,
                  "instruction": "Pick one useful task and give it your full attention for five minutes.",
                  "scriptureReference": "Mark 12:30"
                }
              ]
            }
            """;
}

What does this response contain?

The local response follows the MissionPlan shape. Its three references all belong to the trusted packet.

  • Update the imports and class implementation in DeterministicChatModel.java with these remaining parts:
import java.util.List;

import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;

public final class DeterministicChatModel implements ChatModel {
    private final ThreadLocal<Integer> requestCalls = ThreadLocal.withInitial(() -> 0);

    public void startRequest() {
        this.requestCalls.set(0);
    }

    public int requestCalls() {
        return this.requestCalls.get();
    }

    public void clearRequest() {
        this.requestCalls.remove();
    }

    @Override
    public ChatResponse call(Prompt prompt) {
        int attempt = this.requestCalls.get() + 1;
        this.requestCalls.set(attempt);
        return new ChatResponse(List.of(new Generation(new AssistantMessage(VALID_JSON))));
    }

How does the model behave?

The model counts calls within the current request. Its call(Prompt) method wraps the fixed JSON in Spring AI response types.

This first-pass version returns valid JSON immediately. A generated envelope therefore reports one model attempt.

  • Keep the existing VALID_JSON constant inside the updated class.
  • Save DeterministicChatModel.java.
  • Restart ChristosLocalApplication.main() to verify the custom model compiles.

The server starts successfully with a local implementation of Spring AI's model interface. The existing browser page remains available during this backend change.

Does the model fail to compile?

Check that every Spring AI import matches the complete file. Confirm that the class declaration includes implements ChatModel.

Ask for help with the compiler output: help me fix DeterministicChatModel

  • Return to ChristosLocalApplication.java in IntelliJ.
  • Add the ChatClient and Bean imports below the package declaration:
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

Why are these imports needed?

The application already uses the Spring Boot imports. The new bean annotation registers local objects that Spring can inject into the generation service.

  • Add these bean methods below the existing main(String[] args) method:
    @Bean
    DeterministicChatModel deterministicChatModel() {
        return new DeterministicChatModel();
    }

    @Bean
    ChatClient chatClient(DeterministicChatModel chatModel) {
        return ChatClient.create(chatModel);
    }

    @Bean
    DemoContextRepository demoContextRepository() {
        return new DemoContextRepository();
    }

    @Bean
    GroundingGuard groundingGuard() {
        return new GroundingGuard();
    }

What do these beans provide?

  • The model bean provides deterministic local text.
  • The client bean sends prompts through that model.
  • The repository and guard beans provide trusted context plus domain validation.
  • Save ChristosLocalApplication.java.
  • Restart ChristosLocalApplication.main() to load the four new beans.
  • Refresh Safari to confirm the local page remains available.

Spring now manages the model, client, context repository, and grounding guard. These objects are ready for constructor injection into the generation service.

  • Create MissionGenerationService.java in the com.christos.localmission package with this service shell:
package com.christos.localmission;

import java.util.stream.Collectors;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class MissionGenerationService {

    private final ChatClient chatClient;
    private final DeterministicChatModel chatModel;
    private final DemoContextRepository contextRepository;
    private final GroundingGuard groundingGuard;

    public MissionGenerationService(
            ChatClient chatClient,
            DeterministicChatModel chatModel,
            DemoContextRepository contextRepository,
            GroundingGuard groundingGuard) {
        this.chatClient = chatClient;
        this.chatModel = chatModel;
        this.contextRepository = contextRepository;
        this.groundingGuard = groundingGuard;
    }
}

What does the service shell establish?

The service receives all four managed dependencies through its constructor. This keeps generation logic separate from application setup.

  • Add the system prompt and buildUserPrompt(TrustedContext) method inside MissionGenerationService before its final brace:
    private static final String SYSTEM_PROMPT = """
            Create gentle Christian micro-missions for this local demonstration.
            Use only the server-owned references and reflection cues supplied by the user message.
            Return exactly three actions.
            Do not invent references or scripture quotations.
            Keep every action practical and no longer than five minutes.
            """;

    private String buildUserPrompt(TrustedContext context) {
        String cues = context.sources().stream()
                .map(source -> "- " + source.reference() + ": " + source.cue())
                .collect(Collectors.joining(System.lineSeparator()));

        return """
                User ID: %s
                Energy: %d/5
                Stress: %d/5
                Day part: %s
                Allowed reflection cues:
                %s
                """.formatted(
                        context.userId(),
                        context.energy(),
                        context.stress(),
                        context.dayPart(),
                        cues);
    }

How is the prompt grounded?

The system prompt states the generation rules. The user prompt includes current metrics plus every allowed reflection cue from the repository.

  • Save MissionGenerationService.java.
  • Restart ChristosLocalApplication.main() to verify the service shell and prompt builder compile.

The application starts with a complete trusted prompt builder. The next method turns the local model text into a typed mission plan.

  • Add generate() inside MissionGenerationService before buildUserPrompt(TrustedContext).
    public MissionEnvelope generate() {
        TrustedContext context = this.contextRepository.load();
        this.chatModel.startRequest();

        try {
            MissionPlan plan = this.chatClient.prompt()
                    .system(SYSTEM_PROMPT)
                    .user(buildUserPrompt(context))
                    .call()
                    .entity(MissionPlan.class);

            this.groundingGuard.validate(plan, context);

            return new MissionEnvelope(
                    "LOCAL_DETERMINISTIC",
                    this.chatModel.requestCalls(),
                    context.userId(),
                    context.energy(),
                    context.stress(),
                    context.dayPart(),
                    plan);
        }
        finally {
            this.chatModel.clearRequest();
        }
    }

What happens during generation?

The service loads trusted context before starting a fresh request counter. The ChatClient chain sends both prompts through the deterministic model.

The entity(MissionPlan.class) call converts model text into typed Java data. The grounding guard checks that data before the envelope reaches the browser.

  • Save MissionGenerationService.java.
  • Restart ChristosLocalApplication.main() to confirm the complete service compiles.

The application now has a provider-independent generation service. Its model call stays local while its typed conversion follows the real Spring AI path.

Does the service fail during startup?

Check that MissionGenerationService carries the @Service annotation. Confirm that all four constructor parameter types match the registered beans.

Ask for help with the startup output: help me diagnose MissionGenerationService startup

Expose and consume the endpoint

A REST endpoint gives the browser one server-owned generation action. The client can request missions without controlling the prompt, source packet, or validation rules.

  • Create MissionController.java in the com.christos.localmission package with this code:
package com.christos.localmission;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/missions")
public class MissionController {

    private final MissionGenerationService missionGenerationService;

    public MissionController(MissionGenerationService missionGenerationService) {
        this.missionGenerationService = missionGenerationService;
    }

    @PostMapping("/generate")
    public MissionEnvelope generate() {
        return this.missionGenerationService.generate();
    }
}

What does the controller expose?

The controller maps HTTP POST requests to /api/v1/missions/generate. Its method delegates generation to the service and returns the typed envelope.

  • Save MissionController.java.
  • Restart ChristosLocalApplication.main() to register the endpoint.
  • Refresh the existing Safari page to confirm the application still serves the dashboard.

The backend route is live. The final change replaces browser-owned preview generation with a request to that route.

  • Return to src/main/resources/static/app.js in IntelliJ.
  • Keep the seven existing element constants at the top of the file.
  • Keep the existing generateButton.addEventListener("click", generateMissions); registration.
  • Replace the existing generateMissions() function with this server-backed version:
async function generateMissions() {
    generateButton.disabled = true;
    status.dataset.state = "loading";
    status.textContent = "Generating locally...";
    try {
        const response = await fetch("/api/v1/missions/generate", {
            method: "POST",
            headers: { "Accept": "application/json" }
        });
        if (!response.ok) {
            throw new Error(`Request failed with status ${response.status}`);
        }
        const payload = await response.json();
        renderContext(payload);
        renderMissions(payload.mission.actions);
        status.dataset.state = "success";
        status.textContent = `Recovered in ${payload.modelAttempts} local model calls.`;
        evidence.textContent = `${formatMode(payload.mode)} | ${payload.mission.stateSummary}`;
    }
    catch (error) {
        status.dataset.state = "error";
        status.textContent = "Generation failed as expected. Check the server output.";
        evidence.textContent = error.message;
    }
    finally {
        generateButton.disabled = false;
    }
}

How does the request flow work?

The browser sends a POST request without supplying metrics or references. It then passes the returned envelope into focused rendering functions.

The status reads the model-attempt count from the server response. The evidence strip formats the generation mode plus the typed state summary.

  • Replace the old browser-only preview rendering helper with these server-response helpers:
function renderContext(payload) {
    energy.textContent = `${payload.energy}/5`;
    stress.textContent = `${payload.stress}/5`;
    dayPart.textContent = titleCase(payload.dayPart);
}

What does renderContext do?

The function updates the three context fields from the server envelope. The browser now displays backend-owned values.

function renderMissions(actions) {
    missions.replaceChildren();

    for (const action of actions) {
        const card = document.createElement("article");
        card.className = "mission-card";

        const duration = document.createElement("p");
        duration.className = "duration";
        duration.textContent = `${action.durationMinutes} min`;

        const title = document.createElement("h2");
        title.textContent = action.title;

        const instruction = document.createElement("p");
        instruction.textContent = action.instruction;

        const reference = document.createElement("p");
        reference.className = "reference";
        reference.textContent = action.scriptureReference;

        card.append(duration, title, instruction, reference);
        missions.append(card);
    }
}

What does renderMissions do?

The function clears the previous cards before creating one article for each typed action. Every duration, title, instruction, and reference comes from the backend response.

  • Add these formatting helpers at the bottom of app.js.
function formatMode(value) {
    return value.split("_").join(" ");
}

function titleCase(value) {
    return value.charAt(0) + value.slice(1).toLowerCase();
}

Why format these values?

The mode helper turns the server value into readable evidence text. The title helper converts the uppercase day part into the label used by the dashboard.

  • Save app.js.
  • Restart ChristosLocalApplication.main() so the server serves the updated script.
  • Refresh http://localhost:8080 in Safari.

Does the button show an error?

Confirm that MissionController.java sits inside the com.christos.localmission package. Check that the request path in app.js matches the controller mapping.

Ask for help with both browser and server output: help me debug the local mission endpoint

Before you click the button, do you expect the evidence strip to show browser preview mode or local deterministic mode?

  • Click Generate My Missions in Safari.

You will see three server-returned mission cards. The status reports one local model call while the evidence strip begins with LOCAL DETERMINISTIC.

That is the trust boundary working: the browser requests missions while the server owns context, generation, conversion, and grounding checks.

✔️ Awesome, I've got everything!

Your local endpoint now returns three grounded missions through Spring AI. Double check that every changed file is saved in IntelliJ.

ⓧ I'd like to double check the full code

Compare the following files against your project. These versions represent the complete application at the end of this step.

package com.christos.localmission;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class ChristosLocalApplication {

    public static void main(String[] args) {
        SpringApplication.run(ChristosLocalApplication.class, args);
    }

    @Bean
    DeterministicChatModel deterministicChatModel() {
        return new DeterministicChatModel();
    }

    @Bean
    ChatClient chatClient(DeterministicChatModel chatModel) {
        return ChatClient.create(chatModel);
    }

    @Bean
    DemoContextRepository demoContextRepository() {
        return new DemoContextRepository();
    }

    @Bean
    GroundingGuard groundingGuard() {
        return new GroundingGuard();
    }
}
package com.christos.localmission;

public record ReflectionSource(String reference, String cue) {
}
package com.christos.localmission;

import java.util.List;

public record TrustedContext(
        String userId,
        int energy,
        int stress,
        String dayPart,
        List<ReflectionSource> sources) {
}
package com.christos.localmission;

public record MissionAction(
        String title,
        int durationMinutes,
        String instruction,
        String scriptureReference) {
}
package com.christos.localmission;

import java.util.List;

public record MissionPlan(String stateSummary, List<MissionAction> actions) {
}
package com.christos.localmission;

public record MissionEnvelope(
        String mode,
        int modelAttempts,
        String userId,
        int energy,
        int stress,
        String dayPart,
        MissionPlan mission) {
}
package com.christos.localmission;

import java.util.List;

public final class DemoContextRepository {

    public TrustedContext load() {
        return new TrustedContext(
                "demo-user",
                2,
                4,
                "EVENING",
                List.of(
                        new ReflectionSource("Psalm 46:10", "Take one minute of stillness."),
                        new ReflectionSource("Philippians 4:6-7", "Name one concern and one gratitude."),
                        new ReflectionSource("Mark 12:30", "Choose one wholehearted action.")));
    }
}
package com.christos.localmission;

import java.util.HashSet;
import java.util.Set;

public final class GroundingGuard {

    public void validate(MissionPlan plan, TrustedContext context) {
        if (plan == null || plan.actions() == null) {
            throw new IllegalStateException("Mission plan and actions are required.");
        }
        if (plan.actions().size() != 3) {
            throw new IllegalStateException("Mission plan must contain exactly three actions.");
        }

        Set<String> allowedReferences = new HashSet<>();
        for (ReflectionSource source : context.sources()) {
            allowedReferences.add(source.reference());
        }

        for (MissionAction action : plan.actions()) {
            if (action == null || action.scriptureReference() == null) {
                throw new IllegalStateException("Every action needs a scripture reference.");
            }
            if (!allowedReferences.contains(action.scriptureReference())) {
                throw new IllegalStateException(
                        "Unsupported scripture reference: " + action.scriptureReference());
            }
        }
    }
}
package com.christos.localmission;

import java.util.List;

import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;

public final class DeterministicChatModel implements ChatModel {

    private static final String VALID_JSON = """
            {
              "stateSummary": "Low energy and high stress call for a gentle evening reset.",
              "actions": [
                {
                  "title": "Pause in stillness",
                  "durationMinutes": 1,
                  "instruction": "Sit quietly and take five slow breaths.",
                  "scriptureReference": "Psalm 46:10"
                },
                {
                  "title": "Name gratitude and concern",
                  "durationMinutes": 3,
                  "instruction": "Write one gratitude and one concern in a notebook.",
                  "scriptureReference": "Philippians 4:6-7"
                },
                {
                  "title": "Choose one wholehearted action",
                  "durationMinutes": 5,
                  "instruction": "Pick one useful task and give it your full attention for five minutes.",
                  "scriptureReference": "Mark 12:30"
                }
              ]
            }
            """;

    private final ThreadLocal<Integer> requestCalls = ThreadLocal.withInitial(() -> 0);

    public void startRequest() {
        this.requestCalls.set(0);
    }

    public int requestCalls() {
        return this.requestCalls.get();
    }

    public void clearRequest() {
        this.requestCalls.remove();
    }

    @Override
    public ChatResponse call(Prompt prompt) {
        int attempt = this.requestCalls.get() + 1;
        this.requestCalls.set(attempt);

        return new ChatResponse(List.of(new Generation(new AssistantMessage(VALID_JSON))));
    }
}
package com.christos.localmission;

import java.util.stream.Collectors;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class MissionGenerationService {

    private static final String SYSTEM_PROMPT = """
            Create gentle Christian micro-missions for this local demonstration.
            Use only the server-owned references and reflection cues supplied by the user message.
            Return exactly three actions.
            Do not invent references or scripture quotations.
            Keep every action practical and no longer than five minutes.
            """;

    private final ChatClient chatClient;
    private final DeterministicChatModel chatModel;
    private final DemoContextRepository contextRepository;
    private final GroundingGuard groundingGuard;

    public MissionGenerationService(
            ChatClient chatClient,
            DeterministicChatModel chatModel,
            DemoContextRepository contextRepository,
            GroundingGuard groundingGuard) {
        this.chatClient = chatClient;
        this.chatModel = chatModel;
        this.contextRepository = contextRepository;
        this.groundingGuard = groundingGuard;
    }

    public MissionEnvelope generate() {
        TrustedContext context = this.contextRepository.load();
        this.chatModel.startRequest();

        try {
            MissionPlan plan = this.chatClient.prompt()
                    .system(SYSTEM_PROMPT)
                    .user(buildUserPrompt(context))
                    .call()
                    .entity(MissionPlan.class);

            this.groundingGuard.validate(plan, context);

            return new MissionEnvelope(
                    "LOCAL_DETERMINISTIC",
                    this.chatModel.requestCalls(),
                    context.userId(),
                    context.energy(),
                    context.stress(),
                    context.dayPart(),
                    plan);
        }
        finally {
            this.chatModel.clearRequest();
        }
    }

    private String buildUserPrompt(TrustedContext context) {
        String cues = context.sources().stream()
                .map(source -> "- " + source.reference() + ": " + source.cue())
                .collect(Collectors.joining(System.lineSeparator()));

        return """
                User ID: %s
                Energy: %d/5
                Stress: %d/5
                Day part: %s
                Allowed reflection cues:
                %s
                """.formatted(
                        context.userId(),
                        context.energy(),
                        context.stress(),
                        context.dayPart(),
                        cues);
    }
}
package com.christos.localmission;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/missions")
public class MissionController {

    private final MissionGenerationService missionGenerationService;

    public MissionController(MissionGenerationService missionGenerationService) {
        this.missionGenerationService = missionGenerationService;
    }

    @PostMapping("/generate")
    public MissionEnvelope generate() {
        return this.missionGenerationService.generate();
    }
}
const generateButton = document.querySelector("#generateButton");
const status = document.querySelector("#status");
const evidence = document.querySelector("#evidence");
const missions = document.querySelector("#missions");
const energy = document.querySelector("#energy");
const stress = document.querySelector("#stress");
const dayPart = document.querySelector("#dayPart");

generateButton.addEventListener("click", generateMissions);

async function generateMissions() {
    generateButton.disabled = true;
    status.dataset.state = "loading";
    status.textContent = "Generating locally...";

    try {
        const response = await fetch("/api/v1/missions/generate", {
            method: "POST",
            headers: { "Accept": "application/json" }
        });

        if (!response.ok) {
            throw new Error(`Request failed with status ${response.status}`);
        }

        const payload = await response.json();
        renderContext(payload);
        renderMissions(payload.mission.actions);

        status.dataset.state = "success";
        status.textContent = `Recovered in ${payload.modelAttempts} local model calls.`;
        evidence.textContent = `${formatMode(payload.mode)} | ${payload.mission.stateSummary}`;
    }
    catch (error) {
        status.dataset.state = "error";
        status.textContent = "Generation failed as expected. Check the server output.";
        evidence.textContent = error.message;
    }
    finally {
        generateButton.disabled = false;
    }
}

function renderContext(payload) {
    energy.textContent = `${payload.energy}/5`;
    stress.textContent = `${payload.stress}/5`;
    dayPart.textContent = titleCase(payload.dayPart);
}

function renderMissions(actions) {
    missions.replaceChildren();

    for (const action of actions) {
        const card = document.createElement("article");
        card.className = "mission-card";

        const duration = document.createElement("p");
        duration.className = "duration";
        duration.textContent = `${action.durationMinutes} min`;

        const title = document.createElement("h2");
        title.textContent = action.title;

        const instruction = document.createElement("p");
        instruction.textContent = action.instruction;

        const reference = document.createElement("p");
        reference.className = "reference";
        reference.textContent = action.scriptureReference;

        card.append(duration, title, instruction, reference);
        missions.append(card);
    }
}

function formatMode(value) {
    return value.split("_").join(" ");
}

function titleCase(value) {
    return value.charAt(0) + value.slice(1).toLowerCase();
}

Your mission board now runs through a typed server boundary with one deterministic local model call. Next, you will make unreliable model text visible by forcing this flow to receive malformed output.

Expose Malformed Model Output

Your mission board now receives typed missions through Spring AI. However, one successful response hides a contract risk.

Model output begins as text. This step sends malformed text into MissionPlan conversion so the failure becomes visible before any recovery logic exists.

In this step, get ready to:
  • Make the deterministic model return malformed text on its first call.
  • Trigger the endpoint from Safari.
  • Confirm the structured-output conversion failure in IntelliJ IDEA.
Introduce deterministic shape drift

The DeterministicChatModel already counts calls per request. You will use that counter to return plain text for attempt one while preserving the valid payload for later calls.

  • Return to src/main/java/com/christos/localmission/DeterministicChatModel.java from earlier in IntelliJ IDEA.
  • Insert a malformed-output constant immediately above the existing VALID_JSON field by pasting the code below:
private static final String INVALID_JSON = "This response is not JSON.";

What does this constant do?

The INVALID_JSON constant holds plain text that cannot be converted into a MissionPlan. Its predictable value makes the contract failure repeatable.

  • Scroll to the call(Prompt) method.
  • Replace the complete method with the code below:
@Override
    public ChatResponse call(Prompt prompt) {
        int attempt = this.requestCalls.get() + 1;
        this.requestCalls.set(attempt);

        String output = attempt == 1 ? INVALID_JSON : VALID_JSON;
        return new ChatResponse(List.of(new Generation(new AssistantMessage(output))));
    }

What does this code do?

  • The attempt value increments the request-scoped call counter.
  • The conditional expression selects INVALID_JSON for attempt one.
  • Later attempts select VALID_JSON so the deterministic model still has a valid response available.
  • The selected text travels through the same ChatResponse path as any other model output.
  • Save DeterministicChatModel.java.
  • Confirm IntelliJ IDEA shows no Java syntax errors in call(Prompt).

Seeing Java errors?

  • Check that INVALID_JSON sits inside the DeterministicChatModel class.
  • Check that the valid payload field is named VALID_JSON.
  • Compare the braces around call(Prompt) with the full file below.

Help me fix the malformed-output logic in DeterministicChatModel.

✔️ Awesome, I've got everything!

Your deterministic model is saved with separate malformed and valid responses.

ⓧ I'd like to double check the full code

Compare your complete DeterministicChatModel.java file with this version.

package com.christos.localmission;

import java.util.List;

import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;

public final class DeterministicChatModel implements ChatModel {

    private static final String INVALID_JSON = "This response is not JSON.";

    private static final String VALID_JSON = """
            {
              "stateSummary": "Low energy and high stress call for a gentle evening reset.",
              "actions": [
                {
                  "title": "Pause in stillness",
                  "durationMinutes": 1,
                  "instruction": "Sit quietly and take five slow breaths.",
                  "scriptureReference": "Psalm 46:10"
                },
                {
                  "title": "Name gratitude and concern",
                  "durationMinutes": 3,
                  "instruction": "Write one gratitude and one concern in a notebook.",
                  "scriptureReference": "Philippians 4:6-7"
                },
                {
                  "title": "Choose one wholehearted action",
                  "durationMinutes": 5,
                  "instruction": "Pick one useful task and give it your full attention for five minutes.",
                  "scriptureReference": "Mark 12:30"
                }
              ]
            }
            """;

    private final ThreadLocal<Integer> requestCalls = ThreadLocal.withInitial(() -> 0);

    public void startRequest() {
        this.requestCalls.set(0);
    }

    public int requestCalls() {
        return this.requestCalls.get();
    }

    public void clearRequest() {
        this.requestCalls.remove();
    }

    @Override
    public ChatResponse call(Prompt prompt) {
        int attempt = this.requestCalls.get() + 1;
        this.requestCalls.set(attempt);

        String output = attempt == 1 ? INVALID_JSON : VALID_JSON;
        return new ChatResponse(List.of(new Generation(new AssistantMessage(output))));
    }
}
Reproduce the conversion failure

The service still sends the first response directly into .entity(MissionPlan.class). This check reveals how that typed boundary behaves when the response is plain text.

  • Stop the current local application process with the existing IntelliJ IDEA run controls.
  • Return to ChristosLocalApplication.java.
  • Click the gutter icon beside main(String[] args).
  • Select Run.
  • Wait until the running application output confirms that the embedded server has started.

The application restart may take a moment while the local server starts. The startup output confirms when Safari can connect again.

  • Return to Safari from earlier.
  • Refresh http://localhost:8080.

Before you click the button, which surface do you expect to expose the problem first: Safari or IntelliJ IDEA?

  • Click Generate My Missions.

Safari shows Generation failed as expected. Check the server output. The mission cards do not receive a new server result.

  • Switch back to the running application output in IntelliJ IDEA.
  • Confirm the output reports a structured-output conversion failure.

You have exposed the contract gap on purpose. The malformed first response stops generation after one model call.

Still getting a successful response?

  • Confirm call(Prompt) uses attempt == 1 ? INVALID_JSON : VALID_JSON.
  • Save DeterministicChatModel.java.
  • Restart the application from ChristosLocalApplication.main().

Help me find why my endpoint still returns successful missions.

The malformed-output failure is now reproducible. Next, you will add typed recovery so the same first response leads to three grounded missions.

Add Schema Recovery and Grounding Proof

The browser failure from the last step proved that malformed model text can break typed conversion. That failure gives you a concrete contract to repair.

Now, Spring AI will use schema validation to guide a retry through the deterministic model. GroundingGuard remains the separate boundary for your application rules.

In this step, get ready to:
  • Enable typed self-correction for malformed model output.
  • Test the model-attempt count and three-action contract.
  • Prove that every returned reference belongs to the trusted packet.
Enable typed self-correction

The current entity() call attempts typed conversion once. Adding validateSchema() gives malformed output a structured path back through the model.

  • In MissionGenerationService.java, find the response conversion line near the end of the ChatClient chain:
.entity(MissionPlan.class);

Why does the current line fail?

This version sends the first response directly into typed conversion. The malformed text cannot become a MissionPlan.

  • Replace the current conversion line with this schema-validating version:
.entity(MissionPlan.class, spec -> spec.validateSchema());

How does recovery work?

The entity schema now checks the model response before conversion. Failed validation sends feedback through another deterministic model call.

The recovered MissionPlan still passes through GroundingGuard. Schema validation protects shape while the guard protects domain rules.

  • Save MissionGenerationService.java.
  • Restart the application from the IntelliJ IDEA run panel.

The server output returns to its running state. Your updated service is ready for a recovery check.

Before you test the endpoint, do you expect the dashboard to keep its failure state or recover a typed mission plan?

  • Return to Safari.
  • Refresh http://localhost:8080.
  • Click Generate My Missions.

You will see three mission cards. The status now reads Recovered in 2 local model calls.

That is the failure repaired. The first malformed response now triggers one local correction before typed conversion succeeds.

Still seeing the failure state?

Confirm that the entity() line includes spec -> spec.validateSchema(). A missing callback leaves the service on its single-attempt path.

Confirm that you restarted the application after saving MissionGenerationService.java.

help me debug why schema validation is not retrying my deterministic model

✔️ Awesome, I've got everything!

Your service now recovers malformed output before applying its grounding rules. Double check that MissionGenerationService.java is saved.

ⓧ I'd like to double check the full code

Compare your complete MissionGenerationService.java file with this final version.

package com.christos.localmission;

import java.util.stream.Collectors;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class MissionGenerationService {

    private static final String SYSTEM_PROMPT = """
            Create gentle Christian micro-missions for this local demonstration.
            Use only the server-owned references and reflection cues supplied by the user message.
            Return exactly three actions.
            Do not invent references or scripture quotations.
            Keep every action practical and no longer than five minutes.
            """;

    private final ChatClient chatClient;
    private final DeterministicChatModel chatModel;
    private final DemoContextRepository contextRepository;
    private final GroundingGuard groundingGuard;

    public MissionGenerationService(
            ChatClient chatClient,
            DeterministicChatModel chatModel,
            DemoContextRepository contextRepository,
            GroundingGuard groundingGuard) {
        this.chatClient = chatClient;
        this.chatModel = chatModel;
        this.contextRepository = contextRepository;
        this.groundingGuard = groundingGuard;
    }

    public MissionEnvelope generate() {
        TrustedContext context = this.contextRepository.load();
        this.chatModel.startRequest();

        try {
            MissionPlan plan = this.chatClient.prompt()
                    .system(SYSTEM_PROMPT)
                    .user(buildUserPrompt(context))
                    .call()
                    .entity(MissionPlan.class, spec -> spec.validateSchema());

            this.groundingGuard.validate(plan, context);

            return new MissionEnvelope(
                    "LOCAL_DETERMINISTIC",
                    this.chatModel.requestCalls(),
                    context.userId(),
                    context.energy(),
                    context.stress(),
                    context.dayPart(),
                    plan);
        }
        finally {
            this.chatModel.clearRequest();
        }
    }

    private String buildUserPrompt(TrustedContext context) {
        String cues = context.sources().stream()
                .map(source -> "- " + source.reference() + ": " + source.cue())
                .collect(Collectors.joining(System.lineSeparator()));

        return """
                User ID: %s
                Energy: %d/5
                Stress: %d/5
                Day part: %s
                Allowed reflection cues:
                %s
                """.formatted(
                        context.userId(),
                        context.energy(),
                        context.stress(),
                        context.dayPart(),
                        cues);
    }
}

What should match?

The final service starts request tracking before the prompt chain. It validates the recovered plan before returning the response envelope.

The finally block clears the request counter after every outcome. Each browser request therefore begins with a fresh malformed first attempt.

Prove the contract and allowlist

A JUnit test can exercise the same local ChatClient path as the browser. The test will prove the retry count before checking the trusted reference boundary.

  • In IntelliJ IDEA's project sidebar, locate src/test/java/com/christos/localmission.
  • Create MissionGenerationServiceTest.java inside that package using the sidebar's Java class creation control.
  • Build the test's runnable foundation by replacing the file contents with this code:
package com.christos.localmission;

import java.util.Set;

import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;

class MissionGenerationServiceTest {

    @Test
    void generatesThreeGroundedActionsAfterOneRetry() {
        DeterministicChatModel model = new DeterministicChatModel();
        DemoContextRepository repository = new DemoContextRepository();
        MissionGenerationService service = new MissionGenerationService(
                ChatClient.create(model),
                model,
                repository,
                new GroundingGuard());

        MissionEnvelope response = service.generate();
    }
}

What does this foundation prove?

  • The test constructs the same deterministic model used by the application.
  • The repository supplies the server-owned metrics and trusted references.
  • The service receives a real ChatClient around the local model.
  • The response value holds the typed result produced after recovery.
  • Save MissionGenerationServiceTest.java.
  • Click the gutter icon beside generatesThreeGroundedActionsAfterOneRetry().
  • Select the test's Run action.

The test passes. This first green run proves that the local ChatClient path can produce a typed response.

Does the test fail to compile?

Confirm that the file sits under src/test/java/com/christos/localmission. Its package must match the application classes.

Compare the imported ChatClient and Test types with the snippet above.

help me fix the compilation failure in MissionGenerationServiceTest

  • In MissionGenerationServiceTest.java, locate this line:
MissionEnvelope response = service.generate();

Where do the checks belong?

The contract checks belong immediately after the service returns its response. They can inspect the attempt count and typed action list from the same generation cycle.

  • Add these two checks directly below the response line:
if (response.modelAttempts() != 2) {
    throw new AssertionError("Expected two model attempts.");
}
if (response.mission().actions().size() != 3) {
    throw new AssertionError("Expected exactly three mission actions.");
}

What do these checks enforce?

The first check proves that malformed output caused exactly one retry. The second check proves that typed recovery returned the required mission count.

  • Save MissionGenerationServiceTest.java.
  • Run generatesThreeGroundedActionsAfterOneRetry() again from its gutter icon.

The test remains green. Recovery now has executable proof for two local model attempts and exactly three actions.

Is an attempt or action check failing?

An attempt failure usually means the service lacks validateSchema() or the deterministic model is not resetting each request.

An action-count failure means the valid mission payload no longer contains the three expected actions.

help me diagnose the retry-count or action-count test failure

  • In the same test method, locate the closing brace after the three-action check.
  • Add the trusted-reference proof immediately below that check:
Set<String> allowed = Set.of(
        "Psalm 46:10",
        "Philippians 4:6-7",
        "Mark 12:30");

boolean hasUnsupportedReference = response.mission().actions().stream()
        .map(MissionAction::scriptureReference)
        .anyMatch(reference -> !allowed.contains(reference));

if (hasUnsupportedReference) {
    throw new AssertionError("Every reference must come from the trusted packet.");
}

How does the allowlist proof work?

The allowed set mirrors the three references owned by DemoContextRepository. The stream extracts every returned scriptureReference.

The test fails when any returned value falls outside that set. This proves grounding independently from JSON shape.

  • Save MissionGenerationServiceTest.java.
  • Run the test again from the IntelliJ IDEA gutter icon.

The test passes with all three safety checks active. Your local generation path now proves recovery, action count, and trusted references.

Does the reference check fail?

Compare the three entries in allowed with the references returned by DemoContextRepository. Capitalization and punctuation must match.

Confirm that the stream maps MissionAction::scriptureReference before checking membership.

help me find the unsupported reference in the service test

✔️ Awesome, I've got everything!

Your test now proves the retry count, action count, and trusted-reference boundary. Double check that MissionGenerationServiceTest.java is saved.

ⓧ I'd like to double check the full code

Compare your complete MissionGenerationServiceTest.java file with this final version.

package com.christos.localmission;

import java.util.Set;

import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;

class MissionGenerationServiceTest {

    @Test
    void generatesThreeGroundedActionsAfterOneRetry() {
        DeterministicChatModel model = new DeterministicChatModel();
        DemoContextRepository repository = new DemoContextRepository();
        MissionGenerationService service = new MissionGenerationService(
                ChatClient.create(model),
                model,
                repository,
                new GroundingGuard());

        MissionEnvelope response = service.generate();

        if (response.modelAttempts() != 2) {
            throw new AssertionError("Expected two model attempts.");
        }
        if (response.mission().actions().size() != 3) {
            throw new AssertionError("Expected exactly three mission actions.");
        }

        Set<String> allowed = Set.of(
                "Psalm 46:10",
                "Philippians 4:6-7",
                "Mark 12:30");

        boolean hasUnsupportedReference = response.mission().actions().stream()
                .map(MissionAction::scriptureReference)
                .anyMatch(reference -> !allowed.contains(reference));

        if (hasUnsupportedReference) {
            throw new AssertionError("Every reference must come from the trusted packet.");
        }
    }
}

What should match?

The complete test constructs the service with ChatClient.create(model). It checks the recovered response before scanning every returned reference.

All three allowed references match the server-owned demonstration packet. No provider or network model is involved.

Run the complete local app

The focused test proves the contract in isolation. The final check confirms that the same recovery loop works through the browser and local Spring Boot endpoint.

  • Restart ChristosLocalApplication from the IntelliJ IDEA run panel.

The server returns to its running state on port 8080.

  • Return to Safari.
  • Refresh http://localhost:8080.

Before you generate the final mission set, what evidence should prove that the malformed first response recovered locally?

  • Click Generate My Missions once.

You will see three typed mission cards. The status reads Recovered in 2 local model calls.

The evidence strip begins with LOCAL DETERMINISTIC. This identifies the free local model path behind the response.

  • Repeat the generation twice more using Generate My Missions.

Each request returns the same three grounded missions after two local model calls. The request counter resets between generations.

Before the final test run, do you expect the browser success and the contract proof to agree?

  • Switch back to IntelliJ IDEA.
  • Click the gutter icon beside MissionGenerationServiceTest.
  • Select its Run action.

You will see the test pass. That is the complete local safety boundary proven through both the browser flow and an executable contract check.

Secret mission

Reject an Unsupported Reference

A typed response can still carry an invented citation. Add a negative safety test that proves GroundingGuard blocks Invented 1:1 even when the mission plan has the correct shape.

Clean Up Your Resources

Clean Up Your Resources

Your local ChristOS lab has no ongoing cloud costs or model costs. Choose whether to keep it running, pause it, or delete it.

Resources you used:

  • The christos-local Maven project folder imported in IntelliJ IDEA. It contains the complete application and its tests.
  • The active local Spring Boot process serving the mission board at http://localhost:8080.

Keep everything running

No action needed. Choose this if you want to keep generating missions or continue experimenting with the safety boundary.

  • Keep the christos-local folder in its current location.
  • Leave the ChristosLocalApplication process running in IntelliJ IDEA.

Safari can keep loading the dashboard at http://localhost:8080.

Pause - I'll come back to this later

Pausing stops the local server from using memory. Your project files remain ready for the next session.

  • Return to the process panel at the bottom of IntelliJ IDEA.
  • Click the square stop control for ChristosLocalApplication.
  • Leave the christos-local folder in its current location.
  • Switch back to Safari.
  • Refresh http://localhost:8080.

Safari should report that it cannot reach the local page. You can step away knowing your work is ready for your return.

Delete - I don't want to use this again

Remove the running process and the complete local project to start fresh.

Deleting the folder permanently removes the whole local project. The running app must stop first.

  • Return to the process panel at the bottom of IntelliJ IDEA.
  • Click the square stop control for ChristosLocalApplication.

That is the running process handled. Your files remain safe until you remove the folder.

  • Click Finder in the macOS Dock.
  • Use the Finder search field to search this Mac for christos-local.
  • Select the result that contains pom.xml and src.
  • Move the selected christos-local folder into your Mac's trash.
  • Open your Mac's trash.

Permanent deletion is final. macOS may ask you to confirm the removal.

  • Control-click the christos-local folder in the trash.
  • Choose the permanent deletion option.
  • Approve the removal confirmation if macOS shows one.

That is the complete local project removed from disk.

  • Switch back to Safari.
  • Refresh http://localhost:8080.

Safari should report that it cannot reach the local page. The ChristOS mission lab no longer runs or occupies a project folder on your Mac.

Nice Work!

Nice Work!

That is a serious local AI milestone! Your zero-cost ChristOS mission lab now turns server-owned context into three grounded actions through a deterministic Spring AI recovery flow.

You learned how to:

  • Built a local Spring Boot 4.1.2 application with a responsive browser dashboard. Connected the dashboard to POST /api/v1/missions/generate for server-owned mission generation.
  • Routed generation through Spring AI 2.0.2 ChatClient. Converted model output into typed Java records. Recovered malformed JSON with validateSchema() on the second deterministic model call.
  • Protected every mission with a server-owned reference allowlist. Verified the exact three-action contract with MissionGenerationServiceTest.
  • Secret Mission: Added GroundingGuardTest with a schema-valid plan containing Invented 1:1. The passing negative test proves that unsupported references cannot bypass the grounding guard.

Ready to quiz yourself?