diff --git a/README.md b/README.md
index 6902904..0cc0534 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 체류·계약·서류·신
| 개발·배포 DB | PostgreSQL + Flyway |
| 보안 | JWT Access Token, `ADMIN`·`HR`·`VIEWER` 역할, `company_id` 기반 ActorContext |
| 개발 기반 | Swagger UI, 공통 오류, `request_id`, CI 구성 완료 |
-| AI·Workflow | Knowledge Catalog projection, Task·Checklist·승인·감사 구현. AiRun 연동은 후속 Issue |
+| AI·Workflow | Knowledge Catalog projection, Task·Checklist·승인·감사와 AI Runtime 계약·방어 검증 구현. Remote 연동·AiRun은 후속 Issue |
계획 문서는 현재 동작하는 API가 아닙니다. 구현의 원본은 코드·테스트와 실행 시 생성되는 OpenAPI이고, 장기 아키텍처 결정은 [ADR](docs/adr/README.md), 계획 범위와 예시는 [API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification)와 Issue에서 확인합니다.
@@ -140,6 +140,22 @@ POST /api/v1/tasks/{taskId}/approval-requests
- 상태 변경, 승인 기록, 감사 이벤트는 같은 DB transaction에 기록되므로 중간 하나가 실패하면 함께 되돌아갑니다.
- `GET /api/v1/tasks/{taskId}/activities`는 화면용 안전 타임라인이고, `GET /api/v1/audit-events`는 ADMIN용 필터·cursor 조회입니다. 내부 snapshot 원문은 두 API에 노출하지 않습니다.
+### AI Runtime 계약 기반
+
+Server는 AI Runtime에 보낼 수 있는 field를 typed DTO로 제한하고, 전송 전과 응답 후에
+`ValidatingAiRuntimeClient`로 개인정보·request ID·version·worker·workflow·slot을
+검증합니다.
+
+- `AiRuntimeClient`는 Provider-neutral Port이며 OpenAI·Gemini SDK를 포함하지 않습니다.
+- 테스트에서는 네트워크를 호출하지 않는 `FakeAiRuntimeClient`를 사용합니다.
+- 실제 `RemoteAiRuntimeClient`와 `/internal/v1/analyses` HTTP 연결은 AI 저장소의 원본
+ OpenAPI·JSON Schema가 release된 뒤 추가합니다.
+- Remote Client는 자동 retry하지 않습니다. 후속 #24가 새 AiAttempt를 영속한 경우에만
+ 다시 호출할 수 있습니다.
+
+요청·응답 예시와 차단 규칙은
+[Server ↔ AI Runtime 계약 기반](docs/ai-runtime-contract.md)에서 확인합니다.
+
### PostgreSQL 개발 Profile
```bash
@@ -175,6 +191,7 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 또는 배포 Secret의 12자 이상 값
| Profile | `local`은 H2, `dev`·`prod`는 PostgreSQL을 사용합니다. | `application.yaml` |
| Flyway | 서버 시작 시 적용하지 않은 DB 변경 파일을 순서대로 실행합니다. | `db/migration` |
| Workflow Catalog | Knowledge release의 Server용 read-only projection을 시작 시 검증합니다. | `workflow/` |
+| AI Runtime 계약 | 외부 AI 요청·응답을 allow-list와 version으로 다시 검증합니다. | `aiintegration/` |
| Security | JWT에서 ActorContext와 역할을 만들고 VIEWER의 쓰기 요청을 기본 차단합니다. | `SecurityConfig` |
| Swagger | Controller의 API 설명을 브라우저 문서로 보여줍니다. | `OpenApiConfig` |
| 공통 오류 | 모든 실패를 같은 JSON 구조로 반환합니다. | `common/error` |
@@ -308,6 +325,7 @@ server/
├── auth/
├── worker/
├── task/
+ ├── aiintegration/
└── airun/
```
@@ -338,6 +356,7 @@ server/
| --- | --- |
| 전체 백엔드 목표·작업 순서 | [MVP Epic #2](https://github.com/fowoco/server/issues/2) |
| 저장소·모듈·API·상태 결정 원본 | [Architecture Decision Records](docs/adr/README.md) |
+| Server와 AI Runtime의 구현 계약 | [AI Runtime 계약 기반](docs/ai-runtime-contract.md) |
| 계획 API와 사용자 흐름 | [Wiki API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification) |
| 사람이 읽는 상세 DTO·화면 기획 | [Notion API 명세](https://app.notion.com/p/f250e15aa74e82b8872581be4d7c6c3c?v=f280e15aa74e82ce8d6e8848514d41c3&pvs=23) |
| 화면·사용 흐름 | [Figma](https://www.figma.com/design/eaOD8OXZOGq6vK4H9pGXNi/FOWOCO?node-id=143-2&t=YbytLHiwZ5m1IChO-1) |
diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md
new file mode 100644
index 0000000..6d89c86
--- /dev/null
+++ b/docs/ai-runtime-contract.md
@@ -0,0 +1,143 @@
+# Server ↔ AI Runtime 계약 기반
+
+이 문서는 `fowoco/server`가 별도 배포되는 `fowoco/ai` Runtime을 호출할 때 지켜야 하는
+최소 계약과 방어 규칙을 설명합니다.
+
+현재 단계는 **HTTP 연결 전 계약 기반**입니다. 실제 `/internal/v1/analyses` OpenAPI와
+Structured Output JSON Schema의 원본은 `fowoco/ai`가 소유하며, 원본 계약이 release되면
+Server의 `RemoteAiRuntimeClient`와 fixture를 그 version에 맞춰 연결합니다.
+
+## 초보자용 한 줄 설명
+
+AI 서버에 무엇을 보낼 수 있는지 먼저 좁혀 놓고, AI가 돌려준 값도 그대로 믿지 않고 다시
+검사하는 안전문입니다.
+
+```text
+AiRunWorker (#24, 후속)
+ → ValidatingAiRuntimeClient
+ 1. 요청 개인정보·허용 범위 검사
+ 2. AiRuntimeClient transport를 정확히 한 번 호출
+ 3. 응답 ID·version·worker·workflow·slot 재검사
+ → FakeAiRuntimeClient (test)
+ → RemoteAiRuntimeClient (#8 후속)
+ → POST /internal/v1/analyses (fowoco/ai)
+```
+
+`AiRuntimeClient`는 OpenAI, Gemini, Anthropic 같은 Provider를 직접 호출하지 않습니다.
+Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입니다.
+
+## 요청 계약
+
+```json
+{
+ "requestId": "10000000-0000-0000-0000-000000000001",
+ "attemptId": "20000000-0000-0000-0000-000000000001",
+ "contractVersion": "1.0.0",
+ "requiredKnowledgeVersion": "0.2.0",
+ "deadlineMs": 10000,
+ "maskedInput": {
+ "maskedInstruction": "workerRef 30000000-0000-0000-0000-000000000001의 체류연장 준비",
+ "workers": [
+ {
+ "workerRef": "30000000-0000-0000-0000-000000000001",
+ "preferredLanguage": "vi",
+ "workStatus": "ACTIVE",
+ "stayExpiryDate": "2026-12-31"
+ }
+ ],
+ "workflowConstraints": [
+ {
+ "workflowId": "EXPIRY_RENEWAL",
+ "allowedSlotKeys": [
+ "stay_expiry_date",
+ "contract_end_date",
+ "monthly_wage"
+ ]
+ }
+ ]
+ }
+}
+```
+
+- `requestId`: Server 요청과 Runtime 응답을 같은 실행으로 연결합니다.
+- `attemptId`: 한 번의 `AiRuntimeClient.analyze` 호출과 정확히 하나로 대응합니다.
+- `contractVersion`: 양쪽이 같은 JSON 계약을 사용하는지 확인합니다.
+- `requiredKnowledgeVersion`: Server와 Runtime이 같은 Workflow release를 사용하게 합니다.
+- `deadlineMs`: 이번 시도 전체에서 남은 실행 시간입니다.
+- `maskedInstruction`: 이름과 식별번호를 `workerRef`로 바꾼 자연어입니다.
+- `workflowConstraints`: Knowledge projection에서 가져온 Workflow와 slot allow-list입니다.
+
+근로자 Context에는 여권번호, 외국인등록번호, 전화번호, 계좌번호, 법적 실명, 원본 문서와
+Worker Link token을 추가하지 않습니다.
+
+## 응답 계약
+
+```json
+{
+ "requestId": "10000000-0000-0000-0000-000000000001",
+ "outcome": "REVIEW_REQUIRED",
+ "candidates": [
+ {
+ "candidateRef": "candidate-1",
+ "workerRef": "30000000-0000-0000-0000-000000000001",
+ "workflowId": "EXPIRY_RENEWAL",
+ "extractedSlots": {
+ "stay_expiry_date": "2026-12-31"
+ },
+ "missingSlots": [
+ "contract_end_date",
+ "monthly_wage"
+ ],
+ "confidence": 0.92
+ }
+ ],
+ "validationErrors": [],
+ "versions": {
+ "agentVersion": "agent-1.0.0",
+ "modelProvider": "openai",
+ "modelName": "gpt-5-mini",
+ "modelVersion": "2026-07-01",
+ "promptVersion": "prompt-3",
+ "contextPackVersion": "context-0.2.0",
+ "workflowCatalogVersion": "0.2.0",
+ "contractVersion": "1.0.0"
+ },
+ "providerAttemptCount": 1,
+ "latencyMs": 245
+}
+```
+
+`NEEDS_INFO`와 `REVIEW_REQUIRED`는 정상 분석 결과입니다. 이 값은 AiRun의 기술적
+`FAILED` 상태와 섞지 않습니다. Candidate는 Task도 승인도 아니며, #24에서 HR이 채택한
+후에만 Server Task command로 전달됩니다.
+
+## Server가 거부하는 응답
+
+- 요청과 다른 `requestId`
+- 요청과 다른 contract 또는 Workflow Catalog version
+- 요청에 없던 `workerRef`나 `workflowId`
+- Workflow가 허용하지 않은 slot
+- 0 미만 또는 1 초과 confidence
+- 중복 candidate reference와 잘못된 outcome 구조
+- 여권번호·외국인등록번호·전화번호·계좌번호·Bearer Token·Secret이 섞인 값
+
+거부 예외에는 발견한 원문을 넣지 않습니다. 앞으로 #24 AiAttempt에는
+`AiRuntimeFailureCode`와 `requestId` 같은 안전한 진단값만 저장합니다.
+
+## 테스트와 실제 구현의 차이
+
+- `FakeAiRuntimeClient`: `src/test`에만 있으며 응답이나 예외를 순서대로 예약합니다.
+- `ValidatingAiRuntimeClient`: transport 앞뒤에서 같은 방어 검증을 수행합니다.
+- `RemoteAiRuntimeClient`: 아직 없습니다. AI 원본 계약 release 후 추가합니다.
+
+실제 HTTP 연결 PR에서는 다음을 추가로 검증합니다.
+
+1. `Service-Authorization`, `X-Request-Id`, `traceparent` 전달
+2. 알 수 없는 JSON field와 body size 제한
+3. connect/read/overall deadline
+4. circuit breaker와 concurrency bulkhead
+5. HTTP·parsing·contract 오류의 안정적인 분류
+6. contract fixture와 WireMock 통합 테스트
+
+Remote Client는 자동 HTTP retry를 하지 않습니다. 다시 호출하려면 #24가 먼저 새로운
+AiAttempt를 DB에 기록해야 합니다.
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeContractException.java b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeContractException.java
new file mode 100644
index 0000000..96f3eab
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeContractException.java
@@ -0,0 +1,20 @@
+package com.fowoco.server.aiintegration.application.error;
+
+import java.util.Objects;
+
+/**
+ * Contract failure that never includes the rejected raw value in its message.
+ */
+public final class AiRuntimeContractException extends RuntimeException {
+
+ private final AiRuntimeFailureCode failureCode;
+
+ public AiRuntimeContractException(AiRuntimeFailureCode failureCode, String safeMessage) {
+ super(safeMessage);
+ this.failureCode = Objects.requireNonNull(failureCode, "failureCode must not be null");
+ }
+
+ public AiRuntimeFailureCode failureCode() {
+ return failureCode;
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java
new file mode 100644
index 0000000..4387644
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java
@@ -0,0 +1,16 @@
+package com.fowoco.server.aiintegration.application.error;
+
+/**
+ * Stable, non-sensitive reason stored by a future AiAttempt.
+ */
+public enum AiRuntimeFailureCode {
+ INVALID_REQUEST_CONTRACT,
+ SENSITIVE_DATA_REJECTED,
+ INVALID_RESPONSE_CONTRACT,
+ REQUEST_ID_MISMATCH,
+ CONTRACT_VERSION_MISMATCH,
+ KNOWLEDGE_VERSION_MISMATCH,
+ UNEXPECTED_WORKER_REFERENCE,
+ UNEXPECTED_WORKFLOW,
+ UNEXPECTED_SLOT
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisOutcome.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisOutcome.java
new file mode 100644
index 0000000..f3efd4d
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisOutcome.java
@@ -0,0 +1,11 @@
+package com.fowoco.server.aiintegration.application.model;
+
+/**
+ * A successful business outcome returned by the AI Runtime.
+ *
+ *
Low confidence and missing information are not transport failures.
+ */
+public enum AiAnalysisOutcome {
+ NEEDS_INFO,
+ REVIEW_REQUIRED
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java
new file mode 100644
index 0000000..6dc7a99
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java
@@ -0,0 +1,25 @@
+package com.fowoco.server.aiintegration.application.model;
+
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Provider-neutral body for one Server-managed AI attempt.
+ */
+public record AiAnalysisRequest(
+ UUID requestId,
+ UUID attemptId,
+ String contractVersion,
+ String requiredKnowledgeVersion,
+ long deadlineMs,
+ MaskedAnalysisInput maskedInput
+) {
+
+ public AiAnalysisRequest {
+ Objects.requireNonNull(requestId, "requestId must not be null");
+ Objects.requireNonNull(attemptId, "attemptId must not be null");
+ Objects.requireNonNull(contractVersion, "contractVersion must not be null");
+ Objects.requireNonNull(requiredKnowledgeVersion, "requiredKnowledgeVersion must not be null");
+ Objects.requireNonNull(maskedInput, "maskedInput must not be null");
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisResponse.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisResponse.java
new file mode 100644
index 0000000..d28b869
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisResponse.java
@@ -0,0 +1,29 @@
+package com.fowoco.server.aiintegration.application.model;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Untrusted response returned by the AI Runtime and validated again by the Server.
+ */
+public record AiAnalysisResponse(
+ UUID requestId,
+ AiAnalysisOutcome outcome,
+ List candidates,
+ List validationErrors,
+ AiRuntimeVersions versions,
+ int providerAttemptCount,
+ long latencyMs
+) {
+
+ public AiAnalysisResponse {
+ Objects.requireNonNull(requestId, "requestId must not be null");
+ Objects.requireNonNull(outcome, "outcome must not be null");
+ Objects.requireNonNull(candidates, "candidates must not be null");
+ Objects.requireNonNull(validationErrors, "validationErrors must not be null");
+ Objects.requireNonNull(versions, "versions must not be null");
+ candidates = List.copyOf(candidates);
+ validationErrors = List.copyOf(validationErrors);
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiCandidate.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiCandidate.java
new file mode 100644
index 0000000..400202b
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiCandidate.java
@@ -0,0 +1,31 @@
+package com.fowoco.server.aiintegration.application.model;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Untrusted Workflow candidate returned by the AI Runtime.
+ */
+public record AiCandidate(
+ String candidateRef,
+ UUID workerRef,
+ String workflowId,
+ Map extractedSlots,
+ List missingSlots,
+ BigDecimal confidence
+) {
+
+ public AiCandidate {
+ Objects.requireNonNull(candidateRef, "candidateRef must not be null");
+ Objects.requireNonNull(workerRef, "workerRef must not be null");
+ Objects.requireNonNull(workflowId, "workflowId must not be null");
+ Objects.requireNonNull(extractedSlots, "extractedSlots must not be null");
+ Objects.requireNonNull(missingSlots, "missingSlots must not be null");
+ Objects.requireNonNull(confidence, "confidence must not be null");
+ extractedSlots = Map.copyOf(extractedSlots);
+ missingSlots = List.copyOf(missingSlots);
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeVersions.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeVersions.java
new file mode 100644
index 0000000..7974db4
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeVersions.java
@@ -0,0 +1,29 @@
+package com.fowoco.server.aiintegration.application.model;
+
+import java.util.Objects;
+
+/**
+ * Version evidence persisted with a future AiRun.
+ */
+public record AiRuntimeVersions(
+ String agentVersion,
+ String modelProvider,
+ String modelName,
+ String modelVersion,
+ String promptVersion,
+ String contextPackVersion,
+ String workflowCatalogVersion,
+ String contractVersion
+) {
+
+ public AiRuntimeVersions {
+ Objects.requireNonNull(agentVersion, "agentVersion must not be null");
+ Objects.requireNonNull(modelProvider, "modelProvider must not be null");
+ Objects.requireNonNull(modelName, "modelName must not be null");
+ Objects.requireNonNull(modelVersion, "modelVersion must not be null");
+ Objects.requireNonNull(promptVersion, "promptVersion must not be null");
+ Objects.requireNonNull(contextPackVersion, "contextPackVersion must not be null");
+ Objects.requireNonNull(workflowCatalogVersion, "workflowCatalogVersion must not be null");
+ Objects.requireNonNull(contractVersion, "contractVersion must not be null");
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiValidationError.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiValidationError.java
new file mode 100644
index 0000000..24dda4d
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiValidationError.java
@@ -0,0 +1,17 @@
+package com.fowoco.server.aiintegration.application.model;
+
+import java.util.Objects;
+
+/**
+ * Machine-readable validation result. Free-form Provider error messages are intentionally omitted.
+ */
+public record AiValidationError(
+ String code,
+ String field
+) {
+
+ public AiValidationError {
+ Objects.requireNonNull(code, "code must not be null");
+ Objects.requireNonNull(field, "field must not be null");
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedAnalysisInput.java b/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedAnalysisInput.java
new file mode 100644
index 0000000..3dfea28
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedAnalysisInput.java
@@ -0,0 +1,22 @@
+package com.fowoco.server.aiintegration.application.model;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Pseudonymized instruction and allow-listed context sent to the AI Runtime.
+ */
+public record MaskedAnalysisInput(
+ String maskedInstruction,
+ List workers,
+ List workflowConstraints
+) {
+
+ public MaskedAnalysisInput {
+ Objects.requireNonNull(maskedInstruction, "maskedInstruction must not be null");
+ Objects.requireNonNull(workers, "workers must not be null");
+ Objects.requireNonNull(workflowConstraints, "workflowConstraints must not be null");
+ workers = List.copyOf(workers);
+ workflowConstraints = List.copyOf(workflowConstraints);
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java b/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java
new file mode 100644
index 0000000..191cb8f
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java
@@ -0,0 +1,25 @@
+package com.fowoco.server.aiintegration.application.model;
+
+import java.time.LocalDate;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Minimum worker context allowed to cross the AI boundary.
+ *
+ * Legal name, phone, passport number, residence number, account number, and document contents
+ * must never be added here.
+ */
+public record MaskedWorkerContext(
+ UUID workerRef,
+ String preferredLanguage,
+ String workStatus,
+ LocalDate stayExpiryDate
+) {
+
+ public MaskedWorkerContext {
+ Objects.requireNonNull(workerRef, "workerRef must not be null");
+ Objects.requireNonNull(preferredLanguage, "preferredLanguage must not be null");
+ Objects.requireNonNull(workStatus, "workStatus must not be null");
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/WorkflowConstraint.java b/src/main/java/com/fowoco/server/aiintegration/application/model/WorkflowConstraint.java
new file mode 100644
index 0000000..8130ca7
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/model/WorkflowConstraint.java
@@ -0,0 +1,19 @@
+package com.fowoco.server.aiintegration.application.model;
+
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Knowledge-owned Workflow identifiers and slot names that the Runtime may return.
+ */
+public record WorkflowConstraint(
+ String workflowId,
+ Set allowedSlotKeys
+) {
+
+ public WorkflowConstraint {
+ Objects.requireNonNull(workflowId, "workflowId must not be null");
+ Objects.requireNonNull(allowedSlotKeys, "allowedSlotKeys must not be null");
+ allowedSlotKeys = Set.copyOf(allowedSlotKeys);
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/port/AiRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/application/port/AiRuntimeClient.java
new file mode 100644
index 0000000..c55a03f
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/port/AiRuntimeClient.java
@@ -0,0 +1,15 @@
+package com.fowoco.server.aiintegration.application.port;
+
+import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest;
+import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse;
+
+/**
+ * Server-owned port for one attempt against a separately deployed AI Runtime.
+ *
+ * Implementations must not call a model Provider directly and must not retry transparently.
+ */
+@FunctionalInterface
+public interface AiRuntimeClient {
+
+ AiAnalysisResponse analyze(AiAnalysisRequest request);
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java
new file mode 100644
index 0000000..d1fcf60
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java
@@ -0,0 +1,211 @@
+package com.fowoco.server.aiintegration.application.validation;
+
+import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException;
+import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode;
+import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest;
+import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse;
+import com.fowoco.server.aiintegration.application.model.AiCandidate;
+import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions;
+import com.fowoco.server.aiintegration.application.model.MaskedWorkerContext;
+import com.fowoco.server.aiintegration.application.model.WorkflowConstraint;
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.regex.Pattern;
+import org.springframework.stereotype.Component;
+
+/**
+ * Semantic validation applied before an outbound call and after an untrusted Runtime response.
+ */
+@Component
+public class AiRuntimeContractValidator {
+
+ private static final long MIN_DEADLINE_MS = 100;
+ private static final long MAX_DEADLINE_MS = 60_000;
+ private static final int MAX_WORKERS = 20;
+ private static final int MAX_WORKFLOWS = 20;
+ private static final int MAX_CANDIDATES = 50;
+ private static final Pattern VERSION = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._+-]{0,63}");
+ private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z][A-Za-z0-9._-]{0,127}");
+ private static final Pattern CANDIDATE_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]{0,63}");
+
+ private final AiRuntimePrivacyPolicy privacyPolicy;
+
+ public AiRuntimeContractValidator(AiRuntimePrivacyPolicy privacyPolicy) {
+ this.privacyPolicy = privacyPolicy;
+ }
+
+ public void validateRequest(AiAnalysisRequest request) {
+ if (request == null) {
+ reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime request is missing.");
+ }
+ validateVersion(request.contractVersion(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT);
+ validateVersion(request.requiredKnowledgeVersion(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT);
+ if (request.deadlineMs() < MIN_DEADLINE_MS || request.deadlineMs() > MAX_DEADLINE_MS) {
+ reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime deadline is outside the allowed range.");
+ }
+ privacyPolicy.validateText(request.maskedInput().maskedInstruction(), 10_000, true);
+ validateWorkers(request);
+ validateWorkflowConstraints(request);
+ }
+
+ public void validateResponse(AiAnalysisRequest request, AiAnalysisResponse response) {
+ validateRequest(request);
+ if (response == null) {
+ reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime response is missing.");
+ }
+ if (!request.requestId().equals(response.requestId())) {
+ reject(AiRuntimeFailureCode.REQUEST_ID_MISMATCH, "AI Runtime response requestId does not match.");
+ }
+ validateResponseVersions(request, response.versions());
+ if (response.providerAttemptCount() < 1 || response.providerAttemptCount() > 10) {
+ reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Provider attempt count is invalid.");
+ }
+ if (response.latencyMs() < 0 || response.latencyMs() > 86_400_000) {
+ reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime latency is invalid.");
+ }
+ if (response.candidates().size() > MAX_CANDIDATES) {
+ reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime returned too many candidates.");
+ }
+
+ Map> allowedSlotsByWorkflow = allowedSlotsByWorkflow(request);
+ Set allowedWorkers = request.maskedInput().workers().stream()
+ .map(MaskedWorkerContext::workerRef)
+ .collect(java.util.stream.Collectors.toUnmodifiableSet());
+ Set candidateRefs = new HashSet<>();
+ response.candidates().forEach(candidate ->
+ validateCandidate(candidate, allowedWorkers, allowedSlotsByWorkflow, candidateRefs));
+ response.validationErrors().forEach(error -> {
+ validateIdentifier(error.code(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+ privacyPolicy.validateKey(error.field());
+ validateIdentifier(error.field(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+ });
+ if (response.outcome() == com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome.REVIEW_REQUIRED
+ && response.candidates().isEmpty()) {
+ reject(
+ AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT,
+ "REVIEW_REQUIRED response must include at least one candidate."
+ );
+ }
+ }
+
+ private void validateWorkers(AiAnalysisRequest request) {
+ var workers = request.maskedInput().workers();
+ if (workers.isEmpty() || workers.size() > MAX_WORKERS) {
+ reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime worker context count is invalid.");
+ }
+ Set workerRefs = new HashSet<>();
+ for (MaskedWorkerContext worker : workers) {
+ if (!workerRefs.add(worker.workerRef())) {
+ reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime worker reference is duplicated.");
+ }
+ privacyPolicy.validateText(worker.preferredLanguage(), 32, true);
+ privacyPolicy.validateText(worker.workStatus(), 32, true);
+ validateIdentifier(worker.preferredLanguage(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT);
+ validateIdentifier(worker.workStatus(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT);
+ }
+ }
+
+ private void validateWorkflowConstraints(AiAnalysisRequest request) {
+ var workflows = request.maskedInput().workflowConstraints();
+ if (workflows.isEmpty() || workflows.size() > MAX_WORKFLOWS) {
+ reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime Workflow constraint count is invalid.");
+ }
+ Set workflowIds = new HashSet<>();
+ for (WorkflowConstraint workflow : workflows) {
+ validateIdentifier(workflow.workflowId(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT);
+ if (!workflowIds.add(workflow.workflowId()) || workflow.allowedSlotKeys().size() > 100) {
+ reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime Workflow constraint is invalid.");
+ }
+ workflow.allowedSlotKeys().forEach(slot -> {
+ privacyPolicy.validateKey(slot);
+ validateIdentifier(slot, AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT);
+ });
+ }
+ }
+
+ private void validateResponseVersions(AiAnalysisRequest request, AiRuntimeVersions versions) {
+ validateVersion(versions.agentVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+ validateIdentifier(versions.modelProvider(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+ privacyPolicy.validateText(versions.modelName(), 128, true);
+ validateVersion(versions.modelVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+ validateVersion(versions.promptVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+ validateVersion(versions.contextPackVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+ validateVersion(versions.workflowCatalogVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+ validateVersion(versions.contractVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+
+ if (!request.contractVersion().equals(versions.contractVersion())) {
+ reject(AiRuntimeFailureCode.CONTRACT_VERSION_MISMATCH, "AI Runtime contract version does not match.");
+ }
+ if (!request.requiredKnowledgeVersion().equals(versions.workflowCatalogVersion())) {
+ reject(AiRuntimeFailureCode.KNOWLEDGE_VERSION_MISMATCH, "AI Runtime Knowledge version does not match.");
+ }
+ }
+
+ private void validateCandidate(
+ AiCandidate candidate,
+ Set allowedWorkers,
+ Map> allowedSlotsByWorkflow,
+ Set candidateRefs
+ ) {
+ if (!CANDIDATE_REF.matcher(candidate.candidateRef()).matches() || !candidateRefs.add(candidate.candidateRef())) {
+ reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime candidate reference is invalid.");
+ }
+ if (!allowedWorkers.contains(candidate.workerRef())) {
+ reject(AiRuntimeFailureCode.UNEXPECTED_WORKER_REFERENCE, "AI Runtime returned an unknown worker reference.");
+ }
+ Set allowedSlots = allowedSlotsByWorkflow.get(candidate.workflowId());
+ if (allowedSlots == null) {
+ reject(AiRuntimeFailureCode.UNEXPECTED_WORKFLOW, "AI Runtime returned an unexpected Workflow.");
+ }
+ if (candidate.confidence().compareTo(BigDecimal.ZERO) < 0
+ || candidate.confidence().compareTo(BigDecimal.ONE) > 0) {
+ reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime confidence is invalid.");
+ }
+ candidate.extractedSlots().forEach((key, value) -> {
+ validateAllowedSlot(key, allowedSlots);
+ privacyPolicy.validateText(value, 4_000, true);
+ });
+ Set missingSlots = new HashSet<>();
+ candidate.missingSlots().forEach(slot -> {
+ validateAllowedSlot(slot, allowedSlots);
+ if (!missingSlots.add(slot) || candidate.extractedSlots().containsKey(slot)) {
+ reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime missing slot is invalid.");
+ }
+ });
+ }
+
+ private void validateAllowedSlot(String slot, Set allowedSlots) {
+ privacyPolicy.validateKey(slot);
+ validateIdentifier(slot, AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT);
+ if (!allowedSlots.contains(slot)) {
+ reject(AiRuntimeFailureCode.UNEXPECTED_SLOT, "AI Runtime returned an unexpected slot.");
+ }
+ }
+
+ private Map> allowedSlotsByWorkflow(AiAnalysisRequest request) {
+ Map> allowed = new HashMap<>();
+ request.maskedInput().workflowConstraints()
+ .forEach(workflow -> allowed.put(workflow.workflowId(), workflow.allowedSlotKeys()));
+ return Map.copyOf(allowed);
+ }
+
+ private void validateVersion(String version, AiRuntimeFailureCode failureCode) {
+ if (version == null || !VERSION.matcher(version).matches()) {
+ reject(failureCode, "AI Runtime version is invalid.");
+ }
+ }
+
+ private void validateIdentifier(String identifier, AiRuntimeFailureCode failureCode) {
+ if (identifier == null || !IDENTIFIER.matcher(identifier).matches()) {
+ reject(failureCode, "AI Runtime identifier is invalid.");
+ }
+ }
+
+ private void reject(AiRuntimeFailureCode failureCode, String safeMessage) {
+ throw new AiRuntimeContractException(failureCode, safeMessage);
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java
new file mode 100644
index 0000000..e851c38
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java
@@ -0,0 +1,97 @@
+package com.fowoco.server.aiintegration.application.validation;
+
+import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException;
+import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode;
+import java.text.Normalizer;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.springframework.stereotype.Component;
+
+/**
+ * Rejects sensitive values before they cross the AI boundary or enter an AiRun candidate.
+ */
+@Component
+public class AiRuntimePrivacyPolicy {
+
+ private static final Set FORBIDDEN_KEY_PARTS = Set.of(
+ "passportnumber",
+ "passportno",
+ "alienregistrationnumber",
+ "registrationnumber",
+ "residentnumber",
+ "rrn",
+ "phone",
+ "accountnumber",
+ "bankaccount",
+ "token",
+ "password",
+ "secret",
+ "authorization",
+ "prompt",
+ "여권번호",
+ "외국인등록번호",
+ "주민등록번호",
+ "전화",
+ "계좌",
+ "비밀번호"
+ );
+ private static final Pattern REGISTRATION_NUMBER =
+ Pattern.compile("(? maxLength) {
+ reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI contract text exceeds its size limit.");
+ }
+ if (containsSensitiveValue(normalized)) {
+ reject(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED, "Sensitive data was rejected at the AI boundary.");
+ }
+ }
+
+ public void validateKey(String key) {
+ if (key == null || key.isBlank()) {
+ reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI contract key is missing.");
+ }
+ String normalizedKey = Normalizer.normalize(key, Normalizer.Form.NFKC)
+ .toLowerCase(Locale.ROOT)
+ .replace("_", "")
+ .replace("-", "");
+ boolean forbidden = FORBIDDEN_KEY_PARTS.stream()
+ .map(part -> part.replace("_", "").replace("-", ""))
+ .anyMatch(normalizedKey::contains);
+ if (forbidden) {
+ reject(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED, "Sensitive key was rejected at the AI boundary.");
+ }
+ }
+
+ private boolean containsSensitiveValue(String value) {
+ return REGISTRATION_NUMBER.matcher(value).find()
+ || PHONE_NUMBER.matcher(value).find()
+ || BEARER_TOKEN.matcher(value).find()
+ || SECRET_ASSIGNMENT.matcher(value).find()
+ || LABELED_SENSITIVE_VALUE.matcher(value).find();
+ }
+
+ private void reject(AiRuntimeFailureCode failureCode, String safeMessage) {
+ throw new AiRuntimeContractException(failureCode, safeMessage);
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClient.java
new file mode 100644
index 0000000..dbd8da6
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClient.java
@@ -0,0 +1,28 @@
+package com.fowoco.server.aiintegration.application.validation;
+
+import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest;
+import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse;
+import com.fowoco.server.aiintegration.application.port.AiRuntimeClient;
+import java.util.Objects;
+
+/**
+ * Mandatory defensive decorator around a Fake or remote AI Runtime transport.
+ */
+public final class ValidatingAiRuntimeClient implements AiRuntimeClient {
+
+ private final AiRuntimeClient delegate;
+ private final AiRuntimeContractValidator validator;
+
+ public ValidatingAiRuntimeClient(AiRuntimeClient delegate, AiRuntimeContractValidator validator) {
+ this.delegate = Objects.requireNonNull(delegate, "delegate must not be null");
+ this.validator = Objects.requireNonNull(validator, "validator must not be null");
+ }
+
+ @Override
+ public AiAnalysisResponse analyze(AiAnalysisRequest request) {
+ validator.validateRequest(request);
+ AiAnalysisResponse response = delegate.analyze(request);
+ validator.validateResponse(request, response);
+ return response;
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/package-info.java b/src/main/java/com/fowoco/server/aiintegration/package-info.java
index 8d11c64..00ec762 100644
--- a/src/main/java/com/fowoco/server/aiintegration/package-info.java
+++ b/src/main/java/com/fowoco/server/aiintegration/package-info.java
@@ -1,4 +1,8 @@
/**
- * AI Runtime client ports, service authentication, and response validation.
+ * Provider-neutral boundary between the Server and the separately deployed AI Runtime.
+ *
+ * This module owns the internal request/response contract and defensive validation. Prompt
+ * assembly, model selection, Provider SDKs, and Knowledge content belong to the {@code ai} and
+ * {@code knowledge} repositories.
*/
package com.fowoco.server.aiintegration;
diff --git a/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java
new file mode 100644
index 0000000..67240fd
--- /dev/null
+++ b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java
@@ -0,0 +1,46 @@
+package com.fowoco.server.aiintegration.application.model;
+
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+
+class AiRuntimeOutboundContractTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Test
+ void outboundJsonContainsOnlyTheExplicitMaskedContract() throws Exception {
+ JsonNode json = objectMapper.valueToTree(validRequest());
+ JsonNode worker = json.get("maskedInput").get("workers").get(0);
+
+ assertThat(json.properties().stream().map(java.util.Map.Entry::getKey).toList())
+ .containsExactlyInAnyOrder(
+ "requestId",
+ "attemptId",
+ "contractVersion",
+ "requiredKnowledgeVersion",
+ "deadlineMs",
+ "maskedInput"
+ );
+ assertThat(worker.properties().stream().map(java.util.Map.Entry::getKey).toList())
+ .containsExactlyInAnyOrder(
+ "workerRef",
+ "preferredLanguage",
+ "workStatus",
+ "stayExpiryDate"
+ );
+ assertThat(json.toString().toLowerCase())
+ .doesNotContain(
+ "passportnumber",
+ "alienregistrationnumber",
+ "phone",
+ "accountnumber",
+ "legalname",
+ "token",
+ "authorization"
+ );
+ }
+}
diff --git a/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java
new file mode 100644
index 0000000..b51cdde
--- /dev/null
+++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java
@@ -0,0 +1,190 @@
+package com.fowoco.server.aiintegration.application.validation;
+
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.CONTRACT_VERSION;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.KNOWLEDGE_VERSION;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.REQUEST_ID;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.WORKER_REF;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.WORKFLOW_ID;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.responseWithCandidate;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validCandidate;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validResponse;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validVersions;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException;
+import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode;
+import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome;
+import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse;
+import com.fowoco.server.aiintegration.application.model.AiCandidate;
+import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions;
+import com.fowoco.server.aiintegration.support.AiRuntimeContractFixture;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class AiRuntimeContractValidatorTest {
+
+ private final AiRuntimeContractValidator validator =
+ new AiRuntimeContractValidator(new AiRuntimePrivacyPolicy());
+
+ @Test
+ void acceptsValidRequestAndResponse() {
+ assertThatCode(() -> validator.validateResponse(validRequest(), validResponse()))
+ .doesNotThrowAnyException();
+ }
+
+ @ParameterizedTest
+ @MethodSource("sensitiveInstructions")
+ void rejectsSensitiveInstructionBeforeOutboundCall(String instruction) {
+ assertFailure(
+ () -> validator.validateRequest(AiRuntimeContractFixture.requestWithInstruction(instruction)),
+ AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED
+ );
+ }
+
+ static Stream sensitiveInstructions() {
+ return Stream.of(
+ "연락처는 010-1234-5678입니다",
+ "외국인등록번호 990101-5123456",
+ "passport_number: M12345678",
+ "Authorization: Bearer secret-token-value",
+ "api_key=do-not-send-this"
+ );
+ }
+
+ @Test
+ void rejectsMismatchedRequestIdWithoutLeakingRawResponse() {
+ AiAnalysisResponse response = new AiAnalysisResponse(
+ UUID.randomUUID(),
+ AiAnalysisOutcome.REVIEW_REQUIRED,
+ validResponse().candidates(),
+ List.of(),
+ validVersions(),
+ 1,
+ 100
+ );
+
+ assertFailure(
+ () -> validator.validateResponse(validRequest(), response),
+ AiRuntimeFailureCode.REQUEST_ID_MISMATCH
+ );
+ }
+
+ @Test
+ void rejectsContractAndKnowledgeVersionDrift() {
+ AiRuntimeVersions wrongContract = versions(CONTRACT_VERSION + "-other", KNOWLEDGE_VERSION);
+ assertFailure(
+ () -> validator.validateResponse(validRequest(), responseWithVersions(wrongContract)),
+ AiRuntimeFailureCode.CONTRACT_VERSION_MISMATCH
+ );
+
+ AiRuntimeVersions wrongKnowledge = versions(CONTRACT_VERSION, "9.9.9");
+ assertFailure(
+ () -> validator.validateResponse(validRequest(), responseWithVersions(wrongKnowledge)),
+ AiRuntimeFailureCode.KNOWLEDGE_VERSION_MISMATCH
+ );
+ }
+
+ @Test
+ void rejectsCandidateOutsideRequestAllowList() {
+ AiCandidate unknownWorker = new AiCandidate(
+ "candidate-worker",
+ UUID.randomUUID(),
+ WORKFLOW_ID,
+ Map.of(),
+ List.of("stay_expiry_date"),
+ BigDecimal.ONE
+ );
+ assertFailure(
+ () -> validator.validateResponse(validRequest(), responseWithCandidate(unknownWorker)),
+ AiRuntimeFailureCode.UNEXPECTED_WORKER_REFERENCE
+ );
+
+ AiCandidate unknownWorkflow = new AiCandidate(
+ "candidate-workflow",
+ WORKER_REF,
+ "UNKNOWN_WORKFLOW",
+ Map.of(),
+ List.of(),
+ BigDecimal.ONE
+ );
+ assertFailure(
+ () -> validator.validateResponse(validRequest(), responseWithCandidate(unknownWorkflow)),
+ AiRuntimeFailureCode.UNEXPECTED_WORKFLOW
+ );
+
+ AiCandidate unknownSlot = new AiCandidate(
+ "candidate-slot",
+ WORKER_REF,
+ WORKFLOW_ID,
+ Map.of("passport_number", "M12345678"),
+ List.of(),
+ BigDecimal.ONE
+ );
+ assertFailure(
+ () -> validator.validateResponse(validRequest(), responseWithCandidate(unknownSlot)),
+ AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED
+ );
+ }
+
+ @Test
+ void rejectsSensitiveCandidateValueAndKeepsExceptionMessageSafe() {
+ AiCandidate sensitiveCandidate = new AiCandidate(
+ "candidate-sensitive",
+ WORKER_REF,
+ WORKFLOW_ID,
+ Map.of("contract_end_date", "담당자 전화 010-1234-5678"),
+ List.of(),
+ BigDecimal.ONE
+ );
+
+ assertThatThrownBy(() -> validator.validateResponse(
+ validRequest(),
+ responseWithCandidate(sensitiveCandidate)
+ )).isInstanceOfSatisfying(AiRuntimeContractException.class, exception -> {
+ assertThat(exception.failureCode()).isEqualTo(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED);
+ assertThat(exception.getMessage()).doesNotContain("010-1234-5678");
+ });
+ }
+
+ private AiAnalysisResponse responseWithVersions(AiRuntimeVersions versions) {
+ return new AiAnalysisResponse(
+ REQUEST_ID,
+ AiAnalysisOutcome.REVIEW_REQUIRED,
+ List.of(validCandidate()),
+ List.of(),
+ versions,
+ 1,
+ 100
+ );
+ }
+
+ private AiRuntimeVersions versions(String contractVersion, String knowledgeVersion) {
+ AiRuntimeVersions valid = validVersions();
+ return new AiRuntimeVersions(
+ valid.agentVersion(),
+ valid.modelProvider(),
+ valid.modelName(),
+ valid.modelVersion(),
+ valid.promptVersion(),
+ valid.contextPackVersion(),
+ knowledgeVersion,
+ contractVersion
+ );
+ }
+
+ private void assertFailure(Runnable invocation, AiRuntimeFailureCode expectedCode) {
+ assertThatThrownBy(invocation::run)
+ .isInstanceOfSatisfying(AiRuntimeContractException.class, exception ->
+ assertThat(exception.failureCode()).isEqualTo(expectedCode)
+ );
+ }
+}
diff --git a/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java b/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java
new file mode 100644
index 0000000..f190237
--- /dev/null
+++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java
@@ -0,0 +1,38 @@
+package com.fowoco.server.aiintegration.application.validation;
+
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.requestWithInstruction;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest;
+import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validResponse;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException;
+import com.fowoco.server.aiintegration.support.FakeAiRuntimeClient;
+import org.junit.jupiter.api.Test;
+
+class ValidatingAiRuntimeClientTest {
+
+ private final AiRuntimeContractValidator validator =
+ new AiRuntimeContractValidator(new AiRuntimePrivacyPolicy());
+
+ @Test
+ void validatesBothSidesAndCapturesOneAttemptWithoutTransparentRetry() {
+ FakeAiRuntimeClient fake = new FakeAiRuntimeClient();
+ fake.enqueueResponse(validResponse());
+ ValidatingAiRuntimeClient client = new ValidatingAiRuntimeClient(fake, validator);
+
+ assertThat(client.analyze(validRequest())).isEqualTo(validResponse());
+ assertThat(fake.receivedRequests()).containsExactly(validRequest());
+ }
+
+ @Test
+ void rejectedInputNeverReachesTransport() {
+ FakeAiRuntimeClient fake = new FakeAiRuntimeClient();
+ fake.enqueueResponse(validResponse());
+ ValidatingAiRuntimeClient client = new ValidatingAiRuntimeClient(fake, validator);
+
+ assertThatThrownBy(() -> client.analyze(requestWithInstruction("전화 010-1234-5678")))
+ .isInstanceOf(AiRuntimeContractException.class);
+ assertThat(fake.receivedRequests()).isEmpty();
+ }
+}
diff --git a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java
new file mode 100644
index 0000000..f744cde
--- /dev/null
+++ b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java
@@ -0,0 +1,98 @@
+package com.fowoco.server.aiintegration.support;
+
+import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome;
+import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest;
+import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse;
+import com.fowoco.server.aiintegration.application.model.AiCandidate;
+import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions;
+import com.fowoco.server.aiintegration.application.model.MaskedAnalysisInput;
+import com.fowoco.server.aiintegration.application.model.MaskedWorkerContext;
+import com.fowoco.server.aiintegration.application.model.WorkflowConstraint;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+public final class AiRuntimeContractFixture {
+
+ public static final UUID REQUEST_ID = UUID.fromString("10000000-0000-0000-0000-000000000001");
+ public static final UUID ATTEMPT_ID = UUID.fromString("20000000-0000-0000-0000-000000000001");
+ public static final UUID WORKER_REF = UUID.fromString("30000000-0000-0000-0000-000000000001");
+ public static final String CONTRACT_VERSION = "1.0.0";
+ public static final String KNOWLEDGE_VERSION = "0.2.0";
+ public static final String WORKFLOW_ID = "EXPIRY_RENEWAL";
+
+ private AiRuntimeContractFixture() {
+ }
+
+ public static AiAnalysisRequest validRequest() {
+ return requestWithInstruction(
+ "workerRef 30000000-0000-0000-0000-000000000001의 체류연장 준비"
+ );
+ }
+
+ public static AiAnalysisRequest requestWithInstruction(String instruction) {
+ return new AiAnalysisRequest(
+ REQUEST_ID,
+ ATTEMPT_ID,
+ CONTRACT_VERSION,
+ KNOWLEDGE_VERSION,
+ 10_000,
+ new MaskedAnalysisInput(
+ instruction,
+ List.of(new MaskedWorkerContext(
+ WORKER_REF,
+ "vi",
+ "ACTIVE",
+ LocalDate.of(2026, 12, 31)
+ )),
+ List.of(new WorkflowConstraint(
+ WORKFLOW_ID,
+ Set.of("stay_expiry_date", "contract_end_date", "monthly_wage")
+ ))
+ )
+ );
+ }
+
+ public static AiAnalysisResponse validResponse() {
+ return responseWithCandidate(validCandidate());
+ }
+
+ public static AiAnalysisResponse responseWithCandidate(AiCandidate candidate) {
+ return new AiAnalysisResponse(
+ REQUEST_ID,
+ AiAnalysisOutcome.REVIEW_REQUIRED,
+ List.of(candidate),
+ List.of(),
+ validVersions(),
+ 1,
+ 245
+ );
+ }
+
+ public static AiCandidate validCandidate() {
+ return new AiCandidate(
+ "candidate-1",
+ WORKER_REF,
+ WORKFLOW_ID,
+ Map.of("stay_expiry_date", "2026-12-31"),
+ List.of("contract_end_date", "monthly_wage"),
+ new BigDecimal("0.92")
+ );
+ }
+
+ public static AiRuntimeVersions validVersions() {
+ return new AiRuntimeVersions(
+ "agent-1.0.0",
+ "openai",
+ "gpt-5-mini",
+ "2026-07-01",
+ "prompt-3",
+ "context-0.2.0",
+ KNOWLEDGE_VERSION,
+ CONTRACT_VERSION
+ );
+ }
+}
diff --git a/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java b/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java
new file mode 100644
index 0000000..5e85c5d
--- /dev/null
+++ b/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java
@@ -0,0 +1,46 @@
+package com.fowoco.server.aiintegration.support;
+
+import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest;
+import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse;
+import com.fowoco.server.aiintegration.application.port.AiRuntimeClient;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * Test-only scripted Runtime that makes no network request.
+ */
+public final class FakeAiRuntimeClient implements AiRuntimeClient {
+
+ private final Deque> scripts = new ArrayDeque<>();
+ private final List receivedRequests = new ArrayList<>();
+
+ public void enqueueResponse(AiAnalysisResponse response) {
+ Objects.requireNonNull(response, "response must not be null");
+ scripts.addLast(request -> response);
+ }
+
+ public void enqueueFailure(RuntimeException exception) {
+ Objects.requireNonNull(exception, "exception must not be null");
+ scripts.addLast(request -> {
+ throw exception;
+ });
+ }
+
+ @Override
+ public AiAnalysisResponse analyze(AiAnalysisRequest request) {
+ receivedRequests.add(request);
+ Function script = scripts.pollFirst();
+ if (script == null) {
+ throw new AssertionError("FakeAiRuntimeClient has no scripted result.");
+ }
+ return script.apply(request);
+ }
+
+ public List receivedRequests() {
+ return List.copyOf(receivedRequests);
+ }
+}