45 KiB
title, source_type, status, branch, parent_branch, related_projects, governing_docs, tags, created, target_merge, status_label, id, kind, project, work_item, inherits, refines, overrides, depends_on, contract_packet, contract_packet_sha256
| title | source_type | status | branch | parent_branch | related_projects | governing_docs | tags | created | target_merge | status_label | id | kind | project | work_item | inherits | refines | overrides | depends_on | contract_packet | contract_packet_sha256 | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| branch / feature-rate-limit-idempotency-contract | branch-note | raw | feature-rate-limit-idempotency-contract |
|
|
|
2026-05-21 | in-progress | BR-CA-SKELETON-OPERATIONAL-CONTRACT-016 | project-work-item | ca-skeleton-operational-contract | WI-CA-SKELETON-OPERATIONAL-CONTRACT-016 |
|
1 | f4c77ee0413caae0af0437e46ad05664e0f65e7b3c64923f318cb4512e3da4c7 |
branch: feature-rate-limit-idempotency-contract
Layer:
raw/branch-notes/— rate limit, abuse protection, idempotency 기준을 정의합니다.
[!important] 주도권 이전 고지 (2026-07-28) 아래 관심사의 owner 가 신규 branch 로 이동했다. 근거·절차:
docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md§5.
이전 ID 대상 D-row 이전한 관심사 신규 owner H1 D2·D3·D6·D7·D8·D9 idempotency 저장소·프로토콜·TTL·fingerprint raw/branch-notes/feature-idempotency-ownership-protocol-contract H2 D4·D5 rate limit 키 차원·분산 지원·알고리즘 raw/branch-notes/feature-edge-rate-limit-policy-registry-contract 본문은 아직 제거하지 않았다. 신규 branch 는 현재 스캐폴딩 상태(D-row 없음)이므로, 지금 본문을 지우면 결정 근거가 소실된다. 위 D-row 는 신규 branch 의
/branch-spec이 재판정을 마칠 때까지 잠정 근거로만 유효하며, 그 시점에 설계 §5.2 6단계에 따라 포인터(superseded → [[<신규 branch>]] D<m>)로 치환한다.이 기간 중 새 결정을 본 branch 에 추가하지 않는다 — 신규 owner 에 작성한다.
부모 (필수)
- Parent project (canonical SSOT): raw/project-notes/ca-skeleton-operational-contract
ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
브랜치 계약 패킷
- 생성 시 프로젝트 개정:
1 - 패킷 스키마:
contract_packet: 1 - 완료 조건: principal·tenant key scope와 replay/rate-limit test가 통과한다
상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-001@1 |
idempotency scope는 principal·key·useCase이며 tenant 활성화 시 tenant를 prefix한다 | Work Item 완료 조건에 적용 | raw/project-notes/ca-skeleton-operational-contract |
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RATE-LIMIT-001@1 |
authenticated는 principal, unauthenticated는 IP와 normalized route를 rate-limit key로 사용한다 | Work Item 완료 조건에 적용 | raw/project-notes/ca-skeleton-operational-contract |
브랜치 지역 결정
기존 branch-local 결정은 아래
## Decision Evidence Map / 결정-근거 매핑의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|
선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|
목표
중복 요청, 재시도, abuse traffic은 비즈니스 로직이 없어도 운영 장애로 이어집니다. skeleton은 어떤 요청이 idempotent해야 하는지, rate limit 실패를 어떻게 응답/로그/테스트할지 기준을 가져야 합니다.
- 이슈:
- PR:
범위
포함 범위
- idempotency key header 기준.
- idempotent command storage는 DB table 기반 key/result/status/ttl 기준.
- duplicate request 분류.
- rate limit response/log 기준.
- abuse protection log 기준.
- retry-after header 기준.
제외 범위
- full WAF 구현.
- distributed rate limiter 기본 탑재.
- business quota model.
근거 (필수, 최소 1개+)
본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
| Source | 정당화하는 결정 |
|---|---|
| raw/official-docs/idempotency-stripe-api-ref | Stripe v2 triple `(account, API, key |
| raw/official-docs/idempotency-ietf-draft | 422 mismatch / 409 in-flight 표준 권고와 정합 |
| raw/company-tech-blogs/idempotency-brandur-stripe-postgres | Postgres 구현 reference |
| raw/official-docs/idempotency-paypal-docs | TTL이 가장 김 |
| raw/official-docs/idempotency-aws-lambda-powertools | key 자체가 hash, header 불요 |
| raw/official-docs/idempotency-square-api | — |
| raw/company-tech-blogs/idempotency-toss-payments-techblog | ca-tmpl보다 1 dimension 많고 TTL 더 김 |
| raw/official-docs/idempotency-no-api-level-github-rest | GitHub, server-side dedup 없음 |
| raw/company-tech-blogs/idempotency-redis-vs-db-storage | ca-tmpl이 DB 선택 근거 |
외부 근거 / 대안 조사 (2026-05-22 — Topic 5)
본 branch의 idempotency triple (authenticatedPrincipal, idempotencyKey, useCaseName) + DB table + 24h TTL + 200ms in-flight wait + fingerprint mismatch 422 결정에 대한 외부 source 조사. 비교 분석은 (예정) wiki/concepts/idempotency-key-design.md 참조.
- 채택 결정 (triple scope + DB table + 24h TTL + 422 fingerprint mismatch):
- (가장 가까운 reference) raw/official-docs/idempotency-stripe-api-ref — Stripe v2 triple
(account, API, key)사실상 동등 - raw/official-docs/idempotency-ietf-draft — 422 mismatch / 409 in-flight 표준 권고와 정합
- raw/company-tech-blogs/idempotency-brandur-stripe-postgres — Postgres 구현 reference
- (가장 가까운 reference) raw/official-docs/idempotency-stripe-api-ref — Stripe v2 triple
- 검토한 대안:
- 대안 1: Stripe v1 pair
(account, key)— raw/official-docs/idempotency-stripe-api-ref (endpoint dimension 없음, ca-tmpl보다 덜 보수적) - 대안 2: PayPal
(req-id, API call type)+ 45일 TTL — raw/official-docs/idempotency-paypal-docs (TTL이 가장 김) - 대안 3: Content-hash
(fn, payload_hash)— raw/official-docs/idempotency-aws-lambda-powertools (key 자체가 hash, header 불요) - 대안 4: Square endpoint-scoped — raw/official-docs/idempotency-square-api
- 대안 5: 토스 4-tuple
(account, key, URL, method)+ 15일 TTL — raw/company-tech-blogs/idempotency-toss-payments-techblog (ca-tmpl보다 1 dimension 많고 TTL 더 김) - 대안 6: No API-level idempotency — raw/official-docs/idempotency-no-api-level-github-rest (GitHub, server-side dedup 없음)
- 대안 1: Stripe v1 pair
- 보조 결정 (저장소): raw/company-tech-blogs/idempotency-redis-vs-db-storage — ca-tmpl이 DB 선택 근거
- 비교 핵심: ca-tmpl triple은 Stripe v1보다 보수적, Stripe v2/Square/Toss와 동급. TTL 24h가 모든 reference 중 가장 짧음 (스토리지 비용·키 추측 공격면 최소). 200ms wait는 in-flight retry 친화적 (Brandur lock의 변형). fingerprint 422는 IETF draft 권고 정합.
TODO
TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Rate-limit Key Default" / "Decisionized Work Items" 참조. idempotency key header / DB table storage / duplicate replay response / rate limit error code / retry-after / abuse protection log / idempotent test 기준 모두 결정 라인 또는 표로 반영됨. 잔존 TODO 없음.
진행 중 메모
- idempotency는 transaction/concurrency contract와 함께 봐야 합니다.
결정 사항 (decisions)
- 2026-05-21: rate limit과 idempotency를 API/runtime 운영 표면에 포함.
- 2026-05-22: idempotency key shape의 SSOT는 이 branch. 기본 scope는
(authenticatedPrincipal, idempotencyKey, useCaseName), tenant 활성화 시(tenant, authenticatedPrincipal, idempotencyKey, useCaseName). - 2026-05-22: idempotency 저장소는 DB table 기본이며
key,scope,requestHash,status,responseRef,ttl,createdAt을 가진다. - 2026-05-22: rate-limit key는 authenticated principal 기준, unauthenticated는 IP + normalized route 기준. tenant 활성화 시 tenant prefix.
- 2026-05-22: distributed rate limiter는 core out of scope. multi-instance claim에는 Redis/distributed counter contract가 필요.
- 2026-05-22: idempotency TTL default = 24h. long-running use case(결제/송금 등)는 use case 선언으로 72h까지 override 가능.
- 2026-05-22: in-flight 동시 도착 정책 = insert-or-read with unique constraint + 200ms wait. 200ms 초과 시 409 IDEMPOTENT_IN_FLIGHT (retryable=false, client는 polling).
- 2026-05-22: fingerprint mismatch (same key + different body) = 422 IDEMPOTENT_REQUEST_MISMATCH. body는 SHA-256 hash로 비교.
- 2026-05-22: responseRef 저장 위치 = 응답 body가 ≤8KB면 DB 동일 row, >8KB면 object store (S3-compatible) 키만 row에 보관.
- 2026-05-22: idempotency TTL(24h) ≤ JWT key rotation overlap(24h)는 invariant. security-operational-baseline의 rotation overlap window 변경 시 본 branch TTL도 동시 검토.
Rate-limit Key Default
| caller | rate-limit key |
|---|---|
| authenticated user | user_principal (pseudonymized) |
| service-to-service (API key) | api_key_id |
| unauthenticated | source_ip + uri_template (normalized) |
| tenant 활성 시 | 위 + tenant_id prefix |
Decisionized Work Items
| item | Decision | Allowed | Forbidden | Required test |
|---|---|---|---|---|
| idempotency scope | principal + key + useCase | tenant prefix when enabled | global key only | collision test |
| storage | DB table with unique scope/key | Redis as optional cache only | in-memory prod storage | replay test |
| concurrent arrival | insert-or-read unique constraint | serializable transaction if needed | duplicate write race | concurrent replay test |
| rate-limit key | principal or IP+route | API key as org override | raw token/body-derived key | 429 test |
| distributed limiter | out of core | Redis/distributed counter package | HPA support with local counter | multi-instance test |
결정-근거 매핑
각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog / engineering-blog 출처는 각각
company-case-study/engineering-blog로 라벨링하며 공식 best practice 로 격상하지 않는다.
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|
| D1 | rate limit + idempotency 를 API/runtime 운영 표면에 포함 (2026-05-21) | UNSUPPORTED_DECISION (project-internal scoping decision; 외부 표준이 두 영역의 통합 운영을 normative 로 강제하지 않음) | N/A | 두 영역의 단일 SSOT 운영 정합성은 sibling branch (feature-api-contract-baseline) 와 cross-review 필요 |
| D2 | idempotency key shape SSOT — 기본 scope (authenticatedPrincipal, idempotencyKey, useCaseName) triple, tenant 활성 시 4-tuple |
raw/official-docs/idempotency-stripe-api-ref.md#STRIPE-IDEMP-C5 (Stripe v2 triple (account, API, key) + 30일), raw/official-docs/idempotency-ietf-draft.md#IETF-IDEMP-C2 ("Uniqueness ... MUST be defined by the resource owner"), raw/company-tech-blogs/idempotency-toss-payments-techblog.md#TOSS-IDEMP-C2 (Toss 4-tuple (account, key, URL, method) 비교), raw/company-tech-blogs/idempotency-brandur-stripe-postgres.md#BRANDUR-IDEMP-C3 ((user_id, idempotency_key) 2-tuple 사례) |
official-vendor-doc + official-reference + company-case-study + engineering-blog |
IETF-IDEMP 는 draft 상태 — scope 자유는 표준 인용 가능하나 정식 RFC 아님. Toss 는 vendor case study (best practice 격상 금지). triple vs pair vs body-hash 의 선택은 표준이 강제하지 않음 |
| D3 | idempotency 저장소 — DB table 기본, key/scope/requestHash/status/responseRef/ttl/createdAt 컬럼 |
raw/company-tech-blogs/idempotency-brandur-stripe-postgres.md#BRANDUR-IDEMP-C1 (locked_at 컬럼), #BRANDUR-IDEMP-C2 (params 컬럼으로 fingerprint mismatch error), #BRANDUR-IDEMP-C3 (unique 제약), raw/company-tech-blogs/idempotency-redis-vs-db-storage.md#REDIS-VS-DB-C6 (결제 도메인 vendor 들이 영속 저장 사용) |
engineering-blog + needs-confirmation |
Brandur 는 engineering-blog (Stripe 엔지니어 작성, 비공식) — 공식 Stripe ref 가 backend 미공개. REDIS-VS-DB-C6 자체가 needs-confirmation strength. ca-tmpl 컬럼 구성의 정확한 schema 표준 인용 없음 |
| D4 | rate-limit key — authenticated principal 기준, unauthenticated 는 IP + normalized route, tenant 활성 시 tenant prefix | UNSUPPORTED_DECISION (cited sources 중 rate-limit key shape 에 대한 normative / vendor 진술 없음 — Stripe / Toss / IETF idempotency 자료는 모두 idempotency scope 만 다룸) | N/A | rate-limit key shape 의 정당성은 별도 raw (예: Stripe rate-limit, AWS API Gateway throttling) 인용 보강 필요. ⚠️ error-codes.yaml#RATE_LIMIT_EXCEEDED.owner_layer: presentation 가 이미 registry 에 고정됨 — key shape 가 외부 근거로 보강되기 전에 layer/응답 계약이 굳으면 이후 변경 비용 증가 → 구현 착수 전 보강 권고 |
| D5 | distributed rate limiter 는 core out of scope; multi-instance claim 시 Redis/distributed counter contract 필요 | UNSUPPORTED_DECISION (project-internal scoping decision; 외부 표준 근거 없음) | N/A | multi-instance 배포 시 single-node rate-limit 의 정확성 손실 — distributed limiter 도입 시점의 trigger 정의 필요 |
| D6 | idempotency TTL default = 24h; long-running use case 는 use case 선언으로 72h 까지 override | raw/official-docs/idempotency-stripe-api-ref.md#STRIPE-IDEMP-C2 (Stripe v1 "at least 24 hours" 최소치), raw/official-docs/idempotency-ietf-draft.md#IETF-IDEMP-C5 ("MAY require time based ... SHOULD define ... publish in documentation" — TTL 자유), raw/company-tech-blogs/idempotency-toss-payments-techblog.md#TOSS-IDEMP-C3 (Toss 15일 비교), raw/company-tech-blogs/idempotency-brandur-stripe-postgres.md#BRANDUR-IDEMP-C6 (Brandur 72h 권장 — override 상한 근거) |
official-vendor-doc + official-reference + company-case-study + engineering-blog |
24h 가 결제 도메인 표준 TTL 이라는 일반화 금지 (Toss 15일 / Stripe v2 30일 / PayPal 45일 / IETF draft 자유). ca-tmpl 24h 는 모든 reference 중 가장 짧음 — retry window 손실 vs 저장 비용 trade-off (해석) |
| D7 | in-flight 동시 도착 정책 — insert-or-read with unique constraint + 200ms wait; 초과 시 409 IDEMPOTENT_IN_FLIGHT (retryable=false) | raw/official-docs/idempotency-ietf-draft.md#IETF-IDEMP-C4 ("resource SHOULD respond with a resource conflict error" — 409), raw/company-tech-blogs/idempotency-toss-payments-techblog.md#TOSS-IDEMP-C4 (Toss 즉시 409 IDEMPOTENT_REQUEST_PROCESSING), raw/company-tech-blogs/idempotency-brandur-stripe-postgres.md#BRANDUR-IDEMP-C5 (lock 획득 조건 — stale lock 회수) |
official-reference + company-case-study + engineering-blog |
IETF 권고는 SHOULD (immediate 409); ca-tmpl 의 200ms wait 는 표준의 변형 — 면접 / 외부 인용 시 "표준 따름" 금지, "표준 기반 + 운영 친화적 변형" 표현 필수. PayPal PAYPAL-IDEMP-C4 의 "might fail" 도 명시적 동작 정의는 아님 |
| D8 | fingerprint mismatch (same key + different body) = 422 IDEMPOTENT_REQUEST_MISMATCH; body 는 SHA-256 hash 로 비교 | raw/official-docs/idempotency-ietf-draft.md#IETF-IDEMP-C3 ("resource SHOULD reply with a HTTP 422"), raw/official-docs/idempotency-stripe-api-ref.md#STRIPE-IDEMP-C3 ("errors if they're not the same"), raw/company-tech-blogs/idempotency-brandur-stripe-postgres.md#BRANDUR-IDEMP-C2 (params 저장 목적), #BRANDUR-IDEMP-C4 ("Programs sending ... is a bug") |
official-reference + official-vendor-doc + engineering-blog |
IETF 422 권고는 draft SHOULD; Stripe 는 정확한 status code 미명시 (Toss 도 TOSS-IDEMP-C6 미명시). SHA-256 hash 선택의 표준 인용은 없음 — 운영 선택 |
| D9 | responseRef 저장 위치 — body ≤8KB 면 DB row, >8KB 면 object store (S3-compatible) key 만 row 에 보관 | UNSUPPORTED_DECISION (cited sources 중 response body 저장 threshold / object store 분리에 대한 normative / vendor 진술 없음) | N/A | 8KB threshold 선택의 근거 (DB row size 한계, 응답 크기 분포) 별도 측정 데이터 / vendor ref 보강 필요 |
| D10 | idempotency TTL(24h) ≤ JWT key rotation overlap(24h) invariant; rotation overlap window 변경 시 동시 검토 | UNSUPPORTED_DECISION (project-internal cross-branch invariant; 외부 표준 근거 없음) | N/A | sibling branch (security-operational-baseline) 와 invariant 변경 시 동시 PR 강제 메커니즘 필요 — invariant 가 문서에만 있고 CI gate 없으면 silent drift |
구현 가이드
CLAUDE.md §15.5 3-rule 적용: 각 row 는 본 branch 의
Decision ID+Supporting Claim ID를 reference (R1). 근거 raw 가 원칙만 권고하고 detail (메커니즘/임계값/algorithm) 은 권고 안 한 cell 은UNSUPPORTED_IMPL_DECISION+ trade-off 한 줄 (R2). 본 branch 결정 범위 밖 detail 은 §엣지·실패·의존 으로 위임 (R3).Ground truth (2026-06-09, ca-tmpl
src/+docs/registries/읽기 전용 확인) — 구현 상태 라벨은 코드 grep 으로만 확정한다 (note→note 자기 보고는 근거 아님):
actually-implemented(계약/seam 층):error-codes.yaml3 row (RATE_LIMIT_EXCEEDED·IDEMPOTENT_IN_FLIGHT·IDEMPOTENT_REQUEST_MISMATCH, 모두owner_branch: feature-rate-limit-idempotency-contract),headers.yaml6 row (Idempotency-Key·Retry-After·X-RateLimit-Limit/Remaining/Reset),env-keys.yaml2 row (APP_RATE_LIMIT_ENABLED·APP_IDEMPOTENCY_TTL),application-core/.../capability/Idempotency.java(enum IDEMPOTENT/KEYED/NOT_IDEMPOTENT — design-time annotation),adapter-web/.../http/ApiHeaders.java(IDEMPOTENCY_KEY/RETRY_AFTER상수),adapter-web/.../observability/RetryAfterAdvisor.java(shouldAdvise(code)stub —return code.retryable()).- ⚠️ 위
planned목록은 STALE (2026-06-09 정합) — 아래## 구현 완료섹션이 정확. 본 Ground-truth 블록 작성 시점 이후 runtime 메커니즘이 실제 선박됨(코드 재확인):application-core/.../idempotency/IdempotencyStore.java(포트)·IdempotencyExecutor.java(replay/200ms in-flight→409/SHA-256 fingerprint→422),adapter-persistence/.../idempotency/IdempotencyStoreAdapter.java(DB 기본 + object-store seam) +db/migration/V1__idempotency_record.sql(4-tuple UNIQUE),adapter-web/.../ratelimit/(RateLimiter포트 +FixedWindowRateLimiter기본 +RateLimitAlgorithm/RateLimiterFactory스왑 +RateLimitInterceptorX-RateLimit-*/429 +RateLimitKeyResolver),IdempotencyReaper(@Scheduled TTL purge). 모두actually-implemented/locally-verified(:app-bootstrap:test·:adapter-web:testgreen). 아래 §구현 가이드 표의 개별planned셀은 이 사실로 대체되며, 표 라벨 정합은## 구현 완료섹션을 SSOT 로 본다.- 여전히
planned/위임 (코드 재확인): TTL↔rotation invariant CI gate (코드 주석만, security-operational-baseline 위임 — Coverage #21), 그리고IdempotencyExecutor를 호출하는 production use-case 부재(executor·web helper 는 선박됐으나 도메인 use-case 가 opt-inexecute()호출 — skeleton 의도된 seam-only).
A. Idempotency-Key 수신 + scope 조립 (D2)
| 항목 | In-scope 명세 (anchor) | Status | 근거 / 라벨 |
|---|---|---|---|
| header 이름 | Idempotency-Key (kebab), ApiHeaders.IDEMPOTENCY_KEY 상수 + headers.yaml row (direction: inbound, required: false, owner_branch 본 branch) |
actually-implemented (상수/registry) |
D2 / headers.yaml#Idempotency-Key |
| scope key 조립 | (authenticatedPrincipal, idempotencyKey, useCaseName) triple; tenant 활성 시 앞에 tenant prepend → 4-tuple. useCaseName 은 application-core use case 식별자 (capability Idempotency.KEYED 선언 use case 한정) |
planned (조립 컴포넌트 부재) |
D2 / STRIPE-IDEMP-C5, IETF-IDEMP-C2 |
| principal 표현 | rate-limit key 의 pseudonymized principal 과 동일 표현 사용 (§H 참조) — pseudonymization salt 는 본 branch 소유 아님 | planned |
UNSUPPORTED_IMPL_DECISION: principal→pseudonym 변환은 feature-security-operational-baseline 소유 (salt-rotation-90d). 본 branch 는 "동일 표현 재사용"만 계약, 변환 알고리즘 미결정 → §엣지·실패·의존 위임 |
| 적용 layer | owner_layer: application (error-codes row 와 정합) — interceptor 가 아니라 application use case boundary 에서 scope 검증 |
planned |
D2 / error-codes.yaml#IDEMPOTENT_IN_FLIGHT.owner_layer |
B. Idempotency 저장소 (DB table) (D3)
| 항목 | In-scope 명세 (anchor) | Status | 근거 / 라벨 |
|---|---|---|---|
| 컬럼 집합 | key, scope, requestHash, status, responseRef, ttl, createdAt (결정 사항 2026-05-22 line 3) |
planned (Flyway SQL 부재) |
D3 / BRANDUR-IDEMP-C1(locked_at), C2(params/fingerprint), C3(unique) |
| unique 제약 | UNIQUE(principal, idempotency_key, use_case_name) (+ tenant 활성 시 tenant 포함) — duplicate write 방지 |
planned |
UNSUPPORTED_IMPL_DECISION: 정확한 컬럼명/DDL/index 명명은 source 미권고 (Brandur 는 (user_id, idempotency_key) 2-tuple). triple→3-column unique 는 D2 의 도출이나 물리 컬럼명은 임의 → migration 작성 시 확정 |
| 저장 기술 | DB table 기본; Redis 는 optional cache only, in-memory prod storage 금지 (Decisionized Work Items) | planned |
D3 / REDIS-VS-DB-C6 |
C. In-flight 동시 도착 (200ms wait → 409) (D7)
| 항목 | In-scope 명세 (anchor) | Status | 근거 / 라벨 |
|---|---|---|---|
| 메커니즘 | insert-or-read with unique constraint; 첫 요청이 row 선점, 후속은 read | planned |
D7 / IETF-IDEMP-C4, BRANDUR-IDEMP-C5(lock) |
| 초과 응답 | 200ms 초과 in-flight → IDEMPOTENT_IN_FLIGHT = HTTP 409, category: CONFLICT, retryable: false, retry_after_seconds: null, client_safe_message "...please poll for result" |
actually-implemented (error-codes row) / 발생 로직 planned |
D7 / error-codes.yaml#IDEMPOTENT_IN_FLIGHT |
| 200ms 임계값 | wait window = 200ms | planned |
UNSUPPORTED_IMPL_DECISION: 200ms 는 어떤 source 도 권고 안 함 (IETF 는 즉시 409 SHOULD, Toss 는 즉시 409). trade-off: 즉시 409(표준) 대비 client retry 친화적이나 thread hold 비용 — 부하 테스트로 튜닝 필요 (Claims To Verify). 면접 시 "표준 변형"으로만 표현 |
D. Fingerprint mismatch (SHA-256 → 422) (D8)
| 항목 | In-scope 명세 (anchor) | Status | 근거 / 라벨 |
|---|---|---|---|
| 응답 | same key + different body → IDEMPOTENT_REQUEST_MISMATCH = HTTP 422, category: VALIDATION, retryable: false |
actually-implemented (error-codes row) / 비교 로직 planned |
D8 / error-codes.yaml#IDEMPOTENT_REQUEST_MISMATCH, IETF-IDEMP-C3 |
| hash 알고리즘 | requestHash = body 의 SHA-256 |
planned |
UNSUPPORTED_IMPL_DECISION: SHA-256 선택은 source 미권고 (운영 선택). MD5/SHA-1 대비 충돌저항만 근거, 성능 측정 없음 |
| body canonicalization | content-type별 정규화 (JSON key order, whitespace, multipart, form, encoding) | planned |
UNSUPPORTED_IMPL_DECISION: canonicalization 정책은 source 미권고. 미정 시 false mismatch 위험 (Claims To Verify 의 fingerprint contract test 대상) |
E. TTL (24h, ≤72h override) (D6)
| 항목 | In-scope 명세 (anchor) | Status | 근거 / 라벨 |
|---|---|---|---|
| 기본/상한 | APP_IDEMPOTENCY_TTL default 24h, validation: spring_duration_shorthand_le_72h (≤72h), reload_policy: restart-only |
actually-implemented (env-keys row) |
D6 / env-keys.yaml#APP_IDEMPOTENCY_TTL, STRIPE-IDEMP-C2, BRANDUR-IDEMP-C6(72h) |
| override 경로 | long-running use case 가 use case 선언으로 ≤72h override | planned (선언 메커니즘 부재) |
D6 / IETF-IDEMP-C5 |
| expiry 적용 | expired row replay 거부 + reaper job | planned |
UNSUPPORTED_IMPL_DECISION: reaper 주기/clock skew 처리 source 미권고. batch vs lazy expiry 미결정 (Claims To Verify TTL boundary test) |
F. responseRef 저장 위치 (D9)
| 항목 | In-scope 명세 (anchor) | Status | 근거 / 라벨 |
|---|---|---|---|
| 분기 | body ≤8KB → DB row, >8KB → object store(S3-compatible) key 만 row | planned |
UNSUPPORTED_IMPL_DECISION (D9 자체 UNSUPPORTED_DECISION): 8KB threshold·object store 분리 source 미권고. trade-off: DB row size 한계 vs object store round-trip 지연 — 응답 크기 분포 측정 후 확정 |
G. Rate-limit 응답 표면 (429 + Retry-After + X-RateLimit-*) (D1)
| 항목 | In-scope 명세 (anchor) | Status | 근거 / 라벨 |
|---|---|---|---|
| 초과 응답 | RATE_LIMIT_EXCEEDED = HTTP 429, category: RATE_LIMIT, retryable: true, retry_after_seconds: 1, owner_layer: presentation, log_level: WARN, runbook://rate-limit/exceeded |
actually-implemented (error-codes row) / 발생 로직 planned |
D1 / error-codes.yaml#RATE_LIMIT_EXCEEDED |
| Retry-After | Retry-After (outbound, duration-seconds). RetryAfterAdvisor.shouldAdvise(code) = code.retryable() 가 헤더 부착 여부 판정 — 본 branch 가 owner advice 로 실제 값 부착 |
seam actually-implemented (stub) / 값 부착 planned |
D1 / headers.yaml#Retry-After, RetryAfterAdvisor.java |
| signaling 헤더 | X-RateLimit-Limit(numeric), X-RateLimit-Remaining(numeric), X-RateLimit-Reset(rfc3339-date), 모두 outbound generated_if_missing: true |
actually-implemented (registry row) / emission planned |
D1 / headers.yaml#X-RateLimit-* |
| enable flag | APP_RATE_LIMIT_ENABLED default true, restart-only, compatibility_impact: behavior-change |
actually-implemented (env-keys row, StartupSafetyValidator 가 읽음) |
D1 / env-keys.yaml#APP_RATE_LIMIT_ENABLED |
| limiter 메커니즘 | per-key counter | planned |
UNSUPPORTED_IMPL_DECISION: token-bucket / sliding-window / fixed-window 미결정, source 미권고. single-node in-process counter 전제 (multi-instance 는 D5 out of scope). ⚠️ 순서 의존: X-RateLimit-Remaining(type: numeric)/X-RateLimit-Reset(rfc3339)의 time-window 의미(sliding vs fixed)는 알고리즘 선택에 따라 달라지므로, emission 로직 작성 전에 헤더 semantic 을 선확정해야 registry type 계약이 모호해지지 않음 |
H. Rate-limit key 도출 (D4)
| 항목 | In-scope 명세 (anchor) | Status | 근거 / 라벨 |
|---|---|---|---|
| key 표 | authenticated → user_principal(pseudonymized), s2s → api_key_id, unauth → source_ip + uri_template(normalized), tenant 활성 시 tenant_id prefix (Rate-limit Key Default 표) |
planned |
D4 (UNSUPPORTED_DECISION): rate-limit key shape 에 대한 normative/vendor source 부재 — Stripe rate-limit / AWS API Gateway throttling ref 보강 필요. raw token/body-derived key 금지(Decisionized Work Items) 만 hard rule |
| principal pseudonymization | §A 와 동일 — feature-security-operational-baseline 소유 |
planned |
OUT_OF_BRANCH_SCOPE → §엣지·실패·의존 위임 |
엣지·실패·의존
Cross-branch 의존 (sibling owner — 본 branch 결정 범위 밖, 위임)
| 의존 영역 | 위임처 (sibling branch) | 본 branch 계약 | 근거 |
|---|---|---|---|
| principal pseudonymization (idempotency scope + rate-limit key 의 principal 표현) | raw/branch-notes/feature-security-operational-baseline | "동일 pseudonym 표현 재사용"만 계약. salt/변환 알고리즘 미소유 | rotation 정책 salt-rotation-90d (project-note §22) |
| Idempotency-Key 헤더 이름 SSOT | raw/branch-notes/feature-api-contract-baseline (cross-owner) | 본 branch 는 scope/storage/응답 소유, 헤더 명명 은 api-contract-baseline 과 공유 (headers.yaml owner 본 branch, 명명 결정은 baseline L77) |
headers.yaml#Idempotency-Key 주석 |
| abuse traffic 로그 redaction (token/body 비노출) | raw/branch-notes/feature-operational-error-observability-foundation (logging interceptor 소유) | 본 branch 는 "abuse log 에 token/body 금지" 요구만, redaction 메커니즘은 logging 소유 | 테스트 계약 line 4, Claims To Verify "log scrub" |
| distributed rate limiter (multi-instance 정확성) | out of scope (D5) — 도입 시 Redis/distributed counter 별도 branch | single-node in-process counter 전제 명시 | D5 |
| span/exception event (5xx tracing) | raw/branch-notes/feature-distributed-tracing-contract (RetryAfterAdvisor SPAN STUB) | rate-limit 응답이 tracing 에 남는 방식은 tracing branch 소유 | RetryAfterAdvisor.java SPAN STUB 주석 |
| TTL↔JWT rotation invariant 의 CI 강제 | raw/branch-notes/feature-security-operational-baseline 과 cross-config validator | invariant(D10) 선언 소유, 강제 hook 은 공동. ⚠️ security-operational-baseline 에 "TTL↔rotation invariant 검사" Decision ID 가 아직 부재 — 부재 확인 시 본 branch 가 tracking item 으로 등록(silent drift 방지, Claims To Verify needs-confirmation 항목과 연동) |
D10 |
실패 모드 (구현 시 회피 대상)
- scope 누락 silent 전역 충돌: principal/useCase 없는 key 가 build/runtime 차단 안 되면 전역 key 충돌 → 다른 사용자 응답 replay. application service validator 로 차단 (Claims To Verify).
- 200ms wait 의 thread starvation: in-flight wait 가 thread-blocking 이면 동시 충돌 폭주 시 pool 고갈. polling/async 구현 차이로 timeout 정확성 흔들림 (Claims To Verify concurrent test).
- fingerprint false mismatch: body canonicalization 누락 → 정당한 replay 가 422 오판 (§D, Claims To Verify).
- expired replay 허용: reaper 지연/clock skew 로 24h 경과 row 가 replay 처리 (§E, Claims To Verify TTL boundary).
- invariant silent drift: TTL(24h) > rotation overlap(24h) 로 변경되어도 CI gate 없으면 문서만 정합 깨짐 (D10, Claims To Verify
needs-confirmation). - rate-limit 분류 오염: 429 가 retryable dependency failure 로 분류되면 client 재시도 폭주 —
RATE_LIMITcategory +retryable=true+Retry-After3종 동시 보장 필요 (테스트 계약 line 2~3, Claims To Verify).
Edge cases
- tenant 비활성 vs 활성: scope 가 triple ↔ 4-tuple 로 분기 (D2). 두 모드 모두 unique 제약 일관.
- responseRef >8KB: object store fallback 운영 발생 빈도 미측정 (§F, D9
needs-confirmation). - s2s(API key) caller: rate-limit key 가
api_key_id, org override 허용(Decisionized Work Items) — authenticated user 경로와 분리.
Work Item Contract
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 기준 작성으로 남아 있으면 이 branch는 완료로 보지 않습니다.
| field | required | rule |
|---|---|---|
| Decision | yes | 구현자가 선택해야 하는 기본값 |
| Allowed | yes | 허용되는 예외와 조건 |
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
| Canonical extraction target | yes | wiki/projects 승급 위치 |
테스트 계약
- 같은 idempotency key 재시도가 중복 write를 만들면 실패.
- rate limit 실패가 retryable dependency failure로 분류되면 실패.
- retry-after 기준 없이 429를 반환하면 실패.
- abuse traffic log에 token/body가 남으면 실패.
- principal/useCase scope 없이 idempotency key가 전역 충돌하면 실패.
- idempotency row TTL 미설정 시 실패.
검증해야 할 주장
공식 문서/사례는 근거지만 내 프로젝트에서의 동작을 자동 보장하지 않는다. 구현 전/중/후 실제로 검증해야 하는 주장.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
triple scope (principal, key, useCaseName) 가 DB unique constraint 로 강제되며 collision 감지가 동작하는지 |
unique 제약이 single column 또는 잘못된 column subset 으로 정의될 위험 | Flyway migration grep + DB schema introspection 으로 UNIQUE(principal_id, idempotency_key, use_case_name) 검증 |
planned |
| 200ms in-flight wait 가 정확한 timeout 으로 동작하며 초과 시 409 반환하는지 | thread blocking / loop polling 구현 차이로 timeout 정확성 흔들림 | concurrent integration test (동일 키 2개 simultaneous request, first 가 200ms 이상 hold) — second 응답 status 와 latency 검증 | planned |
| SHA-256 body fingerprint 가 모든 content-type 에 일관되게 동작하는지 (multipart, JSON, form) | body normalization 차이 (whitespace, key order) 로 false mismatch 가능 | fingerprint contract test (의도적 normalization edge case: trailing newline, key order, encoding) | planned |
| 24h TTL 이 모든 idempotency 레코드에 일관 적용되며 expired 레코드의 replay 가 거부되는지 | clock skew / batch reaper 지연 가능성 | TTL boundary test (24h - epsilon: replay 성공, 24h + epsilon: 새 처리) + reaper job 실행 주기 측정 | planned |
| TTL(24h) ≤ JWT rotation overlap invariant 가 CI gate 로 강제되는지 | invariant 가 문서에만 있고 CI 가 없으면 silent drift | sibling branch security-operational-baseline 의 rotation 변경 PR 차단 hook 또는 cross-config validator 구현 검증 | needs-confirmation |
rate-limit 실패 응답이 envelope category RATE_LIMIT + retryable=true + Retry-After header 를 모두 포함하는지 |
gateway-pre-reject 와 app-level rate-limit 의 분리로 일관성 손실 | 429 응답 contract test (envelope shape + Retry-After header 존재 + retryable 플래그) | planned |
| abuse traffic 로그에 token / body raw 가 남지 않는지 | logging interceptor / WAF 로그 의 redaction 누락 위험 | log scrub contract test + DLP scan | planned |
| responseRef >8KB 케이스가 실제 운영에서 발생 시 object store fallback 동작하는지 | 8KB threshold 결정의 측정 근거 없이 선택됨 | response body 크기 분포 측정 + 의도적 large body test | needs-confirmation |
| principal/useCase scope 없는 idempotency key 가 build/runtime 에서 차단되는지 | scope 누락이 silent 로 전역 충돌 유발 가능 | application service 레벨 validator + integration test (scope 누락 request 가 400/422 거부) | planned |
관심사 커버리지
/coverage가 생성하는 생성물 — 손유지 금지. 기준:rules/coverage-gate.md. governing_docs:idempotency-key-design+api-error-envelope-design. 마지막 감사: 2026-06-09 (coverage-auditor) → Covered (Blocking 0 / Should-fix 2 → 해소 / Advisory 1 → 본 섹션 추가로 해소). governing 적정성: 둘 다 OK.
| # | 관심사 | 상태 | owner | 근거 |
|---|---|---|---|---|
| 1 | idempotency key scope (principal, key, useCaseName) triple + tenant 4-tuple |
covered-here | — | D2; Idempotency.java enum + headers.yaml#Idempotency-Key |
| 2 | idempotency 저장소 (DB table 기본, Redis optional cache only) | covered-here | — | D3 (§B); Flyway SQL 부재로 planned |
| 3 | in-flight 동시 도착 (insert-or-read + 200ms wait → 409 IDEMPOTENT_IN_FLIGHT) |
covered-here | — | D7 (§C); error-codes.yaml#IDEMPOTENT_IN_FLIGHT |
| 4 | fingerprint mismatch (SHA-256 → 422 IDEMPOTENT_REQUEST_MISMATCH) |
covered-here | — | D8 (§D); error-codes.yaml#IDEMPOTENT_REQUEST_MISMATCH |
| 5 | idempotency TTL (24h default, ≤72h override, env-driven) | covered-here | — | D6 (§E); env-keys.yaml#APP_IDEMPOTENCY_TTL |
| 6 | responseRef 저장 위치 (≤8KB DB / >8KB object store) | covered-here | — | D9 (§F); UNSUPPORTED_DECISION |
| 7 | TTL ↔ JWT rotation overlap invariant | covered-here | — | D10; CI 강제는 #21 위임 |
| 8 | rate-limit key 도출 (principal / s2s api_key_id / IP+route, tenant prefix) | covered-here | — | D4 + Rate-limit Key Default 표; UNSUPPORTED_DECISION |
| 9 | rate-limit 응답 (429 RATE_LIMIT_EXCEEDED, retryable=true, category RATE_LIMIT) |
covered-here | — | D1 (§G); error-codes.yaml#RATE_LIMIT_EXCEEDED |
| 10 | Retry-After 헤더 발행 |
covered-here | — | D1; headers.yaml#Retry-After, RetryAfterAdvisor.shouldAdvise() stub |
| 11 | X-RateLimit-{Limit/Remaining/Reset} signaling 헤더 | covered-here | — | D1; headers.yaml 3 rows |
| 12 | rate-limit enable toggle (APP_RATE_LIMIT_ENABLED) |
covered-here | — | D1; env-keys.yaml#APP_RATE_LIMIT_ENABLED |
| 13 | distributed rate limiter core out-of-scope 선언 | covered-here | — | D5; §엣지·실패·의존 |
| 14 | Idempotency.KEYED capability (design-time annotation) |
covered-here | — | Idempotency.java enum |
| 15 | 429 envelope 정합 (category/retryable/code 1급 필드) | covered-here | — | api-error-envelope 요구 → D1 + error-codes.yaml row |
| 16 | 409/422 envelope 정합 (category CONFLICT/VALIDATION, retryable false) | covered-here | — | error-codes.yaml 2 rows |
| 17 | principal pseudonymization 알고리즘 (salt/변환) | delegated | raw/branch-notes/feature-security-operational-baseline | §엣지·실패·의존 |
| 18 | Idempotency-Key 헤더 이름 SSOT (naming) |
delegated | raw/branch-notes/feature-api-contract-baseline | §엣지·실패·의존 |
| 19 | abuse traffic 로그 redaction 메커니즘 | delegated | raw/branch-notes/feature-operational-error-observability-foundation | §엣지·실패·의존 (wikilink 보정 2026-06-09) |
| 20 | 5xx span ERROR 기록 / rate-limit tracing | delegated | raw/branch-notes/feature-distributed-tracing-contract | §엣지·실패·의존 |
| 21 | TTL↔JWT rotation invariant CI 강제 | delegated | raw/branch-notes/feature-security-operational-baseline (공동) | §엣지·실패·의존 (D10 tracking item) |
구현 완료 (2026-06-09 — Phase C2 실 코드)
사용자 승인 결정: Flyway+V1 migration / fixed-window counter / 명시적 IdempotencyExecutor 포트. 범위: Coverage #1~#16(covered-here) 구현, #17~#21(delegated)은 seam만 유지.
./gradlew check전체 PASS (전 모듈 test + ArchUnit + verifyCleanArchitectureDependencies + verifyEnvKeys + verifyPublicPathSnapshot). 리뷰: ca-architect-sentinel PASS → ca-spec-reviewer/ca-quality-reviewer NEEDS_FIX → 수정 후 재검 green.
- shared-contract:
OperationalError에 3코드 추가(RATE_LIMIT_EXCEEDED 429/RATE_LIMIT/true, IDEMPOTENT_IN_FLIGHT 409/CONFLICT/false, IDEMPOTENT_REQUEST_MISMATCH 422/VALIDATION/false) — error-codes.yaml 정합. - application-core
dev.caskeleton.application.idempotency:IdempotencyScope(triple/tenant 4-tuple, scope 누락 차단),RequestFingerprint(SHA-256),IdempotencyStatus,StoredResponse,IdempotencyRecord,IdempotencyStore/IdempotentResponseCodec포트,IdempotencyContext,Sleeper,IdempotencyExecutor(claim/replay/200ms in-flight/422 mismatch/discard-on-failure/≤72h cap), 예외 3종. → 상태actually-implemented. - adapter-persistence: Flyway 도입(build.gradle) +
V1__idempotency_record.sql(UNIQUE(tenant,principal,idempotency_key,use_case_name), tenant NOT NULL DEFAULT ''),IdempotencyRecordEntity, JpaRepository,IdempotencyStoreAdapter(만료 reclaim + DataIntegrityViolation race + §F 8KB inline/object-store split + @Nullable objectStore seam),IdempotencyResponseObjectStore(seam), 매퍼,IdempotencyReaper(@Scheduled @Transactional). - adapter-web:
ratelimit(FixedWindowRateLimiter, RateLimitDecision, RateLimitKeyResolver, RateLimitInterceptor[429+Retry-After+X-RateLimit-], RateLimitWebConfig),idempotency(JsonIdempotentResponseCodec, IdempotencyKeySupport), ApiHeaders(+X-RateLimit-), RetryAfterAdvisor(+retryAfterSeconds), GlobalExceptionHandler(+409/422/400 매핑, client-safe message). - app-bootstrap:
IdempotencyProperties(ttl≤72h D6, D10 invariant 주석) +IdempotencyConfig(Clock bean + IdempotencyExecutor bean + @EnableScheduling), application.yml/application-test.yml/.env(APP_RATE_LIMIT_ENABLED·APP_IDEMPOTENCY_TTL). KEYED freeze 해제: ArchUnit rule+helper 제거, KeyedIdempotencyUseCase fixture 삭제, ArchitectureViolationFixtureTest 정리, application-core/CLAUDE.md D14 갱신.
미해결/후속 (follow-up)
IdempotencyStoreAdapterTest는 기존WorkLogRepositoryAdapterTest관례대로 Mockito mock 사용 — 템플릿에 H2/Testcontainers 미도입. 실 unique 제약/Flyway 스키마 검증@DataJpaTest는 별도 인프라 결정 후 추가 권고(Claims To Verify collision/TTL boundary 연동).- object-store(>8KB) 클라이언트 미연동(seam) — 부재 시 inline fallback + WARN.
- D10 TTL↔rotation invariant CI gate는 security-operational-baseline 공동(#21, 미구현).
- full-context boot smoke test 부재 → 본 feature 가 들인 첫 프로덕션 JPA 리포지토리의 스캔 등록(
@EntityScan/@EnableJpaRepositories) 누락이./gradlew check그린을 통과해 런타임 부팅에서야 발견됨(2026-06-10). 프로덕션 데이터소스로@SpringBootTest컨텍스트를 로드하는 smoke test(Testcontainers Postgres) 추가 권고. → raw/errors/jpa-repository-scan-miss-multimodule-2026-06-10
마주친 문제
- 만료 row reclaim 누락 → 유령 409 루프: raw/errors/idempotency-expired-row-reclaim-409-loop-2026-06-09
- reaper @Scheduled 잘못된 config prefix (silent): raw/errors/scheduled-reaper-wrong-config-prefix-2026-06-09
- JPA 리포지토리 스캔 미등록 → 부팅 시
IdempotencyReaperwiring 실패 (2026-06-10,check그린인데 부팅 불가): raw/errors/jpa-repository-scan-miss-multimodule-2026-06-10
묶음
- raw/company-tech-blogs/idempotency-brandur-stripe-postgres
- raw/company-tech-blogs/idempotency-redis-vs-db-storage
- raw/company-tech-blogs/idempotency-toss-payments-techblog
- raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering
- raw/official-docs/config-aws-appconfig-feature-flag-deployment
- raw/official-docs/idempotency-aws-lambda-powertools
- raw/official-docs/idempotency-ietf-draft
- raw/official-docs/idempotency-no-api-level-github-rest
- raw/official-docs/idempotency-paypal-docs
- raw/official-docs/idempotency-square-api
- raw/official-docs/idempotency-stripe-api-ref
- raw/errors/idempotency-expired-row-reclaim-409-loop-2026-06-09
- raw/errors/jpa-repository-scan-miss-multimodule-2026-06-10
- raw/errors/scheduled-reaper-wrong-config-prefix-2026-06-09
- raw/errors/webmvctest-slice-configprops-enum-placeholder-no-default-2026-06-20
본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
오류 기록 (본 feature 작업 중 발생)
- raw/errors/idempotency-expired-row-reclaim-409-loop-2026-06-09 — 만료 row reclaim 누락(TDD 발견)
- raw/errors/scheduled-reaper-wrong-config-prefix-2026-06-09 — @Scheduled config prefix 오타(리뷰 발견)
- raw/errors/jpa-repository-scan-miss-multimodule-2026-06-10 — 첫 프로덕션 JPA 리포지토리 스캔 미등록(@EntityScan/@EnableJpaRepositories), 부팅 후 발견(2026-06-10)
면접 준비 (이 작업에서 나올 수 있는 면접 질문)
Blog topics
관련 일일 노트
이 브랜치를 작업한 날짜들. 양방향 nav 유지.
- 2026-06-09 — Phase C2 실 코드 구현 (전 계층,
./gradlew checkPASS, 3-stage 리뷰 통과)
완료 후 정리
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- wiki 추출 대상 (verified만,
wiki/projects/로만 추출):actually-implemented항목:locally-verified항목:prod-verified항목:
- 추출하지 않을 항목 (planned / documented-only / abandoned):