Series overview
Part 3 of 1718% complete
2026-08-18•6 min read

Build the deterministic operations simulator

Checkpoint tag: chapter-02-operations-simulator — the simulator runs against real PostgreSQL, documented requests pass, and every failure mode the series needs is one HTTP call away.

What will be built

operations-simulator becomes real: a service inventory, per-service health, deployment metadata, incident listing/lookup, idempotent incident creation, note appending, and an admin endpoint that flips the service into latency, flaky, malformed, or outage mode. Along the way we fill domain-contracts with the identifiers and result types the rest of the platform shares.

Why it matters

An agent that calls tools needs something real on the other end. Mocking the downstream in every test would hide exactly the behaviors we most need to exercise — idempotency on retried writes, partial failures, slow responses, corrupt payloads. A deterministic simulator gives us a system of record we fully control: seeded data that never changes underneath a test, and fault modes that make failure-injection a configuration call rather than a broken build.

Prerequisites and starting tag

Starting tag: chapter-01-build-foundation. You need Docker running for the PostgreSQL integration tests.

Architecture before and after

After

operations-simulator

domain-contracts

simdb

Before

operations-simulator (empty shell)

After

operations-simulator

domain-contracts

simdb

Before

operations-simulator (empty shell)

Concepts explained

Contracts vs. entities. The records in domain-contracts describe the API — what a caller may send and receive. The tables in sim describe storage. They are deliberately separate types: renaming a column must never be a wire change, and changing the wire must never require a migration. JPA entities would blur that line, so the simulator uses JdbcClient and writes SQL it owns.

Idempotency keys. A retried POST is indistinguishable from a second request. The fix is standard: the client supplies Idempotency-Key; the server stores (key, request hash, response) and replays the stored response on key reuse — while rejecting key reuse with a different body (that is a client bug, not a retry). Chapter 10’s approval flow leans on this mechanism directly.

Fault injection as a feature, not a hack. The fault registry is a first-class component with its own endpoint. Hiding it in test-only code would mean the failure modes we test with are not the failure modes we ship.

Files added or changed

domain-contracts/src/main/java/in/o612/eng/opsagent/contracts/...
operations-simulator/src/main/resources/application.yml
operations-simulator/src/main/resources/db/migration/V1__init.sql
operations-simulator/src/main/resources/db/migration/V2__seed.sql
operations-simulator/src/main/java/in/o612/eng/opsagent/simulator/... (persistence, web, faults)
operations-simulator/src/test/java/... (Testcontainers tests)
infra/compose/docker-compose.yml (postgres only, for now)
api-requests/simulator.http

Implementation steps

  1. Add contract types to domain-contracts.
  2. Write V1__init.sql (schema + tables) and V2__seed.sql (deterministic data).
  3. Implement repositories on JdbcClient.
  4. Implement controllers and the idempotency store.
  5. Implement the fault registry + servlet filter + admin endpoint.
  6. Wire application.yml, docker-compose Postgres.
  7. Write Testcontainers-backed tests for happy path, idempotency, and each fault mode.

Complete code — domain contracts

domain-contracts/src/main/java/in/o612/eng/opsagent/contracts/ids/TenantId.java
package in.o612.eng.opsagent.contracts.ids;
public record TenantId(String value) {
public TenantId {
if (value == null || !value.matches("[a-z0-9][a-z0-9-]{1,31}")) {
throw new IllegalArgumentException("invalid tenant id: " + value);
}
}
}

The same shape for ServiceId, IncidentId, UserId, CorrelationId, and IdempotencyKey — each a one-field record with a compactness check in the canonical constructor. Typed identifiers are cheap; a String that might be a tenant or might be an incident ID is how cross-tenant bugs start.

domain-contracts/src/main/java/in/o612/eng/opsagent/contracts/incident/Severity.java
package in.o612.eng.opsagent.contracts.incident;
public enum Severity { SEV1, SEV2, SEV3, SEV4 }
domain-contracts/src/main/java/in/o612/eng/opsagent/contracts/incident/IncidentStatus.java
package in.o612.eng.opsagent.contracts.incident;
public enum IncidentStatus { OPEN, INVESTIGATING, MITIGATED, RESOLVED }
domain-contracts/src/main/java/in/o612/eng/opsagent/contracts/incident/CreateIncidentCommand.java
package in.o612.eng.opsagent.contracts.incident;
import in.o612.eng.opsagent.contracts.ids.ServiceId;
public record CreateIncidentCommand(
String title,
Severity severity,
ServiceId serviceId,
String description) {
public CreateIncidentCommand {
if (title == null || title.isBlank() || title.length() > 200) {
throw new IllegalArgumentException("title required, <= 200 chars");
}
if (severity == null || serviceId == null) {
throw new IllegalArgumentException("severity and serviceId required");
}
if (description != null && description.length() > 4000) {
throw new IllegalArgumentException("description <= 4000 chars");
}
}
}
domain-contracts/src/main/java/in/o612/eng/opsagent/contracts/incident/IncidentDetail.java
package in.o612.eng.opsagent.contracts.incident;
import java.time.Instant;
import java.util.List;
public record IncidentDetail(
String id,
String tenantId,
String serviceId,
String title,
Severity severity,
IncidentStatus status,
Instant createdAt,
List<String> notes) {}

ServiceStatusResult (serviceId, health enum HEALTHY/DEGRADED/DOWN, lastDeployment, checkedAt) and IncidentSummary (the detail minus notes) follow the same record pattern. ApiError carries code, message, correlationId — and deliberately never a stack trace.

Complete code — simulator

application.yml

operations-simulator/src/main/resources/application.yml
server:
port: 8082
spring:
application:
name: operations-simulator
datasource:
url: ${SIM_DB_URL:jdbc:postgresql://localhost:5432/simdb}
username: ${SIM_DB_USER:sim}
password: ${SIM_DB_PASSWORD:sim-dev-password}
flyway:
schemas: sim
default-schema: sim
jdbc:
template:
query-timeout: 5s
simulator:
seed-fixed-clock: "2026-09-01T00:00:00Z"

V1__init.sql

operations-simulator/src/main/resources/db/migration/V1__init.sql
CREATE SCHEMA IF NOT EXISTS sim;
CREATE TABLE sim.services (
service_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
health TEXT NOT NULL CHECK (health IN ('HEALTHY','DEGRADED','DOWN')),
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE sim.deployments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
service_id TEXT NOT NULL REFERENCES sim.services(service_id),
version TEXT NOT NULL,
deployed_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE sim.incidents (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
service_id TEXT NOT NULL REFERENCES sim.services(service_id),
title TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (tenant_id, id)
);
CREATE TABLE sim.incident_notes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
incident_id TEXT NOT NULL REFERENCES sim.incidents(id),
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE sim.idempotency_keys (
idem_key TEXT NOT NULL,
tenant_id TEXT NOT NULL,
request_hash TEXT NOT NULL,
incident_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (idem_key, tenant_id)
);

V2__seed.sql seeds two tenants sharing a catalog shape but owning different rows — acme owns payment-gateway and ledger-core; globex owns checkout-web — plus a handful of incidents with fixed created_at values pinned to simulator.seed-fixed-clock. Deterministic seeds are what let Chapter 13’s evaluation cases name expected documents.

FaultRegistry and FaultFilter:

operations-simulator/src/main/java/in/o612/eng/opsagent/simulator/faults/FaultRegistry.java
package in.o612.eng.opsagent.simulator.faults;
import java.util.concurrent.atomic.AtomicReference;
public class FaultRegistry {
public sealed interface FaultMode {
record None() implements FaultMode {}
record Latency(long millis) implements FaultMode {}
record Flaky(double errorRate) implements FaultMode {}
record Malformed() implements FaultMode {}
record Outage() implements FaultMode {}
}
private final AtomicReference<FaultMode> current = new AtomicReference<>(new FaultMode.None());
public FaultMode current() { return current.get(); }
public void set(FaultMode mode) { current.set(mode); }
}
operations-simulator/src/main/java/in/o612/eng/opsagent/simulator/faults/FaultFilter.java
package in.o612.eng.opsagent.simulator.faults;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.concurrent.ThreadLocalRandom;
@Component
@Order(1)
public class FaultFilter extends OncePerRequestFilter {
private final FaultRegistry registry;
public FaultFilter(FaultRegistry registry) { this.registry = registry; }
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/sim/v1/");
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
switch (registry.current()) {
case FaultRegistry.FaultMode.None n -> chain.doFilter(req, res);
case FaultRegistry.FaultMode.Latency l -> {
try { Thread.sleep(l.millis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
chain.doFilter(req, res);
}
case FaultRegistry.FaultMode.Flaky f -> {
if (ThreadLocalRandom.current().nextDouble() < f.errorRate()) {
res.sendError(503, "injected transient failure");
} else {
chain.doFilter(req, res);
}
}
case FaultRegistry.FaultMode.Malformed m -> {
res.setStatus(200);
res.setContentType("application/json");
res.getWriter().write("NOT JSON {{{");
}
case FaultRegistry.FaultMode.Outage o -> res.sendError(503, "injected outage");
}
}
}
operations-simulator/src/main/java/in/o612/eng/opsagent/simulator/faults/FaultAdminController.java
package in.o612.eng.opsagent.simulator.faults;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/sim/admin/faults")
public class FaultAdminController {
private final FaultRegistry registry;
public FaultAdminController(FaultRegistry registry) { this.registry = registry; }
@PostMapping("/{mode}")
public String set(@PathVariable String mode,
@RequestParam(defaultValue = "2000") long latencyMs,
@RequestParam(defaultValue = "0.5") double errorRate) {
registry.set(switch (mode) {
case "none", "reset" -> new FaultRegistry.FaultMode.None();
case "latency" -> new FaultRegistry.FaultMode.Latency(latencyMs);
case "flaky" -> new FaultRegistry.FaultMode.Flaky(errorRate);
case "malformed" -> new FaultRegistry.FaultMode.Malformed();
case "outage" -> new FaultRegistry.FaultMode.Outage();
default -> throw new IllegalArgumentException("unknown fault mode: " + mode);
});
return mode;
}
}

Incident creation with idempotency — the heart of the module:

operations-simulator/src/main/java/in/o612/eng/opsagent/simulator/web/IncidentController.java
package in.o612.eng.opsagent.simulator.web;
import in.o612.eng.opsagent.contracts.incident.*;
import in.o612.eng.opsagent.simulator.persistence.IncidentRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/sim/v1/incidents")
public class IncidentController {
private final IncidentRepository incidents;
public IncidentController(IncidentRepository incidents) { this.incidents = incidents; }
@GetMapping
public List<IncidentSummary> list(@RequestHeader("X-Tenant-Id") String tenant,
@RequestParam(required = false) String serviceId,
@RequestParam(required = false) Severity severity,
@RequestParam(defaultValue = "20") int limit) {
return incidents.listRecent(tenant, serviceId, severity, Math.min(limit, 100));
}
@GetMapping("/{id}")
public IncidentDetail get(@RequestHeader("X-Tenant-Id") String tenant,
@PathVariable String id) {
return incidents.find(tenant, id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such incident"));
}
@PostMapping
public ResponseEntity<IncidentDetail> create(@RequestHeader("X-Tenant-Id") String tenant,
@RequestHeader("Idempotency-Key") String idemKey,
@RequestBody CreateIncidentCommand cmd) {
var outcome = incidents.createIdempotent(tenant, idemKey, cmd);
return switch (outcome) {
case IncidentRepository.CreateOutcome.Created c -> ResponseEntity.status(201).body(c.incident());
case IncidentRepository.CreateOutcome.Replayed r -> ResponseEntity.ok(r.incident());
case IncidentRepository.CreateOutcome.KeyConflict k ->
throw new ResponseStatusException(HttpStatus.CONFLICT,
"idempotency key reused with different payload");
};
}
@PostMapping("/{id}/notes")
public ResponseEntity<Void> addNote(@RequestHeader("X-Tenant-Id") String tenant,
@PathVariable String id,
@RequestBody NoteRequest note) {
incidents.find(tenant, id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such incident"));
incidents.appendNote(id, note.author(), note.body());
return ResponseEntity.accepted().build();
}
public record NoteRequest(String author, String body) {}
}

IncidentRepository.createIdempotent (abridged shape — the full file in the repo hashes the request body with SHA-256, inserts the key row first, and lets the primary-key constraint arbitrate concurrent retries):

operations-simulator/src/main/java/in/o612/eng/opsagent/simulator/persistence/IncidentRepository.java
// Core pattern:
// 1. hash normalized command -> requestHash
// 2. SELECT request_hash, incident_id FROM sim.idempotency_keys WHERE idem_key=? AND tenant_id=?
// 3a. found, hash matches -> Replayed(fetch incident)
// 3b. found, hash differs -> KeyConflict
// 3c. absent -> INSERT key row + INSERT incident in one tx -> Created
// On PK race: re-read row, repeat step 3.

The X-Tenant-Id header is intentionally crude — this is the simulator’s contract for its trusted caller (the MCP server), not end-user auth. Tenant enforcement for real callers happens in Chapters 9–10 at the MCP and agent boundaries.

infra/compose/docker-compose.yml — Postgres only; the stack grows with the chapters:

infra/compose/docker-compose.yml
services:
postgres:
image: pgvector/pgvector:0.8.6-pg17
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres-dev
ports: ["5432:5432"]
volumes:
- ../postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10

infra/postgres/init.sql creates simdb, opsdb, and the sim/agent login roles with least-privilege grants — committed in full in the repo.

Commands to build and run

terminal
docker compose -f infra/compose/docker-compose.yml up -d postgres
./gradlew :operations-simulator:bootRun

API requests and expected responses

api-requests/simulator.http
### health of a seeded service
GET http://localhost:8082/sim/v1/services/payment-gateway/health
X-Tenant-Id: acme
### idempotent create — run twice, identical response, one row
POST http://localhost:8082/sim/v1/incidents
X-Tenant-Id: acme
Idempotency-Key: demo-0001
Content-Type: application/json
{"title":"payment-gateway elevated error rate","severity":"SEV2","serviceId":"payment-gateway","description":"5xx rate above threshold since 02:14Z"}
### flip into outage mode, then reset
POST http://localhost:8082/sim/admin/faults/outage
POST http://localhost:8082/sim/admin/faults/reset

Expected: the first POST returns 201 with a generated inc-<hex> ID; the identical replay returns 200 with the same incident; the same key with a changed title returns 409; with outage active every /sim/v1/** call returns 503 until reset.

Automated tests

Testcontainers-backed (@ServiceConnection for Boot, or an explicit PostgreSQLContainer("pgvector/pgvector:0.8.6-pg17")):

  • IncidentRepositoryTest: create/replay/conflict triple; notes appended in order; list filters by tenant, service, severity.
  • IncidentControllerIT: full HTTP round-trip; second POST with same key returns 200 + identical body and does not create a row (assert count(*) unchanged).
  • FaultFilterIT: latency adds ≥ the configured delay; flaky at errorRate=1.0 always 503s; malformed returns 200 with a body that fails JSON parsing; outage 503s even health-adjacent endpoints but never /sim/admin/**.

Failure-injection lab

This chapter is the failure lab — every mode is exercised:

  1. flaky at 0.5: script 20 identical GETs, count 503s (expect roughly half; the point is variance exists).
  2. malformed while your HTTP client parses JSON: observe the DecodingException — this exact error shape returns in Chapter 8’s client tests.
  3. latency at 3000ms vs. the simulator’s own 5s JDBC timeout: slow ≠ failed; the distinction matters for Chapter 11’s retry classification.

Security considerations

The simulator trusts X-Tenant-Id — safe only because it is an internal test double. It is still scoped to a private network in Compose and K8s, and its write endpoints still require idempotency, because the downstream contract must match what a real system would demand. The admin fault endpoint must never be reachable from the agent-facing boundary; NetworkPolicy in Chapter 15 enforces that.

Observability checks

Actuator health + a simulator.faults.current gauge (mode as a tag) land now; structured access logs carry tenant_id and correlation_id placeholders so Chapter 12’s trace work has somewhere to attach.

Troubleshooting

  • Flyway checksum mismatch after editing a migration: migrations are immutable once applied — add a new V3__… instead; flyway repair exists but hides real history drift.
  • relation sim.services does not exist in tests: the container DB ran init before Flyway — ensure spring.flyway.schemas=sim and the schema is created by the migration, not the init script.

Checkpoint verification checklist

  • ./gradlew :operations-simulator:check green, including Testcontainers tests.
  • Idempotent replay verified by row count, not just identical responses.
  • All four fault modes observed over HTTP.
  • No JPA anywhere; contracts module still dependency-clean (arch tests green).

Commit message and Git tag

feat(simulator): deterministic ops backend with idempotent writes and fault modes

git tag chapter-02-operations-simulator

What comes next

Chapter 3 stands up agent-api with a real model — Ollama locally, a hosted profile behind config — plus the deterministic stub model that keeps default CI offline.

Project State Ledger — chapter-02-operations-simulator

  • Contracts added: ids (TenantId, ServiceId, IncidentId, UserId, CorrelationId, IdempotencyKey), incident (Severity, IncidentStatus, CreateIncidentCommand, IncidentDetail, IncidentSummary), service (HealthStatus, ServiceStatusResult), ApiError
  • Endpoints live: /sim/v1/services[/…], /sim/v1/incidents[/…], /sim/v1/incidents/{id}/notes, /sim/admin/faults/{mode}
  • Migrations: V1__init.sql, V2__seed.sql in sim schema
  • Idempotency: Idempotency-Key + tenant + SHA-256 request hash; replay→200, conflict→409
  • Fault modes: none|latency|flaky|malformed|outage|reset, /sim/v1/** only
  • Tenant model: simulator trusts X-Tenant-Id (internal contract); real enforcement lands Ch 9–10
  • Next: chapter-03-basic-agent-api
Spring BootJavaPostgresTesting

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind