init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -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와 파생 문서 캡처 결과
|
||||
- 남은 위험 또는 후속 작업
|
||||
|
||||
짧은 작업이라도 검증 여부는 생략하지 않는다.
|
||||
@@ -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 3–5 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 자동화 명령으로 보강 필요.
|
||||
@@ -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 저장소·재처리 절차 보강 필요.
|
||||
@@ -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 수치 확정 시 보강 필요.
|
||||
@@ -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 예산 확정 시 보강 필요.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 토픽·승인 체계 확정 시 보강 필요.
|
||||
@@ -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 라우팅·대시보드 링크 확정 시 보강 필요.
|
||||
@@ -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 연동 절차로 보강 필요.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 3–5 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.
|
||||
@@ -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: []
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
+139
@@ -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"]
|
||||
@@ -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
@@ -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)와 달라야 합니다.**
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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=
|
||||
+72
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+25
@@ -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!
|
||||
}
|
||||
+102
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 로 유지된다.
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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=
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+96
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
+125
@@ -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;
|
||||
}
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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");
|
||||
}
|
||||
}
|
||||
+131
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+55
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 으로 라우팅.
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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=
|
||||
+76
@@ -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");
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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));
|
||||
}
|
||||
}
|
||||
+35
@@ -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
Reference in New Issue
Block a user