init: 클린 아키텍처 백엔드

This commit is contained in:
DongHyeonka
2026-07-24 14:29:36 +09:00
parent 9eed16d097
commit 821fe00c32
971 changed files with 74769 additions and 1 deletions
+234
View File
@@ -0,0 +1,234 @@
# AGENTS.md
## 프로젝트 정체성
이 저장소는 단순한 예제 블로그 애플리케이션이 아니라, Java 21 + Spring Boot 4.0.0 + Gradle 멀티모듈 기반의 Clean Architecture 템플릿이다.
기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 엔지니어링 작업 기록 게시판)에 격리한다. 새 프로젝트를 시작할 때는 도메인 이름, 패키지, 엔티티, 유스케이스를 교체할 수 있지만, 모듈 경계와 의존성 방향은 유지해야 한다.
## Prime Directive
에이전트는 속도보다 아키텍처 보존을 우선한다.
동작하는 코드라도 HARD-STOP 조건을 하나라도 위반하면 완료된 작업이 아니다.
HARD-STOP 8개 항목의 SSOT 는 `.agents/plugins/ca-superpowers/rules/clean-architecture.md` §HARD-STOP 이다. 이 문서는 목록 사본을 유지하지 않는다 — 불일치 시 SSOT 파일이 우선한다. (항상 로드되는 요약 사본은 root `CLAUDE.md` Prime Directive 에 있다.)
## Superpowers Workflow
이 프로젝트에서 에이전트는 관련 Superpowers 스킬을 먼저 확인하고, 작업 성격에 맞는 스킬을 사용한다. 사용자 지시와 이 `AGENTS.md`가 로컬 프로젝트의 최상위 규칙이며, Superpowers는 그 규칙을 실행하기 위한 작업 방식이다.
사용 가능한 주요 스킬과 트리거:
- `superpowers:using-superpowers`: 대화나 작업을 시작할 때 관련 스킬을 확인한다.
- `superpowers:brainstorming`: 기능 설계, 구조 변경, 동작 변경, 새 문서 정책 수립 전에 사용한다.
- `superpowers:writing-plans`: 승인된 설계가 있고 작업이 여러 단계로 나뉠 때 사용한다.
- `superpowers:executing-plans`: 작성된 계획을 현재 세션에서 순차 실행할 때 사용한다.
- `superpowers:subagent-driven-development`: 계획을 작업 단위로 나누어 독립 에이전트에게 맡길 때 사용한다.
- `superpowers:dispatching-parallel-agents`: 서로 독립적인 조사나 구현을 병렬로 진행할 때 사용한다.
- `superpowers:test-driven-development`: 기능 추가와 버그 수정을 테스트 우선으로 진행할 때 사용한다.
- `superpowers:systematic-debugging`: 버그, 실패한 테스트, 예상 밖 동작을 다룰 때 사용한다.
- `superpowers:verification-before-completion`: 완료, 수정됨, 통과함을 주장하기 전에 사용한다.
- `superpowers:requesting-code-review`: 의미 있는 구현을 마친 뒤 병합 또는 PR 전에 사용한다.
- `superpowers:receiving-code-review`: 리뷰 피드백을 적용하기 전에 사용한다.
- `superpowers:finishing-a-development-branch`: 구현과 검증이 끝난 브랜치를 정리할 때 사용한다.
- `superpowers:using-git-worktrees`: 격리된 작업 공간이 필요할 때 사용한다.
- `superpowers:writing-skills`: 스킬을 만들거나 수정할 때 사용한다.
## Harness 정책 SSOT
- `.harness/manifest.yaml`: Java 21 / Spring Boot 4.0.0 프로젝트 identity와 task packet 진입점
- `.harness/project/modules.yaml`: 19개 leaf의 ID, 소스 경로, Gradle path, 허용 edge, focused command
- `.harness/core/risk-policy.yaml`: change-surface 기반 risk 분류
- `.harness/core/evidence-policy.yaml`: risk별 evidence/review profile
- `.harness/core/review-policy.yaml`: orchestration, option/counterargument, human-only commit 정책
- `.harness/core/report-policy.yaml`: concise/durable report와 citation self-grep 정책
작업 시작 시 task packet을 한 번 resolve하고 stable packet/rule hash를 재사용한다. overlay나 관련
hash가 바뀔 때만 다시 resolve하거나 rule 전문을 재정독한다. 파일 수는 risk 분류 기준이 아니다.
commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit/amend/push하지 않는다.
## LLM Wiki 캡처 워크플로우
구현, 아키텍처, 빌드, 테스트, 런타임, 문서 워크플로우 변경처럼 의미 있는 작업을 끝낸 뒤에는 최종 응답 전에 LLM Wiki 기록을 갱신한다.
기준 vault:
```text
/home/donghyeon/workspace/ai-tool/llm-wiki-private/
```
에이전트는 해당 vault의 `AGENTS.md`, `CLAUDE.md`, `rules/`, `.agents/`, `.claude/`, `.codex/` 지침을 확인한 뒤 작성한다. ca-tmpl 내부의 상세 실행 규칙은 `.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md`를 따른다.
필수 순서:
1. `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/<branch-name>.md`를 생성하거나 갱신한다.
2. 구현 내용, 변경 파일, 의사결정, 검증 명령, 실패/차단 사항, 증거 등급을 branch-note에 기록한다.
3. 실제로 파생 자료가 있으면 다음 raw 문서를 생성하거나 갱신한다.
- `raw/errors/`: 오류, 실패한 테스트, 샌드박스/도구 문제, 재발 가능한 트러블슈팅
- `raw/interviews/`: 이 작업에서 정직하게 뽑을 수 있는 면접 질문
- `raw/blog-topics/`: 채용공고가 아닌 구현·설계·트러블슈팅 기반 블로그 글감
4. 파생 문서는 `## Parent`에서 branch-note로 upward link하고, branch-note의 `## Cluster / 묶음`에는 파생 문서 wikilink를 되돌려 적는다.
5. canonical 추출 요청이 없는 한 `wiki/blog/`, `wiki/interview/`, `wiki/portfolio/`, `wiki/concepts/`, `wiki/projects/`를 바로 만들지 않는다.
파생 문서가 필요 없을 때도 그냥 생략하지 말고, branch-note의 cluster 섹션에 "없음" 또는 "추출할 별도 글감 없음"처럼 판단 결과를 남긴다.
## 강제 수준
이 문서는 에이전트와 개발자가 따라야 할 작업 규칙을 정의하지만, 그 자체로 빌드나 테스트를 실패시키는 자동 강제 장치는 아니다.
이 프로젝트의 규칙은 세 단계로 관리한다.
1. `AGENTS.md`: 에이전트와 개발자가 따라야 할 아키텍처, 작업 순서, 검증 원칙을 정의한다.
2. ArchUnit/Gradle/Test: 모듈 의존성, 계층 침범, 설정 바인딩, 동작 회귀를 자동으로 탐지한다.
3. Code Review/CI: 문서와 자동 검증이 놓친 설계 품질, 운영 위험, 템플릿 일관성을 최종 확인한다.
에이전트는 `AGENTS.md`를 따라 작업해야 하며, 현업 수준의 스켈레톤 완성도는 반드시 자동 검증과 리뷰로 보강해야 한다.
## 모듈 책임
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 의존성, focused test 명령은
`.harness/project/modules.yaml`이 SSOT다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만
정의한다. 작업 파일에서는 가장 가까운 `src/**/CLAUDE.md`를 함께 읽는다.
- `domain-core`: 순수 도메인 모델, 불변식, 이벤트, port. Spring/JPA/transport/IO 타입 금지.
- `application-core`: command, use case, application policy, transaction port, application 예외.
inbound DTO, persistence entity, adapter 타입 금지.
- `adapter:inbound:*`: HTTP/gRPC/GraphQL/WebSocket transport, DTO, validation, 인증·인가 매핑,
error mapping. repository 직접 호출과 비즈니스 정책 금지.
- `adapter:outbound:persistence-*`: JPA/PostgreSQL 또는 MongoDB persistence 구현과 mapping,
migration/vendor 동작. 유스케이스 정책 금지.
- 그 밖의 `adapter:outbound:*`: support, messaging, cache, notification, object storage,
file server, HTTP client, identifier 능력을 port 뒤에서 구현한다. adapter 간 허용 edge는
registry만 따른다.
- `shared-contract`: skeleton-wide 운영 계약. business/domain 개념 저장 금지.
- `sample-portfolio`: 샘플/fixture consumer. production leaf가 의존하면 안 된다.
- `app-bootstrap`: Spring Boot entrypoint와 composition root. 비즈니스 유스케이스 금지.
## 의존성 방향
family 수준 기본 방향:
```text
app-bootstrap -> adapter:inbound:* -> application-core -> domain-core
app-bootstrap -> adapter:outbound:* -> application-core -> domain-core
runtime modules -> shared-contract
sample-portfolio -> registered runtime leaves (fixture consumer only)
```
개별 edge는 `.harness/project/modules.yaml``allowed_dependencies`가 유일한 목록이다.
Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억에서 leaf edge를 추론하지 않는다.
## 기능 개발 프로토콜
새 기능이나 동작 변경은 다음 순서로 진행한다.
1. 요구사항을 읽고 소유 계층을 판단한다.
2. 구조나 동작이 바뀌면 `superpowers:brainstorming`으로 설계를 먼저 확정한다.
3. 여러 단계의 작업이면 `docs/superpowers/specs/`에 설계 문서를 남긴다.
4. 구현 전 `docs/superpowers/plans/`에 실행 계획을 작성한다.
5. 동작 변경은 테스트를 먼저 작성한다.
6. 새 비즈니스 개념은 `domain-core`에 먼저 둔다.
7. 유스케이스는 `application-core` command와 use case/service method로 표현한다.
8. 외부 연동은 registry가 가리키는 `adapter:outbound:*` leaf에서 port 구현으로 추가한다.
9. endpoint는 마지막에 해당 `adapter:inbound:*` leaf에서 얇게 연결한다.
10. focused test를 먼저 돌리고, 가능한 경우 전체 Gradle test를 돌린다.
11. LLM Wiki branch-note와 필요한 파생 raw 문서를 갱신한다.
12. 완료 응답에는 변경 파일, 검증 결과, Wiki capture 결과를 포함한다.
## 테스트 전략
계층별 테스트 기준:
- `domain-core`: Spring 없이 순수 unit test로 도메인 규칙을 검증한다.
- `application-core`: fake/in-memory port를 사용해 유스케이스 흐름을 검증한다.
- `adapter:inbound:*`: transport validation, auth mapping, status/response contract를 검증한다.
- `adapter:outbound:persistence-*`: persistence mapping, repository adapter, vendor/migration 동작을 검증한다.
- 그 밖의 `adapter:outbound:*`: external capability adapter contract를 검증한다.
- `app-bootstrap/settings`: configuration binding, validation, logging 설정을 검증한다.
검증 원칙:
- 완료를 주장하기 전에 `superpowers:verification-before-completion`을 사용한다.
- 실행한 명령과 결과를 최종 응답에 적는다.
- 테스트를 실행하지 못했다면 이유와 남은 위험을 솔직히 적는다.
권장 절차:
```bash
cd src
python3 ../.harness/validators/resolve_task.py <task-overlay.json>
# resolved packet의 focused_commands를 실행
./gradlew test
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
./gradlew verifyCleanArchitectureDependencies
./gradlew verifyPublicPathSnapshot
./gradlew verifyEnvKeys
```
19개 leaf의 정확한 focused test 명령은 `.harness/project/modules.yaml`과 resolved task packet을
따른다. root 문서에 별도 명령 목록을 복제하지 않는다.
## 설정과 런타임
설정 규칙:
- secrets를 코드, 테스트 fixture, 문서 예시에 하드코딩하지 않는다.
- 새 설정 그룹은 typed settings class로 만든다.
- 흩어진 `@Value`보다 configuration properties와 settings class를 선호한다.
- `.env.local`은 로컬 오버라이드로 취급한다.
- `application.yml`은 환경별로 안전한 기본값과 명확한 placeholder만 담는다.
- 새 settings class를 만들면 binding/validation 테스트를 추가한다.
Docker/runtime 규칙:
- Dockerfile 변경 시 build context와 runtime env 요구사항을 함께 확인한다.
- container 안에서 필요한 profile, port, env var를 문서나 예시 설정에 반영한다.
- local-only 경로와 운영 경로를 섞지 않는다.
## 템플릿 재사용 체크리스트
이 저장소를 새 프로젝트 시작점으로 사용할 때:
1. `src/settings.gradle``rootProject.name`을 새 프로젝트명으로 바꾼다.
2. Java package `dev.caskeleton`을 새 organization/project package로 바꾼다.
3. `CaSkeletonApplication` 이름을 새 애플리케이션 이름으로 바꾼다.
4. production 모듈에는 목표 도메인의 entity, repository port, use case, adapter만 추가하고, 예시 코드는 `sample-portfolio`에 격리한다.
5. 모듈 이름과 경계는 유지한다.
6. Docker image/application 이름을 새 프로젝트 기준으로 수정한다.
7. `.env`, `.env.local`, `application.yml`의 예시 값을 새 런타임 요구사항에 맞춘다.
8. README와 운영 문서를 새 프로젝트 설명으로 갱신한다.
9. 전체 테스트를 실행한다.
```bash
cd src
./gradlew test
```
## 금지된 지름길
에이전트는 다음을 하지 않는다.
- `domain-core`에 Spring/JPA annotation 추가
- controller에서 repository 직접 호출
- application method가 web request DTO를 인자로 받게 만들기
- application이나 domain에서 JPA entity 반환
- mapper에 비즈니스 정책 넣기
- filter/config/settings class에 유스케이스 넣기
- 요청 범위 밖의 대규모 리팩터링
- 사용자 변경사항 되돌리기
- 명시적 요청 없는 destructive git command 실행
- 테스트 미실행 상태에서 "완료"라고 말하기
## 작업 보고 규칙
최종 응답에는 다음을 포함한다.
- 변경한 파일
- 핵심 변경 내용
- 실행한 검증 명령
- 실패하거나 실행하지 못한 검증
- LLM Wiki branch-note와 파생 문서 캡처 결과
- 남은 위험 또는 후속 작업
짧은 작업이라도 검증 여부는 생략하지 않는다.
+119
View File
@@ -0,0 +1,119 @@
# CLAUDE.md
Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture template.
## Prime Directive
Preserve architecture before optimizing for speed. The HARD-STOP SSOT is
`.agents/plugins/ca-superpowers/rules/clean-architecture.md`; its eight current stop conditions are:
1. `domain-core` gains framework, transport, database, or cloud dependencies.
2. A controller directly uses a repository, Spring Data interface, or persistence entity.
3. An inbound DTO leaks into `application-core` or `domain-core`.
4. Business rules move into mappers, filters, configuration, settings, or controllers.
5. Project dependencies violate `.harness/project/modules.yaml` and Gradle verification.
6. Completion is claimed without the relevant verification or a named reason it could not run.
7. A corpus conclusion is made without evidence appropriate to the selected evidence profile.
8. Non-trivial work closes without the required LLM Wiki capture or a reported capture block.
If this summary and the SSOT differ, the SSOT wins.
## Harness policy authorities
- Project manifest and stack: `.harness/manifest.yaml`
- Leaf modules, dependency edges, and focused commands: `.harness/project/modules.yaml`
- Risk classification: `.harness/core/risk-policy.yaml`
- Evidence selection: `.harness/core/evidence-policy.yaml`
- Orchestration and advisory depth: `.harness/core/review-policy.yaml`
- Durable/concise reporting: `.harness/core/report-policy.yaml`
- Physical parity check: `.harness/validators/validate_policy_parity.py`
Resolve a task packet once and reuse its stable task-packet hash and relevant rule hashes. Rerun
resolution or reread a full rule only when the task overlay, packet hash, or rule hash changes.
Commit policy is `human-only`: agents do not stage, commit, amend, or push implementation changes.
## Risk-based orchestration
- Low risk: implement inline or in a focused lane and run the focused check.
- Medium risk: use `ca-implementer`; select proportional review based on affected boundaries and
evidence needs.
- High risk: use `ca-implementer`, then the full chain after a human commit:
`ca-architect-sentinel``ca-spec-reviewer``ca-quality-reviewer``gradle-runner`.
Risk comes from change surface and task flags, not file count.
## Module families
`.harness/project/modules.yaml` owns the complete 19-leaf list. Root guidance summarizes families;
the nearest `src/**/CLAUDE.md` owns local rules.
| Family | Responsibility | Stable dependency direction |
| --- | --- | --- |
| `domain-core` | Pure domain model, invariants, events, ports | Java stdlib and registered value-only contracts |
| `application-core` | Commands, use cases, application policies, transaction ports | `domain-core`, `shared-contract` |
| `adapter:inbound:*` | HTTP, gRPC, GraphQL, WebSocket transport boundaries | application/domain/shared contracts |
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
Read it from `.harness/project/modules.yaml` or from the resolved task packet.
## Layer workflow
For a full use case, work in this order:
```text
domain-core
-> application-core
-> adapter:outbound:* (or persistence/identifier)
-> adapter:inbound:*
-> app-bootstrap wiring
```
Layer-only work stays inside that registered leaf plus its tests. If a required fix crosses a layer
or writable scope, stop and request context rather than expanding silently.
## Testing
- `domain-core`: pure JUnit unit tests.
- `application-core`: use-case tests with hand-rolled fakes; no web or persistence context.
- inbound adapters: focused transport slice/contract tests.
- persistence adapters: mapping/port contract tests; use a real datastore only when vendor semantics
require it.
- other outbound adapters: port contract tests with fake external systems; no real network.
- identifier: pure deterministic unit tests.
- bootstrap/settings: binding, validation, wiring, and architecture tests.
From `src/`, run the exact focused command emitted in the task packet. Architecture-wide commands:
```bash
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyPublicPathSnapshot --console=plain
./gradlew verifyEnvKeys --console=plain
```
Use public-path and env-key checks only when their surfaces changed. Full `test` or `check` requires
the controller's workflow authorization.
## Advisory and reporting
Use dependency-DAG/topological reasoning and 35 materially distinct alternatives when that many
exist; use fewer when the option set is smaller. Judgment findings include falsifiable assumptions
and counterarguments. Deterministic compile, dependency, secret, or failed-test findings use
Rule → Evidence → Fix without manufactured debate.
Citation verification is profile-based: none for `review-lite`, blocking citations for
`review-standard`, and all material citations for `audit-deep`/`regulated`. Durable reports are
triggered by high risk, at least 3 blocking findings, an architecture decision, explicit user request,
or the regulated profile. Otherwise a concise result is allowed.
## LLM Wiki capture
For non-trivial implementation or workflow changes, follow
`.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md`. If the controller explicitly excludes
wiki writes for a dispatched task, report the handoff instead of writing outside scope.
+107 -1
View File
@@ -1,2 +1,108 @@
# clean-architecture-backend-template
# ca-skeleton — Clean Architecture Spring Boot 템플릿
ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean Architecture 백엔드 템플릿입니다. fork해서 도메인·패키지·엔티티·유스케이스만 교체하면 새 서비스를 시작할 수 있고, 모듈 경계와 의존 방향은 그대로 유지합니다. 기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 작업 기록 게시판)에 격리합니다.
이 문서는 전체 구조와 첫 실행만 다룹니다. 모듈별 상세 규칙과 설계 근거는 각 모듈의 README와 `CLAUDE.md`가, 빌드·환경 변수 상세는 [src/README.md](src/README.md)가 소유합니다.
## 아키텍처 한눈에
의존은 항상 바깥에서 안으로 흐릅니다. adapter가 core의 port에 의존하고, core는 adapter를 알지 못합니다. 이 방향이 유지되는 한 도메인 규칙과 기술 선택(웹 프레임워크, DB, 메시징)을 서로 독립적으로 바꿀 수 있습니다.
```text
app-bootstrap -> adapter:inbound:* -> application-core -> domain-core
app-bootstrap -> adapter:outbound:* -> application-core -> domain-core
모든 런타임 모듈 -> shared-contract
sample-portfolio -> 등록된 런타임 리프 (fixture 소비자 전용)
```
family 수준의 책임은 다음과 같습니다.
| 모듈 family | 책임 |
| --- | --- |
| `domain-core` | 순수 도메인 모델·불변식·이벤트·port. 프레임워크·transport·DB·IO 타입 금지 |
| `application-core` | command·유스케이스·application 정책·트랜잭션 port. inbound DTO·persistence entity 금지 |
| `adapter:inbound:*` | HTTP·gRPC·GraphQL·WebSocket transport 경계, DTO·validation·인증·에러 매핑 |
| `adapter:outbound:persistence-*` | JPA/PostgreSQL·MongoDB 영속 구현과 매핑·migration |
| `adapter:outbound:*` | support(공유 베이스)·messaging·cache·notification·object storage·file·HTTP client·identifier 능력을 port 뒤에서 구현. 외부 연동 어댑터(messaging·cache·notification·HTTP client)는 기본 비활성 |
| `shared-contract` | skeleton 전역 운영 계약. business/domain 개념 저장 금지 |
| `app-bootstrap` | Spring Boot entrypoint와 composition root |
| `sample-portfolio` | WorkLog 예시 도메인(fixture/reference). production이 의존하지 않음 |
정확한 19개 leaf 목록, 각 leaf의 Gradle path·허용 의존 edge·focused test 명령은 [.harness/project/modules.yaml](.harness/project/modules.yaml)이 SSOT입니다. root 문서나 기억에서 개별 leaf edge를 추론하지 않습니다.
## 퀵스타트
전제조건은 Temurin 21(루트 [.tool-versions](.tool-versions)에 고정)과 Docker Engine 또는 Docker Desktop입니다. Gradle은 저장소 wrapper를 씁니다. 첫 실행 진입점은 하나입니다.
```bash
cd src
./gradlew bootstrap
```
`bootstrap`은 compile 검사, PostgreSQL Compose 기동, 애플리케이션 이미지 build·기동(startup Flyway 포함), sample 격리 검증, `GET /api/healthcheck` HTTP smoke를 순서대로 실행합니다. 각 단계가 별도 Gradle task라 실패 단계가 task 이름으로 드러납니다. 기동을 확인하려면 health endpoint를 호출합니다.
```bash
curl -fsS http://localhost:8080/api/healthcheck
```
로컬 스택을 내릴 때는 저장소 루트에서 실행합니다.
```bash
docker compose -f docker-compose.yml -f docker-compose.local.yml down
```
`src/.env`는 커밋된 안전 기본값이라 별도 `.env.example`을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 [src/README.md](src/README.md)와 [docs/registries/env-keys.yaml](docs/registries/env-keys.yaml)에 있습니다.
## 새 프로젝트로 시작하기
이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 [AGENTS.md](AGENTS.md)의 "템플릿 재사용 체크리스트"에 있습니다.
1. [src/settings.gradle](src/settings.gradle)의 `rootProject.name`을 새 서비스 이름으로 바꿉니다.
2. 패키지 루트 `dev.caskeleton`을 조직·서비스 패키지로 바꿉니다. 소스뿐 아니라 빌드·설정 파일의 참조도 함께 바꿔야 `mainClass`·`group`이 어긋나 `bootstrap`이 깨지지 않습니다.
```bash
cd src
find . -type f \( -name '*.java' -o -name '*.gradle' -o -name '*.yml' \) -print0 | xargs -0 sed -i 's/dev.caskeleton/com.yourorg.yourservice/g'
```
레지스트리 `package_roots`와 애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다.
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
4. 모듈 이름과 경계는 그대로 유지합니다.
검증은 sample-on과 sample-off를 모두 통과시킵니다.
```bash
cd src
./gradlew test
./gradlew :app-bootstrap:sampleOffTest
```
`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다.
## 아키텍처 규칙과 검증
애플리케이션이 동작하더라도 아래를 어기면 병합하지 않습니다. 8개 HARD-STOP 조건의 SSOT는 [clean-architecture.md](.agents/plugins/ca-superpowers/rules/clean-architecture.md)입니다.
- `domain-core`는 Spring·JPA·Servlet·HTTP·DB·cloud SDK 타입을 import하지 않습니다.
- controller는 repository를 직접 호출하거나 persistence entity를 반환하지 않습니다.
- inbound DTO는 `application-core`나 `domain-core`로 들어가지 않습니다.
- 비즈니스 정책은 mapper·filter·config·settings·controller에 두지 않습니다.
- 새 외부 시스템 연동은 domain/application port와 adapter 모듈로 표현합니다.
이 규칙은 두 축으로 자동 강제합니다. ArchUnit `CleanArchitectureTest`가 컴파일된 소스 의존성을, `verifyCleanArchitectureDependencies` 게이트가 Gradle 프로젝트 의존성을 검사하며, 둘 다 registry의 허용 edge를 읽습니다.
```bash
cd src
./gradlew verifyCleanArchitectureDependencies
```
두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
## 더 알아보기
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
- 모듈 레지스트리(19개 leaf SSOT): [.harness/project/modules.yaml](.harness/project/modules.yaml)
- 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md)
- 빌드·릴리스 공급망 파이프라인: [build-release-supply-chain.yml](.github/workflows/build-release-supply-chain.yml)
- 모듈별 설계 결정: [domain-core](src/domain-core/README.md) · [application-core](src/application-core/README.md) · [adapter:inbound:web](src/adapter/inbound/web/README.md) · [adapter:outbound:persistence-jpa](src/adapter/outbound/persistence-jpa/README.md) · [shared-contract](src/shared-contract/README.md) · [app-bootstrap](src/app-bootstrap/README.md)
+61
View File
@@ -0,0 +1,61 @@
# =============================================================================
# feature-container-runtime-contract — dev environment override
#
# Merge with base:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
#
# Dev intent:
# - Relaxes read-only filesystem (writable, no tmpfs constraint) so developers
# can hot-swap files without image rebuilds.
# - Increases memory allowance for dev/debugging workloads.
# - Disables restart-unless-stopped so crash loops don't mask startup errors.
# - Enables JMX remote port (local only, never in prod).
# - Mounts a local source volume for fast iteration (optional; mount when IDE
# attaches to the running container).
# =============================================================================
services:
app:
# Relax read-only constraint for local development.
read_only: false
tmpfs: [] # no tmpfs in dev; rely on normal writable rootfs
# More memory for dev profiling / heap dumps.
mem_limit: 1g
memswap_limit: 1g
environment:
TZ: "UTC"
LANG: "C.UTF-8"
LC_ALL: "C.UTF-8"
# Use a shorter drain timeout in dev so restarts are faster.
APP_SERVER_SHUTDOWN_TIMEOUT: "5s"
APP_SERVER_SHUTDOWN: "graceful"
# Remote JMX — bind to localhost only; NEVER expose in production.
JAVA_TOOL_OPTIONS: >-
-XX:MaxRAMPercentage=75
-XX:+UseContainerSupport
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/tmp/heap
-Dserver.tomcat.basedir=/tmp
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9999
-Dcom.sun.management.jmxremote.rmi.port=9999
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false
-Djava.rmi.server.hostname=127.0.0.1
ports:
- "8080:8080"
- "9001:9001"
- "127.0.0.1:9999:9999" # JMX — localhost only
# No health check constraint in dev; let the app start at its own pace.
healthcheck:
disable: true
# Do not restart automatically so crash loops stay visible.
restart: "no"
# Optional: mount heap dump directory to host for dev analysis.
volumes:
- type: bind
source: ./tmp/heap-dumps
target: /var/tmp/heap
bind:
create_host_path: true
+83
View File
@@ -0,0 +1,83 @@
# =============================================================================
# feature-container-runtime-contract — local infrastructure override
#
# Merge with base (and optionally dev):
# docker compose -f docker-compose.yml -f docker-compose.local.yml up
#
# Local intent:
# - Starts a local PostgreSQL database for integration testing without Testcontainers.
# - Wires the app environment to point at the local DB.
# - Keeps read-only filesystem and memory limits from the base compose.
# - Does NOT expose the DB port publicly; app and db communicate on the
# internal `caskeleton-local` network only.
# =============================================================================
services:
app:
env_file:
- ./src/.env
# Wire the app to the local Postgres service on the internal network.
environment:
TZ: "UTC"
LANG: "C.UTF-8"
LC_ALL: "C.UTF-8"
# fallback matches env-keys.yaml SSOT default (30s); base compose grace is 40s.
APP_SERVER_SHUTDOWN_TIMEOUT: "${APP_SERVER_SHUTDOWN_TIMEOUT:-30s}"
APP_SERVER_SHUTDOWN: "graceful"
# Database connection — points to the local `db` service below.
# Override with your actual DB credentials in a local .env file.
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
depends_on:
db:
condition: service_healthy
healthcheck:
test:
- "CMD"
- "wget"
- "--no-verbose"
- "--tries=1"
- "--spider"
- "http://localhost:8080/api/healthcheck"
interval: 5s
timeout: 3s
start_period: 20s
retries: 12
networks:
- caskeleton-local
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
TZ: "UTC"
# Persist data between restarts; remove the volume to start fresh.
volumes:
- type: volume
source: caskeleton-db-data
target: /var/lib/postgresql/data
# No host port: startup Flyway runs in the app container over the internal network.
networks:
- caskeleton-local
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
caskeleton-local:
driver: bridge
volumes:
caskeleton-db-data:
driver: local
+80
View File
@@ -0,0 +1,80 @@
# =============================================================================
# feature-container-runtime-contract — base docker-compose service definition
#
# Usage:
# docker compose up # base only
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up # dev override
# docker compose -f docker-compose.yml -f docker-compose.local.yml up # local override
#
# Graceful-shutdown sync table (feature-container-runtime-contract D5):
# App shutdown timeout : 30s (APP_SERVER_SHUTDOWN_TIMEOUT — SSOT: docs/registries/
# env-keys.yaml, owner feature-env-driven-runtime-configuration)
# Container preStop : 5s (handled by the orchestrator / stop_grace_period offset)
# stop_grace_period : 40s (30s app drain + 5s preStop + 5s safety margin)
#
# Ownership: env-driven-config owns the app shutdown *value* (30s); this contract owns the
# *relationship* (stop_grace_period >= app timeout + preStop + margin). The grace is therefore
# sized UP to the env-keys SSOT value (30s) — you do not shrink the app drain window to fit an
# arbitrary grace. This resolves the earlier 20s/35s drift, which had assumed an app-timeout
# value this branch does not own (env-keys.yaml is the SSOT and sets 30s).
#
# Memory limit (D4):
# The container memory limit MUST be set for -XX:MaxRAMPercentage=75 to be meaningful.
# Default 512m suits a small service; adjust per deployment.
# =============================================================================
services:
app:
build:
context: src/
dockerfile: Dockerfile
args:
RELEASE_VERSION: "${RELEASE_VERSION:-0.0.1}"
BUILD_VERSION: "${BUILD_VERSION:-0.0.1+0000000}"
GIT_SHA: "${GIT_SHA:-0000000}"
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000}
ports:
- "${APP_SERVER_PORT:-8080}:8080"
- "9001:9001"
environment:
TZ: "UTC"
LANG: "C.UTF-8"
LC_ALL: "C.UTF-8"
# Graceful shutdown — fallback matches the env-keys.yaml SSOT default (30s).
# Override via .env or docker compose --env-file if you need a different value.
APP_SERVER_SHUTDOWN_TIMEOUT: "${APP_SERVER_SHUTDOWN_TIMEOUT:-30s}"
APP_SERVER_SHUTDOWN: "graceful"
# ---- Read-only root filesystem (D2) -------------------------------------
# The application must write only to the tmpfs mounts declared below.
# /tmp — Tomcat basedir (see -Dserver.tomcat.basedir=/tmp in Dockerfile).
# /var/tmp/heap — heap dump landing zone (see -XX:HeapDumpPath=/var/tmp/heap).
read_only: true
tmpfs:
- /tmp:mode=1777,size=128m
- /var/tmp/heap:mode=1777,size=512m
# ---- Memory limit (D4) --------------------------------------------------
# Must be set so -XX:MaxRAMPercentage=75 can compute a meaningful heap bound.
mem_limit: 512m
memswap_limit: 512m
# ---- Graceful shutdown (D5) ---------------------------------------------
# stop_grace_period = app drain (30s, env-keys SSOT) + preStop (5s) + margin (5s) = 40s.
stop_grace_period: 40s
# ---- Health check -------------------------------------------------------
# Targets the actuator readiness probe on the management port (9001).
# CROSS-FEATURE COUPLING: /actuator/health/readiness is implemented by the
# parallel runtime-health + actuator branches. This healthcheck will report
# UNHEALTHY in this worktree until those branches are merged.
healthcheck:
test:
- "CMD"
- "wget"
- "--no-verbose"
- "--tries=1"
- "--spider"
- "http://localhost:9001/actuator/health/readiness"
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
restart: unless-stopped
+120
View File
@@ -0,0 +1,120 @@
# Registry: Repository Access Capabilities
# SSOT: wiki/projects/ca-tmpl/registries/capabilities.yaml
# Schema owner: feature-contract-registry-governance
# Owner branch: feature-repository-access-permission-contract
# Last updated: 2026-06-05
#
# Notes
# - capability는 사용자 권한이 아니라 application use case가 infrastructure capability를
# 사용할 수 있는지에 대한 계약 (feature-repository-access-permission-contract).
# - enforcement default = ArchUnit annotation-based rule. compile-time annotation processor는
# alternative. runtime AOP는 forbidden.
# - capability 제거는 항상 breaking change. 추가는 additive (registry row 동반 시).
# - annotation 표기 (as-built, F1/F2 reconciled 2026-06-05): 코드 SSOT는 단일
# `@UseCaseCapability` (TYPE target, typed attribute). 노트 D2/D11의 flat
# `@UseCaseRepositoryAccess(Capability[])` 모델은 superseded. 7 capability ↔ as-built 매핑:
# READ_REPOSITORY/WRITE_REPOSITORY → repositoryAccess, TRANSACTION_REQUIRED → transactionMode,
# EXTERNAL_OUTBOUND_ALLOWED → externalOutboundAllowed, SENSITIVE_READ → sensitiveRead,
# BULK_WRITE → bulkWrite, CROSS_TENANT_ADMIN → crossTenantAdmin.
# 각 row의 annotation: 필드는 아래에서 as-built 표기로 정합됨.
capabilities:
# source: feature-repository-access-permission-contract — 판정 기준 "Required capability: READ_REPOSITORY"
# source: feature-application-port-usecase-contract — "read-only query use case는 readOnly 와 READ_REPOSITORY capability만 선언 가능"
- name: READ_REPOSITORY
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(repositoryAccess = READ_REPOSITORY)"
semantics: "use case가 read-only repository operation을 호출하는 것을 허용. query use case의 기본 capability. write/sensitive/bulk 작업은 별도 capability 선언이 없으면 forbidden."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:read-repository-capability
# source: feature-repository-access-permission-contract — 판정 기준 "Required capability: WRITE_REPOSITORY"
# source: feature-application-port-usecase-contract — "write use case는 transactionMode, idempotency, repositoryAccess를 명시해야 함"
- name: WRITE_REPOSITORY
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(repositoryAccess = WRITE_REPOSITORY)"
semantics: "use case가 mutating repository operation(insert/update/delete)을 호출하는 것을 허용. 단일/소량 write 기준이며 batch size > 100은 BULK_WRITE 별도 선언 필요. read-only use case에서 이 capability 없이 write repository 접근하면 fail."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:write-repository-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "SENSITIVE_READ marker = registry-managed metadata table (entity FQN + field name 단위)"
- name: SENSITIVE_READ
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(sensitiveRead = true)"
semantics: "PII/credential 등 sensitive field를 읽는 use case가 선언해야 하는 capability. marker는 registry-managed metadata table(entity FQN + field name 단위)에서 lookup. domain annotation 또는 JPA entity annotation 형태는 forbidden(domain에 framework 의존 회피). pseudonymized data read는 documented 시에만 예외 허용."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:sensitive-read-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "BULK_WRITE threshold = N > 100 또는 batch size > 100. 미만은 일반 WRITE_REPOSITORY로 충분"
- name: BULK_WRITE
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(bulkWrite = true)"
semantics: "단일 transaction 내 N > 100 또는 batch size > 100 mutating operation을 수행하는 use case가 선언해야 하는 capability. 이 미만이면 일반 WRITE_REPOSITORY로 충분. lock 점유 시간, pool 영향, retry 비용이 큰 작업을 명시화."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: WRITE_REPOSITORY
threshold: 100
compatibility_impact: breaking
required_test: architecture-enforcement:bulk-write-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "TRANSACTION_REQUIRED는 application-port branch의 TransactionPort contract와 연결되어야 하며 Spring @Transactional 직접 import로 충족하지 않음"
# source: feature-application-port-usecase-contract — TransactionPort Contract
- name: TRANSACTION_REQUIRED
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(transactionMode = WRITE | READ_ONLY | REQUIRES_NEW)"
semantics: "use case가 TransactionPort(또는 TransactionalUseCaseRunner)를 통해 transactional boundary를 갖는 것을 강제. Spring @Transactional의 application package 직접 import는 forbidden. infrastructure가 Spring transaction implementation을 제공하고 application은 port만 호출."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:transaction-required-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "EXTERNAL_OUTBOUND_ALLOWED 분류 = outbox row INSERT는 in-process(불요), polling publisher의 broker publish는 outbound(필요)"
# source: feature-application-port-usecase-contract — "outbound adapter 호출 use case에 EXTERNAL_OUTBOUND_ALLOWED가 없으면 실패"
- name: EXTERNAL_OUTBOUND_ALLOWED
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(externalOutboundAllowed = true)"
semantics: "use case가 외부 HTTP/message broker로 outbound 호출을 발생시키는 것을 허용. outbox claim 분류: outbox row INSERT는 in-process이므로 본 capability 불요. polling publisher의 broker publish는 outbound이므로 필요. domain event without transport detail은 outbound 호출이 아니므로 별도 분류."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:external-outbound-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "CROSS_TENANT_ADMIN capability를 capability vocabulary에 추가 (tenant branch feature-tenant-context-policy와 cross-link)"
- name: CROSS_TENANT_ADMIN
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(crossTenantAdmin = true)"
semantics: "tenant 경계를 넘어 데이터에 접근/변경하는 admin use case가 선언해야 하는 capability. tenant-context-policy의 cross-tenant 정책과 cross-link되어야 하며, 단일 tenant 범위 use case에서 이 capability를 선언하면 review에서 reject. SENSITIVE_READ가 동반될 가능성이 높지만 자동 결합은 아님."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:cross-tenant-admin-capability
# Row count verification
# - feature-repository-access-permission-contract 판정 기준 "Required capability" 표에 명시된 7개:
# READ_REPOSITORY, WRITE_REPOSITORY, SENSITIVE_READ, BULK_WRITE, TRANSACTION_REQUIRED,
# EXTERNAL_OUTBOUND_ALLOWED, CROSS_TENANT_ADMIN.
# - source에 명시되지 않은 capability는 본 registry에 추가하지 않음 (추측 금지).
File diff suppressed because it is too large Load Diff
+918
View File
@@ -0,0 +1,918 @@
# Registry: Error Codes
# SSOT: wiki/projects/ca-tmpl/registries/error-codes.yaml
# Schema owner: feature-contract-registry-governance
# Category enum owner: feature-operational-error-observability-foundation
# Last updated: 2026-05-22
# Note: 이 파일은 Phase B 산출물. Phase C2(ca-tmpl 실 코드)에서 generated Java constants의 source.
#
# Schema (per row):
# code: UPPER_SNAKE_CASE
# category: VALIDATION | AUTH | AUTHZ | NOT_FOUND | CONFLICT |
# RATE_LIMIT | TRANSIENT_DEPENDENCY | PERMANENT_DEPENDENCY |
# DATA_INTEGRITY | INTERNAL
# http_status: int (async-only failures use 500 placeholder)
# retryable: bool
# retry_after_seconds: int | null (RATE_LIMIT/TRANSIENT 권고 backoff)
# owner_branch: source branch (raw/branch-notes/feature-*.md)
# owner_layer: presentation | application | domain | infrastructure | crosscut
# client_safe_message: no token / no principal raw / no internal path / no stack trace
# log_level: ERROR | WARN | INFO
# runbook_link: runbook://area/scenario OR null (client-error만 null 허용)
# compatibility_impact: none | additive | behavior-change | breaking
# required_test: owning contract test identifier
#
# Runbook policy (operational-runbook-contract L80):
# retryable=false + category ∈ {AUTH, AUTHZ, RATE_LIMIT, INTERNAL,
# TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY} ⇒ runbook_link 필수.
# VALIDATION/NOT_FOUND/CONFLICT/DATA_INTEGRITY는 client-error로 runbook 면제 가능.
# retryable=true 인 모든 row는 runbook_link 필수.
errors:
# ============================================================
# AUTH (feature-security-operational-baseline / Decision Matrix)
# ============================================================
# source: feature-security-operational-baseline L82 — "token 누락 | 401 | AUTH_TOKEN_MISSING | AUTH"
- code: AUTH_TOKEN_MISSING
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication required"
log_level: WARN
runbook_link: "runbook://auth/token-missing"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L83 — "token malformed (parse fail) | 401 | AUTH_TOKEN_MALFORMED | AUTH"
- code: AUTH_TOKEN_MALFORMED
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: WARN
runbook_link: "runbook://auth/token-malformed"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L84 — "token expired (clock skew tolerance 60s 초과) | 401 | AUTH_TOKEN_EXPIRED | AUTH"
- code: AUTH_TOKEN_EXPIRED
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication expired"
log_level: WARN
runbook_link: "runbook://auth/token-expired"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L85 — "invalid signature | 401 | AUTH_TOKEN_INVALID_SIGNATURE | AUTH"
- code: AUTH_TOKEN_INVALID_SIGNATURE
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: ERROR
runbook_link: "runbook://auth/token-invalid-signature"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L86 — "issuer mismatch | 401 | AUTH_ISSUER_MISMATCH | AUTH"
- code: AUTH_ISSUER_MISMATCH
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: ERROR
runbook_link: "runbook://auth/issuer-mismatch"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L87 — "audience mismatch | 401 | AUTH_AUDIENCE_MISMATCH | AUTH"
- code: AUTH_AUDIENCE_MISMATCH
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: ERROR
runbook_link: "runbook://auth/audience-mismatch"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L88 — "unknown kid (JWKS 미캐시) | 401 + Retry-After 5s | AUTH_KID_UNKNOWN | AUTH"
- code: AUTH_KID_UNKNOWN
category: AUTH
http_status: 401
retryable: true # 2026-06-01: false→true. JWKS 키 회전 중 unknown kid 는 ~5s 후 JWKS refresh 로 해소 가능(transient). retry_after_seconds=5 + client_safe_message "please retry" 와 정합. 키 고정 정책으로 전환 시 false 복귀.
retry_after_seconds: 5
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed, please retry"
log_level: WARN
runbook_link: "runbook://auth/kid-unknown"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L89 — "JWKS endpoint outage ... | AUTH_JWKS_UNAVAILABLE | TRANSIENT_DEPENDENCY"
- code: AUTH_JWKS_UNAVAILABLE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 30
owner_branch: feature-security-operational-baseline
owner_layer: infrastructure
client_safe_message: "Authentication service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://auth/jwks-unavailable"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L90 — "claim mapping failure ... | 401 | AUTH_CLAIM_MAPPING_FAILED | AUTH"
- code: AUTH_CLAIM_MAPPING_FAILED
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: ERROR
runbook_link: "runbook://auth/claim-mapping-failed"
compatibility_impact: none
required_test: contract-verification:auth-category
# ============================================================
# AUTHZ (feature-security-operational-baseline)
# ============================================================
# source: feature-security-operational-baseline L91 — "valid token + 권한 부족 | 403 | AUTHZ_INSUFFICIENT_PERMISSION | AUTHZ"
- code: AUTHZ_INSUFFICIENT_PERMISSION
category: AUTHZ
http_status: 403
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Permission denied"
log_level: WARN
runbook_link: "runbook://authz/insufficient-permission"
compatibility_impact: none
required_test: contract-verification:authz-category
# source: feature-security-operational-baseline L92 — "valid token + tenant cross-access | 403 | AUTHZ_TENANT_MISMATCH | AUTHZ"
- code: AUTHZ_TENANT_MISMATCH
category: AUTHZ
http_status: 403
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Permission denied"
log_level: ERROR
runbook_link: "runbook://authz/tenant-mismatch"
compatibility_impact: none
required_test: contract-verification:authz-category
# ============================================================
# INTERNAL (feature-security-operational-baseline + container-runtime)
# ============================================================
# source: feature-security-operational-baseline L93 — "public path misconfiguration ... | 500 + P1 alert | INTERNAL_AUTH_MISCONFIGURATION | INTERNAL"
- code: INTERNAL_AUTH_MISCONFIGURATION
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: crosscut
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://auth/public-path-misconfiguration"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-container-runtime-contract L113 — "JVM OutOfMemoryError → ExitOnOutOfMemoryError로 137 exit, log에 error.code=JVM_OOM 명시"
- code: JVM_OOM
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-container-runtime-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://runtime/jvm-oom"
compatibility_impact: none
required_test: contract-verification:container-runtime-oom
# ============================================================
# DB / Persistence (feature-persistence-failure-baseline / SQLState Matrix)
# ============================================================
# source: feature-persistence-failure-baseline L85 — "08* | all | TRANSIENT_DEPENDENCY | DB_UNAVAILABLE | true"
- code: DB_UNAVAILABLE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://db/unavailable"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L86 — "40001 | Postgres/MySQL | CONFLICT | DB_SERIALIZATION_FAILURE | true"
- code: DB_SERIALIZATION_FAILURE
category: CONFLICT
http_status: 409
retryable: true
retry_after_seconds: 1
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request conflicted with another transaction, please retry"
log_level: WARN
runbook_link: "runbook://db/serialization-failure"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L87 — "40P01 | Postgres | CONFLICT | DB_DEADLOCK | true (backoff)"
- code: DB_DEADLOCK
category: CONFLICT
http_status: 409
retryable: true
retry_after_seconds: 1
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request conflicted, please retry"
log_level: WARN
runbook_link: "runbook://db/deadlock"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L88 — "23502 | Postgres | DATA_INTEGRITY | DB_NULL_VIOLATION | false"
- code: DB_NULL_VIOLATION
category: DATA_INTEGRITY
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request violates a required field constraint"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L89 — "23503 | Postgres | DATA_INTEGRITY | DB_FK_VIOLATION | false"
- code: DB_FK_VIOLATION
category: DATA_INTEGRITY
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request references missing resource"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L90 — "23505 | Postgres | CONFLICT | DB_UNIQUE_VIOLATION | false (business mapping)"
- code: DB_UNIQUE_VIOLATION
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Resource already exists"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L91 — "23514 | Postgres | DATA_INTEGRITY | DB_CHECK_VIOLATION | false"
- code: DB_CHECK_VIOLATION
category: DATA_INTEGRITY
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request violates a value constraint"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L92 — "25P03 | Postgres | TRANSIENT_DEPENDENCY | DB_IDLE_IN_TX_TIMEOUT | true"
- code: DB_IDLE_IN_TX_TIMEOUT
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 2
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://db/idle-in-tx-timeout"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L93 — "57014 | Postgres | TRANSIENT_DEPENDENCY | DB_QUERY_CANCELED | false"
- code: DB_QUERY_CANCELED
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request was canceled, please retry later"
log_level: WARN
runbook_link: "runbook://db/query-canceled"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# ============================================================
# Rate limit / Idempotency (feature-rate-limit-idempotency-contract)
# ============================================================
# source: feature-rate-limit-idempotency-contract — rate limit response/log 기준 / Retry-After header 기준 (scope L29, L33)
- code: RATE_LIMIT_EXCEEDED
category: RATE_LIMIT
http_status: 429
retryable: true
retry_after_seconds: 1
owner_branch: feature-rate-limit-idempotency-contract
owner_layer: presentation
client_safe_message: "Too many requests, please retry after the indicated interval"
log_level: WARN
runbook_link: "runbook://rate-limit/exceeded"
compatibility_impact: none
required_test: contract-verification:rate-limit
# source: feature-rate-limit-idempotency-contract L71 — "200ms 초과 시 409 IDEMPOTENT_IN_FLIGHT (retryable=false, client는 polling)"
- code: IDEMPOTENT_IN_FLIGHT
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-rate-limit-idempotency-contract
owner_layer: application
client_safe_message: "A previous identical request is still being processed, please poll for result"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:idempotency
# source: feature-rate-limit-idempotency-contract L72 — "fingerprint mismatch (same key + different body) = 422 IDEMPOTENT_REQUEST_MISMATCH"
- code: IDEMPOTENT_REQUEST_MISMATCH
category: VALIDATION
http_status: 422
retryable: false
retry_after_seconds: null
owner_branch: feature-rate-limit-idempotency-contract
owner_layer: application
client_safe_message: "Idempotency key reused with different request body"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:idempotency
# ============================================================
# File / Resource (feature-file-resource-handling-contract)
# ============================================================
# source: feature-file-resource-handling-contract L69 — "spring.servlet.multipart.max-file-size 10MB ... Spring 단의 enforcement가 실패 시 envelope 응답 보장" / 테스트 계약 "oversized upload가 generic 500으로 처리되면 실패"
- code: UPLOAD_SIZE_EXCEEDED
category: VALIDATION
http_status: 413
retryable: false
retry_after_seconds: null
owner_branch: feature-file-resource-handling-contract
owner_layer: presentation
client_safe_message: "Uploaded file exceeds maximum size"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:file-upload
# source: feature-file-resource-handling-contract L72 — "allowed content-type allowlist starting set ..."
- code: UPLOAD_CONTENT_TYPE_REJECTED
category: VALIDATION
http_status: 415
retryable: false
retry_after_seconds: null
owner_branch: feature-file-resource-handling-contract
owner_layer: presentation
client_safe_message: "Uploaded content type is not allowed"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:file-upload
# source: feature-file-resource-handling-contract — Decisionized Work Items "path traversal | normalized storage key only ... | traversal test"
- code: PATH_TRAVERSAL_DETECTED
category: VALIDATION
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-file-resource-handling-contract
owner_layer: presentation
client_safe_message: "Invalid file path"
log_level: ERROR
runbook_link: null
compatibility_impact: none
required_test: contract-verification:file-upload
# source: feature-file-resource-handling-contract L73 — "streaming download backpressure = response timeout 60s, max stream 100MB. 초과 시 truncate + ERROR log"
- code: DOWNLOAD_STREAMING_FAILURE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-file-resource-handling-contract
owner_layer: presentation
client_safe_message: "Download failed, please retry"
log_level: ERROR
runbook_link: "runbook://file/download-streaming-failure"
compatibility_impact: none
required_test: contract-verification:file-download
# ============================================================
# API contract transport-standard codes (feature-api-contract-baseline)
# ============================================================
# NOTE: feature-api-contract-baseline owns the transport-shape failure
# classification (D8 413/414, D9 406/415, D12 405, D15 412). These rows mirror
# dev.caskeleton.shared.error.OperationalError; the D11 status-mapping
# consistency test (owner: this branch, producer) fails the build when a code's
# registry http_status and the enum httpStatus() drift apart.
# source: feature-api-contract-baseline.md D12 — "405 Method Not Allowed + Allow header 의무"
- code: METHOD_NOT_ALLOWED
category: VALIDATION
http_status: 405
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "HTTP method not allowed for this resource"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D9 — "406 Not Acceptable = 응답 표현 협상 실패"
- code: NOT_ACCEPTABLE
category: VALIDATION
http_status: 406
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "No acceptable representation for the requested Accept header"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D15 — "If-Match mismatch 시 412 Precondition Failed"
- code: PRECONDITION_FAILED
category: CONFLICT
http_status: 412
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "Resource was modified by another request; refetch and retry"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D8 — "request size limit 실패 분류 (413)"
- code: PAYLOAD_TOO_LARGE
category: VALIDATION
http_status: 413
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "Request payload is too large"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D8 형제 — "URI 길이 실패 분류 (414)"
# NOTE: enforcement is Tomcat/gateway-owned (rejected before Spring dispatch);
# this row + code exist for status-mapping consistency. End-to-end 414 contract
# test is `planned` (gateway/Tomcat maxHttpHeaderSize 8KB boundary).
- code: URI_TOO_LONG
category: VALIDATION
http_status: 414
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "Request URI is too long"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D9 — "415 Unsupported Media Type = 요청 본문 format 미지원"
- code: UNSUPPORTED_MEDIA_TYPE
category: VALIDATION
http_status: 415
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "Request Content-Type is not supported"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# ============================================================
# Tenant (feature-tenant-context-policy)
# ============================================================
# source: feature-tenant-context-policy L71 — "tenant 미지원 모드에서 X-Tenant-Id 헤더 수신 시 400 TENANT_NOT_SUPPORTED (filter 단계)"
- code: TENANT_NOT_SUPPORTED
category: VALIDATION
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-tenant-context-policy
owner_layer: presentation
client_safe_message: "Tenant context is not supported by this deployment"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:tenant-policy
# ============================================================
# Validation / Business rule (feature-business-rule-validation-contract)
# ============================================================
# NOTE: business-rule-validation branch는 mapping 규칙 SSOT (syntax→VALIDATION,
# policy→AUTHZ/CONFLICT, invariant→CONFLICT/VALIDATION, persistence→PERSISTENCE/CONFLICT)
# 이며 구체 code는 example로 VALIDATION_EMAIL_FORMAT만 등장
# (feature-operational-error-observability-foundation L110). 실제 도메인별 code는
# Phase D(도메인 feature 적용) 시 본 registry에 추가.
# source: feature-operational-error-observability-foundation L110 — "code: VALIDATION_EMAIL_FORMAT, // registry-registered code" (validation field error JSON shape example)
- code: VALIDATION_EMAIL_FORMAT
category: VALIDATION
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-operational-error-observability-foundation
owner_layer: presentation
client_safe_message: "Invalid email format"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:validation-envelope
# ============================================================
# Cache (feature-cache-consistency-contract)
# ============================================================
# source: feature-cache-consistency-contract — Decisionized Work Items "Redis unavailable | degrade only if declared | fail-fast for required cache | generic INTERNAL | unavailable mapping" / 테스트 "Redis unavailable이 degrade 가능 여부 없이 INTERNAL로 처리되면 실패"
- code: CACHE_UNAVAILABLE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 2
owner_branch: feature-cache-consistency-contract
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://cache/unavailable"
compatibility_impact: none
required_test: contract-verification:cache-consistency
# source: feature-cache-consistency-contract L70 — "stampede 방지 default = single-instance Caffeine local lock, multi-instance HPA 시 Redisson RLock distributed mutex" / 테스트 "동일 key에 대해 동시 cache miss 시 backend 호출이 1회로 제한되는지 verify (stampede). 미충족 시 실패"
- code: CACHE_STAMPEDE_LOCK_TIMEOUT
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 1
owner_branch: feature-cache-consistency-contract
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: WARN
runbook_link: "runbook://cache/stampede-lock-timeout"
compatibility_impact: none
required_test: contract-verification:cache-consistency
# ============================================================
# Outbound HTTP (feature-outbound-http-client-baseline)
# ============================================================
# source: feature-outbound-http-client-baseline L70 — "outbound HTTP timeout default = connect 2s / read 5s / global call 10s" + scope "timeout/connect/DNS failure 분류" / 테스트 "upstream timeout은 retryable dependency failure로 분류되어야 함"
- code: DEPENDENCY_TIMEOUT
category: TRANSIENT_DEPENDENCY
http_status: 504
retryable: true
retry_after_seconds: 2
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service did not respond in time, please retry"
log_level: ERROR
runbook_link: "runbook://dependency/timeout"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline — scope "timeout/connect/DNS failure 분류" + L70 connect=2s timeout
- code: DEPENDENCY_CONNECT_FAILED
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 2
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service unreachable, please retry"
log_level: ERROR
runbook_link: "runbook://dependency/connect-failed"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline — scope "timeout/connect/DNS failure 분류"
- code: DEPENDENCY_DNS_FAILED
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service unreachable, please retry"
log_level: ERROR
runbook_link: "runbook://dependency/dns-failed"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline — scope "upstream 4xx/5xx 분류" / 테스트 "401/403은 credential/scope/config 문제로 분류되어야 함"
- code: DEPENDENCY_4XX_CLIENT
category: PERMANENT_DEPENDENCY
http_status: 502
retryable: false
retry_after_seconds: null
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service rejected the request"
log_level: ERROR
runbook_link: "runbook://dependency/4xx-client"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline — scope "upstream 4xx/5xx 분류"
- code: DEPENDENCY_5XX_SERVER
category: TRANSIENT_DEPENDENCY
http_status: 502
retryable: true
retry_after_seconds: 2
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service error, please retry"
log_level: ERROR
runbook_link: "runbook://dependency/5xx-server"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline L69 — "circuit breaker metric은 dependency.name, dependency.type, outcome까지만 tag로 허용" + Decisionized "circuit breaker | Resilience4j optional env"
- code: DEPENDENCY_CIRCUIT_OPEN
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 10
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service temporarily unavailable, please retry later"
log_level: WARN
runbook_link: "runbook://dependency/circuit-open"
compatibility_impact: none
required_test: contract-verification:outbound-http
# ============================================================
# Outbox (feature-domain-event-outbox-contract)
# ============================================================
# source: feature-domain-event-outbox-contract L67 — "outbox row status enum = PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD" + scope "publish 실패 분류" / 판정 "publish 실패가 retry/DLQ/log/runbook 기준 없이 삼켜지면 실패"
- code: OUTBOX_PUBLISH_FAILED
category: TRANSIENT_DEPENDENCY
http_status: 500
retryable: true
retry_after_seconds: 30
owner_branch: feature-domain-event-outbox-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://outbox/publish-failed"
compatibility_impact: none
required_test: contract-verification:outbox-publish
# source: feature-domain-event-outbox-contract L67 — outbox status enum "DEAD" / Outbox Defaults "DLQ | background-job branch owner"
- code: OUTBOX_DEAD_LETTER
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-domain-event-outbox-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://outbox/dead-letter"
compatibility_impact: none
required_test: contract-verification:outbox-dlq
# ============================================================
# Background job / Async (feature-background-job-async-contract)
# ============================================================
# source: feature-background-job-async-contract — Decisionized "saturation | bounded executor + rejection log" / L72 "saturation policy default = AbortPolicy" / 테스트 "executor rejection이 structured log 없이 발생하면 실패"
- code: JOB_EXECUTOR_REJECTED
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-background-job-async-contract
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://job/executor-rejected"
compatibility_impact: none
required_test: contract-verification:async-saturation
# source: feature-background-job-async-contract L69 — "기본 backoff는 exponential backoff with jitter, max attempts 3, DLQ after exhausted attempts" + scope "shutdown 중 job 처리 기준" / L73 graceful shutdown ≤19s
- code: JOB_TIMEOUT
category: TRANSIENT_DEPENDENCY
http_status: 500
retryable: true
retry_after_seconds: 10
owner_branch: feature-background-job-async-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://job/timeout"
compatibility_impact: none
required_test: contract-verification:async-timeout
# source: feature-background-job-async-contract L69 — "DLQ after exhausted attempts" + Decisionized "retry/DLQ | exp backoff jitter, max 3, DLQ exhausted | ... | infinite retry"
- code: JOB_DEAD_LETTER
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-background-job-async-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://job/dead-letter"
compatibility_impact: none
required_test: contract-verification:async-dlq
# ============================================================
# Distributed Lock (feature-distributed-lock-contract)
# ============================================================
# source: feature-distributed-lock-contract D7 — "lock 획득 실패/timeout 의 error code =
# LOCK_ACQUISITION_TIMEOUT (category CONFLICT, retryable true, client_safe true) + metric
# lock.acquisition" / D5 — "try-lock + 유한 waitTime + lease(TTL) 필수, 무한 blocking 금지".
# category CONFLICT 는 기존 enum 재사용; retryable=true — 락 보유자가 임계 구역을 빠져나오면
# 동일 요청 재시도로 해소된다(transient contention). DB_DEADLOCK / DB_SERIALIZATION_FAILURE 와
# 같은 retryable CONFLICT 계열(409). 본 코드는 distributedLockProvider 획득 timeout 전용이며
# cache stampede lock 의 CACHE_STAMPEDE_LOCK_TIMEOUT(cache-consistency, TRANSIENT_DEPENDENCY 503)
# 과 의미가 구분된다 — 후자는 캐시 백엔드 의존성 timeout, 전자는 분산 상호배제 contention.
- code: LOCK_ACQUISITION_TIMEOUT
category: CONFLICT
http_status: 409
retryable: true
retry_after_seconds: 1
owner_branch: feature-distributed-lock-contract
owner_layer: infrastructure
client_safe_message: "Resource is busy, please retry"
log_level: WARN
runbook_link: "runbook://lock/acquisition-timeout"
compatibility_impact: none
required_test: contract-verification:lock-acquisition-timeout
# ============================================================
# Migration / Startup (feature-migration-startup-contract)
# ============================================================
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... migration 실패=70 ..." + Decisionized "startup failure log | structured log with startup.phase, error.code, error.category"
- code: MIGRATION_FAILED
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-migration-startup-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://migration/failed"
compatibility_impact: none
required_test: contract-verification:migration-startup
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = env 누락/malformed=78 ..." / 테스트 "required env 누락 시 startup이 성공하면 실패"
- code: STARTUP_VALIDATION_FAILED
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-migration-startup-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://startup/validation-failed"
compatibility_impact: none
required_test: contract-verification:migration-startup
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... required adapter disabled=72" / 테스트 "disabled required adapter로 app이 뜨면 실패"
- code: REQUIRED_ADAPTER_DISABLED
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-migration-startup-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://startup/required-adapter-disabled"
compatibility_impact: none
required_test: contract-verification:migration-startup
# source: feature-integration-adapter-templates §구현 가이드 §4 (Layer 3) + §Audit A2.
# Runtime-lifecycle fail-fast for an invoke against a DISABLED optional adapter
# (Kafka/Redis/Slack/Google Email). Deliberately distinct from the startup-lifecycle
# REQUIRED_ADAPTER_DISABLED above (exit 72): a runtime invoke ≠ a startup validation,
# so reusing the startup code would conflate two lifecycles (A2 resolution — new
# runtime code owned by this branch). retryable=false: the adapter stays disabled
# until redeploy, so retrying the same call never clears it.
- code: ADAPTER_DISABLED
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-integration-adapter-templates
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://adapter/adapter-disabled"
compatibility_impact: none
required_test: adapter-contract:adapter-disabled-runtime-call
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... profile mismatch=71" / 테스트 "prod profile에서 local-only 설정이 켜지면 실패"
- code: PROFILE_MISMATCH
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-migration-startup-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://startup/profile-mismatch"
compatibility_impact: none
required_test: contract-verification:migration-startup
# ============================================================
# Management / Actuator (feature-management-actuator-security-contract)
# ============================================================
# source: feature-management-actuator-security-contract — Exposure Policy "env/configprops | forbidden" "heapdump/threaddump | forbidden unless break-glass runbook" "shutdown | forbidden" / 테스트 "prod에서 env/configprops endpoint가 노출되면 실패"
- code: ACTUATOR_FORBIDDEN
category: AUTHZ
http_status: 403
retryable: false
retry_after_seconds: null
owner_branch: feature-management-actuator-security-contract
owner_layer: presentation
client_safe_message: "Permission denied"
log_level: WARN
runbook_link: "runbook://management/actuator-forbidden"
compatibility_impact: none
required_test: contract-verification:management-actuator
+220
View File
@@ -0,0 +1,220 @@
# Registry: HTTP Headers
# SSOT: wiki/projects/ca-tmpl/registries/headers.yaml
# Schema owner: feature-contract-registry-governance
# Last updated: 2026-05-22
#
# Conventions:
# - HTTP header name: kebab-case (X-Request-Id, X-Tenant-Id)
# - W3C standard headers: lowercase (traceparent, tracestate)
# - mdc_key: snake_case (foundation SSOT)
# - envelope_meta_field: camelCase (envelope SSOT)
headers:
# source: feature-operational-error-observability-foundation.md L97
# "request_id | inbound filter (생성 또는 X-Request-Id 헤더) | response header X-Request-Id"
- name: X-Request-Id
direction: both
type: ulid
required: false
generated_if_missing: true
mdc_key: request_id
envelope_meta_field: requestId
owner_branch: feature-operational-error-observability-foundation
case_style: kebab
compatibility_impact: none
required_test: contract-verification:envelope-headers
# source: feature-api-contract-baseline.md L67
# "X-Api-Version은 실험/compatibility 보조 header이며 path version과 충돌하면 path가 우선"
- name: X-Api-Version
direction: inbound
type: string
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-api-contract-baseline
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:api-versioning
# source: feature-api-contract-baseline.md L77 / feature-rate-limit-idempotency-contract.md L66-67
# "idempotency header 이름은 Idempotency-Key" / "기본 scope는 (authenticatedPrincipal, idempotencyKey, useCaseName)"
- name: Idempotency-Key
direction: inbound
type: string
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:idempotency-replay
# source: feature-rate-limit-idempotency-contract.md L85 / foundation L85
# "RATE_LIMIT | ... | 429 | true (Retry-After 이후)" / "retry-after 기준 없이 429를 반환하면 실패"
- name: Retry-After
direction: outbound
type: duration-seconds
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:rate-limit-headers
# source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준)
# rate-limit 응답 표면 (limit/remaining/reset 3종은 표준 rate-limit signaling)
- name: X-RateLimit-Limit
direction: outbound
type: numeric
required: false
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:rate-limit-headers
# source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준)
- name: X-RateLimit-Remaining
direction: outbound
type: numeric
required: false
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:rate-limit-headers
# source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준)
- name: X-RateLimit-Reset
direction: outbound
type: rfc3339-date
required: false
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:rate-limit-headers
# source: feature-api-compatibility-deprecation-contract.md L87
# "deprecation marker | OpenAPI deprecated: true + branch note | response header optional"
- name: Deprecation
direction: outbound
type: rfc3339-date
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-api-compatibility-deprecation-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:deprecation-marker
# source: feature-api-compatibility-deprecation-contract.md L87
# "deprecation marker | OpenAPI deprecated: true + branch note | response header optional" (RFC 8594 Sunset)
- name: Sunset
direction: outbound
type: rfc3339-date
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-api-compatibility-deprecation-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:deprecation-marker
# source: feature-distributed-tracing-contract.md L64, L85
# "propagation header는 W3C traceparent default" / "HTTP | traceparent, tracestate (W3C)"
- name: traceparent
direction: both
type: string
required: false
generated_if_missing: true
mdc_key: trace_id
envelope_meta_field: traceId
owner_branch: feature-distributed-tracing-contract
case_style: kebab
compatibility_impact: none
required_test: contract-verification:trace-propagation
# source: feature-distributed-tracing-contract.md L66, L85
# "propagation format = W3C traceparent + tracestate only. B3 propagation은 forbidden"
- name: tracestate
direction: both
type: comma-separated
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-distributed-tracing-contract
case_style: kebab
compatibility_impact: none
required_test: contract-verification:trace-propagation
# source: feature-operational-error-observability-foundation.md L100
# "correlation_id | inbound header X-Correlation-Id 또는 생성 | HTTP X-Correlation-Id, message header correlation_id"
- name: X-Correlation-Id
direction: both
type: ulid
required: false
generated_if_missing: true
mdc_key: correlation_id
envelope_meta_field: correlationId
owner_branch: feature-operational-error-observability-foundation
case_style: kebab
compatibility_impact: none
required_test: contract-verification:envelope-headers
# source: feature-tenant-context-policy.md L69, L101 (foundation)
# "tenant resolution 우선순위 = ... (2) 명시적 X-Tenant-Id 헤더 (admin/internal API only)" /
# "tenant_id | tenant context (활성 시) | downstream HTTP X-Tenant-Id (with allowlist)"
- name: X-Tenant-Id
direction: both
type: ulid
required: false
generated_if_missing: false
mdc_key: tenant_id
envelope_meta_field: null
owner_branch: feature-tenant-context-policy
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:tenant-header-policy
# source: feature-security-operational-baseline.md L66
# "JWT Resource Server를 baseline security model로 둠" (Bearer token via Authorization header)
- name: Authorization
direction: inbound
type: bearer-token
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-security-operational-baseline
case_style: kebab
compatibility_impact: none
required_test: contract-verification:jwt-resource-server
# source: feature-security-operational-baseline.md L83-90 (AuthN/AuthZ Decision Matrix)
# 401 응답 시 WWW-Authenticate (Bearer realm/error) — Spring Security JWT Resource Server 표준 challenge header
- name: WWW-Authenticate
direction: outbound
type: string
required: false
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-security-operational-baseline
case_style: kebab
compatibility_impact: none
required_test: contract-verification:jwt-resource-server
+294
View File
@@ -0,0 +1,294 @@
# Registry: MDC / Log Keys
# SSOT: wiki/projects/ca-tmpl/registries/mdc-keys.yaml
# Schema owner: feature-contract-registry-governance
# MDC SSOT: feature-operational-error-observability-foundation
# Last updated: 2026-05-22
#
# Conventions:
# - MDC key naming: snake_case (foundation L93 "snake_case 강제. camelCase / dot.case 금지.")
# - cardinality_safe_for_metric=true 인 key만 metric tag로 사용 가능
# - foundation L93-102 표 "MDC Key Standard (final)" 6개가 core SSOT
mdc_keys:
# source: feature-operational-error-observability-foundation.md L97
# "request_id | inbound filter (생성 또는 X-Request-Id 헤더) | response header X-Request-Id"
- key: request_id
type: ulid
source: inbound_filter
required_in: [request, dependency, security, application]
http_header_mapping: X-Request-Id
envelope_field: meta.requestId
propagation: [http, async, message]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# source: feature-operational-error-observability-foundation.md L98
# "trace_id | Micrometer Tracing | W3C traceparent header"
- key: trace_id
type: string
source: observation_context
required_in: [request, dependency, application]
http_header_mapping: traceparent
envelope_field: meta.traceId
propagation: [http, async, message]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# source: feature-operational-error-observability-foundation.md L99
# "span_id | Micrometer Tracing | W3C traceparent"
# NOTE: background-job-async-contract L71 "span_id는 Micrometer Observation context에서 자동 전파(MDC explicit copy 불필요)"
- key: span_id
type: string
source: observation_context
required_in: [request, dependency]
http_header_mapping: traceparent
envelope_field: null
propagation: [http, async]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# source: feature-operational-error-observability-foundation.md L100
# "correlation_id | inbound header X-Correlation-Id 또는 생성 | HTTP X-Correlation-Id, message header correlation_id"
- key: correlation_id
type: ulid
source: inbound_filter
required_in: [request, dependency, application]
http_header_mapping: X-Correlation-Id
envelope_field: meta.correlationId
propagation: [http, async, message]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# source: feature-operational-error-observability-foundation.md L101 + feature-tenant-context-policy.md L70
# "tenant_id | tenant context (활성 시) | downstream HTTP X-Tenant-Id (with allowlist)" /
# "tenant ID format = opaque ULID (26 chars Crockford base32)"
# NOTE: tenant L73 "tenant_id ULID 원본은 metric tag에 직접 사용 금지"
- key: tenant_id
type: ulid
source: security_context
required_in: [request, dependency, security, audit]
http_header_mapping: X-Tenant-Id
envelope_field: null
propagation: [http, async, message]
owner_branch: feature-tenant-context-policy
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: additive
required_test: contract-verification:tenant-leakage
# source: feature-operational-error-observability-foundation.md L102
# "user_principal | security context (pseudonymized only) | log only, headers forbidden"
- key: user_principal
type: string
source: security_context
required_in: [security, audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# ── log type extensions (log-management-contract L101-109 "Log Type별 필수 필드") ──
# source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms"
# NOTE: application-port-usecase-contract / business 측 operation 식별자 (uri_template과 별도 application-set)
- key: operation
type: string
source: application_set
required_in: [application, dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms"
- key: method
type: string
source: inbound_filter
required_in: [request]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms"
# NOTE: metrics L86 "status_code | 7 (1xx-5xx + ok/other)" — bounded
- key: status
type: numeric
source: inbound_filter
required_in: [request]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L105-106 "request | ... duration_ms" / "dependency | ... duration_ms"
- key: duration_ms
type: numeric
source: application_set
required_in: [request, dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L106 "dependency | dependency_name, dependency_type, duration_ms, outcome, error_code"
# NOTE: metrics L88 "dependency_name | 50" — bounded
- key: dependency_name
type: string
source: application_set
required_in: [dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L106 "dependency | dependency_name, dependency_type, ..."
- key: dependency_type
type: string
source: application_set
required_in: [dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L106 + metrics L91 "outcome (resilience4j) | 5 (SUCCESS/FAILURE/CIRCUIT_OPEN/TIMEOUT/REJECTED)"
- key: outcome
type: string
source: application_set
required_in: [dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L106 "dependency | ... error_code (실패 시)"
# NOTE: metrics L89 "error_code | 100 — error registry row 상한과 정합" — bounded
- key: error_code
type: string
source: application_set
required_in: [dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L107
# "security | event_type, user_principal (pseudonymized), source_ip (anonymized — last octet zeroed)"
- key: event_type
type: string
source: application_set
required_in: [security, audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L107 "security | ... source_ip (anonymized — last octet zeroed)"
# NOTE: metrics L93 "high-cardinality 금지 tag: ... ip_address"
- key: source_ip_anon
type: string
source: inbound_filter
required_in: [security]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L108 "audit | actor, action, target, before_hash, after_hash, occurred_at"
- key: actor
type: string
source: security_context
required_in: [audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L108 "audit | actor, action, target, ..."
- key: action
type: string
source: application_set
required_in: [audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L108 "audit | actor, action, target, ..."
- key: target
type: string
source: application_set
required_in: [audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
+540
View File
@@ -0,0 +1,540 @@
# Registry: Metrics
# SSOT: wiki/projects/ca-tmpl/registries/metrics.yaml
# Schema owner: feature-contract-registry-governance
# Owner branch: feature-metrics-alerting-contract
# Last updated: 2026-05-22
#
# Notes
# - Naming: Micrometer dot.case + unit suffix (.seconds | .bytes | .total).
# - Tag cardinality bounds are SSOT of feature-metrics-alerting-contract "Cardinality Bounds" table.
# - High-cardinality tags forbidden globally: user_id, request_id, raw_url, raw_query,
# raw_header_value, ip_address. These MUST NOT appear in any row.
# - tenant_id label is bounded mapping table id OR cohort bucket only (ULID raw forbidden).
# - error_code tag cardinality_limit follows error-codes.yaml row count (max 100).
metrics:
# === HTTP server (inbound) ===
# source: feature-metrics-alerting-contract — Metric/Alert Defaults
# "HTTP metric | http.server.requests with method/status/uri-template | raw URL or user id tag"
- name: http.server.requests
type: timer
unit: seconds
tags:
- name: method
cardinality_limit: 8
allowed_values: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, OTHER]
- name: status
cardinality_limit: 7
allowed_values: [1xx, 2xx, 3xx, 4xx, 5xx, ok, other]
- name: uri_template
cardinality_limit: 200
validation: must_be_template_not_raw_uri
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p1: "error_rate > 5% for 5m OR > 10% for 1m"
p2: "error_rate > 1% for 10m"
p3: "error_rate > 0.1% for 1h"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [method, status, uri_template]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — P1/P2/P3 정량 기준 (HTTP latency p99)
# "P1: p99 > 5s 5분 / P2: p99 > 1s 10분 / P3: p99 > 500ms 30분"
- name: http.server.requests.latency
type: timer
unit: seconds
tags:
- name: method
cardinality_limit: 8
allowed_values: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, OTHER]
- name: uri_template
cardinality_limit: 200
validation: must_be_template_not_raw_uri
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p1: "p99 > 5s for 5m"
p2: "p99 > 1s for 10m"
p3: "p99 > 500ms for 30m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [method, uri_template, duration_ms]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === HTTP client (outbound dependency) ===
# source: feature-metrics-alerting-contract — Metric/Alert Defaults
# "dependency metric | dependency.client.requests with dependency.name/type/outcome | endpoint with secret tag"
# source: feature-outbound-http-client-baseline — "circuit breaker metric은 dependency.name, dependency.type, outcome까지만 tag로 허용"
- name: dependency.client.requests
type: timer
unit: seconds
tags:
- name: dependency_name
cardinality_limit: 50
- name: dependency_type
cardinality_limit: 10
allowed_values: [http, grpc, db, cache, queue, broker, other]
- name: outcome
cardinality_limit: 5
allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED]
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p1: "required dep unavailable for 2m"
p2: "optional dep degraded for 5m"
p3: "spike alert (10x baseline)"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [dependency_name, dependency_type, outcome, duration_ms]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-outbound-http-client-baseline — decisions
# "retry/circuit breaker 기본 라이브러리는 Resilience4j"
# source: feature-metrics-alerting-contract — "retry/CB minimum: resilience4j.retry.calls{outcome}"
- name: resilience4j.retry.calls
type: counter
unit: total
tags:
- name: name
cardinality_limit: 50
- name: outcome
cardinality_limit: 5
allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "retry exhaustion rate > 1% for 10m"
owner_branch: feature-outbound-http-client-baseline
log_field_mapping: [dependency_name, outcome, retry_attempt]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — "resilience4j.circuitbreaker.state"
- name: resilience4j.circuitbreaker.state
type: gauge
unit: total
tags:
- name: name
cardinality_limit: 50
- name: state
cardinality_limit: 6
allowed_values: [CLOSED, OPEN, HALF_OPEN, DISABLED, FORCED_OPEN, METRICS_ONLY]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "state == OPEN for required dependency for 2m"
p2: "state == OPEN for optional dependency for 5m"
owner_branch: feature-outbound-http-client-baseline
log_field_mapping: [dependency_name]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — "resilience4j.circuitbreaker.calls{outcome}"
- name: resilience4j.circuitbreaker.calls
type: timer
unit: seconds
tags:
- name: name
cardinality_limit: 50
- name: outcome
cardinality_limit: 5
allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED]
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "CIRCUIT_OPEN rate > 1% for 10m"
owner_branch: feature-outbound-http-client-baseline
log_field_mapping: [dependency_name, outcome, duration_ms]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === DB connection pool ===
# source: feature-persistence-failure-baseline — Hikari Alert Threshold
# "pool wait p99 > 100ms 5분 지속 → P2 / pool exhaustion (active = max) > 1분 → P1"
# source: feature-metrics-alerting-contract — "hikaricp.connections.acquire{outcome='timeout'} p99 > 100ms"
- name: hikaricp.connections.acquire
type: timer
unit: seconds
tags:
- name: pool
cardinality_limit: 5
- name: outcome
cardinality_limit: 3
allowed_values: [SUCCESS, TIMEOUT, FAILURE]
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p1: "pool exhaustion (active == max) for 1m"
p2: "acquire p99 > 100ms for 5m"
owner_branch: feature-persistence-failure-baseline
log_field_mapping: [pool, outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-persistence-failure-baseline — In scope "Hikari metric 노출 기준" + Hikari Alert Threshold
- name: hikaricp.connections.usage
type: timer
unit: seconds
tags:
- name: pool
cardinality_limit: 5
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "usage p99 elevated > 10m"
owner_branch: feature-persistence-failure-baseline
log_field_mapping: [pool]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-persistence-failure-baseline — In scope "Hikari metric 노출 기준"
- name: hikaricp.connections.active
type: gauge
unit: total
tags:
- name: pool
cardinality_limit: 5
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "active == max for 1m"
owner_branch: feature-persistence-failure-baseline
log_field_mapping: [pool]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — Histogram Buckets/Percentile "DB query: same"
- name: db.query.duration
type: timer
unit: seconds
tags:
- name: operation
cardinality_limit: 20
allowed_values: [select, insert, update, delete, batch, ddl, other]
- name: outcome
cardinality_limit: 3
allowed_values: [SUCCESS, FAILURE, TIMEOUT]
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "p99 > 1s for 10m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [operation, outcome, duration_ms]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Background job / async executor ===
# source: feature-background-job-async-contract — Decisionized Work Items "saturation policy"
# "AbortPolicy default (core=10, max=50, queue=200)"
- name: executor.saturation
type: gauge
unit: total
tags:
- name: executor_name
cardinality_limit: 10
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "queue size > 80% capacity for 5m"
p1: "rejection rate > 0 for 1m"
owner_branch: feature-background-job-async-contract
log_field_mapping: [executor_name]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-background-job-async-contract — Decisionized Work Items "saturation | bounded executor + rejection log"
- name: executor.rejected.total
type: counter
unit: total
tags:
- name: executor_name
cardinality_limit: 10
- name: policy
cardinality_limit: 3
allowed_values: [AbortPolicy, CallerRunsPolicy, DiscardPolicy]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "rejection_count > 0 for 1m"
owner_branch: feature-background-job-async-contract
log_field_mapping: [executor_name, policy]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-background-job-async-contract — Decisionized Work Items "retry/DLQ | exp backoff jitter, max 3, DLQ exhausted"
- name: job.retry.total
type: counter
unit: total
tags:
- name: job_name
cardinality_limit: 50
- name: outcome
cardinality_limit: 4
allowed_values: [SUCCESS, RETRY, EXHAUSTED, DLQ]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "EXHAUSTED rate > 1% for 10m"
owner_branch: feature-background-job-async-contract
log_field_mapping: [job_name, outcome, retry_attempt]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-background-job-async-contract — "DLQ after exhausted attempts"
- name: job.dlq.total
type: counter
unit: total
tags:
- name: job_name
cardinality_limit: 50
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "DLQ rate sustained > 0 for 5m"
owner_branch: feature-background-job-async-contract
log_field_mapping: [job_name]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Distributed lock ===
# source: feature-distributed-lock-contract D7 — "metric lock.acquisition (tag: outcome =
# acquired/timeout/error) — 신규 제안" / D5 — try-lock + 유한 waitTime + lease(TTL). 분산
# 상호배제(distributedLockProvider) 획득 시도 결과를 센다. key 는 tag 로 넣지 않는다
# (무한 cardinality — 위 전역 금지 규칙). timeout outcome 은 LOCK_ACQUISITION_TIMEOUT 발생과 1:1.
- name: lock.acquisition
type: counter
unit: total
tags:
- name: outcome
cardinality_limit: 3
allowed_values: [acquired, timeout, error]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "timeout rate > 5% for 10m"
owner_branch: feature-distributed-lock-contract
log_field_mapping: [outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-distributed-lock-contract §Edge / D5 (SI-LOCK-C5) — "lease 만료 후 unlock →
# ConcurrentModificationException — 삼킴 금지, 로그+metric 후 정상 흐름 복귀". Counts releases that
# found the lease already expired (the JdbcLock row was reclaimed by another instance before
# the holder called close()). A sustained nonzero rate means lease TTL is shorter than real
# critical-section duration — raise APP/lease TTL or shorten the protected work. Not an
# acquisition outcome, hence a separate counter from lock.acquisition.
- name: lock.lease.expired
type: counter
unit: total
tags: []
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "lease-expired rate sustained > 0 for 10m"
owner_branch: feature-distributed-lock-contract
log_field_mapping: []
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Outbox publisher ===
# source: feature-domain-event-outbox-contract — Outbox Defaults
# "DB outbox table with eventId, aggregateId, eventType, payload, occurredAt, status, attemptCount, nextAttemptAt"
- name: outbox.publisher.published.total
type: counter
unit: total
tags:
- name: event_type
cardinality_limit: 50
- name: outcome
cardinality_limit: 4
allowed_values: [PUBLISHED, FAILED, DEAD, IN_FLIGHT]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "FAILED rate > 1% for 10m"
owner_branch: feature-domain-event-outbox-contract
log_field_mapping: [event_type, outcome, event_id]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-domain-event-outbox-contract — row status enum PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD
- name: outbox.publisher.lag
type: gauge
unit: seconds
tags:
- name: event_type
cardinality_limit: 50
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "lag > 60s for 10m"
p1: "lag > 300s for 5m"
owner_branch: feature-domain-event-outbox-contract
log_field_mapping: [event_type]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-domain-event-outbox-contract — row status enum + Outbox Defaults attemptCount
- name: outbox.pending.size
type: gauge
unit: total
tags:
- name: status
cardinality_limit: 5
allowed_values: [PENDING, IN_FLIGHT, PUBLISHED, FAILED, DEAD]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "PENDING size growing for 10m"
owner_branch: feature-domain-event-outbox-contract
log_field_mapping: [status]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Cache ===
# source: feature-cache-consistency-contract — Decisionized Work Items "cache pattern | cache-aside default"
- name: cache.gets.total
type: counter
unit: total
tags:
- name: cache_name
cardinality_limit: 50
- name: result
cardinality_limit: 3
allowed_values: [hit, miss, error]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p3: "hit_ratio < baseline 0.5x for 1h"
owner_branch: feature-cache-consistency-contract
log_field_mapping: [cache_name, result]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-cache-consistency-contract — "invalidation = after-commit only", "invalidation 실패가 조용히 무시되면 실패"
- name: cache.invalidations.total
type: counter
unit: total
tags:
- name: cache_name
cardinality_limit: 50
- name: outcome
cardinality_limit: 3
allowed_values: [SUCCESS, FAILURE, SKIPPED]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "FAILURE rate > 0 for 5m"
owner_branch: feature-cache-consistency-contract
log_field_mapping: [cache_name, outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Log appender ===
# source: feature-log-management-contract — Sampling Policy (final)
# "async appender overflow default: drop oldest INFO/DEBUG with counter metric (log.appender.dropped.total)"
- name: log.appender.dropped.total
type: counter
unit: total
tags:
- name: appender
cardinality_limit: 5
- name: level
cardinality_limit: 2
allowed_values: [INFO, DEBUG]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "dropped > 0 sustained for 10m"
owner_branch: feature-log-management-contract
log_field_mapping: [appender, level]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Distributed tracing ===
# source: feature-distributed-tracing-contract — decisions
# "trace sampling rate default = prod 1%, staging 10%, dev/local 100%"
- name: tracing.sampling.rate
type: gauge
unit: total
tags:
- name: profile
cardinality_limit: 4
allowed_values: [prod, staging, dev, local]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p3: "effective rate deviates from configured for 1h"
owner_branch: feature-distributed-tracing-contract
log_field_mapping: [profile]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === JVM baseline ===
# source: feature-metrics-alerting-contract — In scope "JVM/process metric"
- name: jvm.memory.used
type: gauge
unit: bytes
tags:
- name: area
cardinality_limit: 2
allowed_values: [heap, nonheap]
- name: id
cardinality_limit: 10
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "heap used / max > 0.85 for 10m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [area, id]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — In scope "JVM/process metric"
- name: jvm.gc.pause
type: timer
unit: seconds
tags:
- name: action
cardinality_limit: 10
- name: cause
cardinality_limit: 10
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "p99 pause > 500ms for 10m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [action, cause]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — In scope "JVM/process metric"
- name: jvm.threads.live
type: gauge
unit: total
tags: []
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p3: "thread count > 2x baseline for 30m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: []
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — In scope "JVM/process metric" (process uptime)
- name: process.uptime
type: gauge
unit: seconds
tags: []
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "uptime reset unexpectedly < 60s (crash loop signal)"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: []
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
+203
View File
@@ -0,0 +1,203 @@
# Registry: Secrets Classification
# SSOT: wiki/projects/ca-tmpl/registries/secrets-classification.yaml
# Schema owner: feature-contract-registry-governance
# Owner branch: feature-secrets-config-source-contract
# Last updated: 2026-05-22
#
# Conventions:
# - 3-tier classification (feature-secrets-config-source-contract 2026-05-22):
# public-config | sensitive-config | secret
# - `secret` rows: prod_default 항상 null. dev fake 식별자는 `__LOCAL_DEV_` prefix
# (feature-secrets-config-source-contract 2026-05-22: "dev/local sentinel value prefix = __LOCAL_DEV_").
# - prod profile에서 `__LOCAL_DEV_` prefix 발견 시 startup fail
# (feature-secrets-config-source-contract 2026-05-22).
# - Masking 기본 = `full_except_last_4` (feature-secrets-config-source-contract 2026-05-22:
# "full mask except last 4 chars for non-secret tokens"). 진짜 secret(password/private key)은 `full`.
# - Naming suffix는 보조 신호 (feature-secrets-config-source-contract: "_TOKEN, _KEY, _PASSWORD").
# - public-config 항목은 env-keys.yaml에서 직접 정의되며 본 파일에는 reference row만 둠.
secrets:
# === Tier 3: secret (true secret — password/private-key/HMAC-salt) ===
- name: APP_DATASOURCE_PASSWORD
# source: feature-secrets-config-source-contract 2026-05-22
# "DB credential은 dual-bind 60s" + "__LOCAL_DEV_FAKE_DB_PASSWORD" 예시
classification: secret
source: secret-manager
rotation_policy: dual-bind-60s
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:db-password-no-leak-in-actuator
- name: APP_SECURITY_JWT_SIGNING_KEY
# source: feature-secrets-config-source-contract 2026-05-22
# "JWT signing key는 24h overlap window 유지 (security branch와 cross-link)"
# + feature-security-operational-baseline "rotation overlap window = 새 kid 도입 → 24h 동안 old kid 병행"
classification: secret
source: secret-manager
rotation_policy: overlap-24h
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:jwt-signing-key-rotation-overlap
- name: APP_SECURITY_OAUTH_CLIENT_SECRET
# source: feature-secrets-config-source-contract 2026-05-22
# "secret classification은 ... naming pattern은 보조(suffix _TOKEN, _KEY, _PASSWORD)"
# + feature-security-operational-baseline "JWT Resource Server를 baseline security model" (OAuth 자격 증명 분류)
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:oauth-client-secret-no-leak
- name: APP_EXTERNAL_API_KEY
# source: feature-secrets-config-source-contract 2026-05-22
# "external API key는 application restart 시 reload"
# (per-dependency suffix는 adapter 등록 시 추가; 본 row는 baseline 분류 정의)
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: breaking
required_test: secrets-contract:external-api-key-no-leak
- name: APP_CACHE_REDIS_PASSWORD
# source: feature-secrets-config-source-contract 2026-05-22
# 3-tier classification "secret" + feature-integration-adapter-templates "Redis | disabled optional module"
# (Redis enabled + auth 사용 시 secret으로 분류)
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:redis-password-no-leak
- name: APP_PRIVACY_PSEUDONYMIZATION_SALT
# source: feature-data-retention-privacy-contract 2026-05-22
# "pseudonymization key = HMAC-SHA-256 with rotating salt. salt rotation interval = 90일.
# rotation 시 old salt 90일 retain (lookup 가능)."
# + feature-tenant-context-policy "tenant identifier는 raw PII가 아니어야 하며 ... pseudonymized id"
classification: secret
source: secret-manager
rotation_policy: salt-rotation-90d
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-data-retention-privacy-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:pseudonymization-salt-rotation
# === Tier 2: sensitive-config (token-bearing URL or id with exposure restriction) ===
- name: APP_NOTIFICATION_SLACK_WEBHOOK_URL
# source: feature-integration-adapter-templates 2026-05-22
# "Slack | disabled optional module | notification failure policy"
# Slack webhook URL은 token을 path에 포함하므로 sensitive-config (URL 형태이지만 secret과 동급 취급)
classification: sensitive-config
source: secret-manager
rotation_policy: manual
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: breaking
required_test: secrets-contract:slack-webhook-no-leak
- name: APP_SECURITY_GOOGLE_OAUTH_CLIENT_ID
# source: feature-secrets-config-source-contract 2026-05-22
# "sensitive-config" tier (id이지만 노출 제한)
# + feature-integration-adapter-templates "Google Email | disabled optional module"
classification: sensitive-config
source: mounted-env
rotation_policy: manual
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: behavior-change
required_test: secrets-contract:google-oauth-client-id-masked
- name: APP_DATASOURCE_USERNAME
# source: feature-secrets-config-source-contract 2026-05-22 — "sensitive-config" tier
# (DB user는 password와 함께 노출되면 위험하므로 sensitive-config)
classification: sensitive-config
source: mounted-env
rotation_policy: dual-bind-60s
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: breaking
required_test: secrets-contract:datasource-username-masked-in-actuator
- name: APP_DATASOURCE_URL
# source: feature-secrets-config-source-contract 2026-05-22 — JDBC URL은 host/db 포함하므로 sensitive-config
# (env-keys.yaml에서는 public-config 처리; 본 파일에서는 노출 통제 관점에서 sensitive로 재분류 — masking 기준 명시 목적)
classification: sensitive-config
source: mounted-env
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: breaking
required_test: secrets-contract:datasource-url-masked-in-actuator
# === Tier 1: public-config (reference only — full row in env-keys.yaml) ===
- name: APP_PROFILE
# source: feature-env-driven-runtime-configuration — public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-env-driven-runtime-configuration
masking_rule: none
reference: env-keys.yaml#APP_PROFILE
- name: APP_NAME
# source: feature-env-driven-runtime-configuration — public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-env-driven-runtime-configuration
masking_rule: none
reference: env-keys.yaml#APP_NAME
- name: SERVER_PORT
# source: feature-env-driven-runtime-configuration — Spring native, public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-env-driven-runtime-configuration
masking_rule: none
reference: env-keys.yaml#SERVER_PORT
- name: SPRING_PROFILES_ACTIVE
# source: feature-env-driven-runtime-configuration — Spring native, public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-env-driven-runtime-configuration
masking_rule: none
reference: env-keys.yaml#SPRING_PROFILES_ACTIVE
- name: OTEL_EXPORTER_OTLP_ENDPOINT
# source: feature-distributed-tracing-contract — public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-distributed-tracing-contract
masking_rule: none
reference: env-keys.yaml#OTEL_EXPORTER_OTLP_ENDPOINT
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — ADAPTER_DISABLED (런타임 어댑터 비활성화 호출)
category: INTERNAL
error_codes: [ADAPTER_DISABLED]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: ADAPTER_DISABLED (`runbook://adapter/adapter-disabled`)
## Symptoms
- HTTP 500 with `error.code=ADAPTER_DISABLED`
- Code invoked an optional adapter (Kafka/Redis/Slack/Email) that is disabled in this deployment
## Diagnosis
- Check adapter name in log (`adapter_name` field)
- Review deployment config — which optional adapters are enabled?
## Action
- Enable the adapter in deployment configuration (env flag)
- Or update application logic to skip disabled-adapter paths
## Escalation
- Escalate to deployment team if adapter should be enabled but isn't
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_AUDIENCE_MISMATCH (대상 불일치)
category: AUTH
error_codes: [AUTH_AUDIENCE_MISMATCH]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_AUDIENCE_MISMATCH (`runbook://auth/audience-mismatch`)
## Symptoms
- HTTP 401 with `error.code=AUTH_AUDIENCE_MISMATCH`
- Token `aud` claim does not include this service's expected audience
## Diagnosis
- Check token `aud` claim value
- Compare against configured `spring.security.oauth2.resourceserver.jwt.audiences`
## Action
- Verify client is requesting tokens scoped to the correct audience
- Update audience configuration if service identifier changed
## Escalation
- Escalate to auth-platform team if misconfiguration is system-wide
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_CLAIM_MAPPING_FAILED (클레임 매핑 실패)
category: AUTH
error_codes: [AUTH_CLAIM_MAPPING_FAILED]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_CLAIM_MAPPING_FAILED (`runbook://auth/claim-mapping-failed`)
## Symptoms
- HTTP 401 with `error.code=AUTH_CLAIM_MAPPING_FAILED`
- Token validated but required claims (sub, roles, tenant) missing or unexpected type
## Diagnosis
- Inspect token payload claims via logs
- Check claim extractor configuration
## Action
- Verify IdP token template includes required claims
- Update claim mapping configuration if IdP schema changed
## Escalation
- Escalate to auth-platform team if IdP changed claim schema
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_ISSUER_MISMATCH (발급자 불일치)
category: AUTH
error_codes: [AUTH_ISSUER_MISMATCH]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_ISSUER_MISMATCH (`runbook://auth/issuer-mismatch`)
## Symptoms
- HTTP 401 with `error.code=AUTH_ISSUER_MISMATCH`
- Token `iss` claim does not match configured expected issuer
## Diagnosis
- Compare token `iss` against `spring.security.oauth2.resourceserver.jwt.issuer-uri`
- Check if IdP environment changed
## Action
- Update issuer config if IdP migrated
- Reject tokens from unexpected issuers
## Escalation
- Escalate to platform-security if unexpected issuer detected
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_JWKS_UNAVAILABLE (JWKS 엔드포인트 장애)
category: TRANSIENT_DEPENDENCY
error_codes: [AUTH_JWKS_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_JWKS_UNAVAILABLE (`runbook://auth/jwks-unavailable`)
## Symptoms
- HTTP 503 with `error.code=AUTH_JWKS_UNAVAILABLE`
- All authentication failing; JWKS refresh attempts failing
## Diagnosis
- Check IdP JWKS endpoint health: `curl -sf https://<idp-host>/.well-known/jwks.json`
- Check network connectivity from app pods to IdP
## Action
- Enable cached JWKS fallback if available
- Coordinate with IdP team for restoration
## Escalation
- P1 page: IdP team immediately if JWKS endpoint unreachable > 2 minutes
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_KID_UNKNOWN (키 ID 미인식)
category: AUTH
error_codes: [AUTH_KID_UNKNOWN]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_KID_UNKNOWN (`runbook://auth/kid-unknown`)
## Symptoms
- HTTP 401 with `error.code=AUTH_KID_UNKNOWN`, `retryable=true`
- Token `kid` header not present in cached JWKS
## Diagnosis
- Check if IdP key rotation occurred recently
- Verify JWKS cache TTL and refresh timing
## Action
- Force JWKS cache refresh
- Confirm new key is published in IdP JWKS endpoint
## Escalation
- Escalate to IdP team if new kid not appearing in JWKS after 10 minutes
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — INTERNAL_AUTH_MISCONFIGURATION (공개 경로 설정 오류)
category: INTERNAL
error_codes: [INTERNAL_AUTH_MISCONFIGURATION]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: INTERNAL_AUTH_MISCONFIGURATION (`runbook://auth/public-path-misconfiguration`)
## Symptoms
- HTTP 500 with `error.code=INTERNAL_AUTH_MISCONFIGURATION`
- Security filter misconfiguration detected at runtime
## Diagnosis
- Check `verifyPublicPathSnapshot` output in CI
- Review recent changes to `SecurityConfig` or `application.yml` public path list
## Action
- Revert misconfigured public path change
- Run `./gradlew verifyPublicPathSnapshot` to compare snapshot
## Escalation
- P1 immediate: if auth bypass is possible due to misconfiguration
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_TOKEN_EXPIRED (토큰 만료)
category: AUTH
error_codes: [AUTH_TOKEN_EXPIRED]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_TOKEN_EXPIRED (`runbook://auth/token-expired`)
## Symptoms
- HTTP 401 with `error.code=AUTH_TOKEN_EXPIRED`
- Spike may indicate clock skew or long-lived token usage
## Diagnosis
- Check `exp` claim vs server clock
- Check NTP sync on token-issuing host
## Action
- Client must refresh tokens before expiry
- Verify clock skew tolerance is configured (default 60s)
## Escalation
- Escalate if spike is widespread or clock drift is confirmed
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_TOKEN_INVALID_SIGNATURE (서명 검증 실패)
category: AUTH
error_codes: [AUTH_TOKEN_INVALID_SIGNATURE]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_TOKEN_INVALID_SIGNATURE (`runbook://auth/token-invalid-signature`)
## Symptoms
- HTTP 401 with `error.code=AUTH_TOKEN_INVALID_SIGNATURE`
- `log_level=ERROR` — may indicate forged tokens or wrong signing key
## Diagnosis
- Check if JWKS endpoint returned a new key set
- Check for token forgery attempts in logs
## Action
- Verify JWKS key IDs match token headers
- Alert security team if forgery suspected
## Escalation
- Immediate P1 escalation if forgery indicators present
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_TOKEN_MALFORMED (토큰 파싱 실패)
category: AUTH
error_codes: [AUTH_TOKEN_MALFORMED]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_TOKEN_MALFORMED (`runbook://auth/token-malformed`)
## Symptoms
- HTTP 401 responses with `error.code=AUTH_TOKEN_MALFORMED`
- Token present but fails JWT parse (not 3-part, non-base64, etc.)
## Diagnosis
- Inspect raw Authorization header value in logs
- Check if token generation tooling has a bug
## Action
- Identify source of malformed tokens
- Fix or update client token generation
## Escalation
- Escalate if spike suggests infrastructure issue
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락)
category: AUTH
error_codes: [AUTH_TOKEN_MISSING]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_TOKEN_MISSING (`runbook://auth/token-missing`)
## Symptoms
- HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING`
- Client missing Authorization header or Bearer token
## Diagnosis
- Check request logs for missing Authorization header
- Verify client SDK configuration
## Action
- Confirm API clients are sending Authorization header
- Check gateway/proxy configuration for header stripping
## Escalation
- Escalate if widespread or affecting critical workflows
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,69 @@
---
title: Runbook — JWT key rotation 시 인증 실패 spike
category: AUTH
error_codes: [AUTH_TOKEN_EXPIRED, AUTH_KID_UNKNOWN, AUTH_JWKS_UNAVAILABLE, AUTH_TOKEN_INVALID_SIGNATURE]
severity: P1
owner: oncall
last_updated: 2026-05-22
status: stub
---
# Runbook: JWT key rotation 시 인증 실패 spike
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `auth_401_error_rate_high` 또는 `jwks_refresh_failure_spike`
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `runbook_link`, `dependency_name`
- 임계: 401 error rate > 5% 5분 지속 OR JWKS refresh failure count > 10건/분
## 2. First Response (5분 이내)
### Step 1 — 확인
1. JWKS endpoint health check: `curl -sf https://<idp-host>/.well-known/jwks.json | jq '.keys | length'`
2. log query에서 `error.code` 분포 확인 — `AUTH_KID_UNKNOWN` 비중이 높으면 rotation 원인 강력 시사
3. IdP rotation schedule 확인 (직전 24h 내 rotation 이벤트가 있었는지)
### Step 2 — 임시 격리
- JWKS cache TTL을 짧게(예: 60s) 강제하여 새 kid 전파 가속
- 새 kid가 JWKS에 publish되어 있는지 확인. 누락이면 IdP에 republish 요청
## 3. Diagnosis
- log query (Loki/CloudWatch): `{service="auth"} | error.category="AUTH" | dependency_name="jwks-endpoint"`
- metric panel: `auth_jwks_cache_hit_ratio`, `auth_jwks_refresh_failure_total`, `auth_kid_unknown_total`
- trace: 실패한 request 1건에서 `traceId` 추출 → IdP outbound span 확인
- 가능한 원인:
- 새 kid가 JWKS에 publish되기 전 token 발급 → 24h overlap window 안에 있는지 확인
- JWKS endpoint 장애 (5xx, timeout) → IdP status page 확인
- 시계 skew로 인한 만료 오판 → NTP sync 상태 확인
## 4. Mitigation
- 단기: old kid를 임시 재허용 (rollback). overlap window를 48h로 일시 확장
- IdP에 새 JWKS publish 재시도 요청
- 장기: rotation 절차에 "publish → 24h 대기 → switch" 단계 강제. observability에 kid 분포 metric 추가
## 5. Escalation
- P2 → P1 격상 조건: 401 error rate > 20% 또는 다중 tenant에 동시 발생
- 다음 on-call로 page: 10분 내 회복 안 되면 IdP team 또는 platform-security team page
## 6. Recovery / Verification
- 회복 확인 metric: `auth_401_error_rate < 1%` 5분 지속, `AUTH_KID_UNKNOWN` 건수 0
- post-incident:
- rotation 절차 RCA 작성
- JWKS overlap window 정책 문서 업데이트
- kid 분포 dashboard 영구화
## 7. Related
- error-codes.yaml rows: `AUTH_TOKEN_EXPIRED`, `AUTH_KID_UNKNOWN`, `AUTH_JWKS_UNAVAILABLE`, `AUTH_TOKEN_INVALID_SIGNATURE`
- metrics.yaml: `auth_jwks_cache_hit_ratio`, `auth_jwks_refresh_failure_total`
- 관련 branch: [[feature-security-operational-baseline]]
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 IdP 종류·rotation 정책·JWKS endpoint URL·dashboard 링크로 보강 필요.
@@ -0,0 +1,72 @@
---
title: Runbook — cross-tenant 접근 시도 감지
category: AUTHZ
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION, AUTHZ_TENANT_MISMATCH]
severity: P2
owner: oncall
last_updated: 2026-05-22
status: stub
---
# Runbook: cross-tenant 접근 시도 감지
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `authz_cross_tenant_violation` 또는 `authz_403_spike`
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `principal_id_pseudonymized`, `tenant_id`, `runbook_link`
- 임계:
- P2: 403 with `error.code=AUTHZ_TENANT_MISMATCH` > 10건/5분
- P1 격상: 동일 principal에서 3개 이상 tenant 시도 OR 5분 내 100건 초과
## 2. First Response (5분 이내)
### Step 1 — 확인
1. log query로 위반 principal 식별 (pseudonymized): `error.code=AUTHZ_TENANT_MISMATCH`
2. principal의 정상 tenant scope 확인 (IdP claim 또는 entitlement table)
3. `CROSS_TENANT_ADMIN` capability 보유 여부 확인 — 보유자라면 false positive 가능성
### Step 2 — 임시 격리
- 명백한 위반 패턴이면 principal session 강제 만료 (token revocation list 추가)
- security incident channel 통보 (`#sec-incident`)
- 위반 request의 source IP / user-agent 기록
## 3. Diagnosis
- log query: `{service="api"} | error.category="AUTHZ" | principal_id_pseudonymized="<hash>"`
- metric panel: `authz_denied_total{reason="tenant_mismatch"}`, `authz_principal_tenant_distribution`
- trace: 위반 request의 `traceId`로 호출 chain 확인. token claim의 `tenant_id`와 요청 path의 `tenant_id` 비교
- 가능한 원인:
- account takeover (계정 탈취) → 즉시 session revoke + 비밀번호 reset 요구
- client bug (잘못된 tenant id 전송) → product team에 통보
- 정상 admin operation 누락된 capability → entitlement 보정
## 4. Mitigation
- 단기: principal session revoke, source IP rate-limit 강화
- 위반이 client bug면 client patch release 협조
- 장기: tenant boundary 검증 layer를 controller가 아닌 repository 진입점에서 강제 ([[feature-repository-access-permission-contract]])
## 5. Escalation
- 다음 on-call로 page: 보안 incident channel 즉시 page. 5분 내 security on-call 응답 없으면 CISO escalation
- legal/compliance 통보 필요 여부 판단 (개인정보 noted시)
## 6. Recovery / Verification
- 회복 확인 metric: `AUTHZ_TENANT_MISMATCH` 건수 정상 baseline 복귀
- post-incident:
- account takeover면 forensics 수행 + audit log 보존
- cross-tenant 검증 unit test 추가
- 위반 패턴 detection rule 영구화
## 7. Related
- error-codes.yaml rows: `AUTHZ_INSUFFICIENT_PERMISSION`, `AUTHZ_TENANT_MISMATCH`
- metrics.yaml: `authz_denied_total`, `authz_principal_tenant_distribution`
- 관련 branch: [[feature-tenant-context-policy]], [[feature-repository-access-permission-contract]]
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 tenant 모델·capability 정의·security team 연락 체계로 보강 필요.
@@ -0,0 +1,34 @@
---
title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족)
category: AUTHZ
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTHZ_INSUFFICIENT_PERMISSION (`runbook://authz/insufficient-permission`)
## Symptoms
- HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION`
- Valid token but missing required role or permission
## Diagnosis
- Check user's assigned roles in IdP
- Review endpoint's required permission annotation
## Action
- Grant correct role/permission to user
- Verify endpoint permission requirement is correct
## Escalation
- Escalate to access-management team if bulk users affected
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTHZ_TENANT_MISMATCH (테넌트 cross-access 시도)
category: AUTHZ
error_codes: [AUTHZ_TENANT_MISMATCH]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTHZ_TENANT_MISMATCH (`runbook://authz/tenant-mismatch`)
## Symptoms
- HTTP 403 with `error.code=AUTHZ_TENANT_MISMATCH`
- `log_level=ERROR` — cross-tenant access attempt detected
## Diagnosis
- Extract `traceId`, check `X-Tenant-Id` vs token tenant claim
- Determine if this is misconfigured client or intentional attack
## Action
- Block repeat offenders at gateway level
- Alert security team for investigation
## Escalation
- P1 if confirmed malicious cross-tenant access attempt
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — CACHE_STAMPEDE_LOCK_TIMEOUT (캐시 스탬피드 락 타임아웃)
category: TRANSIENT_DEPENDENCY
error_codes: [CACHE_STAMPEDE_LOCK_TIMEOUT]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: CACHE_STAMPEDE_LOCK_TIMEOUT (`runbook://cache/stampede-lock-timeout`)
## Symptoms
- HTTP 503 with `error.code=CACHE_STAMPEDE_LOCK_TIMEOUT`
- Multiple concurrent cache misses on same key; lock contention
## Diagnosis
- Check cache hit ratio metrics
- Identify cache keys with high miss rates
## Action
- Verify stampede lock TTL is configured appropriately
- Pre-warm cache for high-traffic keys on startup
## Escalation
- Escalate if backend load spike accompanies stampede
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — CACHE_UNAVAILABLE (캐시 연결 불가)
category: TRANSIENT_DEPENDENCY
error_codes: [CACHE_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: CACHE_UNAVAILABLE (`runbook://cache/unavailable`)
## Symptoms
- HTTP 503 with `error.code=CACHE_UNAVAILABLE`
- Redis connection errors in logs
## Diagnosis
- Check Redis cluster health
- Verify network connectivity from app to Redis
## Action
- Check Redis sentinel/cluster status
- Enable cache degradation path if configured for optional caches
## Escalation
- P1 if required cache is down and no degradation path exists
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_DEADLOCK (데드락)
category: CONFLICT
error_codes: [DB_DEADLOCK]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_DEADLOCK (`runbook://db/deadlock`)
## Symptoms
- HTTP 409 with `error.code=DB_DEADLOCK`
- SQLState 40P01 in Postgres logs
## Diagnosis
- Check `pg_locks` and `pg_stat_activity` during deadlock
- Identify conflicting transaction lock order
## Action
- Client should retry (retryable=true)
- Fix lock ordering in code if recurring
## Escalation
- Escalate to DBA if deadlock rate is sustained > 1% of transactions
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_IDLE_IN_TX_TIMEOUT (트랜잭션 idle 타임아웃)
category: TRANSIENT_DEPENDENCY
error_codes: [DB_IDLE_IN_TX_TIMEOUT]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_IDLE_IN_TX_TIMEOUT (`runbook://db/idle-in-tx-timeout`)
## Symptoms
- HTTP 503 with `error.code=DB_IDLE_IN_TX_TIMEOUT`
- SQLState 25P03; transaction held open too long without activity
## Diagnosis
- Check `idle_in_transaction_session_timeout` Postgres setting
- Look for application-level long-running transaction holders
## Action
- Reduce transaction scope in application code
- Verify `spring.jpa.properties.hibernate.connection.timeout` is bounded
## Escalation
- Escalate to DBA if connection pool exhaustion results
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_QUERY_CANCELED (쿼리 취소)
category: TRANSIENT_DEPENDENCY
error_codes: [DB_QUERY_CANCELED]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_QUERY_CANCELED (`runbook://db/query-canceled`)
## Symptoms
- HTTP 503 with `error.code=DB_QUERY_CANCELED`
- SQLState 57014; query exceeds statement timeout
## Diagnosis
- Check `statement_timeout` in Postgres
- Identify slow queries in `pg_stat_statements`
## Action
- Optimize slow query or add index
- Adjust statement timeout if query is legitimately long
## Escalation
- Escalate to DBA for query optimization if recurring
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_SERIALIZATION_FAILURE (직렬화 실패)
category: CONFLICT
error_codes: [DB_SERIALIZATION_FAILURE]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_SERIALIZATION_FAILURE (`runbook://db/serialization-failure`)
## Symptoms
- HTTP 409 with `error.code=DB_SERIALIZATION_FAILURE`
- SQLState 40001; high concurrent transaction contention
## Diagnosis
- Check DB transaction isolation level
- Identify hot rows / hot tables under high concurrency
## Action
- Client should retry with exponential backoff (retryable=true)
- Optimize transaction scope if spike is sustained
## Escalation
- Escalate to DBA if sustained serialization failure rate > 5%
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_UNAVAILABLE (데이터베이스 연결 불가)
category: TRANSIENT_DEPENDENCY
error_codes: [DB_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_UNAVAILABLE (`runbook://db/unavailable`)
## Symptoms
- HTTP 503 with `error.code=DB_UNAVAILABLE`
- SQLState 08* connection errors in logs
## Diagnosis
- Check DB server health and connection pool exhaustion
- Review network connectivity from app pods to DB
## Action
- Check DB primary health; failover to replica if available
- Drain connection pool and reconnect
## Escalation
- P1: immediate if DB primary is down
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_4XX_CLIENT (업스트림 클라이언트 오류)
category: PERMANENT_DEPENDENCY
error_codes: [DEPENDENCY_4XX_CLIENT]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_4XX_CLIENT (`runbook://dependency/4xx-client`)
## Symptoms
- HTTP 502 with `error.code=DEPENDENCY_4XX_CLIENT`
- Upstream returned 401/403/400 — credential, scope, or request format issue
## Diagnosis
- Check upstream response body in logs for error detail
- Verify API credentials and scopes are valid
## Action
- Rotate credentials if expired
- Fix request format if API contract changed
## Escalation
- Escalate to upstream API owner if contract change is suspected
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_5XX_SERVER (업스트림 서버 오류)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_5XX_SERVER]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_5XX_SERVER (`runbook://dependency/5xx-server`)
## Symptoms
- HTTP 502 with `error.code=DEPENDENCY_5XX_SERVER`
- Upstream returned 5xx; transient server-side failure
## Diagnosis
- Check `dependency_name` tag for which upstream is failing
- Review upstream service status page
## Action
- Client should retry (retryable=true)
- Monitor upstream recovery
## Escalation
- P1 if critical upstream is in sustained 5xx state
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_CIRCUIT_OPEN (서킷 브레이커 개방)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_CIRCUIT_OPEN]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_CIRCUIT_OPEN (`runbook://dependency/circuit-open`)
## Symptoms
- HTTP 503 with `error.code=DEPENDENCY_CIRCUIT_OPEN`
- Circuit breaker (Resilience4j) in OPEN state for a dependency
## Diagnosis
- Check Resilience4j circuit breaker metrics for the dependency
- Check upstream health; circuit opens after failure threshold breached
## Action
- Wait for circuit half-open probe (automatic after wait duration)
- Resolve upstream issue to allow circuit to close
## Escalation
- P1 if circuit remains open > 5 minutes on a critical dependency
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_CONNECT_FAILED (외부 의존성 연결 실패)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_CONNECT_FAILED]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_CONNECT_FAILED (`runbook://dependency/connect-failed`)
## Symptoms
- HTTP 503 with `error.code=DEPENDENCY_CONNECT_FAILED`
- TCP connection refused or network unreachable to upstream
## Diagnosis
- Check `dependency_name` tag for which upstream is unreachable
- Verify network path and firewall rules
## Action
- Check upstream service availability
- Verify service discovery / DNS resolution
## Escalation
- P1 if upstream is a critical service dependency
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_DNS_FAILED (DNS 조회 실패)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_DNS_FAILED]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_DNS_FAILED (`runbook://dependency/dns-failed`)
## Symptoms
- HTTP 503 with `error.code=DEPENDENCY_DNS_FAILED`
- DNS resolution failure for upstream hostname
## Diagnosis
- Test DNS resolution from app pod: `nslookup <upstream-host>`
- Check cluster DNS (CoreDNS) health
## Action
- Verify upstream hostname configuration
- Check CoreDNS / cluster DNS health
## Escalation
- P1 if cluster DNS is degraded
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_TIMEOUT (외부 의존성 타임아웃)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_TIMEOUT]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_TIMEOUT (`runbook://dependency/timeout`)
## Symptoms
- HTTP 504 with `error.code=DEPENDENCY_TIMEOUT`
- Upstream service did not respond within configured timeout (default: global 10s)
## Diagnosis
- Check `dependency_name` in log for which upstream is timing out
- Review upstream service latency metrics
## Action
- Check upstream service health
- Verify timeout settings match SLA expectations
## Escalation
- P1 if critical upstream is timing out at scale
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+81
View File
@@ -0,0 +1,81 @@
---
title: Runbook — 외부 의존성 unavailable
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_TIMEOUT, DEPENDENCY_CONNECT_FAILED, DEPENDENCY_DNS_FAILED, DEPENDENCY_CIRCUIT_OPEN, DEPENDENCY_5XX_SERVER, CACHE_UNAVAILABLE, DB_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-05-22
status: stub
---
# Runbook: 외부 의존성 unavailable
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `dependency_error_rate_critical` 또는 `circuit_breaker_open`
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `dependency_name`, `dependency_kind`(required|optional), `runbook_link`
- 임계:
- P1: required dependency의 error rate > 50% 1분 OR circuit_open state 활성
- P2: optional dependency degraded (fail-open으로 동작 중)
## 2. First Response (5분 이내)
### Step 1 — 확인
1. `dependency_name` 별 status page 확인 (외부 SaaS면 vendor status, internal이면 해당 service dashboard)
2. log query로 실패 패턴 확인: timeout / connect / DNS / 5xx 중 어떤 모드인지
3. runtime-health Dependency Matrix에서 required vs optional 분류 확인
4. circuit breaker state 확인 (Resilience4j metric)
### Step 2 — 임시 격리
- required dep이면 readiness probe로 traffic 차단 (회복 대기) — cascade failure 방지
- optional dep이면 fail-open with degraded mode 확인. degraded banner를 client에 노출
- DNS failure면 resolver/coredns 상태 확인. cache 강제 flush 검토
## 3. Diagnosis
- log query: `{service="app"} | dependency_name="<name>" | stats count by error.code`
- metric panel:
- `resilience4j_circuitbreaker_state{name="<name>"}`
- `resilience4j_retry_calls_total{kind="failed_without_retry"}`
- `hikaricp_connections_active`, `hikaricp_connections_pending` (DB_UNAVAILABLE)
- `http_client_requests_seconds_count{outcome="SERVER_ERROR"}`
- trace: 실패 request의 outbound span에서 timeout/connect/DNS 분류, target endpoint 확인
- 가능한 원인:
- vendor outage → status page 확인, 회복 대기
- 네트워크 문제 (DNS, security group, NAT) → infra team 확인
- connection pool 고갈 (Hikari) → pool size/timeout 점검
- circuit breaker open 후 half-open 전환 실패 → 수동 reset 검토
- retry-storm으로 인한 self-DoS → retry budget 축소
## 4. Mitigation
- 단기: required면 회복 대기 + traffic 차단, optional이면 degraded mode로 유지
- pool 고갈이면 일시 pool size 상향 + leak detection 활성화
- circuit이 stuck이면 수동 reset (`actuator/circuitbreakerevents`)
- 장기: retry budget·timeout·circuit 임계 재조정, fallback path 보강, vendor SLA 재협상
## 5. Escalation
- 다음 on-call로 page: required dep 5분 내 회복 안 되면 외부 dep team 또는 vendor에 page
- 다중 dep 동시 장애면 incident commander 호출 (네트워크 전반 문제 의심)
## 6. Recovery / Verification
- 회복 확인 metric: dependency error rate < 1% 5분 지속, circuit_breaker_state = CLOSED, pool utilization 정상
- post-incident:
- vendor postmortem 요청 (외부 SaaS면)
- timeout/retry/circuit 설정 재검토
- degraded mode가 사용자 경험에 미친 영향 측정
- chaos test에 해당 시나리오 추가
## 7. Related
- error-codes.yaml rows: `DEPENDENCY_TIMEOUT`, `DEPENDENCY_CONNECT_FAILED`, `DEPENDENCY_DNS_FAILED`, `DEPENDENCY_CIRCUIT_OPEN`, `DEPENDENCY_5XX_SERVER`, `CACHE_UNAVAILABLE`, `DB_UNAVAILABLE`
- metrics.yaml: `resilience4j_circuitbreaker_state`, `hikaricp_connections_active`, `http_client_requests_seconds_count`
- 관련 branch: [[feature-outbound-http-client-baseline]], [[feature-persistence-failure-baseline]]
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 dependency 목록·required/optional 분류·vendor 연락 체계·circuit/timeout 임계로 보강 필요.
@@ -0,0 +1,34 @@
---
title: Runbook — DOWNLOAD_STREAMING_FAILURE (스트리밍 다운로드 실패)
category: TRANSIENT_DEPENDENCY
error_codes: [DOWNLOAD_STREAMING_FAILURE]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DOWNLOAD_STREAMING_FAILURE (`runbook://file/download-streaming-failure`)
## Symptoms
- HTTP 503 with `error.code=DOWNLOAD_STREAMING_FAILURE`
- Streaming response truncated; backpressure or timeout (60s / 100MB limit)
## Diagnosis
- Check streaming response timeout configuration
- Review download size vs 100MB limit
## Action
- Verify storage backend is reachable
- Check for network congestion on download path
## Escalation
- Escalate to infra if storage backend is degraded
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+74
View File
@@ -0,0 +1,74 @@
---
title: Runbook — 5xx Internal error spike
category: INTERNAL
error_codes: [INTERNAL_ERROR, INTERNAL_AUTH_MISCONFIGURATION, JVM_OOM]
severity: P1
owner: oncall
last_updated: 2026-05-22
status: stub
---
# Runbook: 5xx Internal error spike
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `http_5xx_error_rate_critical`
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `request_id`, `traceId`, `runbook_link`
- 임계: 5xx error rate > 5% 5분 지속 OR > 10% 1분
## 2. First Response (5분 이내)
### Step 1 — 확인
1. 가장 최근 deploy 시각 확인 (CI/CD dashboard, artifact registry digest)
2. JVM metric 확인: heap usage, GC pause, CPU, thread count
3. log에서 실패 request 1건 추출 → `request_id`, `traceId` 확보
4. error.code 분포 확인: `INTERNAL_ERROR` vs `JVM_OOM` vs `INTERNAL_AUTH_MISCONFIGURATION`
### Step 2 — 임시 격리
- 직전 deploy가 의심되면 즉시 rollback (artifact registry에서 직전 image digest pin)
- OOM 패턴이면 affected pod evict → ASG/HPA로 replacement 유도
- LB에서 unhealthy pod 격리 (readiness probe failure 유도)
## 3. Diagnosis
- log query: `{service="app"} | http.status>=500 | stats count by error.code`
- metric panel: `jvm_memory_used_bytes{area="heap"}`, `jvm_gc_pause_seconds`, `process_cpu_seconds_total`, `http_server_requests_seconds_count{status=~"5.."}`
- trace: 실패 request의 `traceId`로 span chain 확인 → stack trace에서 root exception 추출
- heap dump 위치: `/var/tmp/heap/heapdump-<pid>.hprof` (JVM ergonomics: `-XX:MaxRAMPercentage=75 -XX:+HeapDumpOnOutOfMemoryError`)
- 가능한 원인:
- 직전 deploy의 회귀 버그 → rollback
- JVM OOM (메모리 leak 또는 부하 증가) → heap dump 분석
- 외부 의존성 설정 오류 (`INTERNAL_AUTH_MISCONFIGURATION`) → config secret 확인
- thread starvation (pool 고갈) → thread dump (`jstack <pid>`)
## 4. Mitigation
- 단기: 직전 deploy rollback, OOM pod replacement, traffic 일시 감소(scale-out 또는 rate-limit 강화)
- config 오류면 secret/configmap rollback
- 장기: heap dump 기반 leak 수정, capacity planning 재검토
## 5. Escalation
- 다음 on-call로 page: 10분 내 회복 안 되면 incident commander 호출, severity 1 incident 선언
- 데이터 손상 의심되면 DBA team page
## 6. Recovery / Verification
- 회복 확인 metric: 5xx rate < 0.5% 5분 지속, JVM heap usage < 70%, GC pause p99 < 500ms
- post-incident:
- rollback 원인 RCA 작성 (배포 게이트 강화 필요 여부)
- heap dump 분석 결과 공유
- JVM ergonomics(`-XX:MaxRAMPercentage`) 재검토
- rollback 자동화 절차 점검
## 7. Related
- error-codes.yaml rows: `INTERNAL_ERROR`, `INTERNAL_AUTH_MISCONFIGURATION`, `JVM_OOM`
- metrics.yaml: `jvm_memory_used_bytes`, `jvm_gc_pause_seconds`, `http_server_requests_seconds_count`
- 관련 branch: [[feature-operational-error-observability-foundation]], [[feature-container-runtime-contract]]
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 deploy 파이프라인·heap dump 보관 경로·rollback 자동화 명령으로 보강 필요.
+67
View File
@@ -0,0 +1,67 @@
---
title: Runbook — background job dead letter
category: INTERNAL
error_codes: [JOB_DEAD_LETTER]
severity: P1
owner: oncall
last_updated: 2026-06-13
status: stub
---
# Runbook: background job dead letter (`runbook://job/dead-letter`)
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `job_dead_letter`
- alert payload 필수 field: `error.code=JOB_DEAD_LETTER`, `job_name`, `correlation_id`, `runbook_link`
- 임계: `job.dlq.total` > 0 for 5m (p1) — retry 소진 후 DLQ 진입은 자동 회복이 없으므로 점검 대상
## 2. First Response (5분 이내)
### Step 1 — 확인
1. ERROR log에서 `JOB_DEAD_LETTER` 라인 확인: `job_name`, 최종 실패 원인 예외, `correlation_id` 추출
2. `job.retry.total{outcome=EXHAUSTED}` 추이로 DLQ 유입 규모 파악
3. DLQ 적재 위치(향후 retry carrier 확정 시 DB 테이블/큐) 확인 — 현재 skeleton은 vocabulary 단계
### Step 2 — 임시 격리
- DLQ는 max attempts(3) 소진의 최종 상태 — 자동 재시도 없음, 수동 개입 필수
- 비즈니스 크리티컬 job이면 §4의 수동 처분(재처리 또는 폐기)을 우선 수행
## 3. Diagnosis
- log query: `{service="app"} | error.code="JOB_DEAD_LETTER" | stats count by job_name`
- metric panel: `job.dlq.total{job_name}`, `job.retry.total{job_name, outcome}`
- 최종 실패 원인 분류:
- poison input(직렬화/계약 위반) → 입력 결함, 재처리해도 실패 — 수정 후 재처리 또는 폐기
- 외부 의존성 장기 outage 중 attempts 소진 → 의존성 회복 후 재처리로 해결 가능
- non-transient error(권한/도메인/스키마)인데 retry된 경우 → 분류기 보강 필요(WAF-REL05-C3: 즉시 DLQ가 정답)
## 4. Mitigation (수동 처분 — 둘 중 하나)
- **재처리 (기본)**: 원인 해소 후 해당 job을 다시 enqueue. 소비자는 멱등(idempotencyKey dedupe) 의무가 있으므로 중복 처리 안전
- **폐기 (영구)**: 작업이 더 이상 유효하지 않으면 DLQ에서 제거. ⚠ 비즈니스 오너 승인 후에만 수행하고 incident 기록에 남김
- 장기: poison input 재발 방지(입력 계약 테스트 보강), non-transient error는 retry 없이 즉시 DLQ로 분류
## 5. Escalation
- 처분 판단(재처리 vs 폐기)이 불가하면 해당 job의 비즈니스 오너에게 escalate
- DLQ 누적이 특정 `job_name`에 집중되면 해당 job 코드 오너에게 page
## 6. Recovery / Verification
- 회복 확인: `job.dlq.total` 증가 멈춤, 재처리분의 소비자 dedupe 동작 확인
- post-incident: DLQ 원인 분류 기록, 같은 원인의 재발 방지 테스트 추가
## 7. Related
- error-codes.yaml rows: `JOB_DEAD_LETTER` (INTERNAL, 500, retryable=false)
- metrics.yaml: `job.dlq.total{job_name}`, `job.retry.total{job_name, outcome=DLQ}`
- 코드: `app-bootstrap` `async/BackgroundJobMetrics`(retry/DLQ vocabulary 기록 seam — D2/D4)
- 관련 runbook: [[job-executor-rejected]], [[job-timeout]], [[outbox-dead-letter]]
- 관련 branch: [[feature-background-job-async-contract]] (D4 retry/DLQ vocabulary SSOT — outbox/outbound가 consume)
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. retry carrier(Spring Retry / Resilience4j / 자체) 확정 후 DLQ 저장소·재처리 절차 보강 필요.
+71
View File
@@ -0,0 +1,71 @@
---
title: Runbook — async executor rejected
category: TRANSIENT_DEPENDENCY
error_codes: [JOB_EXECUTOR_REJECTED]
severity: P1
owner: oncall
last_updated: 2026-06-13
status: stub
---
# Runbook: async executor rejected (`runbook://job/executor-rejected`)
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `executor_rejected`
- alert payload 필수 field: `error.code=JOB_EXECUTOR_REJECTED`, `executor_name`, `policy`, `runbook_link`
- 임계: `executor.rejected.total` > 0 for 1m (p1) — bounded pool이 saturation으로 task를 거부
- 보조 신호: `executor.saturation` gauge > queue capacity의 80% for 5m (p2)
## 2. First Response (5분 이내)
### Step 1 — 확인
1. ERROR log에서 `JOB_EXECUTOR_REJECTED` 라인 확인: `executor_name`, `policy=AbortPolicy`, `queue_size` 추출
2. `executor.saturation` 패널에서 큐 점유율 추이 확인 — 일시적 burst인지 지속 saturation인지 판별
3. 동시 유입 원인 파악: 신규 배포 / 트래픽 spike / 다운스트림 지연으로 worker가 장기 점유되는지
### Step 2 — 임시 격리
- AbortPolicy 거부는 호출부에 `RejectedExecutionException`으로 surface됨 — fire-and-forget `@Async` 호출이면 호출부의 async-exception 처리(log/metric)로 흡수됐는지 확인
- 지속 saturation이면 유입 측(트래픽/스케줄러 빈도)을 우선 감속
## 3. Diagnosis
- log query: `{service="app"} | error.code="JOB_EXECUTOR_REJECTED" | stats count by executor_name`
- metric panel: `executor.saturation{executor_name}`, `executor.rejected.total{executor_name, policy}`
- 가능한 원인 우선순위:
- 다운스트림 의존성 지연 → worker가 반납되지 않아 큐 포화 (가장 흔함)
- 트래픽 spike → 정상 부하 한계 초과
- pool 과소 설정 (`APP_ASYNC_EXECUTOR_*`)
- non-idempotent 작업이 retry로 누적
## 4. Mitigation
- 단기: 유입 감속(상위 rate-limit / 스케줄러 interval 확대) 또는 다운스트림 의존성 회복
- pool 재조정(restart-only): `APP_ASYNC_EXECUTOR_CORE_SIZE` / `APP_ASYNC_EXECUTOR_MAX_SIZE` / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`
— ⚠ queue를 무한정 키우지 말 것(unbounded 금지, D7). 부하테스트로 수치 검증 후 변경
- CallerRunsPolicy로의 전환은 use-case 차원의 명시적 결정 필요(request thread latency 침식 — TPE-JDK21-C6)
## 5. Escalation
- 다운스트림 의존성 장애가 근본 원인이면 해당 의존성 오너에게 escalate
- pool 재조정으로도 saturation이 지속되면 용량 계획(capacity planning) 오너에게 page
## 6. Recovery / Verification
- 회복 확인: `executor.rejected.total` 증가 멈춤, `executor.saturation` < 80% 정상화
- 거부된 작업의 재처리 경로(멱등 retry / 다음 스케줄 cycle) 정상 동작 확인
## 7. Related
- error-codes.yaml rows: `JOB_EXECUTOR_REJECTED` (TRANSIENT_DEPENDENCY, 503, retryable=true, retry_after 5s)
- metrics.yaml: `executor.rejected.total{executor_name, policy}`, `executor.saturation{executor_name}`
- 코드: `app-bootstrap` `async/AsyncExecutorConfig`(bounded executor), `async/LoggingAbortPolicy`(reject log+metric), `async/BackgroundJobMetrics`
- env: `APP_ASYNC_EXECUTOR_CORE_SIZE` / `APP_ASYNC_EXECUTOR_MAX_SIZE` / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`
- 관련 runbook: [[job-timeout]], [[job-dead-letter]]
- 관련 branch: [[feature-background-job-async-contract]] (D7 saturation policy)
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 부하 프로파일·alert 채널·pool 수치 확정 시 보강 필요.
+69
View File
@@ -0,0 +1,69 @@
---
title: Runbook — background job timeout
category: TRANSIENT_DEPENDENCY
error_codes: [JOB_TIMEOUT]
severity: P2
owner: oncall
last_updated: 2026-06-13
status: stub
---
# Runbook: background job timeout (`runbook://job/timeout`)
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `job_timeout`
- alert payload 필수 field: `error.code=JOB_TIMEOUT`, `job_name`, `correlation_id`, `runbook_link`
- 임계: `job.retry.total{outcome=RETRY}` 급증 또는 graceful-shutdown 중 in-flight job interrupt 발생
- 연관: shutdown phase에서 19s await 초과로 interrupt된 job (D8)
## 2. First Response (10분 이내)
### Step 1 — 확인
1. ERROR log에서 `JOB_TIMEOUT` 라인 확인: `job_name`, 마지막 단계, 소요 시간 추출
2. timeout이 정상 실행 중 발생인지, graceful-shutdown(배포/스케일다운) 중 interrupt인지 구분
3. 해당 job이 멱등(retry-on-next-cycle 안전)인지 확인 — 비멱등이면 §4에서 신중히 처리
### Step 2 — 임시 격리
- shutdown 중 interrupt면: 다음 기동 시 재시도 대상인지(멱등 전제) 확인, 중복 부작용 여부 점검
- 정상 실행 중 timeout이면: 해당 job의 외부 의존성(DB/HTTP) 지연 여부 확인
## 3. Diagnosis
- log query: `{service="app"} | error.code="JOB_TIMEOUT" | stats count by job_name`
- metric panel: `job.retry.total{job_name, outcome}`
- 가능한 원인 우선순위:
- 외부 의존성(DB lock / 느린 HTTP) 지연으로 job p99 상승
- job 작업량 증가로 단일 cycle이 19s 예산 초과 (D8 — interrupt 노출)
- interrupt 미반응 blocking call(JDBC 등) → awaitTermination 초과 (K8S-POD-LC-C2 SIGKILL 경로)
## 4. Mitigation
- 단기: 의존성 회복 / job 입력 배치 크기 축소
- job p99가 구조적으로 19s를 넘으면: 작업을 분할하거나, grace period 연장 검토(parent project 운영 계약 소유자 승인 필요 — OUT_OF_BRANCH_SCOPE)
- 비멱등 job이 재시도로 중복 부작용을 내면 멱등키/dedupe 도입 우선
## 5. Escalation
- 의존성 지연이 근본 원인이면 해당 의존성 오너에게 escalate
- shutdown 예산(20s) vs k8s `terminationGracePeriodSeconds`(30s) 정합 이슈면 플랫폼/런타임 오너에게 escalate
## 6. Recovery / Verification
- 회복 확인: `JOB_TIMEOUT` 신규 발생 멈춤, `job.retry.total{outcome=SUCCESS}` 정상 비율 회복
- 멱등 재시도분의 부작용 중복 없음 확인
## 7. Related
- error-codes.yaml rows: `JOB_TIMEOUT` (TRANSIENT_DEPENDENCY, 500, retryable=true, retry_after 10s)
- metrics.yaml: `job.retry.total{job_name, outcome}`
- 코드: `app-bootstrap` `async/AsyncExecutorConfig`(awaitTermination 19s — D8 graceful shutdown)
- env: `APP_SERVER_SHUTDOWN_TIMEOUT`(owner: feature-env-driven-runtime-configuration D2)
- 관련 runbook: [[job-executor-rejected]], [[job-dead-letter]]
- 관련 branch: [[feature-background-job-async-contract]] (D4 retry / D8 shutdown)
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 retry carrier·job p99·shutdown 예산 확정 시 보강 필요.
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — LOCK_ACQUISITION_TIMEOUT (분산 락 획득 타임아웃)
category: CONFLICT
error_codes: [LOCK_ACQUISITION_TIMEOUT]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: LOCK_ACQUISITION_TIMEOUT (`runbook://lock/acquisition-timeout`)
## Symptoms
- HTTP 409 with `error.code=LOCK_ACQUISITION_TIMEOUT`
- Distributed lock wait exceeded configured timeout; high contention on a resource
## Diagnosis
- Check `lock.acquisition` metric for lock name and duration
- Identify lock holders (check DB `integration_lock` table)
## Action
- Client should retry with backoff (retryable=true)
- Optimize critical section holding time if lock contention is systemic
## Escalation
- Escalate if lock holder appears stuck (potential deadlock in distributed lock)
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — ACTUATOR_FORBIDDEN (Actuator 접근 거부)
category: AUTHZ
error_codes: [ACTUATOR_FORBIDDEN]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: ACTUATOR_FORBIDDEN (`runbook://management/actuator-forbidden`)
## Symptoms
- HTTP 403 with `error.code=ACTUATOR_FORBIDDEN`
- Attempt to access restricted actuator endpoint (env/configprops/heapdump/shutdown)
## Diagnosis
- Identify which actuator endpoint was accessed
- Check caller identity (internal tooling vs external)
## Action
- Verify management port is not exposed externally
- For heapdump/threaddump: follow break-glass runbook procedure
## Escalation
- P1 if forbidden actuator access appears to be external attack
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+35
View File
@@ -0,0 +1,35 @@
---
title: Runbook — MIGRATION_FAILED (DB 마이그레이션 실패)
category: INTERNAL
error_codes: [MIGRATION_FAILED]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: MIGRATION_FAILED (`runbook://migration/failed`)
## Symptoms
- Container exits with code 70 (migration failure exit)
- Structured log with `error.code=MIGRATION_FAILED`, `startup.phase=migration`
- App refuses to start (fail-fast)
## Diagnosis
- Check Flyway migration log for which script failed and why
- Review latest migration script for SQL errors
## Action
- Fix migration script or roll back to previous migration version
- Run migration manually in repair mode if checksum mismatch
## Escalation
- P1 immediate: app cannot start until migration is resolved
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+73
View File
@@ -0,0 +1,73 @@
---
title: Runbook — outbox dead letter
category: INTERNAL
error_codes: [OUTBOX_DEAD_LETTER]
severity: P1
owner: oncall
last_updated: 2026-06-11
status: stub
---
# Runbook: outbox dead letter (`runbook://outbox/dead-letter`)
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `outbox_dead_letter`
- alert payload 필수 field: `error.code=OUTBOX_DEAD_LETTER`, `event_type`, `event_id`, `correlation_id`, `runbook_link`
- 임계: `outbox.publisher.published.total{outcome=DEAD}` > 0 (DEAD 전이는 자동 회복이 없으므로 단건도 점검 대상)
## 2. First Response (5분 이내)
### Step 1 — 확인
1. ERROR log에서 `OUTBOX_DEAD_LETTER` 라인 확인: `event_id`, `event_type`, `correlation_id`, 마지막 실패 원인 예외 추출
2. DB에서 DEAD row 확인: `SELECT * FROM outbox_event WHERE status = 'DEAD' ORDER BY occurred_at;`
3. **차단 영향 파악 (중요)**: strict per-aggregate FIFO 정책상 DEAD row는 같은 `aggregate_id`의 후행 이벤트를 계속 차단함 —
`SELECT count(*) FROM outbox_event b WHERE b.status <> 'PUBLISHED' AND EXISTS (SELECT 1 FROM outbox_event d WHERE d.status='DEAD' AND d.aggregate_id=b.aggregate_id AND d.occurred_at < b.occurred_at);`
### Step 2 — 임시 격리
- DEAD는 max attempts(3) 소진의 최종 상태 — 자동 재시도 없음, 수동 개입 필수
- 차단된 aggregate가 비즈니스 크리티컬하면 아래 §4의 수동 처분(재발행 또는 skip)을 우선 수행
## 3. Diagnosis
- log query: `{service="app"} | error.code="OUTBOX_DEAD_LETTER" | stats count by event_type`
- 마지막 실패 원인 분류:
- poison event (payload 직렬화/계약 위반) → payload 자체 결함, 재발행해도 실패 — 수정 후 재발행 또는 skip
- broker 장기 outage 중 attempts 소진 → broker 회복 후 재발행으로 해결 가능
- 구성 오류 (Kafka disabled 상태에서 producer 활성) → 구성 수정 후 재발행
- 가능한 원인 우선순위: 구성 오류 > broker outage > poison payload
## 4. Mitigation (수동 처분 — 둘 중 하나)
- **재발행 (기본)**: 원인 해소 후 해당 row를 다시 claim 가능 상태로 되돌림 —
`UPDATE outbox_event SET status = 'PENDING', attempt_count = 0, next_attempt_at = now() WHERE event_id = '<id>' AND status = 'DEAD';`
(consumer는 at-least-once + idempotencyKey dedupe 의무가 있으므로 중복 발행은 안전)
- **skip (영구 폐기)**: 이벤트가 더 이상 유효하지 않으면 PUBLISHED로 마킹해 FIFO 차단을 해제 —
`UPDATE outbox_event SET status = 'PUBLISHED' WHERE event_id = '<id>' AND status = 'DEAD';`
⚠️ skip은 다운스트림에 영구 이벤트 갭을 만든다 — 비즈니스 오너 승인 후에만 수행하고 incident 기록에 남김
- 장기: poison event 재발 방지(payload 계약 테스트 보강), DEAD 빈발 event_type의 producer 검증 강화
## 5. Escalation
- 처분 판단(재발행 vs skip)이 불가하면 해당 이벤트의 비즈니스 오너에게 escalate
- DEAD 누적이 특정 event_type에 집중되면 producer 코드 오너에게 page
## 6. Recovery / Verification
- 회복 확인: `SELECT count(*) FROM outbox_event WHERE status='DEAD';` = 0, 차단됐던 aggregate의 후행 이벤트가 PUBLISHED로 전이
- `outbox.publisher.lag` 정상화(< 60s), 재발행분의 consumer dedupe 동작 확인
- post-incident: DEAD 원인 분류 기록, 같은 원인의 재발 방지 테스트 추가
## 7. Related
- error-codes.yaml rows: `OUTBOX_DEAD_LETTER` (INTERNAL, retryable=false)
- metrics.yaml: `outbox.publisher.published.total{outcome=DEAD}`, `outbox.pending.size{status=DEAD}`, `outbox.publisher.lag`
- 코드: `application-core` `PublishPendingOutboxEventsUseCase`(FAILED→DEAD 전이), `adapter-persistence` `outbox/OutboxEventJpaRepository`(FIFO 게이트 — DEAD가 후행 차단)
- 관련 runbook: [[outbox-publish-failed]]
- 관련 branch: [[feature-domain-event-outbox-contract]], [[feature-background-job-async-contract]] (max attempts/DLQ vocabulary SSOT)
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 broker·DLQ 토픽·승인 체계 확정 시 보강 필요.
+77
View File
@@ -0,0 +1,77 @@
---
title: Runbook — outbox publish 일시 실패
category: TRANSIENT_DEPENDENCY
error_codes: [OUTBOX_PUBLISH_FAILED]
severity: P2
owner: oncall
last_updated: 2026-06-11
status: stub
---
# Runbook: outbox publish 일시 실패 (`runbook://outbox/publish-failed`)
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `outbox_publish_failed_rate` 또는 `outbox_publisher_lag`
- alert payload 필수 field: `error.code=OUTBOX_PUBLISH_FAILED`, `event_type`, `correlation_id`, `runbook_link`
- 임계 (metrics.yaml verbatim):
- P2: `outbox.publisher.published.total{outcome=FAILED}` rate > 1% for 10m
- P2: `outbox.publisher.lag` > 60s for 10m / P1: > 300s for 5m
- P2: `outbox.pending.size{status=PENDING}` growing for 10m
## 2. First Response (5분 이내)
### Step 1 — 확인
1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출
2. broker(기본 Kafka adapter) 상태 확인: `APP_MESSAGING_KAFKA_ENABLED` 값과 broker endpoint 가용성
- Kafka disabled(default) 상태에서 outbox 이벤트가 append 되고 있으면 publish 경로가 `AdapterDisabledException`으로 전부 실패하는 구성 오류 — 이 경우 producer use case 쪽 활성화/구성을 먼저 의심
3. `outbox.pending.size` status 분포 확인 (FAILED 누적 vs PENDING 누적)
### Step 2 — 임시 격리
- 일시 실패는 자동 backoff 재시도(30s × 2^(attempt-1) + jitter, max attempts 3)가 동작 — 즉시 수동 개입 불필요
- broker 장기 다운이면 DEAD 전이 누적 전에 broker 회복을 우선 (max attempts 소진 시 `runbook://outbox/dead-letter`로 이관)
- relay 자체를 멈춰야 하면 `ca-skeleton.outbox.relay-enabled=false`로 스케줄러 비활성 (이벤트는 outbox 테이블에 안전하게 보존됨 — 유실 없음)
## 3. Diagnosis
- log query: `{service="app"} | error.code="OUTBOX_PUBLISH_FAILED" | stats count by event_type`
- metric panel:
- `outbox.publisher.published.total{outcome}` — FAILED 비율
- `outbox.publisher.lag{event_type}` — 최고령 미발행 이벤트 age
- `outbox.pending.size{status}` — 상태별 분포
- DB 확인: `SELECT status, count(*) FROM outbox_event GROUP BY status;`
- 가능한 원인:
- broker outage/네트워크 → broker 측 회복 대기
- Kafka adapter 미구성(enabled인데 brokers 누락은 기동 시 차단됨) / disabled 상태에서 producer 활성화
- poison event (직렬화 불가/payload 계약 위반) → 재시도 무의미, attempts 소진 후 DEAD로 흘러감 (의도된 동작)
- 동일 aggregate head 실패로 후행 이벤트가 FIFO 게이트에 차단되어 lag 증가 (strict per-aggregate FIFO — 설계 의도)
## 4. Mitigation
- 단기: broker 회복 후 backoff 만료 시 자동 재발행 — `outcome=PUBLISHED` 회복 확인
- IN_FLIGHT orphan(claim 후 crash)은 in-flight-timeout(기본 PT5M) 경과 후 자동 재claim — at-least-once이므로 중복 발행 가능, consumer dedupe(idempotencyKey)가 흡수
- 장기: `ca-skeleton.outbox.poll-interval`/`batch-size` 조정, broker 가용성 SLA 점검, 빈발 event_type의 payload 계약 검토
## 5. Escalation
- P1 lag(>300s 5m) 지속 + broker 회복 불가면 broker/infra 팀에 page
- DEAD 전이가 발생하기 시작하면 `runbook://outbox/dead-letter` 절차로 이관
## 6. Recovery / Verification
- 회복 확인 metric: `outcome=FAILED` rate < 1% 10분 지속, `outbox.publisher.lag` < 60s, `outbox.pending.size{status=FAILED}` 감소 추세
- post-incident: 실패 구간의 DEAD row 유무 확인, consumer 측 중복 처리량 확인(dedupe 동작 검증), backoff/attempts 상수 재평가
## 7. Related
- error-codes.yaml rows: `OUTBOX_PUBLISH_FAILED` (TRANSIENT_DEPENDENCY, retryable=true, retry_after 30s)
- metrics.yaml: `outbox.publisher.published.total`, `outbox.publisher.lag`, `outbox.pending.size`
- 코드: `application-core` `PublishPendingOutboxEventsUseCase`(상태머신), `adapter-persistence` `outbox/OutboxEventJpaRepository`(SKIP LOCKED claim + FIFO 게이트), `adapter-outbound` `messaging/outbox/KafkaOutboxMessagePublishAdapter`(fail-closed)
- 관련 runbook: [[outbox-dead-letter]]
- 관련 branch: [[feature-domain-event-outbox-contract]], [[feature-background-job-async-contract]] (retry/DLQ vocabulary SSOT)
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 broker 채택·alert 라우팅·대시보드 링크 확정 시 보강 필요.
+73
View File
@@ -0,0 +1,73 @@
---
title: Runbook — Rate limit 초과 spike
category: RATE_LIMIT
error_codes: [RATE_LIMIT_EXCEEDED, IDEMPOTENT_IN_FLIGHT]
severity: P3
owner: oncall
last_updated: 2026-05-22
status: stub
---
# Runbook: Rate limit 초과 spike
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `rate_limit_429_high`
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `rate_limit_key_type`(ip|principal|tenant), `runbook_link`
- 임계:
- P3: 429 rate > 1% 10분 지속 (일상적 abuse 차단 효과 정상)
- P2 격상: 정상 client(known principal/tenant)에서 spike 또는 spike와 함께 5xx 동반
## 2. First Response (5분 이내)
### Step 1 — 확인
1. rate-limit key 분포 확인: IP/principal/tenant 중 어디서 spike가 발생했는지
- log query: `error.code=RATE_LIMIT_EXCEEDED | stats count by rate_limit_key_type, rate_limit_key`
2. top-N offending key 추출 (상위 10건)
3. 정상 client 식별 — 알려진 partner/internal service면 P2 격상
### Step 2 — 임시 격리
- abuse traffic 패턴이면 WAF/gateway에서 IP block (geo, ASN 단위)
- IDEMPOTENT_IN_FLIGHT 다발이면 client의 retry-storm 의심 → client에 retry-after 협조 요청
## 3. Diagnosis
- log query: `{service="gateway"} | error.code="RATE_LIMIT_EXCEEDED" | stats count by rate_limit_key`
- metric panel: `gateway_rate_limit_dropped_total`, `gateway_rate_limit_bucket_utilization`
- trace: 429 응답의 `Retry-After` 헤더 값, `rate_limit_remaining` header 확인
- 가능한 원인:
- abuse / bot traffic → IP/ASN block
- 정상 client의 traffic 증가 (캠페인, 신규 feature) → limit 일시 상향
- retry-storm (client backoff 미적용) → client에 idempotency-key + exponential backoff 권고
- limit 설정 오류 (잘못된 정량 threshold) → config rollback
## 4. Mitigation
- 단기: abuse면 IP/ASN block, 정상 client면 해당 key의 limit 일시 상향(예: 2x, 1시간 TTL)
- IDEMPOTENT_IN_FLIGHT 다발: idempotency-key 정책 점검, client 협조 요청
- 장기: limit 정책을 tenant tier별 차등으로 재설계, abuse pattern detection 자동화
## 5. Escalation
- 다음 on-call로 page: 30분 내 정상 client 회복 안 되면 product team 통보
- 정상 client에 SLO 위반 가능성 있으면 CSM/계정담당 통보
## 6. Recovery / Verification
- 회복 확인 metric: 429 rate < 0.5% 10분 지속, 정상 client의 success rate 정상화
- post-incident:
- 일시 상향한 limit 원복 (TTL 만료 확인)
- abuse pattern을 detection rule에 영구 등록
- retry-storm이면 client SDK 가이드 보완
## 7. Related
- error-codes.yaml rows: `RATE_LIMIT_EXCEEDED`, `IDEMPOTENT_IN_FLIGHT`
- metrics.yaml: `gateway_rate_limit_dropped_total`, `gateway_rate_limit_bucket_utilization`
- 관련 branch: [[feature-rate-limit-idempotency-contract]]
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 gateway 제품(NGINX/Envoy/Kong 등)·tenant tier 정책·WAF 연동 절차로 보강 필요.
+36
View File
@@ -0,0 +1,36 @@
---
title: Runbook — JVM_OOM (JVM OutOfMemoryError)
category: INTERNAL
error_codes: [JVM_OOM]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: JVM_OOM (`runbook://runtime/jvm-oom`)
## Symptoms
- Container exits with code 137 (ExitOnOutOfMemoryError triggered)
- Structured log entry with `error.code=JVM_OOM` before exit
## Diagnosis
- Check heap dump if `-XX:HeapDumpOnOutOfMemoryError` is configured
- Review memory usage trends before crash
- Check for memory leaks: large cache growth, unbounded lists, session accumulation
## Action
- Restart container immediately (k8s will auto-restart with liveness probe)
- If recurring: increase heap `-Xmx` or fix memory leak
## Escalation
- P1: immediate if multiple pods crashing simultaneously
- Page SRE / infra team for heap analysis
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+35
View File
@@ -0,0 +1,35 @@
---
title: Runbook — PROFILE_MISMATCH (프로파일 불일치)
category: INTERNAL
error_codes: [PROFILE_MISMATCH]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: PROFILE_MISMATCH (`runbook://startup/profile-mismatch`)
## Symptoms
- Container exits with code 71 (profile mismatch exit)
- Structured log with `error.code=PROFILE_MISMATCH`, `startup.phase=profile-check`
- Production profile active with local-only settings enabled
## Diagnosis
- Check active Spring profiles (`spring.profiles.active`)
- Identify which local-only setting is incorrectly enabled in prod profile
## Action
- Remove local-only setting from production deployment config
- Ensure prod profile does not inherit local/dev profile settings
## Escalation
- P1: security risk if local settings expose debug endpoints in production
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — REQUIRED_ADAPTER_DISABLED (필수 어댑터 비활성화)
category: INTERNAL
error_codes: [REQUIRED_ADAPTER_DISABLED]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: REQUIRED_ADAPTER_DISABLED (`runbook://startup/required-adapter-disabled`)
## Symptoms
- Container exits with code 72 (required adapter disabled exit)
- Structured log with `error.code=REQUIRED_ADAPTER_DISABLED`, `startup.phase=adapter-check`
## Diagnosis
- Identify which adapter is disabled but required
- Check adapter enable flags in environment config
## Action
- Enable required adapter in deployment configuration
- If adapter is intentionally disabled, update the required/optional designation
## Escalation
- P1: app cannot start; coordinate with deployment team
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — STARTUP_VALIDATION_FAILED (환경 변수 검증 실패)
category: INTERNAL
error_codes: [STARTUP_VALIDATION_FAILED]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: STARTUP_VALIDATION_FAILED (`runbook://startup/validation-failed`)
## Symptoms
- Container exits with code 78 (env validation failure exit)
- Structured log with `error.code=STARTUP_VALIDATION_FAILED`, `startup.phase=env-validation`
## Diagnosis
- Check which required env variable is missing or malformed
- Review container environment and secrets injection
## Action
- Supply missing environment variables to deployment
- Verify secrets are correctly mounted / injected
## Escalation
- P1: app cannot start; coordinate with deployment/secrets team
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+42
View File
@@ -0,0 +1,42 @@
---
title: Runbook — <TITLE>
category: <CATEGORY>
error_codes: [<ERROR_CODE_1>, <ERROR_CODE_2>]
severity: <P1|P2|P3>
owner: oncall
last_updated: <YYYY-MM-DD>
status: <stub|active>
---
# Runbook: <TITLE> (`runbook://<area>/<scenario>`)
## Symptoms
- What observable signals trigger this runbook?
- Alert name, metric thresholds, log patterns
## Diagnosis
- Step-by-step diagnostic commands and queries
- Log queries (Loki/CloudWatch)
- Metric panels to check
- Trace investigation approach
## Action
- Immediate mitigation steps
- Configuration changes
- Manual intervention procedures
## Escalation
- Conditions for severity upgrade (e.g., P2 → P1)
- Who to page and when
- Fallback procedures if on-call cannot resolve
---
> **Note**: This is the canonical runbook template.
> Copy this file, rename it to match the `runbook://area/scenario` pattern (→ `area-scenario.md`),
> fill in the frontmatter fields, replace section bodies with operational content,
> then set `status: active` and remove from `STUB_ALLOWLIST` in `RunbookCoverageContractTest`.
@@ -0,0 +1,127 @@
# Harness Policy Engine Implementation Plan
> **Spec:** `docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md`
**Goal:** Replace topology- and platform-specific duplicated harness rules with a registry,
strict evidence validators, generated platform variants, and risk-based review policies.
**Working policy:** human-only commits. Each task leaves changes in the working tree.
## Task 1 — Registry, resolver, and Gradle SSOT
**Files:**
- Add `.harness/project/modules.yaml`
- Add `.harness/lib/module_registry.py`
- Add `.harness/validators/validate_modules.py`
- Add `.harness/tests/test_module_registry.py`
- Modify `src/settings.gradle`
- Modify the dependency-verifier section of `src/build.gradle`
**Steps:**
- [ ] Write failing tests for 19-leaf loading, nested owner resolution, nearest `CLAUDE.md`,
unknown paths, and settings/registry parity.
- [ ] Add the registry and stdlib loader/resolver.
- [ ] Make Gradle settings and dependency verification consume registry data.
- [ ] Run Python tests and `./gradlew projects verifyCleanArchitectureDependencies`.
## Task 2 — Registry-driven import gate and mutation suite
**Files:**
- Modify `.claude/hooks/ca_import_gate.py`
- Modify `.claude/hooks/test_ca_import_gate.py`
- Add `.harness/tests/test_import_gate_mutations.py`
**Steps:**
- [ ] Add failing real-path tests for every registered production module.
- [ ] Replace flat-path regex/prefix rules with registry owner and role policy.
- [ ] Normalize Claude snake_case and Antigravity camelCase tool events.
- [ ] Fail closed on malformed in-scope events and marker failures.
- [ ] Run all import-gate tests.
## Task 3 — Verdict schema, evidence artifacts, and platform adapters
**Files:**
- Add `.harness/schemas/verdict.schema.json`
- Add `.harness/schemas/evidence.schema.json`
- Add `.harness/lib/verdict.py`
- Add `.harness/validators/validate_verdict.py`
- Add `.harness/validators/validate_evidence.py`
- Add `.harness/adapters/antigravity_hook.py`
- Add `.harness/tests/test_verdict.py`
- Modify `.claude/hooks/ca_verdict_gate.py`
- Modify `.claude/hooks/test_ca_verdict_gate.py`
- Add `.agents/plugins/ca-superpowers/hooks.json`
**Steps:**
- [ ] Write negative tests for missing required enums, negative counts, Gradle arithmetic,
behavior change without red, missing upstream artifacts, malformed input, and revision
mismatch.
- [ ] Implement strict validation and evidence recording with source/diff hashes.
- [ ] Adapt Claude fenced verdicts to the common model.
- [ ] Add Antigravity Stop/pre-tool adapter and plugin hook wiring.
- [ ] Run validator, adapter, and JSON syntax tests.
## Task 4 — Canonical agents and deterministic rendering
**Files:**
- Add `.harness/agents/*.md`
- Add `.harness/project/platforms.yaml`
- Add `.harness/generators/render_agents.py`
- Add `.harness/tests/test_platform_parity.py`
- Regenerate `.claude/agents/*`, `.codex/agents/*.toml`, `.agents/agents/*/agent.json`
- Update `.agents/plugins/ca-superpowers/README.md` and `plugin.json`
- Update `.codex/agents/README.md`
**Steps:**
- [ ] Seed canonical sources from the newest human-only Claude policy, then update module
discovery and runner validation to use the registry.
- [ ] Add generated metadata and stable output ordering.
- [ ] Render all variants and add a `--check` parity mode.
- [ ] Assert commit policy, source hashes, tool permissions, and body parity in tests.
## Task 5 — Risk/profile policies and guidance drift cleanup
**Files:**
- Add `.harness/manifest.yaml`
- Add `.harness/core/risk-policy.yaml`, `.harness/core/evidence-policy.yaml`
- Add current architecture/language/build/framework/capability profile files
- Add `.harness/validators/resolve_task.py` and tests
- Modify `AGENTS.md`, root `CLAUDE.md`, clean-architecture rule, workflow skill,
advisory-depth rule, reporting-standards rule, and plugin README
- Modify stale module `CLAUDE.md` files and add missing leaf-module guidance where useful
**Steps:**
- [ ] Add failing task-classification tests for high-risk one-file changes and low-risk
multi-file fixture/docs changes.
- [ ] Implement profile resolution.
- [ ] Replace `N!`, routine all-quote grep, file-count report split, and unconditional
counterargument policies with the design profiles.
- [ ] Replace flat module documentation and focused commands with registry-backed nested names.
- [ ] Run policy grep assertions and harness tests.
## Task 6 — Full review and verification
- [ ] Run harness unit/mutation/parity suite.
- [ ] Run `./gradlew projects` and `./gradlew verifyCleanArchitectureDependencies`.
- [ ] Run the focused ArchUnit suite.
- [ ] Run `./gradlew check`.
- [ ] Audit the working-tree diff in order: architecture → spec → quality.
- [ ] Fix findings and restart the review chain, up to three loops.
## Task 7 — LLM Wiki capture
- [ ] Read the LLM Wiki authority and branch-note template.
- [ ] Update/create the detached-HEAD branch note with implementation decisions, changed files,
verification evidence, failures, and open risks.
- [ ] Create/link derived error, interview, or blog-topic raw notes only when supported by the
completed work; otherwise record an explicit “none” judgment in the branch note.
@@ -0,0 +1,187 @@
# Harness Policy Engine Refactoring Design
- **Date:** 2026-07-20
- **Status:** Approved by user request
- **Scope:** repository-local development harness (`.harness`, `.agents`, `.claude`, `.codex`, root/module guidance, Gradle module registry integration)
- **Source:** user-provided “개발 하네스 분석·리뷰” plus repository evidence gathered on 2026-07-20
## 1. Problem Statement
The repository now has 19 nested Gradle leaf modules, but the write-time import gate,
agent prompts, runner allowlist, and root guidance still contain parts of the previous flat
module topology. Platform variants are copied manually, so commit policy and orchestration
already differ between Claude, Codex, and Antigravity. Verdict validation checks a text
summary but does not consistently require enum fields, non-negative counts, or arithmetic
balance.
The harness must move from duplicated platform prompts to a small policy engine with one
project manifest, deterministic renderers, strict validators, and platform adapters.
## 2. Goals
1. Make the actual nested Gradle topology a single machine-readable source of truth.
2. Resolve a touched file to its nearest owning leaf module without assuming `src/<module>`.
3. Generate write-time import policy and focused Gradle task validation from that registry.
4. Validate machine verdicts with required fields, non-negative integers, arithmetic rules,
upstream evidence, revision identity, and TDD red evidence for behavior changes.
5. Materialize validated evidence as JSON artifacts that platform hooks can share.
6. Render Claude, Codex, and Antigravity agent variants from one canonical source and fail
parity checks when generated files drift.
7. Use one human-only commit policy on every platform.
8. Replace file-count and exhaustive-report rules with risk and review profiles.
9. Add mutation and cross-platform static parity tests.
## 3. Non-Goals
- This change does not run authenticated end-to-end golden tasks inside all three external
products. It supplies the deterministic fixtures and validators those runs will consume.
- It does not add application features or alter production Java behavior.
- It does not require PyYAML, jsonschema, Pydantic, or another runtime dependency. Harness
data files use JSON syntax, which is valid YAML, and validators use Python stdlib only.
- It does not make natural-language agent self-reports authoritative. Hooks convert accepted
reports into evidence artifacts; validators remain authoritative.
## 4. Architecture
```text
.harness/project/modules.yaml ──┬── Gradle settings/includes
├── Gradle dependency verification
├── owning-module resolver
├── import gate
└── Gradle command validator
.harness/agents/*.md + platforms.yaml
└── render_agents.py
├── .claude/agents/*.md
├── .codex/agents/*.toml
└── .agents/agents/*/agent.json
Claude hook ───────────────┐
Antigravity hook adapter ──┼── verdict validator ── evidence JSON
Codex validation command ──┘
```
### 4.1 Project registry
`modules.yaml` contains, per leaf module:
- stable module id
- repository-relative source path
- Gradle path
- role
- Java package roots (informational and import-policy lookup only)
- allowed project dependencies
- focused test command
- profiles/capabilities
- owning `CLAUDE.md` when present
- an intentional mutation import used by gate tests
`src/settings.gradle` reads the registry to declare projects. The
`verifyCleanArchitectureDependencies` task reads the same registry instead of maintaining a
second dependency map.
### 4.2 Owning-module resolution
Owner selection uses the longest filesystem-boundary match among registered leaf source
paths. Package prefixes never decide ownership because `support` owns a broad
`dev.caskeleton.adapter.outbound` package and sample code mirrors production packages.
Instruction discovery walks upward from the touched file and returns the nearest
`CLAUDE.md`; if a leaf has none, root `CLAUDE.md` and `AGENTS.md` are the explicit fallback.
### 4.3 Import gate
The import gate first resolves the registered leaf module, then applies:
- dependency-derived sibling module isolation
- role-specific framework rules for domain, application, inbound, outbound, persistence,
identifier, shared-contract, bootstrap, and sample roles
- global unsafe-pattern checks
All registered production modules receive a mutation test using their real nested source
path. Sample-only exemptions are explicit registry data, not accidental regex misses.
### 4.4 Verdict and evidence
The canonical verdict schema requires `agent`, `verdict`, `task_id`, `revision`, and agent
specific evidence. Non-blocked verdicts require every declared field. Counts are non-negative.
Required equations include:
- spec totals balance
- Gradle `run = passed + failed + skipped`
- ready Gradle results include at least one command and no failed command
- behavior-changing implementation requires at least one observed red test
- quality-ready references validated architecture and spec artifacts
Claude's fenced `ca-verdict` remains a compatibility input, but its accepted form is converted
to the same JSON evidence model. Missing or malformed payloads for detected CA agents fail
closed. Evidence records include a source-message hash and current revision/diff identity.
### 4.5 Platform rendering and hook adapters
Canonical agent Markdown lives under `.harness/agents/`; platform metadata lives in
`.harness/project/platforms.yaml`. Generated files carry `generated_from`, `source_hash`,
`generator_version`, and `do_not_edit` metadata.
Antigravity gains a plugin `hooks.json` and a platform adapter using the documented camelCase
stdin/stdout contract. Claude keeps its native hook entry points but calls the common library.
Codex variants instruct the runner/reviewer to invoke the common validation command because
the repository has no equivalent local lifecycle-hook registration surface.
### 4.6 Risk and review profiles
Risk is determined by change surface, not file count:
- high: security, migration/schema, public contract, module dependency, architecture rule,
transaction/concurrency, CI/deployment
- medium: behavior, multiple modules, external integration
- low: docs/comments, test fixture, local refactor protected by characterization tests
Review profiles:
- `review-lite`: direct diff references; no saved report by default
- `review-standard`: verify blocking citations; one report only when risk or findings justify it
- `audit-deep`: verify all quotes and persist detailed findings
- `regulated`: immutable evidence and full traceability
Option analysis uses a dependency DAG and at most 35 materially distinct alternatives.
Counterarguments are required for judgment-dependent findings, not deterministic failures.
## 5. Commit Policy
All platforms use `human-only`. Implementers never stage or commit. Reviewers may inspect a
working-tree diff before commit or an explicit immutable range after the human commits.
## 6. Verification Strategy
1. Stdlib unit tests for registry loading and owner resolution.
2. Mutation tests for every registered production module path.
3. Strict verdict negative tests: missing fields, negatives, arithmetic imbalance, missing
upstream evidence, revision mismatch, and behavior change without red evidence.
4. Golden renderer tests and `--check` parity validation.
5. JSON validation of generated Antigravity hook and agent files.
6. Gradle `projects`, architecture dependency verification, focused ArchUnit test, and full
`check` after harness tests pass.
## 7. Migration and Compatibility
- Existing fenced verdicts remain parseable only when they satisfy the new required fields.
- Generated platform files are overwritten only by the renderer and documented as generated.
- Root and module guidance is updated to the registered nested topology.
- Actual external cross-platform golden executions remain a follow-up; static parity and seeded
mutation coverage become mandatory in this change.
## 8. Acceptance Criteria
- A seeded forbidden import under every nested production module is rejected.
- No legacy flat adapter path remains in gate tests or agent task allowlists.
- `settings.gradle`, dependency verification, import gate, and Gradle runner resolve the same
19 leaf modules from `modules.yaml`.
- Missing/negative/inconsistent ready verdicts fail validation.
- Claude and Antigravity adapters invoke the shared validator; accepted verdicts produce JSON
evidence.
- Rendering followed by `--check` reports no platform drift.
- Agent variants uniformly state human-only commit policy and risk-based orchestration.
- `N!` enumeration, all-quote routine grep, file-count report splitting, and unconditional
counterargument requirements are absent from active rules.
- Harness tests and Gradle checks pass, or every unrun/failing command is reported with risk.
+27
View File
@@ -0,0 +1,27 @@
# Flaky-test quarantine registry — feature-ci-quality-gates-contract §4 (D7 / D9).
#
# This branch is the flaky-quarantine SSOT. A test that flakes may be tagged with JUnit's built-in
# @Tag("quarantine") so it stops blocking the release gate (src/build.gradle: the main `test` task
# runs excludeTags 'quarantine'; the bucket runs separately via `./gradlew quarantineTest`,
# non-blocking). Quarantine is a TEMPORARY escape, never a parking lot — every quarantined test MUST
# be listed here and MUST leave quarantine within 14 days.
#
# The `verifyQuarantineSunset` Gradle gate (wired into `check`) enforces, on every build:
# - schema — each entry has test / quarantined_since / reason / tracking_issue;
# - sunset — quarantined_since is within 14 days (older → build fails);
# - drift — every @Tag("quarantine") test in src/**/test is registered here (and vice-versa,
# a registered test should carry the tag).
#
# This file lives at the repo ROOT (not docs/, which is gitignored) so it is committed and readable
# by CI — same rationale as .trivyignore.yaml. CODEOWNERS governs merge-time approval of changes.
#
# Schema (one list entry per quarantined test):
#
# quarantined:
# - test: "dev.caskeleton.bootstrap.contract.SomeFlakyContractTest" # FQN, optionally "...#method"
# quarantined_since: "2026-06-20" # ISO date; 14-day sunset
# reason: "intermittent timeout under shared CI load — suspected fixed-port bind race"
# tracking_issue: "https://github.com/<org>/<repo>/issues/123"
#
# The skeleton ships with an EMPTY bucket: no flaky tests are quarantined.
quarantined: []
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"description": [
"feature-dependency-vulnerability-management-contract D6 / §4 — dependency SECURITY update automation.",
"Renovate is primary over Dependabot: with a gradle/libs.versions.toml version catalog plus Gradle",
"lockfiles, Dependabot does not refresh the lockfile (issue #12557), which conflicts with",
"feature-build-release-supply-chain-contract D8 (dependency locking). Dependabot stays a conditional",
"alternative for forks that standardize on it or do not use lockfiles.",
"Transitive vulnerabilities are NOT covered by either bot (direct deps only) — pin them with a Gradle",
"dependency constraint / resolutionStrategy.force (§4) and regenerate locks (supply-chain D8)."
],
"extends": [
"config:recommended",
"security:only-security-updates"
],
"vulnerabilityAlerts": {
"description": "Stable GitHub vulnerability alerts → immediate security PR, ignoring schedule.",
"enabled": true,
"labels": ["security", "dependencies"]
},
"osvVulnerabilityAlerts": true,
"packageRules": [
{
"description": "UNSUPPORTED_IMPL_DECISION (team-policy): auto-merge patch-level security updates once CI is green — lowest breaking risk. Requires trustworthy CI coverage; a fork with weak CI should set this to false.",
"matchUpdateTypes": ["patch", "pin", "digest"],
"automerge": true,
"automergeType": "pr"
},
{
"description": "minor/major security updates require human review (breaking-change risk).",
"matchUpdateTypes": ["minor", "major"],
"automerge": false
}
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"version": 1,
"skills": {
"cavecrew": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/cavecrew/SKILL.md",
"computedHash": "505d836228d1c5e14834ff5d62aad72390c7d27f79c6aa7f9a7a55ed6606d6a2"
},
"caveman": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman/SKILL.md",
"computedHash": "1902fa0b569912d0c05736d8d98a72097d9b82719aac88c0c1d03bb546f9176d"
},
"caveman-commit": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-commit/SKILL.md",
"computedHash": "790a4eeace0be35c6691faf923518ba5bd50f1f1305d1101d09dd4971be94e00"
},
"caveman-compress": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-compress/SKILL.md",
"computedHash": "1e9b3e2bf68b75dc0252c4328c8514bea79d4d8f7d7259da616ba7d12cad6865"
},
"caveman-help": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-help/SKILL.md",
"computedHash": "dd85267e76baad76995157e7b9f762dfa557cd58951ee92af0c283f48aa26537"
},
"caveman-review": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-review/SKILL.md",
"computedHash": "fb7214a1c5793bae6ba8b1be4329e2e6f40dbec6dd911dfb335ad29f09c316a1"
},
"caveman-stats": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-stats/SKILL.md",
"computedHash": "47ce2de3d6cb39a75047b5c962e4eb3da15594e7397c94103e9a104d42626553"
}
}
}
+59
View File
@@ -0,0 +1,59 @@
# feature-container-runtime-contract — .dockerignore for src/ build context
#
# The docker build context is src/ (the Gradle root). This file excludes build
# noise that must not enter the image build context, while keeping everything
# the builder stage needs to resolve dependencies and run bootJar.
# ---- Version control --------------------------------------------------------
.git
.gitignore
.gitattributes
# ---- Gradle build output ----------------------------------------------------
# Exclude all module build/ directories; the builder stage produces them inside the container.
build/
**/build/
.gradle/
**/.gradle/
# ---- IDE / editor files -----------------------------------------------------
.idea/
**/.idea/
*.iml
.vscode/
**/.vscode/
*.eclipse
.project
.classpath
.settings/
# ---- Documentation / governance (not needed for image build) ----------------
# Root CLAUDE.md and AGENTS.md are governance docs; not needed at build time.
*.md
docs/
# ---- Environment / secrets (NEVER bake secrets into image layers) -----------
.env
**/.env
*.env
# ---- OS artifacts -----------------------------------------------------------
.DS_Store
**/.DS_Store
Thumbs.db
# ---- Test reports and coverage ----------------------------------------------
**/test-results/
**/reports/
**/jacoco/
# ---- Explicitly keep (negation rules to be safe) ----------------------------
# The Gradle wrapper, source trees, and build configuration are needed.
# Negation rules are not strictly required because the above globs don't
# accidentally exclude src/*, but listed for clarity.
!gradlew
!gradlew.bat
!gradle/
!**/src/
!**/build.gradle
!settings.gradle
+149
View File
@@ -0,0 +1,149 @@
# ----------------------------------------------------------------------------
# 외부화 설정의 단일 출처. spring-dotenv 가 src/.env 에서 로드합니다
# (bootRun.workingDir = src/). 각 키의 허용값·결정 근거는 src/README.md 참조.
# ----------------------------------------------------------------------------
# ----- App identity -----
APP_NAME=ca-skeleton
SPRING_PROFILES_ACTIVE=local
# ----- Runtime safety (StartupSafetyValidator, D8) -----
APP_ERROR_DETAIL_EXPOSURE_ENABLED=false
APP_LOG_BODY_CAPTURE_ENABLED=false
APP_MULTI_INSTANCE_ENABLED=false
APP_MIGRATION_ON_STARTUP=true
APP_RATE_LIMIT_ENABLED=true
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
APP_IDEMPOTENCY_TTL=24h
# ----- Async executor -----
APP_ASYNC_EXECUTOR_CORE_SIZE=10
APP_ASYNC_EXECUTOR_MAX_SIZE=50
APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
# ----- Optional integration adapters (default: all disabled) -----
APP_CACHE_REDIS_ENABLED=false
APP_MESSAGING_BROKER=
APP_MESSAGING_KAFKA_BROKERS=
APP_NOTIFICATION_SLACK_PROVIDER=
APP_NOTIFICATION_EMAIL_PROVIDER=
# ----- Outbound HTTP client -----
APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s
APP_OUTBOUND_HTTP_READ_TIMEOUT=5s
APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s
APP_OUTBOUND_HTTP_RETRY_ENABLED=false
APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3
APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms
APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER=2.0
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED=false
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD=50
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE=100
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS=100
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE=60s
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN=10
APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT=10MB
# ----- Logging: root & app levels -----
APP_LOG_LEVEL_ROOT=INFO
APP_LOG_LEVEL_APP=DEBUG
# ----- Logging: per-package levels -----
APP_LOG_LEVEL_SPRING=INFO
APP_LOG_LEVEL_WEB=INFO
APP_LOG_LEVEL_SQL=WARN
# ----- Logging: file output + rolling -----
APP_LOG_FILE_ENABLED=false
APP_LOG_FILE_PATH=logs/ca-skeleton.json
APP_LOG_FILE_MAX_SIZE=100MB
APP_LOG_FILE_MAX_HISTORY=14
APP_LOG_FILE_TOTAL_SIZE_CAP=3GB
# ----- Logging: async appender -----
APP_LOG_ASYNC_ENABLED=true
APP_LOG_ASYNC_QUEUE_SIZE=512
APP_LOG_ASYNC_DISCARDING_THRESHOLD=20
# ----- Logging: JSON encoder -----
APP_LOG_JSON_TIMEZONE=UTC
APP_LOG_JSON_TIMESTAMP_PATTERN=yyyy-MM-dd'T'HH:mm:ss.SSSXXX
APP_LOG_JSON_INCLUDE_CALLER_DATA=false
APP_LOG_JSON_LOGGER_NAME_LENGTH=0
# ----- Logging: sampling -----
APP_LOG_SAMPLING_RATE=1.0
# ----- Distributed tracing -----
OTEL_EXPORTER_OTLP_ENDPOINT=
APP_TRACING_ENABLED=true
APP_TRACING_SAMPLE_RATE=
# ----- Privacy: user_principal pseudonymization -----
APP_PRIVACY_PSEUDONYMIZATION_SALT=__LOCAL_DEV_pseudonymization_salt
# ----- Spring Boot bootstrap -----
SPRING_BANNER_MODE=console
SPRING_MAIN_LAZY_INITIALIZATION=false
SPRING_MAIN_LOG_STARTUP_INFO=true
SPRING_THREADS_VIRTUAL_ENABLED=true
# ----- Jackson: deserialization policy -----
SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES=true
SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES=true
SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES=true
SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL=false
# ----- Jackson: serialization policy -----
SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS=false
# ----- Server / Tomcat -----
APP_SERVER_PORT=8080
APP_SERVER_SHUTDOWN=graceful
APP_SERVER_SHUTDOWN_TIMEOUT=30s
APP_SERVER_TOMCAT_MAX_THREADS=200
APP_SERVER_TOMCAT_MIN_SPARE_THREADS=10
APP_SERVER_TOMCAT_ACCEPT_COUNT=100
APP_SERVER_TOMCAT_MAX_CONNECTIONS=8192
APP_SERVER_TOMCAT_CONNECTION_TIMEOUT=20s
APP_SERVER_COMPRESSION_ENABLED=true
APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE=1024
APP_SERVER_FORWARD_HEADERS_STRATEGY=framework
APP_SERVER_ERROR_INCLUDE_STACKTRACE=never
APP_SERVER_ERROR_INCLUDE_MESSAGE=never
# ----- Presentation -----
PRESENTATION_API_BASE_PATH=/api
# ----- Auth (OIDC resource server) -----
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
SECURITY_PUBLIC_PATHS=/api/healthcheck
# ----- CORS -----
APP_SECURITY_CORS_ENABLED=true
APP_SECURITY_CORS_ORIGINS=http://localhost:3000
APP_SECURITY_CORS_ALLOWED_METHODS=
APP_SECURITY_CORS_ALLOWED_HEADERS=*
APP_SECURITY_CORS_ALLOW_CREDENTIALS=true
APP_SECURITY_CORS_MAX_AGE=3600
# ----- Database (Postgres) -----
APP_DATASOURCE_URL=jdbc:postgresql://localhost:5433/ca_skeleton
APP_DATASOURCE_USERNAME=ca_skeleton
APP_DATASOURCE_PASSWORD=ca_skeleton
APP_DATASOURCE_DRIVER=org.postgresql.Driver
APP_DATASOURCE_DDL_AUTO=update
APP_DATASOURCE_SHOW_SQL=false
APP_DATASOURCE_FORMAT_SQL=false
APP_DATASOURCE_OPEN_IN_VIEW=false
# ----- Database: HikariCP connection pool -----
APP_DATASOURCE_POOL_MAX_SIZE=10
APP_DATASOURCE_POOL_MIN_IDLE=2
APP_DATASOURCE_CONNECTION_TIMEOUT=30000
APP_DATASOURCE_POOL_IDLE_TIMEOUT=600000
APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
# ----- Management / Actuator -----
MANAGEMENT_SERVER_PORT=9001
+14
View File
@@ -0,0 +1,14 @@
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea/
*.iml
.vscode/
.project
.classpath
.settings/
# OS
.DS_Store
+139
View File
@@ -0,0 +1,139 @@
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
# =============================================================================
# feature-container-runtime-contract — multi-stage image build
#
# Build requirements:
# docker build -f src/Dockerfile src/ -t caskeleton:local \
# --build-arg RELEASE_VERSION=1.2.3 \
# --build-arg BUILD_VERSION=1.2.3+a1b2c3d4e5f6 \
# --build-arg GIT_SHA=a1b2c3d4e5f6 \
# --build-arg SOURCE_URL=https://github.com/your-org/your-repo
#
# Design decisions (feature-container-runtime-contract D2/D3/D4):
# D2 — read-only root filesystem: writable mounts must be declared explicitly.
# D3 — non-root user, JRE-only final stage (no full JDK).
# D4 — JVM ergonomics via JAVA_TOOL_OPTIONS and ExitOnOutOfMemoryError.
# =============================================================================
ARG RELEASE_VERSION
ARG BUILD_VERSION
ARG GIT_SHA
ARG SOURCE_URL
# ---- Stage 1: builder -------------------------------------------------------
# Uses the full JDK only in the build stage, never in the final image.
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
ARG RELEASE_VERSION
ARG GIT_SHA
WORKDIR /build
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
# single structure-preserving glob replaces the former per-module COPY list: new modules are
# picked up automatically and this stage never drifts out of sync with settings.gradle again.
# STRICT lock mode still rejects missing/drifted state at verifyDependencyLocks below.
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
COPY gradlew ./
COPY gradle/ gradle/
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
# custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries
# while still returning exit code 0 and therefore is not a release gate.
RUN test -n "${RELEASE_VERSION}" \
&& test -n "${GIT_SHA}" \
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# Copy full source and build the JAR
COPY . .
RUN ./gradlew :app-bootstrap:bootJar --no-daemon -x test \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# Locate the produced JAR (avoids hardcoding the version string)
RUN cp $(ls app-bootstrap/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
# ---- Stage 2: runtime image -------------------------------------------------
# JRE-only slim image (D3: no full JDK in production image).
# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant.
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
ARG BUILD_VERSION
ARG GIT_SHA
ARG SOURCE_URL
# OCI image labels (build-arg placeholders — supply at docker build time).
LABEL org.opencontainers.image.title="caskeleton" \
org.opencontainers.image.source="${SOURCE_URL}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.version="${BUILD_VERSION}"
# A release image without source/version metadata is not an artifact this contract permits.
RUN test -n "${BUILD_VERSION}" && test -n "${GIT_SHA}" && test -n "${SOURCE_URL}"
# ---- Locale / timezone (D4) -------------------------------------------------
# C.UTF-8 is available in eclipse-temurin:21-jre-jammy without installing extra packages.
# Do NOT use en_US.UTF-8 — it requires the locales package and may not exist in a slim image.
ENV TZ=UTC \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8
# ---- Writable HOME under read-only root fs (D2) -----------------------------
# The app user is created with --no-create-home and the root filesystem is
# read-only at runtime. Point $HOME at the writable /tmp tmpfs so libraries that
# write under $HOME (e.g. java.util.prefs -> ~/.java/.userPrefs, some SDK caches)
# do not fail with a read-only-filesystem error.
ENV HOME=/tmp
# ---- JVM ergonomics (D4) ----------------------------------------------------
# -XX:MaxRAMPercentage=75 — use up to 75% of the container memory limit for heap.
# -XX:+UseContainerSupport — respect cgroup memory limits (default on JDK 10+, explicit here).
# -XX:+ExitOnOutOfMemoryError — terminate immediately on OOM so the orchestrator can restart.
# -XX:+HeapDumpOnOutOfMemoryError / -XX:HeapDumpPath — write a heap dump to the writable
# /var/tmp/heap mount (see tmpfs mounts in compose files, D2).
# -Dserver.tomcat.basedir=/tmp — redirect Tomcat temp files to /tmp (D2: read-only root fs).
ENV JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=75 \
-XX:+UseContainerSupport \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/tmp/heap \
-Dserver.tomcat.basedir=/tmp"
# ---- Filesystem layout (D2: read-only root filesystem) ----------------------
# /var/tmp/heap — heap dump landing zone; must be a writable mount at runtime.
# /tmp — Tomcat working directory (see JAVA_TOOL_OPTIONS above).
# Both directories are declared here so tooling is aware of them; at runtime they
# MUST be mounted as tmpfs (or host volumes) by the orchestrator (see compose files).
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
# ---- Non-root user (D3) -----------------------------------------------------
RUN groupadd --system --gid 1000 app \
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=builder --chown=app:app /build/app.jar app.jar
USER app
# ---- Ports ------------------------------------------------------------------
# 8080 — application HTTP port
# 9001 — management / actuator port (parallel actuator branch wires this endpoint)
EXPOSE 8080 9001
# ---- Health check -----------------------------------------------------------
# Targets the actuator readiness probe on the management port (9001).
# CROSS-FEATURE COUPLING: the /actuator/health/readiness endpoint is implemented
# by the parallel runtime-health + actuator branches. The HEALTHCHECK is wired here
# (container-side) and will pass once those branches are merged. In this worktree
# the endpoint may return 404; the container will be UNHEALTHY until merged.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider \
http://localhost:9001/actuator/health/readiness || exit 1
# ---- Entrypoint -------------------------------------------------------------
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+131
View File
@@ -0,0 +1,131 @@
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
# =============================================================================
# sample-portfolio standalone demo image — twin of src/Dockerfile.
#
# Builds and runs the REFERENCE app (SamplePortfolioApplication), not the
# production composition root (CaSkeletonApplication) that src/Dockerfile builds.
# The sample is the demo-friendly entrypoint: its application.yml self-provides
# defaults for every env placeholder, so the only external dependency it needs
# to boot is a reachable PostgreSQL (datasource + Flyway sample migrations).
#
# Build (no build-args required — this is a disposable demo, not a release artifact):
# docker build -f src/Dockerfile.sample src/ -t ca-sample:local
#
# Run (point APP_DATASOURCE_URL at a reachable Postgres; localhost default shown):
# docker run --rm -p 8080:8080 -p 9001:9001 \
# -e APP_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/ca_skeleton \
# ca-sample:local
#
# The multi-stage build, --parents descriptor glob, STRICT lock verification,
# non-root user, read-only-root-fs writable mounts, and JVM container ergonomics
# are all identical to src/Dockerfile — only the bootJar target differs. Keep the
# two files' builder stages in sync.
# =============================================================================
# Demo defaults so the image builds with zero build-args. Two format constraints from the root
# build.gradle configuration guard (feature-build-release-supply-chain-contract D1/D9):
# - RELEASE_VERSION must be bare MAJOR.MINOR.PATCH — no pre-release/build suffix (build.gradle L21).
# The "-sample" marker therefore lives only on BUILD_VERSION, which is a label, not a gradle prop.
# - GIT_SHA must be 7-40 hex chars (build.gradle L35); 0000000 is the placeholder.
ARG RELEASE_VERSION=0.0.0
ARG BUILD_VERSION=0.0.0-sample
ARG GIT_SHA=0000000
ARG SOURCE_URL=https://example.invalid/ca-tmpl-sample
# ---- Stage 1: builder -------------------------------------------------------
# Uses the full JDK only in the build stage, never in the final image.
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
ARG RELEASE_VERSION
ARG GIT_SHA
WORKDIR /build
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
# single structure-preserving glob replaces a per-module COPY list: new modules are picked up
# automatically and this stage never drifts out of sync with settings.gradle.
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
COPY gradlew ./
COPY gradle/ gradle/
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either).
RUN test -n "${RELEASE_VERSION}" \
&& test -n "${GIT_SHA}" \
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# Copy full source and build the sample JAR.
COPY . .
RUN ./gradlew :sample-portfolio:bootJar --no-daemon -x test \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# Locate the produced JAR (avoids hardcoding the version string).
RUN cp $(ls sample-portfolio/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
# ---- Stage 2: runtime image -------------------------------------------------
# JRE-only slim image (no full JDK in the demo image either).
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
ARG BUILD_VERSION
ARG GIT_SHA
ARG SOURCE_URL
# OCI image labels. Unlike the release image (src/Dockerfile), the demo image does NOT hard-fail
# on missing metadata — the ARG defaults above keep it buildable with no build-args.
LABEL org.opencontainers.image.title="caskeleton-sample" \
org.opencontainers.image.description="ca-tmpl sample-portfolio reference/demo application" \
org.opencontainers.image.source="${SOURCE_URL}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.version="${BUILD_VERSION}"
# ---- Locale / timezone ------------------------------------------------------
ENV TZ=UTC \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8
# ---- Writable HOME under read-only root fs ----------------------------------
ENV HOME=/tmp
# ---- JVM ergonomics ---------------------------------------------------------
# Identical to src/Dockerfile: container-aware heap, fail-fast on OOM, heap dump to a
# writable mount, and Tomcat temp redirected to /tmp for a read-only root filesystem.
ENV JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=75 \
-XX:+UseContainerSupport \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/tmp/heap \
-Dserver.tomcat.basedir=/tmp"
# ---- Filesystem layout (read-only root filesystem) --------------------------
# At runtime /var/tmp/heap and /tmp MUST be writable mounts (tmpfs/emptyDir).
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
# ---- Non-root user ----------------------------------------------------------
RUN groupadd --system --gid 1000 app \
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=builder --chown=app:app /build/app.jar app.jar
USER app
# ---- Ports ------------------------------------------------------------------
# 8080 — application HTTP port
# 9001 — management / actuator port
EXPOSE 8080 9001
# ---- Health check -----------------------------------------------------------
# Actuator readiness probe on the management port (9001). Ignored by Kubernetes,
# which uses its own probes — kept for docker/compose parity with src/Dockerfile.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider \
http://localhost:9001/actuator/health/readiness || exit 1
# ---- Entrypoint -------------------------------------------------------------
# mainClass (SamplePortfolioApplication) is baked into the bootJar manifest.
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+428
View File
@@ -0,0 +1,428 @@
# src — 빌드 스크립트 / 환경 변수 참조
`src/` 는 Gradle 멀티모듈 루트입니다. 모듈 경계·의존 방향 규칙은 루트 [CLAUDE.md](../CLAUDE.md)
와 [AGENTS.md](../AGENTS.md), 모듈별 규칙은 각 모듈의 `CLAUDE.md` 가 SSOT 입니다.
이 문서는 [build.gradle](build.gradle) 과 [.env](.env) 의 코드 주석에서 덜어낸 **설정 항목 설명과
결정 근거**를 모아둔 참조용 기록입니다. 두 파일에는 짧은 기능 주석과 "자세한 내용은 README 참조"
포인터만 남기고, "왜 이렇게 했나"는 여기서 풀어 설명합니다.
---
## build.gradle — 빌드 / 검증 게이트
모든 모듈의 `check` 태스크는 아래 verify 게이트에 의존합니다. 빌드를 통과하려면 검사가
모두 green 이어야 합니다.
| 게이트 | 하는 일 |
| --- | --- |
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
| `verifyEnvKeys` | `env-keys.yaml``application.yml``src/.env` 가 어긋나지 않는지 검사 |
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
| `verifyReadmeCommands` | root README의 실행 가능한 Gradle/Compose/Make 명령이 실제 task/file/target과 일치하는지 검사 |
### Local bootstrap
`./gradlew bootstrap``bootstrapCompile``bootstrapDependencies`
`bootstrapMigrateAndStart``bootstrapSampleContract``bootstrapSmoke`를 순서대로 실행합니다.
DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유하며, app startup Flyway가
끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의
SSOT이고 bootstrap이 별도 env template을 만들지 않습니다.
README command drift는 다음 명령으로 독립 실행할 수 있습니다.
```bash
./gradlew verifyReadmeCommands
```
### Traceable version + dependency locking
- 모든 project version은 `<MAJOR>.<MINOR>.<PATCH>+<12자리 git sha>`입니다. base version은
`-PreleaseVersion`/`RELEASE_VERSION`, revision은 `-PgitRevision`/`GIT_SHA`/`GITHUB_SHA` 순으로
주입하고, 로컬에서는 현재 Git commit을 읽습니다.
- 모든 JAR manifest에 `Implementation-Version``Build-Revision`을 기록합니다. Git metadata도
revision property도 없는 상태는 traceable artifact를 만들 수 없으므로 configuration 단계에서
실패합니다.
- 모든 subproject가 `lockAllConfigurations()` + `LockMode.STRICT`를 사용하고, lock state는 Gradle
기본 `<module>/gradle.lockfile`에 둡니다. 이 경로는 Renovate Gradle manager 기본 인식 경로와
같습니다.
- lock 갱신은 `./gradlew resolveAndLockAll --write-locks` 한 가지 명령으로 수행합니다. 이 task는
`--write-locks`가 없으면 실패하며, 일반 build가 lock state를 조용히 다시 쓰지 못하게 합니다.
### Reproducible archives (D10)
모든 `AbstractArchiveTask`는 file timestamp 보존을 끄고, file order를 재현 가능하게 정렬하며,
directory/file mode를 각각 `0755`/`0644`로 고정합니다. Java toolchain은 21이고 로컬·CI patch
version은 root `.tool-versions`의 Temurin 값으로 맞춥니다.
두 번의 clean, no-cache `bootJar` SHA-256 비교는 다음 명령으로 실행합니다.
```bash
cd ..
bash .github/scripts/verify-reproducible-build.sh
```
이 검사는 동일 toolchain·동일 source revision 안에서 archive 재현성을 검증합니다. 서로 다른 JDK
vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 않습니다.
### `-parameters` 컴파일러 플래그
- **결정.** 모든 subproject 의 Java 컴파일에 `-parameters` 플래그를 직접 설정합니다.
- **근거.** Spring MVC 는 `@PathVariable` / `@RequestParam` 의 이름을 reflection 의 parameter
metadata 에서 읽습니다. 이 플래그가 없으면 파라미터 이름이 `arg0`, `arg1` 로 컴파일되어 바인딩이
깨집니다. Spring Boot Gradle 플러그인은 이 플래그를 자동으로 켜 주지만, 이 프로젝트는 플러그인을
`apply false` 로 두기 때문에 자동 적용이 일어나지 않습니다. 그래서 각 subproject 의 `JavaCompile`
에 직접 설정합니다.
### `verifyCleanArchitectureDependencies`
- **하는 일.** `allowedProjectDependencies` 맵에 모듈별로 허용된 의존 대상을 선언하고, 실제 Gradle
프로젝트 의존(`api` / `implementation` / `compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를
실패시킵니다.
- **이 맵이 의존 방향의 SSOT 입니다.** 새 모듈이나 새 의존 edge 를 추가하면 이 맵과 ArchUnit
규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. 모르는 코드를 검사하지 못하는 게이트는
보호 기능을 못 합니다.
### `verifyOneTypePerFile` (code-conventions I6)
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
파일 이름과 같은지 검사합니다 (Google Java Style Guide §3.4.1). `package-info.java`,
`module-info.java` 는 예외입니다.
- **근거.** 이 "파일 모양(file-shape)" 규칙은 ArchUnit 으로는 잡을 수 없습니다. ArchUnit 은 컴파일된
bytecode 를 읽기 때문에 "한 파일에 몇 개의 타입이 있었는지", "파일 이름이 무엇이었는지" 같은 소스
파일 레벨 정보를 볼 수 없습니다. 그래서 다른 `verify*` 게이트와 똑같이 기계적으로 강제하려고 소스
파일을 직접 스캔하는 별도 태스크로 만들어 `check` 에 연결했습니다.
### `verifyEnvKeys`
- **하는 일.** `docs/registries/env-keys.yaml`, `application.yml`, `src/.env` 세 곳을 lock-step(서로
어긋나지 않게) 으로 유지합니다. `env-keys.yaml``APP_` 키의 SSOT 이고, drift 가 생기면 빌드를
실패시킵니다.
- **막으려는 것 3가지.** (1) 필수 env 가 조용히 누락되는 것, (2) 더 이상 쓰지 않는 stale env 키가
`.env` 에 남는 것, (3) 실제로 쓰는 `APP_` 키가 registry 에 등록되지 않고 빠져나가는 것.
- **검사 항목.**
- **A.** `application.yml` 의 placeholder 중 inline default(`${VAR:default}`)가 없는 **필수**
placeholder(`${VAR}`)는 반드시 `.env` 에 존재해야 합니다.
- **B.** `.env` 의 모든 키는 `application.yml` 의 어떤 `${...}` placeholder 가 참조해야 합니다.
(아무도 안 쓰는 키는 orphan 으로 간주해 실패)
- **C.** `.env` 의 모든 `APP_` 키는 registry 에 `- name: <KEY>` 행이 있어야 합니다.
- **`SPRING_*` 키는 왜 registry 추적 대상이 아닌가.** `SPRING_*` 는 Spring Boot 가 정의한 native 키라
프로젝트가 소유한 계약이 아닙니다. 그래서 C 검사는 일부러 `APP_` prefix 로만 범위를 좁혔습니다.
- **비고.** `src/.env` 가 프로젝트의 커밋된 env 파일이며, 별도의 `.env.example` 템플릿은 두지
않습니다.
### `verifyPublicPathSnapshot`
- **하는 일.** deny-by-default public path 표면이 승인 없이 바뀌면 빌드를 실패시킵니다.
- **배경.** 이 앱은 deny-by-default 입니다. 즉 `SECURITY_PUBLIC_PATHS` 가 먹이는 명시적 `permitAll()`
경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env``SecuritySettings.publicPaths()`
`SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange`
snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후
반영합니다.
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
`RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()`
을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. `docs/` 는 gitignore 대상이라, fresh
checkout 에서는 snapshot 이 없으므로 "처음엔 만들고 통과"한 뒤 이후 변경부터 감시합니다.
### `verifyTrivyignore`
- **하는 일.** repo 루트 `.trivyignore.yaml` 의 모든 Trivy suppression 항목이 (1) `id`, (2) 비어있지
않은 `statement`(사유), (3) 미래이면서 90일 이내인 `expired_at`(만료일) 을 갖추었는지 검사하고,
하나라도 빠지거나 이미 만료됐거나 90일을 초과하면 `./gradlew check` 를 실패시킵니다.
- **막으려는 것.** 2026-05-25 ca-tmpl audit 에서 발견된 "만료일·사유 없는 suppression 을 추가해
취약점을 영구히 조용히 우회"하는 구멍입니다. Trivy 는 `expired_at` 이 없으면 **영구 유효**로
취급하므로(공식 문서), 만료일 누락 자체를 차단해야 합니다.
- **두 겹의 보완 통제.** 이 게이트는 *필드 검증*(CI), `.github/CODEOWNERS`*merge 승인*(GitHub
네이티브)을 담당합니다. CODEOWNERS 는 "누가 파일을 바꿀 수 있는가"만, 이 게이트는 "필드가 갖춰졌는가"
만 잡으므로 둘은 대체재가 아니라 보완재입니다.
- **결정 — 90일 상한 (프로젝트 선택).** Trivy 문서는 `expired_at` 필드의 *존재*만 보장하고
기간 상한은 권고하지 않습니다. 짧으면 재검토 부담이 늘고, 길면 사실상 영구 ignore 가 되는
trade-off 에서 90일을 기본값으로 두었습니다. fork 는 `src/build.gradle``maxWindowDays`
조정합니다.
- **위치.** suppression 파일은 `docs/` 가 아니라 repo 루트(`.trivyignore.yaml`)에 둡니다 — Trivy 가
스캔 루트에서 자동으로 읽는 커밋 대상 파일이기 때문입니다. 정책 전문(severity·KEV·license·SLA)은
`.github/dependency-vulnerability-policy.md`, CI 배선은 `.github/workflows/dependency-vulnerability.yml`
에 있습니다.
### `verifyQuarantineSunset` + 플래키 격리
- **하는 일.** 플래키(간헐 실패) 테스트는 JUnit 기본 `@Tag("quarantine")` 를 붙여 격리합니다. 메인
`test` 태스크는 `excludeTags 'quarantine'` 로 이들을 **릴리스 게이트에서 제외**하므로 플래키 테스트가
merge 를 막지 않습니다. 격리된 테스트는 별도 `./gradlew quarantineTest`(비차단, `ignoreFailures`)로만
돕니다.
- **막으려는 것.** 격리가 *영구 주차장* 이 되는 것. `verifyQuarantineSunset`(루트 태스크, `check`
연결)이 매 빌드마다 (1) 레지스트리 스키마(`test`/`quarantined_since`/`reason`/`tracking_issue`),
(2) **14일 sunset**(`quarantined_since` 가 14일을 넘으면 빌드 실패), (3) **drift**(소스에
`@Tag("quarantine")` 가 달렸는데 레지스트리에 없으면 실패)를 검사합니다.
- **결정 — 14일 sunset (프로젝트 선택).** Spotify/Google/MS 사례는 격리 버킷의 정당성만
보이고(Fowler 는 반대), 14일이라는 정량값·자동 강제는 ca-tmpl 절충안입니다(`company-case-study`
강도 — 공식 best practice 아님). fork 는 `src/build.gradle``sunsetDays` 로 조정합니다.
- **위치.** 레지스트리는 `docs/`(gitignore) 가 아니라 repo 루트 `flaky-quarantine.yaml` 에 둡니다 —
CI 가 읽어야 하는 커밋 대상 파일이기 때문입니다(`.trivyignore.yaml` 과 같은 이유). 스켈레톤은 빈
버킷(`quarantined: []`)으로 출고됩니다.
### CI 게이트 배선
- **소유 범위.** 이 계약은 *게이트 배선*(어떤 게이트가 CI 에서 돌고 실패 시 어떻게 릴리스를 막는가)을
소유합니다. 개별 scanner/tool/severity *정책* 은 owner 브랜치가 소유하며, 그 20행 매핑의 in-repo
SSOT 가 `.github/ci-gate-matrix.yml` 입니다. `.github/scripts/verify-gate-matrix.sh`(`gate-matrix-lint`
잡)가 표 ↔ 실제 task/test/job 정합을 매 PR 마다 cross-check 합니다.
- **워크플로.** `.github/workflows/ci-quality-gates.yml``release-gate` 잡이 모든 release-blocking
게이트의 fan-in(단일 required status check)입니다. 플래키 `quarantine` 잡은 의도적으로 `needs` 에서
제외(비차단)됩니다. 위임 게이트(Trivy SCA/이미지 스캔)는 `dependency-vulnerability.yml` 가 소유하며,
GitHub Actions 는 워크플로 간 `needs` 를 못 쓰므로 branch protection 의 required check 합집합으로
묶습니다.
---
## .env — 환경 변수 레퍼런스
`spring-dotenv``src/.env` 를 읽어 외부화 설정을 주입합니다(`bootRun` 의 working dir 가 `src/`
이 파일이 잡힙니다). 아래는 섹션별 키 설명입니다. 따로 표기가 없으면 `restart-only`(값 변경 시 재기동
필요)로 간주하세요.
### App identity
- **`APP_NAME`** — `spring.application.name` 과 JSON 로그의 `app` 필드. 자유 문자열.
- **`SPRING_PROFILES_ACTIVE`** — 활성 Spring profile. 보통 `local` | `dev` | `stage` | `prod`. JSON
로그의 `profile` 필드도 이 값을 씁니다.
### Runtime safety (기동 시 `StartupSafetyValidator` 가 fail-fast 검사, D8)
- **`APP_ERROR_DETAIL_EXPOSURE_ENABLED`** — 응답에 내부 에러 상세를 노출할지. `true` | `false`.
**`prod` 프로필에서는 반드시 `false`** 여야 하며, 아니면 기동이 실패합니다.
- **`APP_LOG_BODY_CAPTURE_ENABLED`** — 요청/응답 body 를 로그에 캡처할지. `true` | `false`.
**`prod` 에서는 반드시 `false`**, 아니면 기동 실패.
- **`APP_MULTI_INSTANCE_ENABLED`** — `true` 면 인스턴스 협조용 빈 5종(lock / cache-stampede /
leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다.
- **`APP_RATE_LIMIT_ENABLED`** — fixed-window rate-limit interceptor 활성화
(429 + `Retry-After` + `X-RateLimit-*` 응답).
- **`APP_RATE_LIMIT_CLIENT_IP_MODE`** — 클라이언트 IP 판별 방식. `remote-addr-only` |
`forwarded-headers-trusted`. **신뢰된 ingress/LB 가 `X-Forwarded-For` 를 앱 도달 전에 덮어쓸 때만**
`forwarded-headers-trusted` 를 쓰세요. 아니면 IP 위조에 노출됩니다.
- **`APP_IDEMPOTENCY_TTL`** — idempotency 레코드 기본 TTL. duration(예: `24h`, `72h`). 오래 도는 use
case 는 최대 72h 까지 override 가능. (D6)
### Async executor
`@Async` `ThreadPoolTaskExecutor` 풀 크기 설정입니다.
- **`APP_ASYNC_EXECUTOR_CORE_SIZE`** — 항상 살아있는 워커 수. 1 이상 정수.
- **`APP_ASYNC_EXECUTOR_MAX_SIZE`** — 워커 수 상한. core-size 이상.
- **`APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`** — 백로그 큐 용량. **bounded(유한) 필수, unbounded 금지(D7)**.
1 이상 정수.
### Optional integration adapters
선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. **기본은 전부 비활성**(비활성 = 선택
모듈의 기본값). Layer 1 의 `@ConditionalOnProperty` 가 enabled 일 때만 실제 어댑터를 등록하고, 아니면
fail-fast sentinel 이 포트를 충족합니다(Layer 3).
- **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`.
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시
fail-fast).
- **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수,
아니면 빈 값.
- **`APP_NOTIFICATION_SLACK_PROVIDER`** — 활성 Slack provider id(예: `webhook`). 빈 값 = Slack 비활성.
- **`APP_NOTIFICATION_EMAIL_PROVIDER`** — 활성 email provider id(예: `google-email`). 빈 값 = email
비활성.
### Outbound HTTP client
- **결정 — timeout 은 필수(D5).** timeout 미설정 또는 무한 timeout 은 금지이며, 기동 시 0 이 아닌 값을
강제합니다. 무한 timeout 은 네트워크 호출이 영원히 매달릴 수 있어 런타임 장애가 아니라 설정 실수로
보고 즉시 기동을 실패시킵니다.
- **`APP_OUTBOUND_HTTP_CONNECT_TIMEOUT`** — TCP connect timeout. duration(예: `2s`), 필수, non-zero.
- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero.
- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예:
`10s`), 필수, non-zero.
- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이
있어야 하며(D3 가드), 없으면 기동 실패.
- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존):
- **`..._RETRY_MAX_ATTEMPTS`** — 총 시도 횟수(최초 시도 포함). 1 이상 정수.
- **`..._RETRY_INITIAL_BACKOFF`** — exponential backoff 시작 간격. duration, non-zero.
- **`..._RETRY_BACKOFF_MULTIPLIER`** — backoff 배수. 1.0 이상 double.
- **`APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED`** — circuit breaker on/off. `true` 로 켜면
`MeterRegistry` 빈 필요(D3), 없으면 기동 실패.
- circuit breaker 튜닝(아래는 `circuit-breaker-enabled=true` 일 때 적용, 기본값은 Resilience4j
`ofDefaults()`):
- **`..._FAILURE_RATE_THRESHOLD`** — open 으로 전환되는 실패율 임계치(%). (0, 100] 범위 float.
- **`..._SLIDING_WINDOW_SIZE`** — COUNT_BASED sliding window 크기. 1 이상 정수.
- **`..._MINIMUM_NUMBER_OF_CALLS`** — 실패율 계산을 시작하는 최소 호출 수. 1 이상 정수.
- **`..._WAIT_DURATION_IN_OPEN_STATE`** — open 상태 유지 시간. duration, non-zero.
- **`..._PERMITTED_CALLS_IN_HALF_OPEN`** — half-open 에서 허용하는 시험 호출 수. 1 이상 정수.
- **`APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT`** — 메모리에 받는 응답 본문 최대 크기(예: `10MB`). 이를
넘는 응답은 streaming API 를 써야 합니다(D7).
### Logging
**Root / app 레벨** — 허용값은 모두 `TRACE` | `DEBUG` | `INFO` | `WARN` | `ERROR` | `OFF`.
- **`APP_LOG_LEVEL_ROOT`** — root 로거 레벨.
- **`APP_LOG_LEVEL_APP`** — 앱 패키지 레벨.
**패키지별 레벨**(root 를 덮어씀) — 동일 허용값.
- **`APP_LOG_LEVEL_SPRING`** / **`APP_LOG_LEVEL_WEB`** — 각 패키지 레벨.
- **`APP_LOG_LEVEL_SQL`** — `DEBUG` 로 두면 JPA/jdbc 연결 후 SQL 문이 출력됩니다.
**파일 출력 + rolling**
- **`APP_LOG_FILE_ENABLED`** — `true` 면 rolling JSON 파일 appender 를 붙입니다.
- **`APP_LOG_FILE_PATH`** — `bootRun` working dir(`src/`) 기준 상대 경로 또는 절대 경로.
- **`APP_LOG_FILE_MAX_SIZE`** — 파일 1개 최대 크기(단위 `KB` | `MB` | `GB`).
- **`APP_LOG_FILE_MAX_HISTORY`** — 보관할 rolled archive 개수. 1 이상 정수.
- **`APP_LOG_FILE_TOTAL_SIZE_CAP`** — 전체 rolled 파일 용량 상한(단위 `KB` | `MB` | `GB`, `0` = 비활성).
**Async appender**
- **`APP_LOG_ASYNC_ENABLED`** — `true` 면 appender 를 `AsyncAppender` 로 감싸 non-blocking I/O.
- **`APP_LOG_ASYNC_QUEUE_SIZE`** — back-pressure 전 in-memory 큐 깊이. 1 이상 정수.
- **`APP_LOG_ASYNC_DISCARDING_THRESHOLD`** — 남은 큐 용량이 이 값 미만이면 `TRACE`/`DEBUG`/`INFO`
이벤트를 버립니다(`WARN`/`ERROR` 는 항상 유지). `0` = 절대 버리지 않음. 0 이상 정수.
**JSON 인코더 세부**
- **`APP_LOG_JSON_TIMEZONE`** — IANA timezone(예: `UTC`, `Asia/Seoul`) 또는 `default`(JVM 기본).
- **`APP_LOG_JSON_TIMESTAMP_PATTERN`** — 타임스탬프 패턴. 보통 ISO 8601
(`yyyy-MM-dd'T'HH:mm:ss.SSSXXX`).
- **`APP_LOG_JSON_INCLUDE_CALLER_DATA`** — `true` 면 file/method/line 을 추가. **성능 비용이 큽니다.**
- **`APP_LOG_JSON_LOGGER_NAME_LENGTH`** — `0` = 로거 이름 전체, 양수 = 패키지 축약(예: `36`
`dev.caskeleton.bootstrap.Foo``d.c.bootstrap.Foo` 로).
**Sampling (`SamplingTurboFilter`)**
- **`APP_LOG_SAMPLING_RATE`** — `INFO` 이하 로그를 남길 확률. [0.0, 1.0] float. prod 는 `0.1`(10%
샘플링)이 권장, `WARN`/`ERROR` 는 항상 유지. `1.0` = 샘플링 없음(dev/local/staging 기본).
### Distributed tracing
- **`OTEL_EXPORTER_OTLP_ENDPOINT`** — OTLP exporter endpoint. 빈 값 = exporter off(스켈레톤에서 OTel
SEAM 미활성). 값이 있으면 유효한 URL 이어야 하며 기동 시 `TracingProperties` 가 검증합니다.
- **`APP_TRACING_ENABLED`** — `false` 면 tracing seam 은 꺼지지만, `meta.traceId`
`RequestLoggingFilter` 가 W3C `traceparent` 로 여전히 생성합니다(D4 disabled-fallback 보장).
- **`APP_TRACING_SAMPLE_RATE`** — per-profile 기본값을 덮어쓰는 샘플 비율. [0.0, 1.0] float.
- per-profile 기본값(D6): prod = `0.01`, staging = `0.10`, dev/local = `1.0`.
- **결정 — 빈 값으로 두는 이유(D-1 ISSUE-1 fix).** 빈 값이어야 per-profile resolver 의 기본값이
실제 tracer sampler 까지 도달합니다(`TracingSampleRateResolver` 가 SSOT). 값을 박으면 프로필별
기본값이 무시되므로, 특정 비율을 강제하고 싶을 때만 채웁니다.
### Privacy: user_principal pseudonymization
- **`APP_PRIVACY_PSEUDONYMIZATION_SALT`** — secret 등급 HMAC-SHA-256 salt. `__LOCAL_DEV_` prefix 는 로컬 전용 sentinel 값이며,
**prod 에서는 secret-manager 가 주입하는 실제 값**을 써야 합니다.
### Spring Boot bootstrap
- **`SPRING_BANNER_MODE`** — `off` | `console` | `log`.
- **`SPRING_MAIN_LAZY_INITIALIZATION`** — `true` 면 빈 생성을 첫 사용 시점까지 지연.
- **`SPRING_MAIN_LOG_STARTUP_INFO`** — `true``Starting`/`Started` 로그 출력.
- **`SPRING_THREADS_VIRTUAL_ENABLED`** — `true` 면 Tomcat 요청 처리에 Java 21 virtual threads 사용.
### Jackson — deserialization policy
모든 request DTO 는 Jackson 경계를 지납니다. 아래 4개 스위치는 잘못된 입력을 **조용히 강제 변환하지
않고 즉시 실패**하게 만듭니다. 개별 DTO 에 클래스 레벨 `@JsonIgnoreProperties(ignoreUnknown = true)`
로 이 정책을 완화하는 것은 **금지**이며 ArchUnit 규칙으로 막혀 있습니다.
- **`..._FAIL_ON_UNKNOWN_PROPERTIES`** — `true`: 타입에 선언되지 않은 JSON 키를 거부(Jackson 2.13+
기본).
- **`..._FAIL_ON_NULL_FOR_PRIMITIVES`** — `true`: primitive 필드에 JSON `null` 이 와도 `0`/`false`
로 강제 변환하지 않고 "필수 필드 누락" 에러로 노출. 또는 wrapper 타입(`Integer`/`Long`/`Boolean`)
`Optional<T>` 를 쓰세요.
- **`..._FAIL_ON_IGNORED_PROPERTIES`** — `true`: JSON 에 `@JsonIgnore` 처리된 필드가 들어오면 throw
(조용히 버리는 대신 계약 drift 를 노출).
- **`..._READ_UNKNOWN_ENUM_VALUES_AS_NULL`** — `false`(Jackson 기본 유지): 모르는 enum 값이 조용히
`null` 이 되지 않고 throw 되어 `VALIDATION_FAILED` 로 드러나게 합니다.
### Jackson — serialization policy
응답 생성 쪽 정책입니다. 현재 Jackson/Spring Boot 기본값과 같지만 **명시적으로 못박아**, 미래에 Spring
Boot 기본값이 바뀌어도 wire 계약이 조용히 깨지지 않게 합니다(`spring.mvc.problemdetails.enabled=false`
와 같은 근거). `JacksonSerializationPolicyTest` 가 강제하며, `new BigDecimal(double)` 생성자는
`no_bigdecimal_double_constructor` ArchUnit 규칙으로 금지됩니다.
- **`..._WRITE_DATES_AS_TIMESTAMPS`** — `false`(D2 / RFC 3339): `java.time` 값을 ISO-8601 문자열로
직렬화(`OffsetDateTime``"...Z"`, `LocalDate``"YYYY-MM-DD"`). `true` 면 epoch 숫자나
`[y,m,d,...]` 배열로 나가 datetime 계약이 깨집니다.
- **BigDecimal plain output** — Jackson 3에는 별도 `WRITE_BIGDECIMAL_AS_PLAIN` 설정 키가 없습니다.
`JacksonSerializationPolicyTest``"12300000000.00"` plain 출력을 직접 검증하고,
`no_bigdecimal_double_constructor` ArchUnit 규칙이 부정확한 `new BigDecimal(double)` 생성을
금지합니다. 엔드포인트별 string vs number 선택은 그대로 명시적으로 둡니다(공개/금융 API 는 string,
내부 API 는 number+plain).
### Server / Tomcat
- **`APP_SERVER_PORT`** — 1~65535 정수.
- **`APP_SERVER_SHUTDOWN`** — `graceful` | `immediate`.
- **`APP_SERVER_SHUTDOWN_TIMEOUT`** — duration(예: `30s` | `1m` | `500ms`).
- **`APP_SERVER_TOMCAT_MAX_THREADS`** — 동시 요청 워커 상한. 1 이상 정수.
- **`APP_SERVER_TOMCAT_MIN_SPARE_THREADS`** — idle 워커 풀 하한. 0 이상 정수.
- **`APP_SERVER_TOMCAT_ACCEPT_COUNT`** — 들어오는 TCP 연결의 OS backlog 큐 깊이. 0 이상 정수.
- **`APP_SERVER_TOMCAT_MAX_CONNECTIONS`** — 동시에 열 수 있는 연결 수 상한. 1 이상 정수.
- **`APP_SERVER_TOMCAT_CONNECTION_TIMEOUT`** — duration(예: `20s` | `1m`).
- **`APP_SERVER_COMPRESSION_ENABLED`** — `true` | `false`.
- **`APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE`** — 이 크기 미만 payload 는 압축하지 않음(bytes 또는
단위, 예: `1024` | `1KB` | `2KB`).
- **`APP_SERVER_FORWARD_HEADERS_STRATEGY`** — `none` | `native` | `framework`. LB/proxy 뒤에서
`X-Forwarded-*` 를 신뢰할지.
- **`APP_SERVER_ERROR_INCLUDE_STACKTRACE`** — `always` | `never` | `on_param`.
- **`APP_SERVER_ERROR_INCLUDE_MESSAGE`** — `always` | `never` | `on_param`.
### Presentation
- **`PRESENTATION_API_BASE_PATH`** — 모든 controller 앞에 붙는 leading-slash 경로(예: `/api` | `/v1` |
`""`).
### Auth (OIDC resource server)
- **`APP_SECURITY_JWT_ISSUER`** — OIDC issuer URI(Keycloak realm, Auth0 tenant 등). **필수** — 없으면
기동 실패. 예: `https://keycloak.example.com/realms/ca-skeleton`.
- **`APP_SECURITY_JWT_AUDIENCE`** — 기대하는 `aud` claim. 빈 값으로 두면 audience 검증을 건너뜁니다.
- **`SECURITY_PUBLIC_PATHS`** — 인증을 우회하는 경로 CSV. `api-base-path` 뒤의 full path 를 씁니다(예:
`/api/healthcheck`). 이 값의 변경은 `verifyPublicPathSnapshot` 게이트가 감시합니다(위 build.gradle
설명 참조).
### CORS
- **`APP_SECURITY_CORS_ENABLED`** — `true` | `false`.
- **`APP_SECURITY_CORS_ORIGINS`** — 허용 origin CSV(예:
`http://localhost:3000,https://app.example.com`).
- **`APP_SECURITY_CORS_ALLOWED_METHODS`** — 허용 메서드 CSV. 빈 값이면 기본값 사용(GET, POST, PATCH,
PUT, DELETE, OPTIONS).
- **`APP_SECURITY_CORS_ALLOWED_HEADERS`** — 허용 헤더 CSV. `*` = 모든 헤더 허용.
- **`APP_SECURITY_CORS_ALLOW_CREDENTIALS`** — `true` | `false`.
- **`APP_SECURITY_CORS_MAX_AGE`** — preflight 캐시 TTL(초).
### Database (Postgres)
- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`).
- **`APP_DATASOURCE_USERNAME`** / **`APP_DATASOURCE_PASSWORD`** — DB 접속 계정.
- **`APP_DATASOURCE_DRIVER`** — Hibernate dialect 에 맞는 드라이버(예: `org.postgresql.Driver`).
- **`APP_DATASOURCE_DDL_AUTO`** — `none` | `validate` | `update` | `create` | `create-drop`. **prod
`validate` 또는 `none`**, local 은 `update` 가 편리합니다.
- **`APP_DATASOURCE_SHOW_SQL`** — `true` 면 SQL 을 로그로 echo.
- **`APP_DATASOURCE_FORMAT_SQL`** — SQL pretty-print(`SHOW_SQL=true` 일 때만 의미 있음).
- **`APP_DATASOURCE_OPEN_IN_VIEW`** — Hibernate OSIV. **prod 에서는 피하세요.**
**HikariCP 커넥션 풀**
- **`APP_DATASOURCE_POOL_MAX_SIZE`** — DB 동시 연결 최대 수. 1 이상 정수.
- **`APP_DATASOURCE_POOL_MIN_IDLE`** — warm 하게 유지하는 최소 idle 연결 수. 0 이상 정수.
- **`APP_DATASOURCE_CONNECTION_TIMEOUT`** — `acquire()` 가 실패하기 전 대기 시간(ms).
- **`APP_DATASOURCE_POOL_IDLE_TIMEOUT`** — idle 연결 회수 임계 시간(ms).
- **`APP_DATASOURCE_POOL_MAX_LIFETIME`** — 연결의 최대 수명(ms). broker timeout 전에 rotate 하도록
설정합니다.
### Management / Actuator
- **결정 — management 포트 분리.** actuator 엔드포인트를 앱 API 와 **같은 소켓에 노출하지 않으려고**
별도 management 포트를 둡니다.
- **`MANAGEMENT_SERVER_PORT`** — 1~65535 정수. **`APP_SERVER_PORT`(8080)와 달라야 합니다.**
+60
View File
@@ -0,0 +1,60 @@
# adapter:inbound:graphql — inbound GraphQL adapter (skeleton machinery)
## Registered identity
- Module ID: `adapter-inbound-graphql`
- Gradle path: `:adapter:inbound:graphql`
- Focused test: `./gradlew :adapter:inbound:graphql:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `.harness/project/modules.yaml`.
Package root: `dev.caskeleton.adapter.inbound.graphql`.
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
규칙 SSOT).
## Responsibility
- GraphQL 전송 인프라만: 최소 health 스키마(`skeleton.graphqls`) + `HealthGraphqlController`,
프로토콜 에러 매핑(`GraphqlExceptionResolver`). Spring for GraphQL 이 스키마와 컨트롤러를
자동 합성/바인딩하도록 얹는 얇은 계층이다.
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract`.
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
## Forbidden
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`, 일반 `..adapter.inbound..` 규칙이 이
모듈을 자동 커버 — per-module 규칙 추가 불필요).
- 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두는 것 — health 표면만 (web 의
`HealthcheckController` 와 동일 원칙). feature 스키마/컨트롤러/매퍼는 sample 모듈이 소유한다.
- 모듈별 `yml` — 설정은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에 산다.
## Error mapping (`Category → ErrorType`)
feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)를 던지면
`GraphqlExceptionResolver``GraphQLError`(ErrorType + `extensions{code, category}`)로 매핑한다.
비-`ApiErrorCarrier` 예외는 `null` 반환 → 다른 resolver / Spring 기본 처리. 표는 [README.md](README.md).
## Feature 기여 방법
- **스키마**: `src/main/resources/graphql/*.graphqls` 를 두면 `classpath:graphql/**` 병합으로 합쳐진다.
- **핸들러**: `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동 바인딩된다.
- **도메인 예외 매핑**: sample 이 자신의 `DataFetcherExceptionResolver` 를 추가해 도메인 예외를
`PortfolioErrorCode` 로 매핑한다(스켈레톤 resolver 보다 앞 순서). 스켈레톤은 `ApiErrorCarrier` 만 처리.
`sample-portfolio` 를 지워도 스켈레톤은 health 스키마만으로 부팅한다 (disposability).
## Test
```bash
cd src
./gradlew :adapter:inbound:graphql:test
```
+76
View File
@@ -0,0 +1,76 @@
# adapter-graphql — 설계 결정 참조
인바운드 GraphQL 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트:
`dev.caskeleton.adapter.inbound.graphql`.
허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
모아둔 참조용 기록이다.
---
## 왜 스켈레톤에 health 스키마 + 컨트롤러만 두는가
Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하므로 스켈레톤은
`src/main/resources/graphql/skeleton.graphqls` 에 최소 스키마(`type Query { _health: String! }`)를
싣고, `HealthGraphqlController` 가 그 필드를 상태 토큰(`UP`)으로 resolve 한다. web 어댑터의
`HealthcheckController` 와 동일 원칙 — 스켈레톤은 **RPC/쿼리 0개**의 feature 로도 health 표면만으로
부팅한다. 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두지 않는다.
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** Spring for GraphQL 이 두 축으로 자동 합성한다:
- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의
`worklog.graphqls` 가 스켈레톤의 `skeleton.graphqls` 와 자동으로 합쳐진다.
- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller``@QueryMapping`/`@MutationMapping`
메서드를 바인딩한다. sample 의 `WorkLogGraphqlController` 가 스켈레톤을 수정하지 않고 등록된다.
`sample-portfolio` 를 지우면 스켈레톤은 여전히 health 스키마만으로 부팅한다(web 과 동일한
disposability 보장).
## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제
`GraphqlExceptionResolver``DataFetcherExceptionResolverAdapter` 를 확장해, 데이터 페처가
동기적으로 던진 예외 중 안정적 `ApiErrorCode` 를 실은 것(전송-중립 hook `ApiErrorCarrier` 구현)을
`GraphQLError` 로 변환한다. 데이터 페처는 web 컨트롤러처럼 "그냥 던지기만" 하고, 이 resolver 가
와이어 계약을 단일 소유한다.
- **`ErrorType` 분류**: `errorCode().category()` 를 GraphQL `ErrorType` 으로 매핑한다(아래 표).
정확한 `code`/`category` 는 error `extensions{code, category}` 로 실어 클라이언트가 switch 하게
한다(gRPC 가 status trailer 에 싣는 것과 동형).
- **`ApiErrorCode` 추출**: shared-contract 의 `PersistenceFailureException` /
`DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)과 feature 예외(자신의 도메인
`ApiErrorCode` 를 실은 것)를 단일 `instanceof ApiErrorCarrier` 분기로 인식한다.
- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 error message/extensions 로 노출하고, raw
예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다.
- **비-`ApiErrorCarrier`** 예외는 `null` 을 반환해 다른
`DataFetcherExceptionResolver` 빈(예: sample 의 도메인 예외 resolver)과 Spring 기본 처리로
넘긴다.
`Category → ErrorType` 표(설계 스펙 Error Mapping SSOT):
| `Category` | GraphQL `ErrorType` |
|---|---|
| VALIDATION | BAD_REQUEST |
| AUTH | UNAUTHORIZED |
| AUTHZ | FORBIDDEN |
| NOT_FOUND | NOT_FOUND |
| CONFLICT | BAD_REQUEST |
| RATE_LIMIT | BAD_REQUEST |
| TRANSIENT_DEPENDENCY | INTERNAL_ERROR |
| PERMANENT_DEPENDENCY | INTERNAL_ERROR |
| DATA_INTEGRITY | INTERNAL_ERROR |
| INTERNAL | INTERNAL_ERROR |
## 의존성 버전 — strict locking
gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한다. 그래서 이 모듈은
버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만
선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다.
## 설정 — 프레임워크 `spring.graphql.*`
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
location 은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에서 설정한다
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
+28
View File
@@ -0,0 +1,28 @@
// Driving adapter: GraphQL API (skeleton machinery, transport-only).
//
// Spring for GraphQL is schema-first: schema files live in src/main/resources/graphql/*.graphqls
// and are merged from classpath:graphql/** at boot. This skeleton ships ONLY the minimal health
// schema + @Controller so the module boots standalone with zero features (an empty schema fails to
// start); feature schema/controllers live in the sample module and compose automatically.
//
// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit
// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc
// coordinates the BOM does not manage).
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
dependencies {
implementation project(':application-core')
implementation project(':domain-core')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
// controller through a real AnnotatedControllerConfigurer and drives it with an
// ExecutionGraphQlServiceTester.
testImplementation 'org.springframework.boot:spring-boot-starter-graphql-test'
}
+171
View File
@@ -0,0 +1,171 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:context-propagation:1.2.0=runtimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-codec:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.graphql;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import java.util.Map;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.stereotype.Component;
/**
* Centralises the GraphQL error contract: a data fetcher just throws, and this resolver translates
* any throwable carrying a stable {@link ApiErrorCode} (via the shared-contract {@link
* ApiErrorCarrier} hook) into a {@link GraphQLError} with an {@link ErrorType} classification plus
* machine-readable {@code code} / {@code category} extensions — the GraphQL sibling of the web
* adapter's {@code GlobalExceptionHandler} and the gRPC adapter's {@code
* GrpcExceptionHandlingInterceptor}.
*
* <p>The {@link ApiErrorCarrier} hook is implemented by the shared-contract {@code
* PersistenceFailureException} / {@code DependencyFailureException} (an error surfacing from an
* outbound adapter) and by feature throwables (which carry a mapped domain {@code ApiErrorCode}),
* so a single {@code instanceof ApiErrorCarrier} branch covers them all. A non-carrier throwable
* returns {@code null}: Spring for GraphQL then merges the other {@link
* org.springframework.graphql.execution.DataFetcherExceptionResolver} beans (e.g. a feature's own
* resolver mapping its domain exceptions) and finally its default handling. Only the stable {@link
* ApiErrorCode#code()} reaches the client — never the raw exception message, which may carry a
* SQLState or upstream detail.
*/
@Component
public class GraphqlExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
if (!(ex instanceof ApiErrorCarrier carrier)) {
return null; // fall through to other resolvers / Spring's default handling
}
ApiErrorCode code = carrier.errorCode();
var builder =
GraphqlErrorBuilder.newError()
.errorType(classify(code.category()))
.message(code.code())
.extensions(Map.of("code", code.code(), "category", code.category().name()));
// A real GraphQL execution always supplies the environment; a unit test may pass null. Only
// attach the field path/location when they are present.
if (env != null) {
builder.path(env.getExecutionStepInfo().getPath());
if (env.getField() != null) {
builder.location(env.getField().getSourceLocation());
}
}
return builder.build();
}
/**
* Maps the 10-value operational {@link Category} SSOT to a GraphQL {@link ErrorType} (design
* Error-Mapping table). The switch is exhaustive, so a new {@link Category} fails to compile
* until a mapping decision is made.
*/
private static ErrorType classify(Category category) {
return switch (category) {
case VALIDATION, CONFLICT, RATE_LIMIT -> ErrorType.BAD_REQUEST;
case AUTH -> ErrorType.UNAUTHORIZED;
case AUTHZ -> ErrorType.FORBIDDEN;
case NOT_FOUND -> ErrorType.NOT_FOUND;
case TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL ->
ErrorType.INTERNAL_ERROR;
};
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.inbound.graphql;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
/**
* Minimal GraphQL health surface so the skeleton module boots standalone with zero features — the
* GraphQL sibling of the web adapter's {@code HealthcheckController}. The {@code _health} query
* resolves the {@code skeleton.graphqls} field of the same name to a fixed liveness token. Feature
* queries/mutations are contributed by the sample module's own {@code @Controller} beans and merged
* by Spring for GraphQL; this controller never names a feature type.
*/
@Controller
public class HealthGraphqlController {
/** Stable liveness token, matching the web adapter's {@code status=UP} health semantics. */
static final String STATUS_UP = "UP";
// The schema field is `_health` (a conventional meta-field name); the Java method is `health` so
// it satisfies the method-name checkstyle rule, with the field bound explicitly via `name`.
@QueryMapping(name = "_health")
public String health() {
return STATUS_UP;
}
}
@@ -0,0 +1,10 @@
# Minimal GraphQL schema for the skeleton machinery module (schema-first).
#
# Spring for GraphQL merges every classpath:graphql/**/*.graphqls file at boot, so this health
# schema composes automatically with any feature schema the sample module contributes. It exists so
# the module boots standalone with zero features: Spring for GraphQL refuses to start on an empty
# schema, and the skeleton must never name a feature type (mirrors web's HealthcheckController).
type Query {
"Liveness token for the GraphQL transport — mirrors the web adapter's /healthcheck."
_health: String!
}
@@ -0,0 +1,102 @@
package dev.caskeleton.adapter.inbound.graphql;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
import graphql.GraphQLError;
import java.util.EnumSet;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.graphql.execution.ErrorType;
/**
* Pins the 10-value {@link Category} → {@link ErrorType} classification table (design
* Error-Mapping) and the {@code code} / {@code category} extensions. One assertion per Category
* value guards against a silent remap on a Spring/graphql-java upgrade. Driven directly against
* {@code resolveToSingleError} with a null environment (no GraphQL engine needed), so it is a pure
* mapping unit test — the boot-level wiring is covered by {@link HealthGraphqlControllerTest}.
*/
class GraphqlExceptionResolverTest {
private final GraphqlExceptionResolver resolver = new GraphqlExceptionResolver();
@ParameterizedTest
@CsvSource({
"VALIDATION,BAD_REQUEST",
"AUTH,UNAUTHORIZED",
"AUTHZ,FORBIDDEN",
"NOT_FOUND,NOT_FOUND",
"CONFLICT,BAD_REQUEST",
"RATE_LIMIT,BAD_REQUEST",
"TRANSIENT_DEPENDENCY,INTERNAL_ERROR",
"PERMANENT_DEPENDENCY,INTERNAL_ERROR",
"DATA_INTEGRITY,INTERNAL_ERROR",
"INTERNAL,INTERNAL_ERROR",
})
void mapsEachCategoryToItsErrorTypeWithExtensions(Category category, ErrorType expected) {
GraphQLError error =
resolver.resolveToSingleError(new CarrierException("SOME_CODE", category), null);
assertThat(error).isNotNull();
assertThat(error.getErrorType()).isEqualTo(expected);
assertThat(error.getExtensions())
.containsEntry("code", "SOME_CODE")
.containsEntry("category", category.name());
}
@Test
void coversEveryCategoryValue() {
// Fails the moment a new Category is added without a mapping decision (switch is exhaustive).
for (Category category : EnumSet.allOf(Category.class)) {
assertThat(resolver.resolveToSingleError(new CarrierException("C", category), null))
.isNotNull();
}
}
@Test
void surfacesOnlyTheStableCodeAsTheMessageNotTheRawException() {
GraphQLError error =
resolver.resolveToSingleError(
new CarrierException("WORKLOG_NOT_FOUND", Category.NOT_FOUND), null);
assertThat(error.getMessage()).isEqualTo("WORKLOG_NOT_FOUND");
}
@Test
void returnsNullForNonCarrierExceptionSoOtherResolversHandleIt() {
assertThat(resolver.resolveToSingleError(new IllegalStateException("boom"), null)).isNull();
}
/**
* Feature-style throwable carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
*/
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
private final ApiErrorCode errorCode;
CarrierException(String code, Category category) {
super(code);
this.errorCode = new TestErrorCode(code, category);
}
@Override
public ApiErrorCode errorCode() {
return errorCode;
}
}
/** Minimal {@link ApiErrorCode} — only {@code code} / {@code category} matter for the mapping. */
private record TestErrorCode(String code, Category category) implements ApiErrorCode {
@Override
public int httpStatus() {
return 0;
}
@Override
public boolean retryable() {
return false;
}
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.inbound.graphql;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.graphql.ExecutionGraphQlService;
import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer;
import org.springframework.graphql.execution.DefaultExecutionGraphQlService;
import org.springframework.graphql.execution.GraphQlSource;
import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester;
import org.springframework.graphql.test.tester.GraphQlTester;
/**
* Assembles the skeleton schema ({@code graphql/skeleton.graphqls}) and the {@link
* HealthGraphqlController}'s {@code @QueryMapping} through a real {@link
* AnnotatedControllerConfigurer} — the same wiring Spring for GraphQL uses at runtime — and drives
* the {@code _health} query with a {@link GraphQlTester}. Self-contained (no Spring Boot context),
* so it proves the module stands up a working GraphQL surface (schema + controller binding) with
* zero features, the GraphQL sibling of the gRPC skeleton's health boot test.
*/
class HealthGraphqlControllerTest {
@Test
void healthQueryReturnsUpLivenessToken() {
graphQlTester()
.document("{ _health }")
.execute()
.path("_health")
.entity(String.class)
.isEqualTo("UP");
}
private static GraphQlTester graphQlTester() {
AnnotationConfigApplicationContext appContext = new AnnotationConfigApplicationContext();
appContext.registerBean(HealthGraphqlController.class);
appContext.refresh();
AnnotatedControllerConfigurer controllerConfigurer = new AnnotatedControllerConfigurer();
controllerConfigurer.setApplicationContext(appContext);
controllerConfigurer.afterPropertiesSet();
GraphQlSource source =
GraphQlSource.schemaResourceBuilder()
.schemaResources(new ClassPathResource("graphql/skeleton.graphqls"))
.configureRuntimeWiring(controllerConfigurer)
.build();
ExecutionGraphQlService service = new DefaultExecutionGraphQlService(source);
return ExecutionGraphQlServiceTester.create(service);
}
}
+64
View File
@@ -0,0 +1,64 @@
# adapter:inbound:grpc — inbound gRPC adapter (skeleton machinery)
## Registered identity
- Module ID: `adapter-inbound-grpc`
- Gradle path: `:adapter:inbound:grpc`
- Focused test: `./gradlew :adapter:inbound:grpc:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `.harness/project/modules.yaml`.
Package root: `dev.caskeleton.adapter.inbound.grpc`.
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
규칙 SSOT).
## Responsibility
- gRPC 전송 인프라만: 서버 수명주기(`GrpcServerRunner`), 타입드 설정(`GrpcServerProperties`),
프로토콜 에러 매핑(`GrpcStatusMapper` + `GrpcExceptionHandlingInterceptor`), 그리고 `.proto`
없이도 부팅하는 최소 표면(standard health + reflection).
- feature-agnostic: 모든 `io.grpc.BindableService` 빈을 generic 하게 등록한다. **WorkLog 등
구체 기능을 이름으로 알지 않는다.**
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract`.
- `io.grpc:*` (grpc-netty-shaded / grpc-protobuf / grpc-stub / grpc-services), `spring-boot-starter`.
## Forbidden
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`).
- 이 스켈레톤 모듈에서의 `com.google.protobuf` 플러그인 / `.proto` — 스키마와 서비스는 feature
(sample) 모듈이 소유한다.
- 프로덕션 feature RPC 를 스켈레톤에 두는 것 — health/reflection 표면만 (web 의
`HealthcheckController` 와 동일 원칙).
## Config knobs (`ca-skeleton.grpc.*`)
타입드 `@ConfigurationProperties` 만 두고, 값은 composition-root `application.yml` 에 산다
(모듈별 `yml` 없음).
| key | default | 의미 |
|---|---|---|
| `enabled` | `true` | gRPC 서버 기동 여부. 프로덕션 composition root 는 property 로 끌 수 있다 |
| `port` | `9090` | 바인딩 TCP 포트. `0` 이면 ephemeral 포트(테스트) |
| `reflectionEnabled` | `true` | v1 server reflection 노출(grpcurl/Postman 편의; 프로덕션에선 끄기) |
| `shutdownGraceSeconds` | `5` | graceful shutdown 시 in-flight RPC 대기 초 |
## Feature 기여 방법
feature 모듈은 `io.grpc.BindableService``@Bean` 으로 등록하기만 하면
`GrpcServerRunner``ObjectProvider` 가 자동으로 인터셉터 뒤에 등록한다. 에러는
`ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)로 던지면
`GrpcExceptionHandlingInterceptor` 가 매핑한다. `sample-portfolio` 를 지워도 스켈레톤은
health + reflection 만으로 부팅한다 (disposability).
## Test
```bash
cd src
./gradlew :adapter:inbound:grpc:test
```
+85
View File
@@ -0,0 +1,85 @@
# adapter-grpc — 설계 결정 참조
인바운드 gRPC 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트:
`dev.caskeleton.adapter.inbound.grpc`.
허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
모아둔 참조용 기록이다.
---
## 왜 self-managed Netty 인가
- **third-party grpc-spring-boot starter 를 쓰지 않는다.** `GrpcServerRunner` 가 io.grpc Netty
`Server` 를 Spring `SmartLifecycle` 빈으로 직접 소유한다. starter 를 쓰면 Spring Boot 릴리스에
버전이 커플링되는데, 스켈레톤은 io.grpc 런타임에만 의존해 그 커플링을 피한다(web 어댑터가
third-party 없이 서블릿 컨테이너를 쓰는 것과 같은 정신).
- **`getPhase()` = `Integer.MAX_VALUE - 1`.** web 서버가 뜬 **뒤에** 시작하고 종료 시 web 서버
**전에** 멈춘다(SmartLifecycle: 높은 phase 가 늦게 시작·먼저 종료). gRPC 는 web 과 별개의 TCP
포트를 소유하는 부가 전송이므로 애플리케이션 수명주기 맨 바깥에 둔다.
- **graceful shutdown.** `shutdownGraceSeconds` 동안 in-flight RPC 를 기다린 뒤
`shutdownNow()`. 종료 진입 시 health 를 `enterTerminalState()`(NOT_SERVING)로 뒤집어
로드밸런서가 드레이닝을 인지하게 한다.
- **insecure bind (기본).** 스켈레톤은 참조 포스처와 동일하게 평문으로 바인딩하고 mTLS 는
범위 밖(문서화된 knob). 프로덕션 fork 가 전송 보안을 얹는다.
## 왜 `.proto` 도 protobuf 플러그인도 없는가
이 모듈은 protobuf 를 **하나도 컴파일하지 않는다**`com.google.protobuf` 플러그인도,
`src/main/proto` 도 없다. health(`grpc.health.v1`) 와 v1 server reflection 은 `grpc-services`
런타임 jar 에 이미 컴파일된 채 들어 있어, 스켈레톤은 **RPC 0개**로도 동작하는 health +
reflection 표면을 갖고 부팅한다. 기능(feature)의 `.proto`/서비스/매퍼는 `sample-portfolio`
gRPC 어댑터가 `com.google.protobuf` 플러그인과 함께 소유한다.
`compileOnly org.apache.tomcat:annotations-api` 는 생성된 stub 이 참조하는
`javax.annotation.Generated` 때문 — 스켈레톤 자체는 stub 을 생성하지 않지만 feature 모듈과의
패리티를 위해 선언한다.
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** `GrpcServerRunner` 는 생성자에서
`ObjectProvider<io.grpc.BindableService>` 를 받아, 컨텍스트에 존재하는 **모든**
`BindableService` 빈을 `ServerInterceptors.intercept(service, exceptionInterceptor)` 로 감싸
등록한다. 그래서 sample 의 `WorkLogGrpcService` 같은 feature 서비스가 스켈레톤을 수정하지 않고
자동 등록된다. `sample-portfolio` 를 지우면 스켈레톤은 여전히 health + reflection 만으로 부팅한다
(web 과 동일한 disposability 보장).
## 에러 매핑 — web `GlobalExceptionHandler` 의 gRPC 형제
`GrpcExceptionHandlingInterceptor` 가 핸들러에서 동기적으로 던져진 `RuntimeException` 을 잡아
`ServerCall.close(status, trailers)` 로 변환한다. 서비스 구현은 web 컨트롤러처럼 "그냥 던지기만"
하고, 이 인터셉터가 와이어 계약을 단일 소유한다.
- **와이어 status(coarse)**`GrpcStatusMapper.toStatus(Category)` 가 결정한다(HTTP status 가
coarse 인 것과 동형). 정확한 `code`/`category``Status` trailer `Metadata`(`error-code` /
`error-category`)에 실어 클라이언트가 switch 하게 한다.
- **`ApiErrorCode` 추출**: feature 예외는 `ApiErrorCarrier`(이 모듈이 제공하는 전송-중립 hook)를
구현해 자신의 `ApiErrorCode` 를 노출한다. shared-contract 의 `PersistenceFailureException` /
`DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)도 직접 인식한다.
- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 status description/trailer 로 노출하고, raw
예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다. 인식되지
않은 `RuntimeException``Status.INTERNAL` + `INTERNAL_ERROR` 로 폴백한다.
`Category → Status` 표(설계 스펙 Error Mapping SSOT):
| `Category` | gRPC `Status` |
|---|---|
| VALIDATION | INVALID_ARGUMENT |
| AUTH | UNAUTHENTICATED |
| AUTHZ | PERMISSION_DENIED |
| NOT_FOUND | NOT_FOUND |
| CONFLICT | ABORTED |
| RATE_LIMIT | RESOURCE_EXHAUSTED |
| TRANSIENT_DEPENDENCY | UNAVAILABLE |
| PERMANENT_DEPENDENCY | INTERNAL |
| DATA_INTEGRITY | INTERNAL |
| INTERNAL | INTERNAL |
## 의존성 버전 — strict locking
Spring Boot BOM 은 `io.grpc:*`/protobuf 버전을 관리하지 않고 이 저장소엔 version catalog 도
없다. 그래서 `io.grpc:grpc-bom` + `com.google.protobuf:protobuf-bom` 을 **이 모듈의**
`dependencyManagement` 에서 platform 으로 import 한다(루트 `ext.grpcVersion`/`ext.protobufVersion`
가 단일 SSOT). 모듈 스코프로 두어 strict per-module lockfile 의 blast radius 를 이 모듈에만
가둔다 — 공유 루트 dependencyManagement 블록은 io.grpc-free 로 유지된다.
+36
View File
@@ -0,0 +1,36 @@
// Driving adapter: gRPC API (skeleton machinery, transport-only).
//
// A SmartLifecycle bean (GrpcServerRunner) owns the io.grpc Netty server, so this module depends on
// NO third-party grpc-spring-boot starter (no Spring Boot version coupling). The skeleton compiles
// NO protobuf: there is no `com.google.protobuf` plugin and no `.proto` here health + reflection
// come from grpc-services at runtime, and feature `.proto`/services live in the sample module.
//
// io.grpc:* / protobuf versions are NOT managed by the Spring Boot BOM, and this repo has no version
// catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root
// `ext.grpcVersion` / `ext.protobufVersion` SSOT this keeps the strict-locking blast radius to
// this module (the shared root dependencyManagement block stays io.grpc-free).
dependencyManagement {
imports {
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
mavenBom "com.google.protobuf:protobuf-bom:${protobufVersion}"
}
}
dependencies {
implementation project(':application-core')
implementation project(':domain-core')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'io.grpc:grpc-netty-shaded'
implementation 'io.grpc:grpc-protobuf'
implementation 'io.grpc:grpc-stub'
implementation 'io.grpc:grpc-services' // health + reflection (grpc.health.v1 / reflection)
// grpc-java generated stubs reference javax.annotation.Generated; kept compileOnly for parity
// with the feature module (the skeleton itself generates no stubs).
compileOnly 'org.apache.tomcat:annotations-api:6.0.53'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
+173
View File
@@ -0,0 +1,173 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
com.google.api.grpc:proto-google-common-protos:2.41.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.code.gson:gson:2.13.2=runtimeClasspath,spotbugs,testRuntimeClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=runtimeClasspath,spotbugs,testRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.2.1-android=compileClasspath,testCompileClasspath
com.google.guava:guava:33.2.1-jre=runtimeClasspath,testRuntimeClasspath
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
com.google.j2objc:j2objc-annotations:2.8=runtimeClasspath,testRuntimeClasspath
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-context:1.68.1=runtimeClasspath,testRuntimeClasspath
io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-protobuf-lite:1.68.1=runtimeClasspath,testRuntimeClasspath
io.grpc:grpc-protobuf:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.perfmark:perfmark-api:0.27.0=runtimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat:annotations-api:6.0.53=compileClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.mojo:animal-sniffer-annotations:1.24=runtimeClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.grpc;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
/**
* Adapter-level throwable a feature gRPC service throws after mapping a domain exception to a
* stable {@link ApiErrorCode} (typically a feature code such as {@code
* PortfolioErrorCode.WORKLOG_NOT_FOUND} or a skeleton {@code OperationalError}). It implements the
* shared-contract {@link ApiErrorCarrier} hook so the {@link GrpcExceptionHandlingInterceptor}
* translates it to the matching gRPC {@code Status} plus {@code code} / {@code category} trailers
* through the same single carrier branch that handles the shared-contract infra exceptions.
*
* <p>This is the gRPC sibling of "the web adapter service just throws and one handler owns the wire
* mapping": a feature service does the domain-exception → {@code ApiErrorCode} mapping (its own
* concern) and throws this; the transport mapping stays in the interceptor. The supplied message is
* server-log-only detail only {@link #errorCode()} reaches the client.
*/
public class ApiErrorException extends RuntimeException implements ApiErrorCarrier {
private static final long serialVersionUID = 1L;
private final transient ApiErrorCode errorCode;
/**
* @param errorCode the classified, client-facing code surfaced on the gRPC status trailers
* @param message server-log-only diagnostic detail never surfaced to the client
*/
public ApiErrorException(ApiErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
@Override
public ApiErrorCode errorCode() {
return errorCode;
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.inbound.grpc;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.OperationalError;
import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Centralises the gRPC error contract: a feature service just throws, and this {@link
* ServerInterceptor} translates a synchronous {@link RuntimeException} from the handler into a
* {@code ServerCall#close(Status, Metadata)} carrying the mapped {@link Status} plus {@code code} /
* {@code category} trailers the gRPC sibling of the web adapter's {@code GlobalExceptionHandler}.
*
* <p>A stable {@link ApiErrorCode} is recognised through the shared-contract {@link
* ApiErrorCarrier} hook implemented by a feature throwable (the gRPC {@link ApiErrorException}
* carrying a mapped domain code) and by the shared-contract {@code PersistenceFailureException} /
* {@code DependencyFailureException}, so a single {@code instanceof ApiErrorCarrier} branch covers
* them all. An unrecognised {@link RuntimeException} maps to {@link Status#INTERNAL} with {@link
* OperationalError#INTERNAL_ERROR}. Only the stable code string reaches the client (via the status
* description and trailers) a raw exception message, which may carry a SQLState or upstream
* detail, is never surfaced.
*/
public class GrpcExceptionHandlingInterceptor implements ServerInterceptor {
private final GrpcStatusMapper statusMapper;
public GrpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) {
this.statusMapper = statusMapper;
}
@Override
public <REQT, RESPT> ServerCall.Listener<REQT> interceptCall(
ServerCall<REQT, RESPT> call, Metadata headers, ServerCallHandler<REQT, RESPT> next) {
AtomicBoolean closed = new AtomicBoolean(false);
ServerCall.Listener<REQT> delegate;
try {
delegate = next.startCall(call, headers);
} catch (RuntimeException e) {
closeWithError(call, closed, e);
return new ServerCall.Listener<>() {};
}
return new SimpleForwardingServerCallListener<>(delegate) {
@Override
public void onMessage(REQT message) {
runGuarded(() -> super.onMessage(message));
}
@Override
public void onHalfClose() {
runGuarded(super::onHalfClose);
}
@Override
public void onReady() {
runGuarded(super::onReady);
}
private void runGuarded(Runnable action) {
try {
action.run();
} catch (RuntimeException e) {
closeWithError(call, closed, e);
}
}
};
}
private void closeWithError(
ServerCall<?, ?> call, AtomicBoolean closed, RuntimeException exception) {
if (!closed.compareAndSet(false, true)) {
return; // the call was already closed once never double-close.
}
ApiErrorCode code = errorCodeOf(exception);
Status status;
if (code != null) {
status = statusMapper.toStatus(code.category()).withDescription(code.code());
} else {
code = OperationalError.INTERNAL_ERROR;
status = Status.INTERNAL.withDescription(code.code());
}
call.close(status, statusMapper.trailersFor(code));
}
private static ApiErrorCode errorCodeOf(Throwable throwable) {
if (throwable instanceof ApiErrorCarrier carrier) {
return carrier.errorCode();
}
return null;
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.inbound.grpc;
import io.grpc.BindableService;
import io.grpc.protobuf.services.HealthStatusManager;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Wires the gRPC transport machinery, only when {@code ca-skeleton.grpc.enabled=true} (default).
* All collaborators are plain objects composed here, mirroring the clean DI style used across the
* skeleton. Feature {@link BindableService} beans are injected via {@link ObjectProvider} and
* registered generically by {@link GrpcServerRunner}. See README.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
prefix = "ca-skeleton.grpc",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
@EnableConfigurationProperties(GrpcServerProperties.class)
public class GrpcServerConfig {
@Bean
GrpcStatusMapper grpcStatusMapper() {
return new GrpcStatusMapper();
}
@Bean
GrpcExceptionHandlingInterceptor grpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) {
return new GrpcExceptionHandlingInterceptor(statusMapper);
}
@Bean
HealthStatusManager grpcHealthStatusManager() {
return new HealthStatusManager();
}
@Bean
GrpcServerRunner grpcServerRunner(
ObjectProvider<BindableService> services,
GrpcServerProperties properties,
GrpcExceptionHandlingInterceptor exceptionInterceptor,
HealthStatusManager healthStatusManager) {
return new GrpcServerRunner(services, properties, exceptionInterceptor, healthStatusManager);
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.inbound.grpc;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* gRPC server settings bound from {@code ca-skeleton.grpc.*}. Typed configuration only (no
* per-module {@code yml}); values live in the composition-root {@code application.yml}, matching
* the ca-skeleton config convention. See README for the self-managed-Netty rationale.
*/
@ConfigurationProperties(prefix = "ca-skeleton.grpc")
public class GrpcServerProperties {
/** Whether to start the gRPC server at all (feature composition can keep it off by property). */
private boolean enabled = true;
/** TCP port the gRPC server binds to. Set to {@code 0} to bind an ephemeral port (tests). */
private int port = 9090;
/** Expose server reflection (handy for grpcurl / Postman; disable in production). */
private boolean reflectionEnabled = true;
/** Seconds to wait for in-flight RPCs to finish on graceful shutdown. */
private int shutdownGraceSeconds = 5;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public boolean isReflectionEnabled() {
return reflectionEnabled;
}
public void setReflectionEnabled(boolean reflectionEnabled) {
this.reflectionEnabled = reflectionEnabled;
}
public int getShutdownGraceSeconds() {
return shutdownGraceSeconds;
}
public void setShutdownGraceSeconds(int shutdownGraceSeconds) {
this.shutdownGraceSeconds = shutdownGraceSeconds;
}
}
@@ -0,0 +1,125 @@
package dev.caskeleton.adapter.inbound.grpc;
import io.grpc.BindableService;
import io.grpc.Grpc;
import io.grpc.InsecureServerCredentials;
import io.grpc.Server;
import io.grpc.ServerInterceptors;
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
import io.grpc.protobuf.services.HealthStatusManager;
import io.grpc.protobuf.services.ProtoReflectionServiceV1;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.SmartLifecycle;
/**
* Owns the io.grpc Netty {@link Server} lifecycle as a Spring {@link SmartLifecycle} bean start
* on context refresh, graceful shutdown on close. Deliberately avoids any third-party
* grpc-spring-boot starter so the skeleton has no Spring Boot version coupling.
*
* <p>Feature services are discovered generically: every {@link BindableService} bean is registered
* behind the {@link GrpcExceptionHandlingInterceptor}, so a feature (e.g. the sample WorkLog
* service) auto-registers without the skeleton naming it. The skeleton also registers the standard
* {@code grpc.health.v1} health service and, when enabled, the v1 server reflection service, so it
* boots with a working surface and ZERO {@code .proto}. See README.
*/
public class GrpcServerRunner implements SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(GrpcServerRunner.class);
private final ObjectProvider<BindableService> services;
private final GrpcServerProperties properties;
private final GrpcExceptionHandlingInterceptor exceptionInterceptor;
private final HealthStatusManager healthStatusManager;
private volatile Server server;
public GrpcServerRunner(
ObjectProvider<BindableService> services,
GrpcServerProperties properties,
GrpcExceptionHandlingInterceptor exceptionInterceptor,
HealthStatusManager healthStatusManager) {
this.services = services;
this.properties = properties;
this.exceptionInterceptor = exceptionInterceptor;
this.healthStatusManager = healthStatusManager;
}
@Override
public void start() {
if (isRunning()) {
return;
}
var builder =
Grpc.newServerBuilderForPort(properties.getPort(), InsecureServerCredentials.create());
int registered = 0;
for (BindableService service : services) {
builder.addService(ServerInterceptors.intercept(service, exceptionInterceptor));
registered++;
}
healthStatusManager.setStatus(
HealthStatusManager.SERVICE_NAME_ALL_SERVICES, ServingStatus.SERVING);
builder.addService(healthStatusManager.getHealthService());
if (properties.isReflectionEnabled()) {
builder.addService(ProtoReflectionServiceV1.newInstance());
}
try {
server = builder.build().start();
log.info(
"gRPC server started on port {} ({} feature service(s), reflection={})",
server.getPort(),
registered,
properties.isReflectionEnabled());
} catch (IOException e) {
throw new UncheckedIOException(
"failed to start gRPC server on port " + properties.getPort(), e);
}
}
@Override
public void stop() {
Server current = this.server;
if (current == null) {
return;
}
healthStatusManager.enterTerminalState();
try {
current.shutdown();
if (!current.awaitTermination(properties.getShutdownGraceSeconds(), TimeUnit.SECONDS)) {
current.shutdownNow();
}
log.info("gRPC server stopped");
} catch (InterruptedException e) {
current.shutdownNow();
Thread.currentThread().interrupt();
} finally {
this.server = null;
}
}
@Override
public boolean isRunning() {
Server current = this.server;
return current != null && !current.isShutdown();
}
/**
* Actual bound port useful when configured with port 0 for tests; {@code -1} when not started.
*/
public int getListeningPort() {
Server current = this.server;
return current != null ? current.getPort() : -1;
}
@Override
public int getPhase() {
// Start after the web server is up, stop before it during shutdown.
return Integer.MAX_VALUE - 1;
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.inbound.grpc;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
import io.grpc.Metadata;
import io.grpc.Status;
/**
* Pure translation of the 10-value operational {@link Category} SSOT to an {@link io.grpc.Status},
* plus helpers to carry the machine-readable {@code code} / {@code category} on the response
* trailer {@link Metadata}. This is the gRPC sibling of the web adapter's error contract: the wire
* status (like an HTTP status) is coarse, while the exact {@link ApiErrorCode#code()} and the
* category name ride in the trailers for the client to switch on.
*
* <p>The classification table is fixed by the design spec's Error Mapping section; see README.
*/
public class GrpcStatusMapper {
/**
* Trailer key carrying the stable {@link ApiErrorCode#code()} (e.g. {@code WORKLOG_NOT_FOUND}).
*/
static final Metadata.Key<String> CODE_KEY =
Metadata.Key.of("error-code", Metadata.ASCII_STRING_MARSHALLER);
/** Trailer key carrying the {@link Category} enum name (e.g. {@code NOT_FOUND}). */
static final Metadata.Key<String> CATEGORY_KEY =
Metadata.Key.of("error-category", Metadata.ASCII_STRING_MARSHALLER);
/**
* Maps an operational {@link Category} to its gRPC {@link Status} (design Error-Mapping table).
*/
public Status toStatus(Category category) {
return switch (category) {
case VALIDATION -> Status.INVALID_ARGUMENT;
case AUTH -> Status.UNAUTHENTICATED;
case AUTHZ -> Status.PERMISSION_DENIED;
case NOT_FOUND -> Status.NOT_FOUND;
case CONFLICT -> Status.ABORTED;
case RATE_LIMIT -> Status.RESOURCE_EXHAUSTED;
case TRANSIENT_DEPENDENCY -> Status.UNAVAILABLE;
case PERMANENT_DEPENDENCY -> Status.INTERNAL;
case DATA_INTEGRITY -> Status.INTERNAL;
case INTERNAL -> Status.INTERNAL;
};
}
/**
* Writes the {@code code} and {@code category} of {@code errorCode} onto a fresh trailer {@link
* Metadata}, returned for {@code ServerCall#close(Status, Metadata)}.
*/
public Metadata trailersFor(ApiErrorCode errorCode) {
Metadata trailers = new Metadata();
trailers.put(CODE_KEY, errorCode.code());
trailers.put(CATEGORY_KEY, errorCode.category().name());
return trailers;
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.inbound.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.OperationalError;
import org.junit.jupiter.api.Test;
/**
* Verifies {@link ApiErrorException} carries its {@link dev.caskeleton.shared.error.ApiErrorCode}
* through the shared-contract {@link ApiErrorCarrier} hook unchanged, and keeps the diagnostic
* message off the carrier surface.
*/
class ApiErrorExceptionTest {
@Test
void errorCodeRoundTripsThroughTheCarrierHook() {
ApiErrorException exception =
new ApiErrorException(OperationalError.BAD_PARAMETER, "server-log-only detail");
assertThat(exception).isInstanceOf(ApiErrorCarrier.class);
assertThat(exception.errorCode()).isEqualTo(OperationalError.BAD_PARAMETER);
assertThat(exception.getMessage()).isEqualTo("server-log-only detail");
}
}
@@ -0,0 +1,131 @@
package dev.caskeleton.adapter.inbound.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.OperationalError;
import dev.caskeleton.shared.error.PersistenceFailureException;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.Status;
import org.junit.jupiter.api.Test;
/**
* Verifies the interceptor translates a synchronous handler exception into a mapped {@code
* close(status, trailers)} recognising the {@link ApiErrorCarrier} feature hook and the
* shared-contract {@link PersistenceFailureException}, and falling back to {@link Status#INTERNAL}
* for an unrecognised {@link RuntimeException}. Driven with a capturing fake {@link ServerCall}, so
* no channel/server is needed.
*/
class GrpcExceptionHandlingInterceptorTest {
private final GrpcExceptionHandlingInterceptor interceptor =
new GrpcExceptionHandlingInterceptor(new GrpcStatusMapper());
@Test
void mapsApiErrorCarrierToItsCategoryStatusWithTrailers() {
CapturingServerCall call =
closeAfterThrowing(new CarrierException(OperationalError.BAD_PARAMETER));
assertThat(call.status.getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT);
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("BAD_PARAMETER");
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("VALIDATION");
}
@Test
void mapsApiErrorExceptionByItsCarriedCode() {
CapturingServerCall call =
closeAfterThrowing(
new ApiErrorException(OperationalError.ROUTE_NOT_FOUND, "server-log-only detail"));
assertThat(call.status.getCode()).isEqualTo(Status.Code.NOT_FOUND);
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("ROUTE_NOT_FOUND");
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("NOT_FOUND");
}
@Test
void mapsSharedPersistenceFailureByItsClassifiedCode() {
CapturingServerCall call =
closeAfterThrowing(
new PersistenceFailureException(OperationalError.DB_UNAVAILABLE, "08006", null));
assertThat(call.status.getCode()).isEqualTo(Status.Code.UNAVAILABLE);
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("DB_UNAVAILABLE");
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("TRANSIENT_DEPENDENCY");
}
@Test
void mapsUnknownRuntimeExceptionToInternal() {
CapturingServerCall call = closeAfterThrowing(new IllegalStateException("boom"));
assertThat(call.status.getCode()).isEqualTo(Status.Code.INTERNAL);
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("INTERNAL_ERROR");
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("INTERNAL");
}
private CapturingServerCall closeAfterThrowing(RuntimeException thrown) {
CapturingServerCall call = new CapturingServerCall();
ServerCallHandler<String, String> handler =
(serverCall, headers) ->
new ServerCall.Listener<>() {
@Override
public void onHalfClose() {
throw thrown;
}
};
ServerCall.Listener<String> listener = interceptor.interceptCall(call, new Metadata(), handler);
listener.onHalfClose();
return call;
}
/**
* Feature-style exception carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
*/
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
private final ApiErrorCode errorCode;
CarrierException(ApiErrorCode errorCode) {
super(errorCode.code());
this.errorCode = errorCode;
}
@Override
public ApiErrorCode errorCode() {
return errorCode;
}
}
/** Minimal {@link ServerCall} that records the {@code close(status, trailers)} arguments. */
private static final class CapturingServerCall extends ServerCall<String, String> {
private Status status;
private Metadata trailers;
@Override
public void request(int numMessages) {}
@Override
public void sendHeaders(Metadata headers) {}
@Override
public void sendMessage(String message) {}
@Override
public void close(Status status, Metadata trailers) {
this.status = status;
this.trailers = trailers;
}
@Override
public boolean isCancelled() {
return false;
}
@Override
public MethodDescriptor<String, String> getMethodDescriptor() {
return null;
}
}
}
@@ -0,0 +1,99 @@
package dev.caskeleton.adapter.inbound.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.health.v1.HealthCheckRequest;
import io.grpc.health.v1.HealthCheckResponse;
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
import io.grpc.health.v1.HealthGrpc;
import io.grpc.reflection.v1.ServerReflectionGrpc;
import io.grpc.reflection.v1.ServerReflectionRequest;
import io.grpc.reflection.v1.ServerReflectionResponse;
import io.grpc.reflection.v1.ServiceResponse;
import io.grpc.stub.StreamObserver;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
/**
* Boots the skeleton gRPC machinery in a real Spring context on an ephemeral port ({@code
* ca-skeleton.grpc.port=0}) with ZERO feature services and proves it stands up a working surface:
* the {@link GrpcServerRunner} SmartLifecycle starts, the standard {@code grpc.health.v1} health
* service reports SERVING, and v1 server reflection lists the built-in services. A real Netty
* channel exercises the wire, so this is a genuine transport smoke test, not a wiring mock.
*/
class GrpcServerRunnerBootTest {
private final ApplicationContextRunner contextRunner =
new ApplicationContextRunner()
.withUserConfiguration(GrpcServerConfig.class)
.withPropertyValues("ca-skeleton.grpc.port=0");
@Test
void skeletonServerStartsAndServesHealthAndReflectionWithNoFeatures() {
contextRunner.run(
context -> {
GrpcServerRunner runner = context.getBean(GrpcServerRunner.class);
assertThat(runner.isRunning()).isTrue();
int port = runner.getListeningPort();
assertThat(port).isGreaterThan(0);
ManagedChannel channel =
ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build();
try {
HealthCheckResponse health =
HealthGrpc.newBlockingStub(channel).check(HealthCheckRequest.newBuilder().build());
assertThat(health.getStatus()).isEqualTo(ServingStatus.SERVING);
assertThat(listServicesViaReflection(channel))
.contains("grpc.health.v1.Health", "grpc.reflection.v1.ServerReflection");
} finally {
channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
}
});
}
private static List<String> listServicesViaReflection(ManagedChannel channel)
throws InterruptedException {
List<String> services = new ArrayList<>();
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
StreamObserver<ServerReflectionRequest> requests =
ServerReflectionGrpc.newStub(channel)
.serverReflectionInfo(
new StreamObserver<>() {
@Override
public void onNext(ServerReflectionResponse response) {
for (ServiceResponse service :
response.getListServicesResponse().getServiceList()) {
services.add(service.getName());
}
}
@Override
public void onError(Throwable t) {
error.set(t);
done.countDown();
}
@Override
public void onCompleted() {
done.countDown();
}
});
requests.onNext(ServerReflectionRequest.newBuilder().setListServices("").build());
requests.onCompleted();
assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
assertThat(error.get()).isNull();
return services;
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.inbound.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.error.Category;
import dev.caskeleton.shared.error.OperationalError;
import io.grpc.Metadata;
import io.grpc.Status;
import java.util.EnumSet;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
/**
* Pins the 10-value {@link Category} {@link Status} classification table (design Error-Mapping)
* and the {@code code} / {@code category} trailer helper. One assertion per Category value guards
* against a silent remap on a Spring/grpc upgrade.
*/
class GrpcStatusMapperTest {
private final GrpcStatusMapper mapper = new GrpcStatusMapper();
@ParameterizedTest
@CsvSource({
"VALIDATION,INVALID_ARGUMENT",
"AUTH,UNAUTHENTICATED",
"AUTHZ,PERMISSION_DENIED",
"NOT_FOUND,NOT_FOUND",
"CONFLICT,ABORTED",
"RATE_LIMIT,RESOURCE_EXHAUSTED",
"TRANSIENT_DEPENDENCY,UNAVAILABLE",
"PERMANENT_DEPENDENCY,INTERNAL",
"DATA_INTEGRITY,INTERNAL",
"INTERNAL,INTERNAL",
})
void mapsEachCategoryToItsGrpcStatusCode(Category category, Status.Code expected) {
assertThat(mapper.toStatus(category).getCode()).isEqualTo(expected);
}
@Test
void coversEveryCategoryValue() {
// Fails the moment a new Category is added without a mapping decision (switch is exhaustive).
for (Category category : EnumSet.allOf(Category.class)) {
assertThat(mapper.toStatus(category)).isNotNull();
}
}
@Test
void trailersCarryStableCodeAndCategoryName() {
Metadata trailers = mapper.trailersFor(OperationalError.RATE_LIMIT_EXCEEDED);
assertThat(trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("RATE_LIMIT_EXCEEDED");
assertThat(trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("RATE_LIMIT");
}
}
+198
View File
@@ -0,0 +1,198 @@
# adapter:inbound:web — inbound HTTP adapter
## Registered identity
- Module ID: `adapter-inbound-web`
- Gradle path: `:adapter:inbound:web`
- Focused test: `./gradlew :adapter:inbound:web:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `.harness/project/modules.yaml`.
Package root: `dev.caskeleton.adapter.inbound.web`.
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT).
## Responsibility
- HTTP controllers.
- Request/response DTOs.
- Request DTO to application command mapping.
- Authentication, validation, error mapping, filters, and web/security settings.
## Allowed
- `:application-core`
- `:domain-core`
- `:shared-contract`
- Spring Web/Security/Validation dependencies.
## Forbidden
- Direct dependency on `adapter-persistence` or `adapter-outbound`.
- Direct repository or JPA entity access from controllers.
- Core business rules in controller, filter, config, mapper, or settings code.
- DTO leakage into application or domain.
## Boundary validation & mapper contract
`feature-boundary-validation-mapping-contract` (LLM Wiki branch note) fixes the
behaviour at this layer's boundaries. The repo-level guardrails (ArchUnit +
Jackson config + handler) only catch the static violations — the contract below
also drives the runtime patterns reference implementations must follow.
- **B1 — Jackson policy at the request boundary.** `spring.jackson.deserialization`
pins `fail-on-unknown-properties`, `fail-on-null-for-primitives`,
`fail-on-ignored-properties` to `true` and `read-unknown-enum-values-as-null`
to `false`. Do NOT undo this per-DTO with class-level
`@JsonIgnoreProperties(ignoreUnknown = true)` — ArchUnit rule
`request_dtos_do_not_silence_unknown_fields` blocks it. Use wrapper types
(`Integer`, `Long`, `Boolean`, `Optional<T>`) in request records so JSON
`null` cannot become primitive `0`.
- **B2 — PATCH semantics.** Do not adopt RFC 7396 `application/merge-patch+json`
(`null = deletion`). PATCH endpoints must distinguish *absent* (no change),
*explicit null* (clear field), and *value* (replace). Use
`org.openapitools:jackson-databind-nullable` (`JsonNullable<T>`) or
`Optional<T>` wrappers on request records.
- **B3 — Mapper-internal failures.** Map record canonical-constructor
`IllegalArgumentException`, MapStruct generated NPE, ACL normalization
failures, etc. by throwing `MappingException` (sample implementation in
`sample-portfolio`); the global handler routes it to `MAPPING_FAILED` (HTTP 400),
never to `BAD_PARAMETER` or `INTERNAL_ERROR`. Plain `IllegalArgumentException`
remains `BAD_PARAMETER` for non-mapper callers.
- **B4 — Validation layering.** Class-level Bean Validation constraints belong
to the *syntax* layer (request DTO). Domain invariants belong to
`application-core` / `domain-core`. Use `@GroupSequence(...)` to short-circuit
invariant evaluation when syntax fails. Keep `@Valid` cascade depth ≤ 3.
- **B5 — Polymorphic deserialization.** Calling
`ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping()` or
referencing `LaissezFaireSubTypeValidator` is the CVE-2019-14379 RCE entry
point and is blocked by ArchUnit (`no_jackson_laissez_faire_subtype_validator`,
`no_jackson_enable_default_typing_call`). Sealed `Command` types must use
`@JsonTypeInfo(use = NAME)` + `@JsonSubTypes`, or a
`BasicPolymorphicTypeValidator` allowlist.
- **B6 — Virtual thread context propagation.** With
`spring.threads.virtual.enabled=true`, do not use `InheritableThreadLocal`
(ArchUnit rule `no_inheritable_thread_local`). Filters and interceptors must
propagate `requestId` / `traceId` via SLF4J 2.0+ MDC or
`RequestContextHolder`.
- **B7 — Outbound ACL mapper scope.** Outbound HTTP / messaging adapter
responses must pass through an ACL mapper (normalization, masking, public
field selection) before reaching `application-core` or `domain-core` — the
same boundary contract as inbound. Raw external response types must not leak
into `domain-core`.
- **B8 — Bulk endpoint partial success.** Envelope `success = true` only when
every item succeeded. Partial failure responds with `success = false` +
`error.code = BATCH_PARTIAL_FAILURE` + `error.details[]` (per-item array) —
a different shape from the single-item endpoint. Document the shape divergence
in the OpenAPI spec.
Domain `@RestControllerAdvice` in a consuming module must be annotated
`@Order(Ordered.HIGHEST_PRECEDENCE)` (or otherwise ordered ahead of this
module's base `GlobalExceptionHandler`), because the base handler's catch-all
`@ExceptionHandler(Exception.class)` would otherwise resolve domain exceptions
to `INTERNAL_ERROR`. See `sample-portfolio`'s `DomainExceptionHandler` for the
pattern.
The base operational handler (`error/GlobalExceptionHandler`), the error-code
contract (`dev.caskeleton.shared.error.ApiErrorCode` + `OperationalError`), the
`error/ErrorResponseFactory`, and the `envelope/EnvelopeBodyAdvice` now live in
production modules (`adapter:inbound:web` / `shared-contract`), so the running application
provides them without depending on `sample-portfolio`. Domain-specific exception
handlers and error codes live in the consuming module (see sample's
`DomainExceptionHandler` / `PortfolioErrorCode`).
## Schema / serialization contract
`feature-schema-serialization-contract` (LLM Wiki branch note) fixes the
*response producer* side of the wire contract — the sibling of the B1 *request
consumer* policy above. The deserialization switches (B1) and the
null/empty/missing 3-state (`Patch<T>` + `JsonNullable`, B2) already cover the
inbound side; the rules below cover the outbound side. The Jackson properties
live in `app-bootstrap` (`application.yml` `spring.jackson.serialization.*` /
`spring.jackson.generator.*`); ArchUnit + effective-config tests live in
`app-bootstrap` (`JacksonSerializationPolicyTest`, `no_bigdecimal_double_constructor`).
- **S1 — Date / time / timezone (D2).** `WRITE_DATES_AS_TIMESTAMPS=false` is
pinned, so `java.time` values serialize as ISO-8601 strings via `JavaTimeModule`
(`OffsetDateTime``"...Z"`, `LocalDate``"YYYY-MM-DD"`), never a numeric
epoch or `[y,m,d,...]` array. Server timezone is **UTC**: emit instants as
`OffsetDateTime`/`Instant` with a `Z` offset. Use `LocalDate` only for
date-only calendar fields. Do **not** put timezone-less `LocalDateTime` on a
response DTO — it serializes without an offset and breaks the contract.
- **S2 — Money / BigDecimal (D3).** Default scale 2, rounding `HALF_UP` unless
the domain documents otherwise (KRW/JPY = scale 0 with a schema note).
`WRITE_BIGDECIMAL_AS_PLAIN=true` is pinned so values never serialize in
scientific notation. Pick **one** JSON representation per API and state it in
the OpenAPI schema: **string** (`@JsonSerialize(using = ToStringSerializer.class)`)
for public / financial endpoints (client parses, no precision loss), or
**number + plain** for internal service-to-service endpoints. Never rely on
the default — decide at endpoint design time.
- **S3 — `new BigDecimal(double)` is banned.** The `double`/`float` constructors
capture binary floating-point error (`new BigDecimal(0.1)``0.1`). Build from
a `String` (`new BigDecimal("0.1")`) or `BigDecimal.valueOf(double)`. Enforced
by the `no_bigdecimal_double_constructor` ArchUnit rule (D3 / SBMS-C3).
- **S4 — Enum / null·empty·missing.** Request-side unknown enum →
`VALIDATION_FAILED` (B1 `read-unknown-enum-values-as-null=false`); legacy values
map through an explicit adapter, never a silent fallback. The
absent / explicit-null / value distinction is owned by the inbound web mapper
(Controller DTO → Command), expressed with `Patch<T>` (B2); `domain-core` and
`application-core` receive the already-resolved 3-state, never a wire type.
- **S5 — Out of this branch's scope.** OpenAPI drift enforcement (D5) is owned by
the verification suite / api-contract-baseline; removed-field-reuse ban tooling
(D6, `x-removed-fields` vs markdown catalog) is `needs-confirmation`; Avro
Schema Registry for outbox/event (D7) and response field rename/versioning
(`feature-api-compatibility-deprecation-contract`) are separate branches.
## Business rule validation contract
`feature-business-rule-validation-contract` (LLM Wiki branch note) fixes **which
rule is validated at which boundary**, so "validation" does not collapse into the
controller DTO or a DB constraint. It sits on top of the boundary/mapping contract
above and is enforced by ArchUnit + contract tests (not new runtime mechanism).
| Layer | Owner | Validates | `error.category` | Enforced by |
|---|---|---|---|---|
| syntax / shape | `adapter:inbound:web` request DTO (`@Valid` / `jakarta.validation`) | request shape, types, required fields | `VALIDATION` | `validation_constraints_stay_at_web_boundary` ArchUnit rule |
| use case policy | `application-core` | authorization, cross-aggregate policy, state preconditions | `AUTHZ` / `CONFLICT` | `BusinessRuleValidationContractTest` |
| domain invariant | `domain-core` model / value object **constructor** | business invariants (e.g. end ≥ start) | `CONFLICT` / `VALIDATION` | domain unit tests (e.g. `PeriodTest`) — constructor is the sole, immutable construction path |
| persistence integrity | `adapter-persistence` (translator owned by `feature-persistence-failure-baseline`) | unique / FK / check / serialization | `DATA_INTEGRITY` / `CONFLICT` | `BusinessRuleValidationContractTest` + leak test |
- **C1 — Validation annotations stay at the web boundary.** `jakarta.validation`
(`@NotNull`, `@Valid`, …) must appear only in `adapter:inbound:web`. `domain-core` and
`application-core` express invariants and policy as plain Java. The
`validation_constraints_stay_at_web_boundary` ArchUnit rule fails the build if a
Bean Validation annotation leaks into `..domain..` or `..application..`.
- **C2 — Business invariants live in the domain, un-bypassable.** Enforce invariants
in the value-object / entity **constructor** (the sole construction path) and keep
the type immutable, so no application-service or persistence path can hand out an
invariant-violating instance. A DB constraint is a backstop, never the only check
(Forbidden: "DB constraint as only invariant").
- **C3 / C7 / D9 — Persistence integrity maps to an operational error, leak-free.** A
unique/FK/check/serialization failure maps to `DATA_INTEGRITY` / `CONFLICT` with a
**client-safe message only**. The raw SQL, constraint/index name, SQLState code,
exception class, and stack frame must never reach `error.message` or
`error.details`. The base `GlobalExceptionHandler` catch-all already replaces the
message with `"Internal server error"` and emits `null` details; the
category-correct mapping (23505 → `CONFLICT/DB_UNIQUE_VIOLATION`, …) is owned by
`feature-persistence-failure-baseline`'s persistence-adapter translator.
- **C8 — Duplicate validation needs a canonical owner.** The same rule MAY be
pre-checked at another layer for UX / performance (e.g. an application pre-check
mirroring a DB unique constraint), but the **canonical owner** of the rule must be
named in a code comment or the relevant `CLAUDE.md`. A duplicate validator with no
documented owner is a review failure (silent contradiction risk). This is a process
gate (PR review), not an automated rule — `needs-confirmation` until an owner-marker
annotation is justified.
Out of this branch's scope (cross-referenced, not re-implemented here): the
SQLState→code 9-row matrix and the `DataAccessException` translator
(`feature-persistence-failure-baseline`); the Jackson B1/B2 request-boundary switches
and mapper sentinel (`feature-boundary-validation-mapping-contract`); the envelope,
`Category` enum, and `OperationalError` codes
(`feature-operational-error-observability-foundation`).
## Test
```bash
cd src
./gradlew :adapter:inbound:web:test --console=plain
```
+414
View File
@@ -0,0 +1,414 @@
# adapter-web — 설계 결정 참조
인바운드 HTTP / 보안 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.web`.
허용/금지 의존, 경계 계약(B1~B8), 스키마/직렬화 계약(S1~S5), 비즈니스 규칙 검증 계약(C1~C8),
테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서
덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할
때 본다. 아래 설명은 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로
풀어 둔다.
---
## auth — 인증 (OIDC resource server)
### SecurityConfig
- **Spring Security 기본 Cache-Control writer 비활성화.** 이 모듈이
HTTP cache 헤더 정책을 소유한다(`CacheControlFilter``Cache-Control: no-store` + `Vary` 방출).
헤더의 단일·결정적 소유자를 보장하기 위해 Spring Security 자체의 기본 writer 를 끈다.
- **AuthN/AuthZ 분류기를 `exceptionHandling` 과 `oauth2ResourceServer` 양쪽에 설정.** entry point 는
missing-token(authorization-layer)과 invalid-token(bearer-filter-layer) 실패를, access-denied
handler 는 403 을 담당한다. 두 곳 모두에 설정해야 bearer filter 와 authorization filter 가 동일한
Envelope writer 로 귀결된다.
### JwtDecoderConfig
- Spring Boot auto-config decoder 를 대체해 validator chain 을 기본값 의존이 아닌 **명시적 구성**으로 만든다.
- **D2 — clock skew 60s 명시 고정**(`JwtTimestampValidator`). framework 기본값에 의존하면 Spring
업그레이드로 기본값이 바뀔 때 silent-drift 위험이 있어 여기서 못박는다.
- **D4 — issuer 검증**(`SecuritySettings.issuerUri()`). **D3 — audience 검증**(`SecuritySettings.audience()`),
단 blank audience 면 검사 건너뜀(기존 settings 계약과 일치).
- **JWKS lazy discovery** (`SupplierJwtDecoder`): 기동 시 IdP 가 reachable 일 필요가 없고, 첫 decode
시점에 issuer-uri/`.well-known` 네트워크 호출이 일어난다(Spring Boot auto-config 와 동일한 lazy 동작).
- **Minimal 결정**: JWKS cache TTL 과 unknown-kid rate-limit 은 override 하지 않는다. 정확한 수치는
IdP-side token TTL 에 달린 NEEDS_CONTEXT 라 Nimbus/Spring 기본값을 쓰고 문서로만 남긴다.
- `jwtValidator` 가 package-private + static 인 이유: 네트워크/IdP 의존 없이 단위 테스트 가능하게 하려고.
- audience validator 의 오류 description(`"The aud claim is not valid"`)은 `SecurityErrorClassifier`
"aud claim" 휴리스틱과 매칭되어 `AUTH_AUDIENCE_MISMATCH` 로 분류되도록 **의도적으로 맞춘 문자열 계약**이다.
### JwtToAuthenticatedPrincipalConverter
- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며
`ObjectOutputStream` 으로 round-trip 되지 않는다(이 템플릿엔 Java-직렬화 세션 저장소가 없음 — grep 확인).
Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다.
### SecurityErrorClassifier
- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가
선언한 세분화 코드를 방출한다.
- **메커니즘 & 트레이드오프**: Spring Security 는 JWT 실패에 단일 typed reason 을 노출하지 않으므로,
classifier 가 예외 그래프와 validator/Nimbus 메시지 텍스트를 검사한다. 매핑:
- missing token → `InsufficientAuthenticationException``AUTH_TOKEN_MISSING`
- claim validators(`JwtValidationException`) → description 에 따라 `AUTH_TOKEN_EXPIRED` /
`AUTH_ISSUER_MISMATCH` / `AUTH_AUDIENCE_MISMATCH`
- decode/signature/unknown-kid(`BadJwtException`/`JwtException` cause) →
`AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_MALFORMED` / `AUTH_KID_UNKNOWN`
- JWKS endpoint 장애 → `AUTH_JWKS_UNAVAILABLE` (503, transient)
- 텍스트 휴리스틱은 의도적으로 **좁고 순서가 있다**. 매핑되지 않은 실패는 500 이 아니라 안전한
`AUTH_TOKEN_MALFORMED`(401)로 폴백한다.
- `AUTHZ_TENANT_MISMATCH` 는 여기서 추론 불가 — application-layer 의 cross-tenant 결정이며, 일반 `AccessDeniedException` 에는 `AUTHZ_INSUFFICIENT_PERMISSION`
만 방출한다.
### AuthErrorResponseWriter
- **토큰/PII 리댁션.** 응답 본문엔 해당
코드의 일반 `client_safe_message` 만 담고, 원시 예외 텍스트·`Authorization` 헤더·issuer·audience 는
절대 포함하지 않는다. 로그 라인엔 code/category/요청 path 만 기록하고 bearer token 은 절대 로깅하지
않는다(leak 테스트가 강제하는 계약). 전체 로그 마스킹 필터는 별도 log-management 영역에서 다룬다.
- **WWW-Authenticate(RFC 9110 §15.5.2).** 401 응답은 반드시 WWW-Authenticate 헤더를 갖되, `error_description`
으로 issuer/token 세부가 새지 않도록 최소한으로 유지한다.
### EnvelopeAuthenticationEntryPoint
- AuthN matrix 구현(인증 실패를 세분화 `OperationalError` 로 분류).
- resource-server 인증 실패는 filter layer(`BearerTokenAuthenticationFilter` / `ExceptionTranslationFilter`)에서
처리되어 `@RestControllerAdvice` 에 도달하지 않는다. 따라서 세분화 분류는 `GlobalExceptionHandler`
아니라 반드시 이 entry point 에 위치해야 한다.
### EnvelopeAccessDeniedHandler
- AuthN/AuthZ decision matrix 의 AuthZ 분기(유효 토큰 + 권한 부족 →
`AUTHZ_INSUFFICIENT_PERMISSION` 403).
- `AUTHZ_TENANT_MISMATCH` 는 application-layer 의 cross-tenant 결정이라
일반 Spring `AccessDeniedException` 으로는 추론 불가 — 여기서 방출하지 않는다.
---
## authz — 인가 (`@RequiresPermission` 강제)
### MethodSecurityConfig
- `RequiresPermission` 강제 지점을 Spring method security 에 배선한다.
- `@EnableMethodSecurity(prePostEnabled = false)` — method-security 인프라는 켜되 `@PreAuthorize`/
`@PostAuthorize` 인터셉터는 등록하지 않는다(의도적). 컨텍스트 내 유일한 authorization advice 가 아래
커스텀 advisor 가 되게 하기 위함.
- 이 선택이 **애플리케이션 계층을 Spring Security 애너테이션으로부터 자유롭게 유지(D1)**: 유스케이스는
프레임워크 독립적 plain 애너테이션 `RequiresPermission` 만 선언하고 Spring-aware 강제는 이 어댑터가 공급.
- advisor 는 `ROLE_INFRASTRUCTURE` static `@Bean` 으로 등록 — 일반 싱글톤보다 먼저 인스턴스화되어
애플리케이션 빈을 조기 초기화로 끌어들이지 않는다.
### RequiresPermissionAuthorizationManager
- `RequiresPermission` 의 Spring-aware 강제 메커니즘(커스텀 `AuthorizationManager<MethodInvocation>`).
- 가로챈 메서드(또는 선언 타입)에서 애너테이션을 읽고, 현재 `Authentication` 을 프레임워크 독립적
`AuthorizationPrincipal` 로 매핑해 결정을 application `AuthorizationPort` 에 위임한다. 따라서
application/domain 은 어떤 Spring Security 타입도 갖지 않으며, **이 어댑터가 두 세계가 만나는 유일한 지점**이다.
- 포트가 거부 시 application `AuthorizationDeniedException` 을 던지고, 이 매니저가 그것을 거부된
`AuthorizationDecision` 으로 변환한다. method-security 인터셉터가 이를 `AccessDeniedException`
`AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 만든다(§4). `null` 반환은 기권(abstain)이라 애너테이션 없는
메서드는 영향받지 않는다.
- 매핑은 **fail-closed**: 미인증 요청이거나 우리 `AuthenticatedPrincipal` 이 아닌 principal 은 0개 role 로
해석되어 거부된다.
### AuthorizationAdapter
- application `AuthorizationPort` 의 web-adapter 구현체.
- 결정은 **fail-closed**: principal 의 유효 권한 집합에 요구 권한이 없으면 `AuthorizationDeniedException` 으로
거부 → `RequiresPermissionAuthorizationManager` 가 변환 → 최종 `AUTHZ_INSUFFICIENT_PERMISSION` 403.
### RolePermissionRegistry
- 호출자의 raw role 들을 유효 `Permission` 집합으로 해석한다.
- role 키를 **소문자로 normalize**: Keycloak 이 role 대소문자를 보장하지 않으므로 조회를 대소문자 무관으로.
- 권한은 role 별로 **명시적으로 열거**한 집합이며 와일드카드(예: `worklog:*`)는 의도적으로 미지원 — 미래의
`worklog:delete` 가 암묵적으로 부여되지 않도록(least-privilege, OWASP-AUTHZ-C4; §3 default B).
- 해석은 fail-closed: 알 수 없는 role / 빈 role 집합 / 빈 registry 모두 0개 권한.
### RolePermissionPolicy
- app-side role→permission 매핑 소스.
- 키가 raw IdP role 이름인 이유: `ROLE_` 접두사는 Spring `GrantedAuthority` 에만 있고 principal 의 raw role
집합엔 없으므로 붙이지 않는다.
- startup-bound static config 라 staleness 가 없다.
- **app-side config 를 기본값으로 택한 근거**: resource server 를 IdP 의 permission-claim 발급으로부터
디커플링한다. IdP-authoritative 소스(Keycloak Authorization Services / permission claims)는 본 contract 에서
의도적으로 out-of-scope 인 대안이다.
---
## error — 에러 → Envelope 변환
### GlobalExceptionHandler
스켈레톤 공통 기반 에러 → `Envelope` 변환기.
- **D5: RFC 7807 `ProblemDetail` 표현은 거부**하고 자체 `Envelope` 형식을 쓴다.
- **운영/전송/보안 예외만** 처리한다. 도메인 예외는 소비 모듈의 별도 `@RestControllerAdvice` 가 처리하고
Spring 이 두 advice 를 합성(compose)한다(CLAUDE.md 의 `@Order(HIGHEST_PRECEDENCE)` 규칙 참조).
- `adapter-web` 에 위치하는 이유: 실행 앱이 어떤 sample 모듈에도 의존하지 않고 envelope 형식 에러 응답을
제공하도록.
- **`spanErrorRecorder`.** 프로덕션 코드를 특정 트레이서
라이브러리에 결합하지 않고 span 에러를 기록하기 위한 이음새. 기본값 `SpanErrorRecorder.NOOP`. `@Autowired`
생성자가 `ObjectProvider` 로 self-default 하므로 전체 컨텍스트 / `@WebMvcTest` 슬라이스 / 순수 단위 테스트
모두 seam 빈 등록을 강제하지 않고 와이어링된다. Micrometer Tracing fork 는 자체 `SpanErrorRecorder` 빈만
등록하면 no-op 을 오버라이드한다.
**예외 → 에러코드 → HTTP 상태 매핑 계약** (매핑 자체는 코드가 SSOT; 아래는 근거):
| 예외 | 코드 | 상태 | 근거 |
|---|---|---|---|
| `MappingException` | `MAPPING_FAILED` | 400 | B3: 매퍼 내부 실패는 `MAPPING_FAILED` 로, `BAD_PARAMETER`/`INTERNAL_ERROR` 로 보내지 않음 |
| `AdapterDisabledException` | `ADAPTER_DISABLED` | 500 (retryable=false) | Layer 3 런타임 fail-fast(integration-adapter-templates §4/D4). 시작-수명주기용 `REQUIRED_ADAPTER_DISABLED` 가 아님(§Audit A2). 예외 메시지의 어댑터 이름은 서버 로그용, 클라이언트는 `client_safe_message` 만 |
| `IllegalArgumentException` | `BAD_PARAMETER` | 400 | B3: 매퍼가 아닌 호출자의 일반 예외 |
| `ConstraintViolationException` | `VALIDATION_FAILED` | 400 | field/message violation 리스트를 details 로 |
| `MethodArgumentTypeMismatchException` | `BAD_PARAMETER` | 400 | expectedType 을 details 로 |
| `InvalidBearerTokenException` | `INVALID_TOKEN` | 코드 상태 | |
| `AuthenticationException` | `UNAUTHENTICATED` | 코드 상태 | |
| `AccessDeniedException` | `SecurityErrorClassifier` 결정(예: `AUTHZ_INSUFFICIENT_PERMISSION`) | 분류기 결정 | 메서드-시큐리티 거부가 컨트롤러를 빠져나오면 여기 도달. 필터 계층 `EnvelopeAccessDeniedHandler` 와 **동일한 세분화 코드**를 내도록 무상태 classifier 에 위임 |
| `PreconditionFailedException` | `PRECONDITION_FAILED` | 412 | D15: `If-Match` 불일치 쓰기 = 낙관적 동시성 충돌 → 412 (raw 409/500 금지) |
| `PageValidationException` | `VALIDATION_FAILED` | 400 | D18/D20/D21: 계약 범위 밖 페이지/정렬/필터 파라미터. field + reasonCode 를 details 로 |
| `CursorException` | `VALIDATION_FAILED` | 400 | D22: 변조/만료/손상 커서. 조언 "첫 페이지 재요청", details field="cursor" code="CURSOR_INVALID" |
| `IdempotencyInFlightException` | `IDEMPOTENT_IN_FLIGHT` | 409 (retryable=false) | 대기 후에도 원본 처리 중. 진단정보(scope/principal)는 클라이언트 미도달 |
| `IdempotencyRequestMismatchException` | `IDEMPOTENT_REQUEST_MISMATCH` | 422 | D8: `Idempotency-Key` 를 다른 본문으로 재사용. fingerprint/scope 노출 금지 |
| `IdempotencyScopeMissingException` | `VALIDATION_FAILED` | 400 | §실패모드: 해석 가능한 scope 없는 키(예: 미인증 호출자)는 전역 충돌 대신 400 거부 |
| `PersistenceFailureException` | `ex.errorCode()` (사전분류 `DB_*`) | 코드 결정 | adapter-persistence translator 가 SQLState→`DB_*` 로 이미 분류. 클라이언트 메시지는 category-derived 안전 문자열, **절대 `ex.getMessage()` 아님**(SQLState/제약명 담음, 서버 로그 전용) |
| `DependencyFailureException` | `ex.errorCode()` (사전분류 `DEPENDENCY_*`) | 코드 결정 | adapter-outbound `OutboundHttpErrorMapper` 가 upstream 실패를 분류. 클라이언트 메시지는 per-code 고정 문자열(error-codes.yaml), **절대 `ex.getMessage()` 아님**. retryable + `retry_after_seconds` 있으면 `RetryAfterAdvisor``Retry-After` 부착 |
| `HttpRequestMethodNotSupportedException` | `METHOD_NOT_ALLOWED` | 405 | D12: 405 는 지원 메서드를 나열한 `Allow` 헤더 필수 |
| `HttpMediaTypeNotSupportedException` | `UNSUPPORTED_MEDIA_TYPE` | 415 | D9: 요청 본문 형식 미지원 — 406 과 구별 |
| `MaxUploadSizeExceededException` | `PAYLOAD_TOO_LARGE` | 413 | D8: 과대 본문은 envelope 내 413, raw 500 금지. 멀티파트 전용 413(`UPLOAD_SIZE_EXCEEDED`)은 이 영역의 책임 — 병합 후 정제 |
| `HttpMediaTypeNotAcceptableException` | `NOT_ACCEPTABLE` | 406 | D9: Accept 에 맞는 표현 없음 — 415 와 구별(합치면 RFC 9110 의미론 상실) |
| `MethodArgumentNotValidException` | `VALIDATION_FAILED` | 코드 상태 | field/rejectedValue/message 리스트를 details 로 |
| `HttpMessageNotReadableException` | `VALIDATION_FAILED` | 코드 상태 | cause 클래스명을 details 로 |
| `NoHandlerFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | |
| `Exception` (catch-all) | `INTERNAL_ERROR` | 500 | span 에러 기록 + "Internal server error" 고정 메시지 |
### ErrorResponseFactory
- 기반 운영 핸들러와 모든 도메인 핸들러가 **공유**하여 envelope 형식이 정확히 한 곳에서만 만들어지게
하는 단일-소스 컴포넌트(`httpStatus()` → Spring `HttpStatus` 매핑, `error.category` 운반, MDC 에서 `meta` 추출).
---
## envelope / filter
### EnvelopeBodyAdvice
- 컨트롤러는 도메인/DTO 타입을 반환하고, 이 advice 가 와이어 형태를 항상
`{success, data | error, traceId}` 로 보장한다.
- 위치: sample 모듈이 아니라 adapter-web. 실행 앱은 adapter-web 에 의존하지만 sample-portfolio 에는 의존하지
않으므로, 응답 래핑이 실제로 동작하려면 여기 있어야 한다.
### CacheControlFilter
- 스켈레톤 기본 HTTP 캐시 정책.
- `Cache-Control: no-store` 는 인증된 API 의 안전한 기본값. `Vary: Accept, Accept-Encoding, Authorization`
공유 프록시/CDN 이 협상이나 주체를 가로질러 콘텐츠를 오염(poison)시키지 못하게 한다.
- 기본값을 체인 **이전**에 설정: 캐시 가능한 엔드포인트가 반환값 처리에서 `Cache-Control`(예:
`private, max-age=60`)을 가진 `ResponseEntity` 를 반환해 기본값을 덮어쓰는 opt-in 이 가능하도록.
- 책임 경계: 이 모듈은 캐시 *헤더 정책*을 소유하고, 캐시 *레이어*(Redis/CDN)는 별도 인프라가 소유한다.
- 단일 소유권: Spring Security 기본 `Cache-Control``SecurityConfig` 에서 비활성화 → 실행 앱에서 이 필터가
헤더 단일 소유자. 독립 MockMvc(보안 체인 없음)에서도 이 필터가 유일 writer.
### RequestLoggingFilter
- **MDC 키 정책(D11/D19).** `MdcKeys` 의 snake_case 키 사용.
- **인바운드 id 헤더(D14/D15).** `X-Request-Id` / `X-Correlation-Id` 는 사용 전 sanitize(CR/LF + control 제거)
및 길이 제한. 부재/공백은 서버 생성.
- **W3C `traceparent`(D5/D7/D4).** 유효한 인바운드 traceparent 가 있으면 채택해 그 `traceId`→MDC `trace_id`,
`spanId``span_id`. 부재/공백/무효면 fresh ROOT traceparent 생성(32-hex traceId, 16-hex spanId,
sampled=false)하여 MDC `trace_id`**항상** 의미 있는 W3C id 이고 절대 null 이 아니게 한다(D4: 추적 비활성
상태에서도 `meta.traceId` non-null 보장). 해석된 traceparent 는 응답 헤더에 설정.
- `sampled=false` 근거: tracer seam 이 실제 sampling 결정을 소유하며 스켈레톤엔 exporter 가 없다.
- `freshHex16` 근거: 16-char span id 는 fresh UUID 의 least-significant bits 에서 파생·zero-pad — 64비트
전체가 entropy 를 갖도록(UUIDv4 version nibble 은 most-significant bits 라 제외). variant bits 가 값을
non-zero 로 유지해 W3C non-all-zero 규칙 충족.
- **사용자 주체 가명화.** `user_principal` 은 MDC 에 놓이기 전
`UserPrincipalPseudonymizerPort` 로 가명화. raw `idpUserId()` 는 절대 MDC/로그에 기록되지 않는다.
- **route template 해석.** 저-cardinality 매칭 라우트 템플릿 반환. `BEST_MATCHING_PATTERN_ATTRIBUTE`
는 handler mapping 이후 DispatcherServlet 이 설정하므로 `finally` 블록에서 항상 사용 가능.
- **주의 — 생성된 `trace_id` 는 실제 span 의 trace-id 가 아니다.** 무-tracer
스켈레톤에선 이 필터가(인바운드 traceparent 부재 시) `trace_id` 를 발급(MINT)하고 `ResponseMetaFactory`
이를 `meta.traceId` 로 투영한다. fork 가 Micrometer Tracing 을 켜면 OTel SDK 도 같은 요청에 trace-id 를
발급하고 SLF4J-Micrometer 브리지가 *자신의* id 를 MDC `trace_id` 에 쓴다. 어느 값이 최종 반영될지는
필터/observation 의 **ORDER 와 scope** 에 달려 있다 — 이 필터가 이기면 클라이언트의 `meta.traceId`
실제 export 된 span 의 trace-id 와 불일치해, "응답 id 로 trace 조회"라는 D4 의 핵심 목적이 조용히 깨진다.
실제 tracer 를 연결하는 fork 는 tracer 가 MDC `trace_id` 의 유일 소유자가 되게 해야 한다(이 필터를 tracing
observation *이후*로 정렬하거나, 생성 대신 `Span.current()` 채택). 현 green 테스트 스위트는 이를 잡지
못한다 — 무-tracer 메커니즘만 검증한다.
---
## ratelimit
### 알고리즘 seam (RateLimiter / RateLimiterFactory / RateLimitAlgorithm / FixedWindowRateLimiter)
- 알고리즘은 프로젝트마다 바뀔 수 있는 운영 선택이라 `RateLimiter` 인터페이스 뒤에 둔다.
- **OCP(개방-폐쇄)**: `RateLimitInterceptor``RateLimiter` 타입에만 의존하고, `RateLimiterFactory` 의 단일
`switch` 가 설정에서 구체 전략을 선택한다. 새 알고리즘 추가 = "새 `RateLimiter` 구현 + `RateLimitAlgorithm`
enum 값 + factory case" 이며 interceptor/web config 변경 불요. 향후 후보: `SLIDING_WINDOW`, `TOKEN_BUCKET`.
- **알고리즘 중립 출력 계약**: 구현마다 카운트 방식이 달라도(fixed-window end vs 연속 sliding vs token refill)
`X-RateLimit-*` 헤더 계약이 안정적이도록 모든 구현이 `RateLimitDecision` 을 아래 의미로 채운다.
- `limit` — 설정 quota
- `remaining` — 해당 키에 지금 아직 허용되는 요청 수, 0 으로 floor
- `resetAt` — 키가 최소 1개 요청 capacity 를 다시 얻는 시각(fixed-window=window 종료, token-bucket=다음
refill, sliding-window=가장 오래된 카운트 요청 만료 시점)
- `allowed` — quota 소진 시 false (→ 429)
- **FixedWindowRateLimiter 트레이드오프**: `X-RateLimit-Reset` 시각은 정확(window 종료)한 대신 window 경계를
가로지르는 burst 를 허용 — 스켈레톤 계약상 허용 가능. **D5**: 분산 limiter 는 core 범위 밖이라 per-instance
전용이며, 다중 인스턴스 배포 시 유효 한도는 설정값의 N배. key→window 맵은 evict 되지 않는다(single-node,
distinct active key 수로 bounded) — 키 cardinality 무제한 배포는 expiry/eviction 추가 필요.
### RateLimitKeyResolver
- 키 형태: service-to-service
`apikey:<id>`(api_key_id override), 인증 `user:<id>`, 미인증 `ip:<source-ip>:<METHOD route-template>`(정규화).
- principal 은 로그 `user_principal`(`AuthenticatedPrincipal#idpUserId`)과 동일 표현 재사용. pseudonymization 은
이 영역의 책임 seam — 이 브랜치는 표현을 재사용만 하고 변환하지 않는다.
- tenant prefixing 은 아직 구현하지 않은 확장 지점이다.
- **하드 룰:** 키는 raw 토큰이나 요청 본문에서 절대 도출하지 않는다.
- `HandlerInterceptor` 입력으로 resolve 하는 이유: route template(`/v1/worklogs/{id}`)을 쓰기 위함. servlet
filter 는 handler mapping 이전에 실행돼 구체 경로만 보므로 모든 id 가 서로 다른 키가 되어버린다.
### RateLimitClientIpMode
- 비인증 rate-limit 키의 클라이언트 IP 소스 선택 enum.
- `REMOTE_ADDR_ONLY` — 직접 노출 배포의 안전한 기본값(spoofing 가능한 forwarded 헤더 무시).
- `FORWARDED_HEADERS_TRUSTED` — 신뢰할 수 있는 ingress/LB 가 forwarded 헤더를 덮어쓰는 경우에만 사용.
### RateLimitWebConfig
- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서
resolve 되기 때문(RateLimitKeyResolver 참조).
- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest`
슬라이스에서도 `RateLimitSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에선 `Clock#systemUTC()` 로 fallback.
### RateLimitInterceptor
- fixed-window rate limit 을 매핑된 handler 실행 전에 적용. 모든 응답에 `X-RateLimit-*` 헤더 포함(generated_if_missing=true).
- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable
의존성 장애로 오분류하는 것을 막는다.
---
## settings / config / http
### CorsSettings
- **3계층 검증 전략.**
1. 단순 제약(범위/필수/정규식)은 JSR-303 + `@Validated` 로 선언해 잘못된 값이 `BindValidationException` 으로
기동 실패(`maxAgeSeconds`).
2. JSR-303 로 표현 불가한 조건부/교차필드 규칙은 compact constructor 의 fail-fast `throw` 로 강제(관대한 기본값
폴백 금지).
3. 정상 기본값(CORS disabled 시 빈 origins, 미설정 method/header)은 invalid 가 아니라 합리적 기본값으로 채움.
- **교차필드 불변식**: CORS enabled 시 최소 하나의 allowed origin 필수(JSR-303 표현 불가 → fail-fast). 빈 목록
관대한 폴백은 모든 브라우저 호출자를 조용히 거부하게 된다.
- **D9 (WHATWG Fetch §3.3, FETCH-CORS-C3)**: wildcard origin + credentials 금지 — `Access-Control-Allow-Origin: *`
`Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동
시점에 fail-fast 거부.
### RateLimitSettings
- `ca-skeleton.rate-limit.*` 에서 바인딩되고, composition root 의 `@ConfigurationPropertiesScan` 으로 자동 등록된다.
- `enabled``APP_RATE_LIMIT_ENABLED`(env-keys.yaml, restart-only, behavior-change)에 매핑.
- `limit`/`window`/`algorithm` 은 env key 없음 — 리미터 튜닝 파라미터(`프로젝트 선택`; 멀티 인스턴스
정확성은 범위 밖, D5)이며 fork 가 레지스트리 변경 없이 `application.yml` 에서 재정의하도록 in-code 기본값.
`algorithm` 기본값 `RateLimitAlgorithm.FIXED_WINDOW`.
### SecuritySettings
- OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가
기동 시 실패하므로 여기서 명확한 에러를 먼저 표면화한다. 나머지 knob 은 warn 후 폴백.
### PresentationSettings
- 검증 정책 "warn-and-default": 부재/잘못된 prefix 값은 앱을 멈추는 대신 빈 prefix 로 폴백 — 모든 엔드포인트가
(/api 없이) 계속 접근 가능하게 유지.
### JacksonNullableConfig
- `JsonNullableModule` 을 Spring 관리 `ObjectMapper` 에 등록. 없으면 PATCH 요청 DTO 의 `JsonNullable<T>`(B2)를
Jackson 이 역직렬화하지 못해 absent / explicit-null 구분이 조용히 붕괴된다.
- 스켈레톤 전역 web 관심사(공유 `Patch<T>` 타입과 짝)라 도메인 샘플 모듈이 아니라 adapter-web 에 위치.
### ApiHeaders
- 인바운드 web 어댑터 전역의 HTTP 헤더명 상수 — `docs/registries/headers.yaml`
코드 미러. 리터럴을 중앙집중해 controller/filter/advice 가 casing 으로 drift 하지 않게 하고, 레지스트리
일관성 테스트가 단일 출처를 참조하게 한다.
- 소유권: `X-Api-Version`(D2)·`Idempotency-Key`(D3 — 이름만; key shape/scope/replay 정책은
application-core 소유)는 여기서 생산. conditional-request(D15)·cache(D16)·method/negotiation/
LRO(D12/D17)·pagination(D18)·rate-limit signaling(generated_if_missing=true; Limit/Remaining 은 numeric, Reset 은
rfc3339 = fixed-window end)·deep-offset deprecation marker(D18)·always-emitted(D24)는 표준 RFC 9110/9111 이름 참조.
---
## observability
### MdcKeys
- snake_case MDC 키 이름은 로그/진단 레지스트리(`mdc-keys.yaml`)를 따른다.
같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며,
변환 단일 지점은 `ResponseMetaFactory`.
### HeaderSanitizer
- 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을
가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를
제거 후 길이 제한.
- `프로젝트 선택`: 구체 문자셋 정책(strip vs encode)과 최대 길이는 ca-tmpl 트레이드오프. OWASP 는
원칙만 규정하고 정규식/한계는 규정하지 않는다.
### ResponseMetaFactory
- snake_case MDC 진단 키를 camelCase `ResponseMeta` wire 객체로 projection 하는 D19 단일 변환 지점. adapter-web
에 위치하는 이유: shared-contract 는 프레임워크 중립이라 MDC 를 읽으면 안 된다.
### RetryAfterAdvisor
- **`Retry-After` 노출 지점.** 구체 헤더 값과 429/503 세부는 이 영역의 책임이고,
per-code `retry_after_seconds` 는 error-codes.yaml 에 존재한다.
이 helper 는 "재시도 가능한 코드가 `Retry-After` 헤더를 받을 자격이 있는가?"만 답해, 호출부가
재시도 가능 여부를 재도출하지 않고 헤더를 붙이게 한다.
- **Tracing wiring:** 운영 5xx 는 서버 span 에 `exception` 이벤트 + span status ERROR 를 기록해야 하나,
Micrometer-Tracing/OTel 가 classpath 에 없어 wiring 은 이 영역의 책임 — 의도적
미구현.
- 필드 `RETRY_AFTER_SECONDS` 는 error-codes.yaml 의 `retry_after_seconds` 컬럼 미러.
이 advisor 가 유일한 Retry-After 노출 지점이라 여기 중앙화한다.
`DEPENDENCY_4XX_CLIENT` 는 비재시도(retryable=false)라 `shouldAdvise` 가드로 empty 반환.
---
## pagination
### PageParams
- 검증된 offset 페이지네이션 파라미터. `page` 0-indexed: Spring `Pageable`
parity(SPRING-PAGE-C1). `size` 기본 20 / min 1 / max 100: 프로젝트 DoS 캡(Spring 자체 `DEFAULT_MAX_PAGE_SIZE`
는 2000, SPRING-PAGE-C4).
- `프로젝트 선택`: 정확한 size 캡(100)/min(1)/deep-offset 임계값(10000)은 프로젝트 내부
트레이드오프 — 표준은 원칙만 고정하고 숫자는 고정하지 않는다.
### SortParam
- Spring `Pageable` 네이티브 문법 `field,direction` 의 단일 정렬 term(D20). 비-네이티브 문법 거부 근거:
JSON:API prefix(`-foo`)·colon form(`foo:desc`)·AIP-132 space form(`"foo desc"`)은 모두 Spring 자동 바인딩을
깨뜨리므로 금지.
### PageValidationException
- 페이지네이션/정렬 요청 파라미터가 스켈레톤의 요청 경계를 위반할 때 발생.
---
## cursor
### CursorCodec
- 불투명·서명·시간 제한 페이지네이션 커서 코덱(D22, AIP158-C5).
- **SEAM(producer-only)**: HMAC 키와 회전 정책은 이 영역의 책임. 해당 브랜치가 이
저장소에 없어 프로덕션 키 wiring 은 `planned`. 코덱은 주입된 키를 받고 테스트/로컬용 `withDevKey()` 팩토리
제공(프로덕션 금지). encode/decode 메커니즘·opacity·무결성 검사·TTL 은 여기 구현.
- `DEFAULT_TTL`: D22 의 24h TTL 은 프로젝트 내부 숫자(AIP-158 은 opacity 만 고정, TTL 미고정).
### CursorException
- 불투명 페이지네이션 커서 검증 실패 시 발생(D22).
---
## conditional
### ETags
- HTTP 계층 낙관적 동시성/캐시 검증용 weak-ETag 도출 및 조건부 요청 매칭(D15, RFC9110-C13..C17).
`weakFromVersion` 산출물 `W/"<version>"` 는 스켈레톤의 예시 wire 형태다.
- `프로젝트 선택`: RFC 9110 은 `If-Match` 에 strong 비교를 의무화하나, 이 스켈레톤은 불투명 값을
leniently 비교(`W/` weak 마커와 둘러싼 따옴표 무시)해 문서화된 weak-ETag 형태로도 낙관적 잠금을 구동한다.
strong ETag 를 발행하는 프로덕션 fork 도 동일 호출 지점을 유지 가능.
### PreconditionFailedException
- 쓰기 요청의 `If-Match` validator 가 현재 리소스 ETag 와 불일치할 때 발생(D15). 412 로 매핑해 raw 409/500 과
구분 — persistence 계층이 serialization failure 로 surface 할 동일한 낙관적 동시성 충돌의 HTTP 계층 표현.
---
## idempotency
### IdempotencyKeySupport
- HTTP 요청으로부터 application `IdempotencyExecutor` 입력을 조립하는 web 측 helper.
- principal 은 인증된 `AuthenticatedPrincipal#idpUserId()` — rate-limit 키 및 로그 `user_principal` 과 동일
표현. 미인증 호출자는 principal 이 없어 `IdempotencyScope.of`
`IdempotencyScopeMissingException`(→ 400)으로 거부 → scope 없는 키의 전역 충돌 방지.
- `프로젝트 선택`: fingerprint 는 raw 전송 바이트가 아니라 직렬화된 command payload 기준으로
계산 → JSON 키 순서/공백 차이로 인한 false mismatch 방지. 단, 바이트 동일 body 를 두 번 POST 한 클라이언트는
여전히 매칭. 완전한 요청 canonicalization 은 실제 요청 패턴으로 추가 검증이 필요하다.
- tenant 는 null(단일 테넌트); tenant scoping 은 아직 구현하지 않은 확장 지점이다.
### JsonIdempotentResponseCodec
- Jackson 기반 `IdempotentResponseCodec`(§B): web 어댑터가 application executor 의 저장/replay JSON wire 포맷을
소유. (역)직렬화 실패는 `MappingException` 으로 surface 되어 base handler 가 raw 500 이 아닌 `MAPPING_FAILED`
400 으로 라우팅.
+16
View File
@@ -0,0 +1,16 @@
// HTTP / web adapters. Depends on application, domain, and shared operational contracts.
dependencies {
implementation project(':domain-core')
implementation project(':application-core')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
// feature-api-contract-baseline D10: OpenAPI producer. springdoc exposes the
// running app's machine-readable contract at /v3/api-docs (OAS 3.1, generated
// never a hand-maintained stale schema). The release-blocking drift gate is
// owned by feature-contract-verification-test-suite (planned).
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6'
}
+181
View File
@@ -0,0 +1,181 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springdoc:springdoc-openapi-starter-common:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
import dev.caskeleton.shared.error.OperationalError;
import dev.caskeleton.shared.response.Envelope;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import tools.jackson.databind.ObjectMapper;
/**
* Writes a classified security failure to the servlet response as the skeleton-wide {@link
* Envelope} (same shape as every other error), and logs it safely. The response body and log line
* carry only redacted, client-safe metadata. See README for the design rationale.
*/
class AuthErrorResponseWriter {
private static final Logger log = LoggerFactory.getLogger(AuthErrorResponseWriter.class);
/** Client-safe messages, aligned with the registry {@code client_safe_message} column. */
private static final Map<OperationalError, String> CLIENT_MESSAGES =
Map.of(
OperationalError.AUTH_TOKEN_MISSING, "Authentication required",
OperationalError.AUTH_TOKEN_EXPIRED, "Authentication expired",
OperationalError.AUTH_KID_UNKNOWN, "Authentication failed, please retry",
OperationalError.AUTH_JWKS_UNAVAILABLE, "Authentication service temporarily unavailable",
OperationalError.AUTHZ_INSUFFICIENT_PERMISSION, "Permission denied",
OperationalError.AUTHZ_TENANT_MISMATCH, "Permission denied");
private final ObjectMapper objectMapper;
AuthErrorResponseWriter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
void write(HttpServletRequest request, HttpServletResponse response, OperationalError code)
throws IOException {
// Log only safe metadata never the token or the raw failure message.
log.warn(
"security failure: code={} category={} method={} path={}",
code.code(),
code.category(),
request.getMethod(),
request.getRequestURI());
response.setStatus(code.httpStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
// A 401 response carries a minimal WWW-Authenticate header (no issuer / token detail).
if (code.httpStatus() == 401) {
response.setHeader(
HttpHeaders.WWW_AUTHENTICATE,
code == OperationalError.AUTH_TOKEN_MISSING
? "Bearer"
: "Bearer error=\"invalid_token\"");
}
RetryAfterAdvisor.retryAfterSeconds(code)
.ifPresent(
seconds -> response.setHeader(HttpHeaders.RETRY_AFTER, Integer.toString(seconds)));
Envelope<Void> body = ErrorResponseFactory.body(code, clientMessage(code), null);
objectMapper.writeValue(response.getWriter(), body);
}
private String clientMessage(OperationalError code) {
return CLIENT_MESSAGES.getOrDefault(code, "Authentication failed");
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.inbound.web.auth;
import java.util.Set;
/**
* Principal exposed to controllers via {@code @AuthenticationPrincipal}. Carries the IdP-side
* identifier ({@code idpUserId}, JWT {@code sub} claim) plus any claims a controller is likely to
* want without reaching into the raw Jwt.
*/
public record AuthenticatedPrincipal(String idpUserId, String email, Set<String> roles) {
public AuthenticatedPrincipal {
roles = roles == null ? Set.of() : Set.copyOf(roles);
}
public boolean hasRole(String role) {
return roles.contains(role);
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.web.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import tools.jackson.databind.ObjectMapper;
/**
* Resource-server {@link AccessDeniedHandler} that maps an authorization failure (valid token,
* insufficient permission) to {@code AUTHZ_INSUFFICIENT_PERMISSION} (403) and writes it as the
* skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design
* rationale.
*/
public class EnvelopeAccessDeniedHandler implements AccessDeniedHandler {
private final SecurityErrorClassifier classifier;
private final AuthErrorResponseWriter writer;
public EnvelopeAccessDeniedHandler(
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
this.classifier = classifier;
this.writer = new AuthErrorResponseWriter(objectMapper);
}
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException {
writer.write(request, response, classifier.classifyAccessDenied(accessDeniedException));
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.web.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import tools.jackson.databind.ObjectMapper;
/**
* Resource-server {@link AuthenticationEntryPoint} that classifies an authentication failure into a
* fine-grained {@link dev.caskeleton.shared.error.OperationalError} and writes it as the
* skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design
* rationale.
*/
public class EnvelopeAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final SecurityErrorClassifier classifier;
private final AuthErrorResponseWriter writer;
public EnvelopeAuthenticationEntryPoint(
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
this.classifier = classifier;
this.writer = new AuthErrorResponseWriter(objectMapper);
}
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
writer.write(request, response, classifier.classifyAuthentication(authException));
}
}

Some files were not shown because too many files have changed in this diff Show More