Files
llm-wiki/raw/branch-notes/feature-redis-atomic-program-catalog-contract.md

468 lines
70 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-redis-atomic-program-catalog-contract
source_type: branch-note
status: raw
branch: feature-redis-atomic-program-catalog-contract
parent_branch:
related_projects: [ca-skeleton]
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
tags: [branch, ca-skeleton, redis, lua, atomicity, script-catalog]
created: 2026-07-28
target_merge:
status_label: in-progress
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-062
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-062
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-PROGRAM-001@1]
refines: []
overrides: []
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-061]
imports: []
delegates: []
accepts_delegations: []
contract_packet: 1
---
# branch: feature-redis-atomic-program-catalog-contract
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-redis-atomic-program-catalog-contract` 로 채운다.
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 기술 런타임 (Tier T). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-062` 의 실행 단위다.
형제 branch (같은 부모의 다른 자식 — 인접 영역):
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]]
- [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]]
- [[raw/branch-notes/feature-fenced-lock-coordination-contract]]
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: program descriptor·golden vector·NOSCRIPT 재적재 test 와 unsafe recipe 경합 재현 test 가 통과한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-PROGRAM-001@1` | 다단계 read-decide-write는 버전 있는 atomic program으로만 제공하고 각 helper는 unsafe recipe와 안전한 대체를 쌍으로 문서화하며 경합 test로 증명한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 상세 근거와 선택 조건은 아래 결정-근거 매핑 표의 동일 D-row 가 소유한다. 여기에는 1줄 요약만 둔다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
| D1 | atomic program 본문의 SSOT 는 `.lua` classpath resource 파일이고, 실행 메타데이터는 별도 descriptor 가 소유한다 | `refines DEC-…-REDIS-PROGRAM-001@1` | `spring-data-redis-scripting-execution.md#SDR-SCRIPT-C6`·`#SDR-SCRIPT-C5` | `proposed` |
| D2 | EVALSHA 우선 + `NOSCRIPT` 를 받은 그때만 EVAL 로 1회 폴백. Spring Data Redis 에 위임하지 않고 Lettuce 위에 직접 구현한다 | `local` | **as-built** `RedisLuaProgramExecutor.execute()`, `redis-eval-scripting-atomicity.md#REDIS-EVAL-C3` | `actually-implemented` |
| D3 | pipeline 경로에서는 EVALSHA 를 쓰지 않고 plain EVAL 로 되돌린다 | `local` | `redis-eval-scripting-atomicity.md#REDIS-EVAL-C4` | `proposed` |
| D4 | bounded execution 은 descriptor 선언 + 빌드타임 정적 검사를 1차 강제로 두고, 서버 실행시간 설정·kill 런북은 상시 병행하는 사후 안전망으로 분리한다 | `refines DEC-…-REDIS-PROGRAM-001@1` | `redis-eval-scripting-atomicity.md#REDIS-EVAL-C1`·`#REDIS-EVAL-C6`, `redis-functions-library-deployment.md#REDIS-FN-C4` | `proposed` |
| D5 | 모든 key 는 `KEYS` 로 선언하고, 미선언 key·프로그램 생성 key 접근을 program 작성 규칙으로 금지한다 | `local` | `redis-eval-scripting-atomicity.md#REDIS-EVAL-C5`, `redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C5` | `proposed` |
| D6 | 배포 모드 기본값은 EVAL script 이고 Redis Functions 는 3조건 동시 충족 시에만 opt-in | `local` | `redis-functions-library-deployment.md#REDIS-FN-C1`·`#REDIS-FN-C2`·`#REDIS-FN-C6`, `redis-eval-scripting-atomicity.md#REDIS-EVAL-C2` | `proposed` |
| D7 | unsafe recipe ↔ 대체 쌍은 **읽은 값 기반 분기 유무**로 갈라 등재한다 (분기 없음 → `MULTI`/`EXEC`, 분기 있음 → atomic program) | `refines DEC-…-REDIS-PROGRAM-001@1` | `redis-incr-rate-limiter-pattern.md#REDIS-INCR-C2`·`#REDIS-INCR-C3`·`#REDIS-INCR-C4` | `proposed` |
| D8 | typed facade 는 semantics-free — program 식별자·KEYS·ARGV·반환 타입만 다루고 능력 의미(rate limit·lock·idempotency)를 알지 않는다 | `local` | `spring-data-redis-scripting-execution.md#SDR-SCRIPT-C3`·`#SDR-SCRIPT-C4` + 소비 branch 4건의 out-of-scope 선언 | `proposed` |
| D9 | 경합 증명은 2층 분리 — unsafe recipe 의 race 는 fake + 결정론적 인터리빙(focused), atomic 대체의 원자성은 실제 Redis 통합 test | `local` | `test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C3`·`#TC-OFFICIAL-C5`, `redis-eval-scripting-atomicity.md#REDIS-EVAL-C1` | `proposed` |
| D10 | program 실행 실패는 backend 실패와 program 결함으로 등급을 나눈다 (결함은 miss 로 위장 금지) | `local` (hub §11 매트릭스 consume) | `redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C1`·`#REDIS-LUAAPI-C2` | `proposed` |
| D11 | program 실행에 bounded cardinality metric 1개 + 실행당 trace span 1개를 짝지어 붙이고, key·ARGV 는 절대 tag/attribute 로 넣지 않는다 | `local` (`feature-metrics-alerting-contract` D2·D8 consume) | [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`·`D8` + `metrics.yaml``lock.acquisition` 선례 | `proposed` |
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: project-contract-imports:start -->
## 가져온 프로젝트 계약
| Ref | Owner | 요약 | Branch 적용 |
|---|---|---|---|
<!-- GENERATED: project-contract-imports:end -->
<!-- section-id: branch-goal -->
## 목표
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-062` 의 완료 조건을 구현한다: program descriptor·golden vector·NOSCRIPT 재적재 test 와 unsafe recipe 경합 재현 test 가 통과한다
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- atomic program 카탈로그와 `ScriptDescriptor` 필수 필드
- EVALSHA 실행 + NOSCRIPT 안전 재적재
- bounded execution 규칙 (O(1)/유한, KEYS 선언, 무한 루프·대량 스캔 금지)
- unsafe multi-command recipe ↔ atomic 대체의 쌍 문서화와 경합 test 의무
- Redis Functions 배포 모드의 opt-in 조건
- golden vector 와 typed facade
### 제외 범위
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
- 능력별 상태 프로그램의 의미 — 각 능력 branch 소유
- Redis 연결·키·코덱 — #061 소유
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
- project decision registry 변경 — owner 는 project-note
## 근거 (필수, 최소 1개+)
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/redis-eval-scripting-atomicity]] | 다단계 read-decide-write 는 EVALSHA 로 실행하는 버전 있는 atomic program 으로만 제공하고, NOSCRIPT 시 안전 재적재하며, bounded execution(유한 실행) 규칙을 둔다 — 스크립트 원자성(블로킹 실행)·EVALSHA/NOSCRIPT 복구·KEYS 선언 규칙·SCRIPT KILL 근거 |
| [[raw/official-docs/spring-data-redis-scripting-execution]] | ⚠️ **채택하지 않은 대안의 근거** — 초판은 이 문서를 D1·D2 의 기반으로 삼았으나 as-built 에 `spring-data-redis` 의존성이 없어 폐기했다. 현재는 "프레임워크에 위임했다면 어땠을지" 의 비교 기준(`#SDR-SCRIPT-C1` 의 evalsha→eval 자동 폴백)으로만 남는다. `#SDR-SCRIPT-C6`(script 를 classpath resource 로 두는 패턴)만 D1 에서 여전히 유효 |
| [[raw/official-docs/redis-lua-api-determinism-replication]] | call/pcall 오류 전파 차이, typed error/status reply(`redis.error_reply`/`redis.status_reply`), effects replication(Redis 7.0 기준 유일 모드) + `redis.replicate_commands()` 폐기 이력, KEYS 선언 강제, sandbox 제약(전역 변수 금지·`require` 비활성화)의 공식 근거 |
| [[raw/official-docs/redis-functions-library-deployment]] | Redis Functions(FUNCTION LOAD/LIST, Shebang 선언, `no-writes` flag, Redis 7.0 버전 요구, cluster 수동 배포)의 opt-in 조건을 무엇으로 삼을지의 공식 근거 |
| [[raw/official-docs/redis-incr-rate-limiter-pattern]] | unsafe multi-command recipe (`GET`→판단→`INCR`→조건부 `EXPIRE`) 의 race condition 을 공식 문서가 명시 — 각 atomic program helper 가 unsafe recipe 와 안전한 atomic 대체(MULTI/EXEC 또는 Lua/EVAL)를 쌍으로 문서화해야 한다는 결정의 근거 |
| [[raw/official-docs/redis-programmability-max-execution-time]] | bounded execution 규칙을 예방 계층(정적 검사)과 사후 대응 계층(서버 설정 + kill 런북)으로 분리해 상시 병행한다는 결정(D4)의 세부 근거 — 기본 5초 임계값·`busy-reply-threshold` 설정명, 시간 초과 시 자동 종료하지 않는 이유(atomicity 계약 보호), `BUSY` 에러 상태의 허용 명령(`SCRIPT KILL`/`FUNCTION KILL`/`SHUTDOWN NOSAVE`), 쓰기 수행 스크립트는 `SHUTDOWN NOSAVE` 만 허용됨을 공식 확인 |
| [[raw/official-docs/test-taxonomy-testcontainers-official]] (재사용 — owner 는 `feature-test-taxonomy-fixture-contract`) | D9 의 "atomic 원자성은 실제 Redis 로만 증명" — `#TC-OFFICIAL-C3`(mock/in-memory 없이 production 과 동일 type 서비스), `#TC-OFFICIAL-C5`(in-memory 는 production service 의 기능을 다 갖지 않음). ⚠️ 원문의 예시는 H2 이고 **Redis·Lua 를 직접 말하지 않는다** — "임베디드 Redis 는 Lua 를 온전히 못 돌린다" 는 이 claim 의 *유추*이지 인용이 아니다. D9 자체는 `#REDIS-EVAL-C1`+`#TC-OFFICIAL-C3` 로 닫힌다 |
**추가 수집 완료** (`busy-reply-threshold`/`BUSY`/`SHUTDOWN NOSAVE` bounded-execution 세부 설정값): `[[raw/official-docs/redis-programmability-max-execution-time]]` (`develop/programmability/` §Maximum execution time)로 닫혔다 — `REDIS-PROG-C1`~`C4`. **D4 의 Supporting Claims·Open Risk 와 §구현 가이드 3 의 (3)층 표, §엣지·실패·의존에 이미 반영 완료**(2026-07-28 동일 `/branch-spec` 실행). 구 파라미터명 `lua-time-limit``maxmemory` 상호작용은 이 자료에도 없어 미확인으로 남으며, 후자는 §Claims To Verify 에 등록했다.
**미수집 — 결정에 사용하지 않음**: LINE engineering 의 "Lua 채택 + EVALSHA 의도적 거부" 사례(§Audit `EVALSHA_COUNTEREXAMPLE`). D2 의 반례 후보이나 raw archive 전이므로 인용하지 않았다.
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
## TODO
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
- [x] `/branch-spec` 로 D-row(D1~D11)·§구현 가이드(9절) 작성 — 등급: `documented-only`
- [x] `.lua` 카탈로그 + descriptor + manifest 대조 test (D1) — 등급: `actually-implemented` (`RedisProgramCatalog`·`RedisProgramDescriptor`·`program-set.json`)
- [x] EVALSHA + NOSCRIPT EVAL 폴백 (D2) — 등급: `actually-implemented` (`RedisLuaProgramExecutor` + test 2건)
- [x] 런타임 admission — arity·바이트 상한·카탈로그 소유권 (D4 (i)층) — 등급: `actually-implemented` (`RedisLuaProgramExecutor.validate()`)
- [x] typed facade 2층 구조 (D8) — 등급: `actually-implemented` (`RedisAtomicPrimitives` + `RedisProgramExecutor` seam)
- [ ] **§1·§2·§6·§7 을 as-built 로 정정** — 초판 Spring Data Redis 전제 잔존 (§구현 가이드 preamble 표 참조) — 등급: `planned`
- [ ] **`2026-07-26-redis-production-capability-design.md`(6,700줄) 정독** 후 `UNSUPPORTED_IMPL_DECISION` 라벨 재판정 — 등급: `planned`
- [ ] **unsafe recipe 경합 재현 test** — WI-062 완료 조건 중 유일한 미구현분 (D9) — 등급: `planned`
- [ ] bounded execution 빌드타임 정적 검사 + denylist 시드 (D4 (ii)층) — 등급: `planned`
- [ ] unsafe recipe ↔ 대체 쌍 카탈로그 2행 등재 (D7) — 등급: `planned`
- [ ] program 실패 error code + metric·span registry 등재 제안 (D10·D11) — 등급: `planned`
- [ ] **선행 확인**: 배포 이미지의 실제 `redis_version` ≥ 7.2 — 등급: `needs-confirmation`
- [ ] **harness 정정 요청 전달**: `/branch-spec` 명령 문서의 ca-tmpl ground truth 경로 (§Audit `STALE_CLONE_ROOT_CAUSE`) — 등급: `needs-confirmation`
## 진행 중 메모
- 2026-07-28 — **본 branch 는 greenfield 가 아니다.** foundation 이 이미 구현돼 있다(`RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisProgramExecutor`·`RedisLuaProgramExecutor`·`RedisAtomicPrimitives` + `.lua` 3개 + `program-set.json` + test 7개). 착수는 "신규 작성" 이 아니라 **"as-built 확장"** 으로 계획할 것.
- ⚠️ **초판의 3중 오판을 정정했다** — "코드 없음 / Redis 버전 미고정 / 설계문서 부재" 는 전부 낡은 clone(`workspace/ca-tmpl`)을 조회한 결과였다. 실제 ground truth 는 `desktop-server-git/clean-architecture-backend-template/` 다(§Audit `STALE_CLONE_ROOT_CAUSE`).
- SDK 는 `io.lettuce:lettuce-core` 단독이며 `spring-data-redis` 는 없다. Redis 최소 버전은 `program-set.json``minimumRedisVersion: "7.2"` 로 고정돼 있다.
- **다음 세션 우선 작업**: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`(6,700줄) 정독. 이 문서가 golden vector 형식·BUSY 관측·Functions 호환·버전 pin 을 직접 다루므로, 현재 `UNSUPPORTED_IMPL_DECISION` 으로 남긴 항목 상당수가 근거를 얻을 가능성이 높다.
## 결정 사항
- 2026-07-28: **atomic program 본문은 `.lua` classpath resource, 메타데이터는 별도 descriptor** (D1) / 이유: Spring 공식 예시가 `ResourceScriptSource`+`ClassPathResource` 를 표준 패턴으로 제시하고, SHA1 이 본문에서 자동 도출돼 "변경 시 버전 변경" 을 별도 규약 없이 만족 / 검토한 대안: Java 인라인 문자열 상수(LINE 프로덕션 선례 있음, 다만 미archive), Redis Functions library 파일, 빌드타임 코드생성 / 근거: [[raw/official-docs/spring-data-redis-scripting-execution]]
- 2026-07-28: **EVALSHA 우선 + `NOSCRIPT` 시에만 EVAL 1회 폴백을 Lettuce 위에 직접 구현** (D2, 등급 `actually-implemented`) / 이유: as-built 가 이미 이 형태이고 `spring-data-redis` 의존성이 없다 / 검토한 대안: Spring Data Redis `ScriptExecutor` 위임 — SDK 를 새로 들여야 하고 #061 이 raw Lettuce 로 기울어 채택하지 않음 / 위험: 공식 절차(`SCRIPT LOAD`+재시도)와 경로가 달라 캐시 재적재를 코드가 관측하지 않음 — §Claims To Verify 등록 / 근거: [[raw/official-docs/redis-eval-scripting-atomicity]] + as-built `RedisLuaProgramExecutor`
- 2026-07-28: **bounded execution 은 예방(정적)과 사후 대응(서버 설정)을 분리해 둘 다 채택** (D4) / 이유: 쓰기를 수행한 program 은 `SCRIPT KILL` 로 죽일 수 없어(공식) 예방이 필수이고, 데이터 규모에서만 드러나는 초과는 정적 검사로 원천 재현 불가라 사후 층이 필수 / 검토한 대안: 서버 설정만, 리뷰 체크리스트만 / 근거: [[raw/official-docs/redis-eval-scripting-atomicity]]
- 2026-07-28: **배포 모드 기본은 EVAL script, Functions 는 3조건 opt-in** (D6) / 이유: Functions 는 Redis 7.0+ 필요한데 대상 버전이 확정돼 있지 않다(§Audit `NO_VERSION_PIN`) / 검토한 대안: Functions 를 SSOT 로 채택 — SHA 기반 버전 식별이 성립하지 않아 보류 / 근거: [[raw/official-docs/redis-functions-library-deployment]]
- 2026-07-28: **unsafe 쌍은 분기 유무로 분리 등재** (D7) / 이유: 공식이 분기 없는 recipe 에는 `MULTI`/`EXEC`, 분기 있는 recipe 에는 Lua 를 **서로 다르게** 권고한다. 한 항목으로 뭉치면 이 구분이 사라진다 / 근거: [[raw/official-docs/redis-incr-rate-limiter-pattern]]
- 2026-07-28: **경합 증명을 focused / integration 2층으로 분리** (D9) / 이유: race 는 클라이언트 측 순서 문제라 fake+barrier 로 결정론적 재현이 가능하지만, 원자성은 서버가 보장하는 성질이라 실제 Redis 없이는 증명 불가 / 제약: focused 레벨 Testcontainers 금지는 [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] 가 ArchUnit 으로 이미 강제 / 근거: [[raw/official-docs/redis-eval-scripting-atomicity]] · [[raw/official-docs/test-taxonomy-testcontainers-official]]
<!-- section-id: decision-evidence -->
## Decision Evidence Map / 결정-근거 매핑
> 2026-07-28 `/branch-spec` 조사(Redis 공식 6건 + Spring Data Redis 1건 + 기존 Testcontainers raw 재사용)로 채웠다.
> **본 branch 는 greenfield 가 아니다 — foundation 이 이미 구현돼 있다.** 실제 ground truth 는 `/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/` 이며(§Audit `GROUND_TRUTH_PATH_DRIFT`), `adapter/outbound/cache-redis` 에 `RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisProgramExecutor`·`RedisLuaProgramExecutor`·`RedisProgramId` + `.lua` 3개 + `program-set.json` manifest + 전용 test 7개가 존재한다(등급 `actually-implemented`).
> 따라서 아래 D-row 는 **as-built 를 서술**하거나(등급 표기) **as-built 를 확장**하는 결정이다. 초기 작성 시 `planned` 로 적었던 것을 코드 대조 후 정정했다.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | atomic program 본문의 SSOT 는 `.lua` classpath resource, 식별·서명은 **닫힌 enum 카탈로그**(`RedisProgramId``RedisProgramDescriptor`)가 소유하고, 외부 노출은 `program-set.json` manifest 가 미러링한다 | 항상. D6 이 Functions 모드로 뒤집히면 SSOT 가 library 파일(`#!lua name=…`)로 이동한다 — `redis-functions-library-deployment.md#REDIS-FN-C3` | `raw/official-docs/spring-data-redis-scripting-execution.md#SDR-SCRIPT-C6`(script 본문을 classpath resource 로 두는 패턴) + **as-built**: `RedisProgramId`(3 program, `<name>-v1` + `redis/scripts/*.lua`), `RedisProgramCatalog.foundation()`, `redis/program-set.json` | `official-vendor-doc` + `actually-implemented` | as-built 는 **버전을 id 문자열(`-v1`)에 넣고 SHA-256 을 source digest 로 따로 보관**한다 — 내가 처음 적은 "SHA1 이 곧 버전" 은 as-built 와 다르다(SHA-1 은 EVALSHA 전송용으로만 쓰임). manifest 와 카탈로그의 drift 는 unit test 가 잡지만, `.lua` 와 descriptor 의 **arity** drift 는 실행 시점에만 드러난다 |
| D2 | EVALSHA 우선 + `NOSCRIPT` 를 받은 **그때만** EVAL 로 1회 폴백. Spring Data Redis 에 위임하지 않고 Lettuce 위에 직접 구현한다 | 항상 — as-built 가 이미 이 형태다. Spring Data Redis 를 도입하면 `ScriptExecutor` 위임으로 대체 가능하나, #061 이 raw Lettuce 유지로 기운 상태라 전환 근거가 없다 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C3`(NOSCRIPT 복구 절차) + **as-built**: `RedisLuaProgramExecutor.execute()``commands.evalSha(...)``catch RedisNoScriptException``commands.eval(...)`. test `fallsBackToEvalOnlyWhenEvalShaReportsNoScript`·`doesNotEvalAgainWhenCachedScriptExecutes` | `official-vendor-doc` + `actually-implemented` | as-built 는 `SCRIPT LOAD` 를 쓰지 않고 **EVAL 직접 실행**으로 복구한다. `#REDIS-EVAL-C3` 이 서술한 "`SCRIPT LOAD` 후 EVALSHA 재시도" 와 다른 경로이며, 결과적으로 캐시 재적재 여부를 코드가 관측하지 않는다(EVAL 이 부수적으로 캐시에 넣음). 기능상 동등하나 공식이 명시한 절차는 아님 |
| D3 | pipeline 경로에서는 EVALSHA 를 쓰지 않고 plain EVAL 로 되돌린다 | program 을 pipeline 안에서 실행할 때. 단건 실행이면 D2 의 EVALSHA 경로 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C4`(pipeline 안에서는 NOSCRIPT 가 반환돼도 handle 할 수 없으므로 plain EVAL 로 revert 해야 함) | `official-vendor-doc` | 근거의 구속력이 `should`(권고)이지 `MUST` 가 아니다(`#REDIS-EVAL-C4` 의 does-not-prove). 또 Spring 의 `ScriptExecutor` 가 pipeline 안에서 이 revert 를 자동으로 하는지는 `#SDR-SCRIPT-C1` 이 말하지 않는다 |
| D4 | bounded execution 은 (i) descriptor 선언 + **런타임 admission**(as-built) + (ii) 빌드타임 정적 검사(신규)를 1차 강제로, (iii) 서버 실행시간 설정 + kill 런북을 상시 병행 안전망으로 **분리** | (i)은 arity·바이트 상한·카탈로그 소유권처럼 **호출 시점에 판정 가능한** 위반에 적용(as-built 가 이미 강제). (ii)는 `.lua` 텍스트의 정적 위반(비유한 명령 토큰·무한 루프 리터럴)에 적용 — **as-built 에 없음**. 운영 **데이터 규모**에서만 드러나는 초과는 (i)(ii)로 원천 재현 불가하므로 (iii)이 담당 — (iii)은 선택이 아니라 필수 병행 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C1`(실행 중 모든 서버 활동 블로킹), `#REDIS-EVAL-C6`(`SCRIPT KILL` 은 dataset 을 수정하지 **않은** 스크립트에만 가능), `raw/official-docs/redis-functions-library-deployment.md#REDIS-FN-C4`(Functions 도 동일 블로킹), `raw/official-docs/redis-programmability-max-execution-time.md#REDIS-PROG-C1`~`#REDIS-PROG-C4`(기본 5초 `busy-reply-threshold` · 자동 종료 안 함 · `BUSY` 허용 명령 · 쓰기 후엔 `SHUTDOWN NOSAVE` 뿐) | `official-vendor-doc` ×3 | **쓰기를 수행한 program 의 유일한 종료 수단이 `SHUTDOWN NOSAVE`(인스턴스 강제 정지)** 라는 것이 강제가 필요한 진짜 이유다(`#REDIS-PROG-C4`). 남은 미확인: 구 파라미터명 `lua-time-limit` 과의 관계, `maxmemory` 근접 시 script abort 동작 — 두 자료 모두에 없다 |
| D5 | 모든 key 는 `KEYS` 입력 인자로 선언. 프로그램 생성 key·데이터 내용 기반 key 접근을 program 작성 규칙으로 금지 | 항상 — standalone·cluster 무관. cluster 에서는 추가로 같은 slot 으로 해싱되어야 multi-key 연산이 성립한다 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C5`, `raw/official-docs/redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C5` (양쪽 모두 "should only access keys given as input arguments" / "should never access programmatically-generated names") | `official-vendor-doc` ×2 (독립 페이지 2곳) | 미선언 key 를 실제로 접근했을 때의 **거부 동작·에러 코드가 공식에 없다**(`#REDIS-EVAL-C5` does-not-prove) — 즉 Redis 가 막아주지 않으므로 우리가 강제하지 않으면 조용히 통과한다. hash slot 라우팅 자체는 #061 소유 |
| D6 | 배포 모드 기본값은 **EVAL script**. Redis Functions 는 ① 대상 Redis 7.0+ 확정 ② NOSCRIPT 재적재 부담 제거가 실제 문제로 관측 ③ cluster 전 노드 수동 적재 운영을 수용 — 3조건 동시 충족 시에만 opt-in | 위 3조건 중 하나라도 미충족이면 EVAL script. 특히 **대상 Redis 버전이 확정되지 않은 현재 상태에서는 Functions 를 고를 수 없다** | `raw/official-docs/redis-functions-library-deployment.md#REDIS-FN-C1`(7.0+ 필요), `#REDIS-FN-C2`(Functions 는 AOF 영속+복제 / script 는 휘발성 캐시), `#REDIS-FN-C6`(cluster 전 노드 적재는 자동화되지 않음), `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C2`(캐시는 재시작·failover·`SCRIPT FLUSH` 시 소실) | `official-vendor-doc` | ca-tmpl 어디에도 **Redis 버전이 고정돼 있지 않다** — compose 에 redis 서비스 없음, SDK 의존성 없음(§Audit `NO_VERSION_PIN`). 조건 ①을 지금 판정할 수 없다. `#REDIS-FN-C2` 의 durability 가 `appendonly no` 배포에서도 성립하는지도 미서술 |
| D7 | unsafe recipe ↔ 안전한 대체 쌍은 **읽은 값 기반 분기 유무**로 갈라 등재한다 | 분기 없음(고정 키 카운터 등) → `MULTI`/`EXEC` 로 충분하며 **program 을 만들지 않는다**. 분기 있음(`IF value == 1` 류) → `MULTI`/`EXEC` 로 불충분하므로 atomic program 필수 | `raw/official-docs/redis-incr-rate-limiter-pattern.md#REDIS-INCR-C4`(공식이 두 변형에 서로 다른 대체를 권고), `#REDIS-INCR-C2`(rate limiter 2 에 race condition 존재를 공식이 명시), `#REDIS-INCR-C3`(실패 모드 = `EXPIRE` 유실 → key leak) | `official-vendor-doc` | 이 분기 기준을 잃고 "INCR+EXPIRE" 를 한 항목으로 뭉치면 `#REDIS-INCR-C4` 의 구분이 사라진다. 또 공식은 key leak 의 **정량적 심각도**를 말하지 않는다(정성 서술만) |
| D8 | typed facade 는 semantics-free — program 식별자·KEYS·ARGV·상태 문자열만 다루고 능력 의미를 알지 않는다. descriptor·catalog·executor 는 **package-private** 로 유지해 adapter 밖으로 새지 않는다 | 항상. 능력 의미(rate limit 판정·lock fencing·idempotency claim 결과)가 facade 에 새어들면 소비 branch 의 owner 경계를 침범한다 | **as-built**: `RedisProgramExecutor` javadoc — "Implementations may use Functions or EVALSHA, but application code must only depend on semantic ports and typed facades". 전 program 타입이 package-private. 반환은 `descriptor.statuses()` 에 속한 ASCII 상태 문자열 | `actually-implemented` | as-built 반환 타입은 `#SDR-SCRIPT-C4` 의 4종이 아니라 **상태 문자열 단일 형태**다(≤128 byte). 값을 함께 돌려줘야 하는 program(예: 남은 quota)이 생기면 이 형태로 부족하고, 그 확장 형식은 근거가 없다 |
| D9 | 경합 증명을 2층으로 분리 — unsafe recipe 의 race 는 fake + 결정론적 인터리빙으로 focused test, atomic 대체의 원자성은 **실제 Redis** 통합 test | 원자성 주장은 서버가 보장하는 성질이므로 fake 로 증명 불가 → 실제 Redis 필수. 반대로 unsafe recipe 의 인터리빙은 클라이언트 측 순서 문제이므로 fake 로 결정론적 재현이 가능하고, 그래야 focused test 의 "실제 네트워크 금지" 규칙을 지킨다 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C1`(원자성은 서버 보장), `raw/official-docs/test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C3`(mock/in-memory 없이 production 과 동일 type 서비스로 test), `#TC-OFFICIAL-C5`(in-memory 는 production service 의 기능을 다 갖지 않음) | `official-vendor-doc` + `official-reference` | **WI-062 완료 조건의 미충족분이 정확히 여기다.** as-built 는 NOSCRIPT 재적재 test 와 golden-vector 성격 test 를 이미 갖췄지만(`fallsBackToEvalOnlyWhenEvalShaReportsNoScript`·`loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature`), **unsafe recipe 경합 재현 test 는 모듈에 존재하지 않는다**(`CountDownLatch`/`Thread` grep 0건). 반복 횟수·스레드 수의 공식 근거도 없다 |
| D11 | program 실행에 bounded cardinality metric 1개(`redis.program.executions.total`, tag = `program_id`+`outcome`)와 실행당 trace span 1개를 짝지어 붙이고, key·ARGV 는 tag/attribute 로 넣지 않는다 | 항상. `program_id` 를 tag 로 쓰는 것은 **카탈로그가 닫힌 enum 이라 cardinality 가 구조적으로 유한할 때만** 유효하다 — 런타임 등록으로 카탈로그가 열리면 이 tag 를 빼야 한다 | [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`(Micrometer dot.case 명명)·`D8`(high-cardinality tag 금지 + bounded whitelist, Prometheus·Micrometer 공식 근거 보유) + `metrics.yaml``lock.acquisition` 선례("key 는 tag 로 넣지 않는다") + **as-built** 닫힌 `EnumMap` 카탈로그 | `project-registry-contract` (owner 는 metrics-alerting) + `actually-implemented`(카탈로그 유한성) | metric 이름·`outcome` 어휘·cardinality 상한은 공식 근거가 없다(§구현 가이드 9 에서 `UNSUPPORTED_IMPL_DECISION` 라벨). `metrics.yaml` 에 script/program row 가 **0건**이므로 전부 신규 제안이며 registry-governance 절차를 거쳐야 한다. **`BUSY` 상태는 서버 측 현상이라 이 클라이언트 metric 으로 직접 관측되지 않는다** — D4 (iii)층의 신호는 별도다 |
| D10 | program 실행 실패를 backend 실패와 program 결함으로 등급 분리. 결함은 해당 연산을 fail closed 하고 **miss 로 위장하지 않는다** | backend 연결·timeout 실패 → 기존 `CACHE_UNAVAILABLE`(`TRANSIENT_DEPENDENCY`) 선례를 따름. program 결함(Lua 런타임 오류·반환 계약 위반) → fail closed. 이 경계는 hub §11 매트릭스의 "cache codec·프로그래밍 결함은 fail closed, miss 로 위장 금지" 와 동일 규칙 | `raw/official-docs/redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C1`(`redis.call` 은 예외를 그대로 올려 스크립트 중단 / `redis.pcall` 은 절대 throw 하지 않고 error 테이블 반환), `#REDIS-LUAAPI-C2`(`redis.error_reply`/`redis.status_reply` 가 typed 반환의 공식 수단) | `official-vendor-doc` (등급 분리 규칙 자체는 hub §11 이 owner) | **신규 error code 가 필요하다**`docs/registries/error-codes.yaml` 에 script/program 관련 code 가 하나도 없다(grep 확인). 신규 code 는 registry-governance 절차를 거쳐야 하고 owner 는 본 branch 다. `Category` enum 10종 밖의 값은 만들 수 없다(`shared/error/Category.java` 확인) |
<!-- section-id: implementation -->
## 구현 가이드
> 2026-07-28 `/branch-spec` 조사로 in-scope detail 을 채웠다. 근거가 *원칙*만 지지하고 *detail* 은 지지하지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄로 표시했다(CLAUDE.md §15.5 R2). 다른 branch 결정 영역은 남기지 않았다(R3).
>
> ⚠️ **부분 재작성 상태 (다음 세션 최우선)** — §3·§8·§9 는 as-built 로 재작성했으나 **§1·§2·§6·§7 은 아직 초판의 Spring Data Redis 전제가 남아 있다.** 아래 as-built 사실과 충돌하는 서술이 그 4개 절에 있으므로, 착수 전 반드시 정정해야 한다:
>
> | as-built 사실 | 근거 | 충돌하는 절 |
> |---|---|---|
> | 리소스 경로는 `redis/scripts/*.lua` (`META-INF/scripts/` 아님) | `RedisProgramId` | §1 |
> | 로딩은 `ClassLoader.getResourceAsStream` + `RedisProgramCatalog.foundation()` (`ResourceScriptSource`/`DefaultRedisScript` 아님) | `RedisProgramCatalog` | §1·§2 |
> | descriptor 실제 필드 8개 = `id`·`sha256`·`scriptBytes`·`keyCount`·`argumentCount`·`maximumKeyBytes`·`maximumArgumentBytes`·`statuses` | `RedisProgramDescriptor` | §1 |
> | manifest 에 `complexity: "O(1)"`·`timeoutCertainty` 가 이미 있다 — §3 (1)층의 `writes` 신설 전에 이 필드 재사용을 먼저 검토 | `program-set.json` | §3 |
> | 실제 Redis lane 은 **Testcontainers 가 아니다** — `@Tag("redis-service")` + `./gradlew :adapter:outbound:cache-redis:redisServiceTest` + `-Dredis.test.host`/`-Dredis.test.port`. 모듈에 Testcontainers 참조 0건 | `build.gradle`, `LettuceRedisRuntimeServiceTest` | §6 |
> | typed facade 가 이미 있다 — `RedisAtomicPrimitives` 의 `compareAndDelete`/`compareAndExpire`/`setIfAbsentWithTtl`, 반환은 program 별 typed enum. 즉 **2층 구조**(executor seam = 상태 문자열 / facade = typed enum) | `RedisAtomicPrimitives` | §7·D8 |
>
> 모듈 좌표(`actually-implemented`): id `adapter-outbound-cache-redis`, Gradle `:adapter:outbound:cache-redis`, package root `dev.caskeleton.adapter.outbound.cache`. **이 저장소는 harness-free 라 `.harness/project/modules.yaml` 이 없다** — 초판의 그 인용은 낡은 clone 기준이었다.
### 1. program 카탈로그와 `ScriptDescriptor`
> **Trace**: D1 ← `spring-data-redis-scripting-execution.md#SDR-SCRIPT-C6`(classpath resource 표준 패턴)·`#SDR-SCRIPT-C5`(단일 인스턴스로 SHA1 재계산 회피)
>
> - **UNSUPPORTED_IMPL_DECISION**: descriptor 를 **YAML registry 로 둘지 Java enum 으로 둘지**. `#SDR-SCRIPT-C6` 은 script *본문*을 classpath resource 로 두라고만 말하고 메타데이터의 소재를 말하지 않는다. trade-off: 이 repo 는 계약 값을 `docs/registries/*.yaml` 로 관리하는 관행이 이미 있어(error-codes·env-keys·metrics) YAML 을 택하면 검증 스타일을 재사용할 수 있다. 대신 `.lua` 파일과 YAML 행이 물리적으로 분리돼 drift 가 생기므로 §3 의 정적 검사가 두 쪽을 대조해야 한다.
> - **UNSUPPORTED_IMPL_DECISION**: program id 명명 규칙. 공식 근거 없음. trade-off: 카탈로그가 능력 의미를 알면 안 되므로(D8) `rate-limit-token-bucket` 처럼 능력 이름을 쓰지 않고 `incr-with-ttl-if-absent` 처럼 **연산을 서술하는** 중립 이름을 쓴다.
| 항목 | planned 명세 | 근거 |
|---|---|---|
| script 본문 | `.lua` 파일 1개 = program 1개. `META-INF/scripts/` 아래 classpath resource | `#SDR-SCRIPT-C6` |
| 로딩 | `ResourceScriptSource(new ClassPathResource(...))``RedisScript.of(source, <resultType>)` | `#SDR-SCRIPT-C6` |
| 인스턴스 수명 | program 당 `DefaultRedisScript` 를 **컨텍스트에 단일 인스턴스**로 등록 — 매 실행 SHA1 재계산 금지 | `#SDR-SCRIPT-C5` |
| descriptor 필수 필드 | `programId` · `resourcePath` · `keyCount` · `argSchema` · `resultType` · `writes(boolean)` · `ownerBranch` | `UNSUPPORTED_IMPL_DECISION` (필드 집합은 공식 근거 없음). `resultType``#SDR-SCRIPT-C4` 의 4종으로 제한, `writes` 는 §3 이 요구 |
| 버전 식별 (**3축 분리**) | ① **계약 version** = program id 의 `-v1` 접미사 — 호환 불가 변경 시 신규 id 로 올린다(소비자가 pin 하는 축). ② **source digest** = SHA-256, manifest 와 카탈로그 대조용. ③ **실행 식별자** = SHA-1, EVALSHA 전송용으로만 쓰이며 계약 의미 없음 | **as-built** `RedisProgramId`(`compare-and-delete-v1` 등) · `RedisProgramDescriptor.sha256()` · `RedisLuaProgramExecutor.sha1()`. `#SDR-SCRIPT-C5` 는 SHA1 재계산 회피만 말하고 **버저닝 전략은 다루지 않는다**(그 문서의 does-not-prove) |
| golden vector | program 당 `(KEYS, ARGV) → 기대 반환` 고정 벡터 ≥1. descriptor 의 `keyCount`·`resultType` 과 대조 | `UNSUPPORTED_IMPL_DECISION` (형식은 공식 근거 없음). trade-off: 벡터가 없으면 script 수정이 조용히 반환 계약을 깬다 |
### 2. 실행 경로 — 위임과 pipeline 예외
> **Trace**: D2 ← `#SDR-SCRIPT-C1`(evalsha 시도 → 미적재 시 eval 폴백)·`redis-eval-scripting-atomicity.md#REDIS-EVAL-C3`(NOSCRIPT 복구) / D3 ← `#REDIS-EVAL-C4`(pipeline 안에서는 NOSCRIPT 를 handle 못 함)
>
> - **UNSUPPORTED_IMPL_DECISION**: 재적재 로직을 **직접 짜지 않는다**는 선택. 두 근거가 서로 다른 복구 경로를 말한다(Redis = `SCRIPT LOAD`+재시도 / Spring = `eval` 폴백). trade-off: 직접 구현하면 두 경로를 우리가 책임져야 하고, 위임하면 프레임워크 동작에 종속되지만 재적재 버그를 우리가 만들지 않는다. **어느 쪽이든 NOSCRIPT 재적재 test 는 실제 Redis 에서 `SCRIPT FLUSH` 후 재실행으로 증명해야 한다**(§6).
| 경로 | planned 동작 | 근거 |
|---|---|---|
| 단건 실행 | `execute(RedisScript, List<K> keys, Object... args)` — EVALSHA 우선은 프레임워크가 처리 | `#SDR-SCRIPT-C1`, `#SDR-SCRIPT-C3` |
| 캐시 소실 | 재시작·failover·`SCRIPT FLUSH` 로 언제든 발생 가능. 애플리케이션이 복구 책임을 진다 | `#REDIS-EVAL-C2`, `#REDIS-EVAL-C3` |
| pipeline | **EVALSHA 금지** — plain EVAL 로 되돌린다. pipeline 안에서는 NOSCRIPT 가 와도 처리할 수 없다 | `#REDIS-EVAL-C4` |
| 반환 타입 | `Long`·`Boolean`·`List`·역직렬화 값 중 하나. `OK` 류 상태 반환은 `null` | `#SDR-SCRIPT-C4` |
### 3. bounded execution — 강제 3층
> **Trace**: D4 ← `#REDIS-EVAL-C1`(실행 중 서버 전면 블로킹)·`#REDIS-EVAL-C6`(`SCRIPT KILL` 은 쓰기 안 한 스크립트만) / D5 ← `#REDIS-EVAL-C5`·`redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C5`(KEYS 선언) / sandbox ← `#REDIS-LUAAPI-C6`(전역 선언 차단)·`#REDIS-LUAAPI-C7`(`require` 비활성)
>
> - **UNSUPPORTED_IMPL_DECISION**: 정적 검사를 **ArchUnit 이 아니라 일반 JUnit test 로** 구현. ArchUnit 의 검사 대상은 JVM 바이트코드이므로 `.lua` 텍스트는 그 DSL 대상이 아니다. trade-off: 같은 test 스위트 안에 두어 실행 위치는 통일하되, 규칙은 텍스트 검사로 짠다.
> - **UNSUPPORTED_IMPL_DECISION**: 금지 토큰 목록(denylist)의 구체 항목과 파서 수준(정규식 vs Lua lexer). 공식 문서는 원칙만 말한다. trade-off: 정규식은 문자열 조립(`'S'..'MEMBERS'`)으로 우회되므로 **완전한 강제가 아님을 문서에 명시**하고, 우회는 리뷰가 맡는다.
| 층 | 무엇을 잡는가 | 무엇을 못 잡는가 | 근거 |
|---|---|---|---|
| (1) descriptor 선언 | `keyCount`·`writes` 를 program 이 스스로 선언 — 선언 없이는 카탈로그 등재 불가 | 선언 자체가 거짓인 경우 | D1 |
| (2) 빌드타임 정적 검사 | 하드코딩 key 리터럴, 비유한 명령 토큰, 무한 루프 리터럴, `KEYS[]` 미사용 | 문자열 조립 우회, **데이터 규모에 비례하는 사실상 무한** | `#REDIS-EVAL-C5`, `#REDIS-LUAAPI-C5` |
| (3) 서버 설정 + kill 런북 | 운영 데이터 규모에서만 드러나는 실행 시간 초과 | 근본 원인(미선언 key·비유한 명령) 자체 — 예방이 아니라 사후 대응 | `#REDIS-EVAL-C6`, `redis-programmability-max-execution-time.md#REDIS-PROG-C1`~`#REDIS-PROG-C4` |
(3)층의 확정된 값 (전부 `official-vendor-doc`):
| 항목 | 값 | 근거 |
|---|---|---|
| 최대 실행 시간 기본값 | **5초**, 설정명 `busy-reply-threshold` (밀리초 단위, `redis.conf` 또는 `CONFIG SET`) | `#REDIS-PROG-C1` |
| 임계 초과 시 | Redis 가 **자동 종료하지 않는다** — 종료하면 원자성 계약이 깨지므로 | `#REDIS-PROG-C2` |
| 초과 후 서버 상태 | 다른 클라이언트에 `BUSY` 응답. 허용 명령은 `SCRIPT KILL` · `FUNCTION KILL` · `SHUTDOWN NOSAVE` 뿐 | `#REDIS-PROG-C3` |
| 쓰기를 이미 수행한 경우 | **`SHUTDOWN NOSAVE` 만 가능** — 데이터셋을 디스크에 저장하지 않고 서버를 정지시킨다(사실상 강제 종료) | `#REDIS-PROG-C4`, `#REDIS-EVAL-C6` |
**(3)이 선택이 아니라 필수 병행인 이유**: 쓰기를 수행한 program 이 폭주하면 (1)(2)를 통과했더라도 죽일 수단이 `SHUTDOWN NOSAVE`**인스턴스 강제 정지** 뿐이다(`#REDIS-PROG-C4`). 즉 bounded execution 위반의 최종 비용이 가용성 사고이므로 예방 층을 생략할 수 없다. 런북에 이 3단계(BUSY 관측 → 쓰기 여부 판별 → kill 또는 SHUTDOWN NOSAVE)를 명시한다.
program 작성 규칙 (카탈로그 등재 조건):
| 규칙 | 내용 | 근거 |
|---|---|---|
| KEYS 선언 | 접근하는 모든 key 를 `KEYS` 인자로 받는다. 프로그램 생성 key·데이터 내용 기반 key 접근 금지 | `#REDIS-EVAL-C5`, `#REDIS-LUAAPI-C5` |
| 전역 금지 | 모든 선언은 `local`. 전역 변수·함수 선언은 샌드박스가 차단 | `#REDIS-LUAAPI-C6` |
| 모듈 금지 | `require` 비활성 — 허용된 런타임 라이브러리만 사용 | `#REDIS-LUAAPI-C7` |
| 복제 | effects replication 이 Redis 7.0 기준 유일 모드이며 정의상 결정론적이다. `redis.replicate_commands()` 를 호출하지 않는다 | `#REDIS-EVAL-C7`, `#REDIS-LUAAPI-C3`·`#REDIS-LUAAPI-C4` |
| **오류 처리** | `redis.call` 을 쓰되 **오류가 날 상황을 미리 방어**한다 — 진입부에서 `#KEYS`/`#ARGV`/길이를 검사해 `INVALID`, `TYPE` 을 확인해 `WRONG_TYPE` 을 **상태 문자열로 반환**한다. `redis.pcall` 과 `redis.error_reply` 는 as-built 가 쓰지 않는다 | `#REDIS-LUAAPI-C1`(`call` 은 오류를 그대로 올려 스크립트 중단) + **as-built** `.lua` 3개 전부 이 패턴 |
**as-built 가 `pcall`/`error_reply` 대신 방어적 상태 반환을 택한 결과**: 예상 가능한 실패(잘못된 arity·타입 불일치·소유자 불일치)는 **오류가 아니라 계약된 상태값**으로 나오고, `redis.call` 이 실제로 오류를 올리는 경우는 *예상 못 한* 결함뿐이다. 즉 상태값 = 정상 흐름, 예외 = 결함이라는 경계가 script 안에서 이미 갈린다.
### 4. 배포 모드 — EVAL script 기본, Functions opt-in 게이트
> **Trace**: D6 ← `redis-functions-library-deployment.md#REDIS-FN-C1`(7.0+)·`#REDIS-FN-C2`(영속·복제 vs 휘발성 캐시)·`#REDIS-FN-C6`(cluster 전 노드 수동 적재)·`#REDIS-FN-C4`(Functions 도 동일 블로킹) / `#REDIS-EVAL-C2`(캐시 휘발)
| opt-in 조건 | 판정 | 근거 |
|---|---|---|
| ① 대상 Redis 7.0+ 확정 | **충족**`program-set.json``minimumRedisVersion: "7.2"` 를 선언한다 | `#REDIS-FN-C1` + as-built manifest |
| ② NOSCRIPT 재적재 부담이 실제 문제로 관측됨 | 미관측 (구현 전) | `#REDIS-FN-C2`, `#REDIS-EVAL-C2` |
| ③ cluster 전 노드 수동 적재 운영을 수용 | 미결정 | `#REDIS-FN-C6` |
**3조건 모두 충족 전까지 EVAL script 가 기본값이다.** Functions 로 전환하면 SSOT 가 library 파일(`#!lua name=…`)로 이동하고(`#REDIS-FN-C3`) §1 의 SHA1 기반 버전 식별이 성립하지 않으므로, 전환은 D1 재검토를 동반한다.
### 5. unsafe recipe ↔ 안전한 대체 쌍 카탈로그
> **Trace**: D7 ← `redis-incr-rate-limiter-pattern.md#REDIS-INCR-C4`(공식이 두 변형에 서로 다른 대체를 권고)·`#REDIS-INCR-C2`(race 명시)·`#REDIS-INCR-C3`(EXPIRE 유실 → key leak)
>
> - 이 표의 **행 자체가 계약**이다. helper 를 추가할 때 unsafe 쌍과 경합 test 없이 등재할 수 없다.
| unsafe recipe | 분기 | 만드는 경합 | 안전한 대체 | 근거 |
|---|---|---|---|---|
| 고정 타임스탬프 키 카운터 — `INCR``EXPIRE` | 없음 | `INCR` 은 성공했는데 `EXPIRE` 가 유실될 수 있음 | **`MULTI`/`EXEC` 로 충분 — program 을 만들지 않는다** | `#REDIS-INCR-C4`(a) |
| 단일 카운터 — `GET` → 판단 → `INCR` → 조건부 `EXPIRE` | 있음(`IF value == 1`) | 공식이 "race condition" 을 명시. `EXPIRE` 미수행 시 key 가 TTL 없이 leak | **atomic program 필수** (Lua/EVAL) | `#REDIS-INCR-C2`·`#REDIS-INCR-C3`·`#REDIS-INCR-C4`(b) |
두 변형을 하나의 "INCR+EXPIRE" 항목으로 뭉치면 `#REDIS-INCR-C4` 의 구분이 사라진다 — **별개 행으로 유지한다.**
### 6. 테스트 계약 — 경합 증명 2층
> **Trace**: D9 ← `#REDIS-EVAL-C1`(원자성은 서버가 보장)·`test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C3`(mock/in-memory 없이 실제 서비스)·`#TC-OFFICIAL-C5`(in-memory 는 기능 결여)
>
> - **선행 계약 (다른 branch owner, 재진술 아님)**: focused(unit/contract/architecture) 레벨의 Testcontainers 금지는 [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] 가 소유하며 ca-tmpl 에 **ArchUnit 으로 실제 강제**돼 있다 — `TestTaxonomyArchitectureTest.contractLevelTestsHaveNoTestcontainersDependency` / `architectureLevelTestsHaveNoTestcontainersDependency` + 비공허성 증명 `banRuleFiresOnTestcontainersUsage` (등급 `actually-implemented`, `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TestTaxonomyArchitectureTest.java`).
> - **UNSUPPORTED_IMPL_DECISION**: 반복 횟수·스레드 수. 어떤 공식 문서도 수치를 권고하지 않는다. trade-off: barrier 로 순서를 강제하면 확률적 반복에 의존하지 않으므로 반복 수를 작게 유지한다 — `flaky-quarantine.yaml` 의 14일 sunset 압력을 피하는 방향.
| 층 | 무엇을 증명 | 어떻게 | 왜 이 층인가 |
|---|---|---|---|
| focused (unit/contract) | unsafe recipe 의 race 가 **실제로 발생함** | fake `RedisClient` + latch/barrier 로 read 와 write 사이 인터리빙을 **결정론적으로 강제** | race 는 클라이언트 측 순서 문제라 서버 없이 재현 가능. 실제 네트워크 금지 규칙 준수 |
| focused (unit/contract) | atomic 대체에 **재현 가능한 client-side window 가 없음** | 성공 경로에서 round-trip 이 정확히 1회임을 spy 로 assert | 구조적 회귀 방지 — 나중에 누가 보조 `GET` 을 끼워 2-round-trip 으로 되돌리는 것을 잡는다 |
| integration | atomic 대체의 **실제 원자성** + NOSCRIPT 재적재 | 실제 Redis(Testcontainers) — 동시 호출 후 불변식 유지 확인, `SCRIPT FLUSH` 후 재실행 | 원자성은 서버가 보장하는 성질이라 fake 로 증명 불가(`#REDIS-EVAL-C1`). in-memory 대체는 기능 결여 위험(`#TC-OFFICIAL-C5`) |
내부 선례(등급 `actually-implemented`): latch 기반 결정론적 동시성 test 는 이 repo 에 이미 있다 — `src/adapter/outbound/persistence-jpa/.../lock/LockRegistryDistributedLockAdapterTest.java` (`CountDownLatch` 사용).
**환경 게이트**: Redis 를 요구하는 test 는 `@EnabledIfRedisCacheEnabled`(`APP_CACHE_REDIS_ENABLED=true` 일 때만 실행, 아니면 SKIPPED) 로 감싼다 — 등급 `actually-implemented`, `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfRedisCacheEnabled.java`.
### 7. typed facade 와 소비 경계
> **Trace**: D8 ← `#SDR-SCRIPT-C3`(execute 시그니처)·`#SDR-SCRIPT-C4`(반환 타입 제약) + 소비 branch 4건이 명시적으로 "Lua program 실행 기반 — #062 소유" 로 위임
>
> - **UNSUPPORTED_IMPL_DECISION**: facade 타입·메서드 명명. 공식 근거 없음. trade-off: 능력 이름이 들어가면 D8 이 깨지므로 연산 서술 이름만 쓴다.
| 규칙 | planned 동작 |
|---|---|
| 노출 | program 식별자 + KEYS + ARGV + 반환 타입. **그 이상 없음** |
| 비노출 | rate limit 판정·lock fencing·idempotency claim 결과 등 능력 의미 — 각 소비 branch 소유 |
| 반환 | `#SDR-SCRIPT-C4` 의 4종을 넘는 복합 구조가 필요하면 program 이 직렬화 형식을 정하고 facade 가 파싱. 그 형식 결정은 근거가 없다(`UNSUPPORTED_IMPL_DECISION`) |
| 소비자 | [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]] · [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] · [[raw/branch-notes/feature-fenced-lock-coordination-contract]] · [[raw/branch-notes/feature-cache-strategy-registry-contract]] |
### 8. 실패 등급과 error code
> **Trace**: D10 ← `#REDIS-LUAAPI-C1`(`call` 은 예외를 그대로 올려 중단 / `pcall` 은 error 테이블 반환)·`#REDIS-LUAAPI-C2`(`error_reply`/`status_reply` 가 typed 반환 수단). 등급 분리 규칙 자체의 owner 는 hub §11 매트릭스.
>
> - **신규 제안임을 명시** — `docs/registries/error-codes.yaml` 에 script/program 관련 code 가 **하나도 없다**(grep 확인). 아래는 기존 값이 아니라 제안이며 registry-governance 절차를 거쳐야 한다.
**등급 판별은 as-built 예외 타입으로 결정론적으로 갈린다** — 추론이 아니라 타입 매칭이다:
| as-built 신호 | 의미 | 등급 | 제안 category | 근거 |
|---|---|---|---|---|
| `RedisCommandFailureException(kind=UNAVAILABLE)` | 연결 불가 | backend 실패 → 선언된 경우 degrade 가능 | `TRANSIENT_DEPENDENCY` | 기존 `CACHE_UNAVAILABLE`(503, retryable) 선례 |
| `RedisCommandFailureException(kind=OVERLOADED)` | admission 거부(in-flight 상한) | backend 실패 | `TRANSIENT_DEPENDENCY` | `RedisCommandAdmission` — 단 이 상한의 owner 는 #061 |
| `RedisProgramCompatibilityException` | 반환 상태가 `descriptor.statuses()` 밖 | **program 결함 → fail closed** | `INTERNAL` | hub §11 "codec·프로그래밍 결함은 fail closed" |
| `IllegalArgumentException` (arity·바이트 상한·카탈로그 소유권) | 호출 계약 위반 | **program 결함 → fail closed** | `INTERNAL` | `RedisLuaProgramExecutor.validate()` |
| 계약된 상태 문자열 (`INVALID`·`WRONG_TYPE`·`NOT_OWNER` 등) | **실패가 아니다** — 정상 반환 | 호출자가 의미를 해석 | — | §3 오류 처리 규칙 |
`RedisCommandFailureException.certainty()``NOT_APPLIED` / `INDETERMINATE` 를 구분한다 — **재시도 안전성 판단에 필요**하다. `INDETERMINATE`(적용 여부 불명)를 무조건 재시도하면 program 이 원자적이어도 **중복 적용**이 생길 수 있다. 이 축의 owner 는 #061(전송 계층)이며 본 branch 는 소비한다.
`Category``shared/error/Category.java`**10종 밖 값을 만들 수 없다** (`VALIDATION`·`AUTH`·`AUTHZ`·`NOT_FOUND`·`CONFLICT`·`RATE_LIMIT`·`TRANSIENT_DEPENDENCY`·`PERMANENT_DEPENDENCY`·`DATA_INTEGRITY`·`INTERNAL`, 등급 `actually-implemented`).
`redis.error_reply` 는 as-built 가 쓰지 않는다 — §3 의 방어적 상태 반환이 그 역할을 대신한다. `#REDIS-LUAAPI-C2` 는 typed 오류 반환의 *공식 수단*을 증명할 뿐 as-built 가 그것을 쓴다는 근거가 아니다.
### 9. program 실행 관측 — metric + span
> **Trace**: D11 ← [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`(Micrometer dot.case + unit suffix 명명)·`D8`(high-cardinality tag 금지 + bounded whitelist) — 그 branch 가 owner 이고 본 §는 소비자다. 선례: `metrics.yaml` 의 `lock.acquisition`(owner `feature-distributed-lock-contract`) — "key 는 tag 로 넣지 않는다(무한 cardinality)".
>
> - **UNSUPPORTED_IMPL_DECISION**: metric 이름과 `outcome` 어휘. 공식 근거 없음. trade-off: `lock.acquisition` 의 형태(counter + `outcome` 단일 bounded tag)를 그대로 따라 registry 스타일 일관성을 택했다. `program_id` 를 tag 로 **추가**한 것은 카탈로그가 닫힌 enum(현재 3개)이라 cardinality 가 구조적으로 유한하기 때문이다.
> - **UNSUPPORTED_IMPL_DECISION**: cardinality 상한 수치. metrics-alerting `D8` 자체가 정량 상한(≤200/≤50)을 "공식 spec 없는 운영 가정" 으로 라벨했으므로 본 §도 같은 성격이다.
| 항목 | planned 명세 | 근거 |
|---|---|---|
| metric | `redis.program.executions.total` (counter, unit `total`) | metrics-alerting `D2` 명명 규약 |
| tag `program_id` | 값 = `RedisProgramId.externalId()`. cardinality 는 **닫힌 enum 크기**로 bounded (현재 3, 상한 50 제안) | as-built 카탈로그가 `EnumMap` 닫힌 집합 |
| tag `outcome` | `success` · `noscript_reload` · `defect` · `backend_failure` (4종 고정) | §8 의 예외 타입 매핑과 1:1 |
| **금지 tag** | key·KEYS 값·ARGV 값·상태 문자열 원문 — 사용자 입력 유래이거나 무한 cardinality | metrics-alerting `D8`, `lock.acquisition` 선례 |
| alert | `defect` rate 지속 > 0 이면 P2 — program 결함은 조용히 넘어가면 안 된다 | hub §11 fail-closed 원칙 |
| trace span | program 실행 1회 = span 1개. attribute 는 `program_id`·`outcome` 만. **key·ARGV 를 span attribute 로 넣지 않는다** | hub §36.2 "bounded cardinality metric 과 trace span" |
| `noscript_reload` 의 쓰임 | 이 값이 **꾸준히 0 이 아니면** 캐시가 반복 소실된다는 신호 — D2 의 EVAL 폴백이 정상 경로가 되어버린 상태를 드러낸다 | `#REDIS-EVAL-C2`(캐시 휘발성) |
**registry 등재**: 위 metric 은 `docs/registries/metrics.yaml`**신규 제안** 이다(현재 script/program/redis 관련 row 0건 — grep 확인). `owner_branch: feature-redis-atomic-program-catalog-contract`, `compatibility_impact: additive`, `required_test: contract-verification:metrics-cardinality` 로 등재하며 절차는 [[raw/branch-notes/feature-contract-registry-governance]] 소관이다.
<!-- section-id: edge-failure-dependency -->
## 엣지·실패·의존
- **실패·엣지 경로**
- **스크립트 캐시 소실** — 재시작·failover·`SCRIPT FLUSH` 로 언제든 발생. EVALSHA 가 `NOSCRIPT` 를 받는다 → 재적재 후 재시도(§구현 가이드 2). 애플리케이션 책임임이 공식 명시(`#REDIS-EVAL-C2`·`#REDIS-EVAL-C3`)
- **pipeline 안의 NOSCRIPT** — 처리 불가. pipeline 경로는 애초에 plain EVAL 로 되돌린다(`#REDIS-EVAL-C4`, D3)
- **폭주 program 이 쓰기를 이미 수행함** — `SCRIPT KILL` 로 죽일 수 없다(`#REDIS-EVAL-C6`). 5초(`busy-reply-threshold`) 초과 후 서버는 다른 클라이언트에 `BUSY` 로 응답하고, 이 상태에서 남은 수단은 **`SHUTDOWN NOSAVE` — 인스턴스 강제 정지뿐**이다(`#REDIS-PROG-C1`·`#REDIS-PROG-C3`·`#REDIS-PROG-C4`). 즉 이 엣지의 최종 비용은 가용성 사고다(§구현 가이드 3)
- **미선언 key 접근** — Redis 가 막아주지 않는다(`#REDIS-EVAL-C5` 의 does-not-prove: 거부 동작·에러 코드가 공식에 없음). 우리가 정적 검사로 잡지 못하면 조용히 통과한다
- **program 결함** — fail closed. cache miss 로 위장하지 않는다(hub §11, D10)
- **descriptor ↔ `.lua` drift** — `keyCount`/`resultType` 은 수기 선언이라 본문 변경 시 자동 갱신되지 않는다. golden vector 가 이 drift 를 잡는 유일한 장치(§구현 가이드 1)
- **경합 test 자체의 flakiness** — 확률적 재현에 의존하면 `flaky-quarantine.yaml` 의 14일 sunset 압력을 받는다. barrier 로 순서를 강제해 회피(§구현 가이드 6)
- **다른 계약 의존** (대상 브랜치 + 그 Decision ID)
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] (`WI-…-061`) — 그 branch 가 **이미 D1~D22 를 보유**하며(스캐폴딩 아님) 다음을 소유한다: `D5`(key builder)·`D6`(digest)·`D8`(key/payload/policy **3 version 축 분리**`D11`(envelope)·`D10`(`byte[]` codec) 은 **program descriptor 의 입력**이고, `D16`~`D18`(Lettuce ClientOptions·timeout·queue) 은 본 branch 의 실행 경로가 올라타는 전송 계층이다. `D2`(SDK 선택)가 raw Lettuce 로 확정된 것이 본 branch D1·D2 의 전제다
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] `D3`(focused 레벨 Testcontainers 금지, ArchUnit 강제)·`D4`(CI 시간 budget) — D9 의 2층 분리는 이 계약의 *결과*이지 본 branch 의 재량이 아니다(재진술 아님, 포인터만)
- **위임을 선언한 소비 branch (2건, 확인됨)** — [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]] (§제외 범위 "Lua program 실행 기반 — #062 소유") · [[raw/branch-notes/feature-fenced-lock-coordination-contract]] (§제외 범위 "Redis 연결·스크립트 기반 — #061·#062 소유")
- **위임 미선언 소비 branch (2건)** — [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] · [[raw/branch-notes/feature-cache-strategy-registry-contract]] 는 §제외 범위에 program 위임 문구가 **없다**(grep 0건). hub §25 Owner Map 은 이 둘을 consumer 로 적지만 노트 쪽 선언이 비어 있어 **양방향 정합이 깨져 있다**`/sync` 로 맞춰야 하며, 그 전까지 "4건 모두 위임" 이라고 주장할 수 없다
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — D10 의 신규 error code 는 `error-codes.yaml` 스키마와 `Category` enum(10종)을 따른다. 해당 branch 의 category enum owner D-row 를 pin 해야 하나 아직 번호 미확인(`Should-fix`)
- [[raw/branch-notes/feature-capability-provider-selection-contract]] (`WI-…-060`, **선행 필수**) — D6 의 배포 모드(EVAL script ↔ Functions) 는 hub §25 기준 **capability 활성화·provider 선택**의 일종이며, 그 판정은 "typed descriptor 로만" 하고 bean 이름·classpath 존재를 근거로 삼지 않는다. 본 branch 는 3조건 opt-in *논리*만 소유하고, 그 스위치가 어떤 typed descriptor 로 노출되는지는 그 branch 소유다
- [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`(dot.case 명명)·`D8`(cardinality bounds·금지 tag) — D11 의 metric·span 은 이 계약의 소비자다. `D8` 이 바뀌면 §구현 가이드 9 의 tag 집합이 함께 바뀐다
<!-- section-id: claims-to-verify -->
## 검증해야 할 주장 / Claims To Verify
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| 배포 이미지의 실제 `redis_version` 이 7.2 이상이다 | `program-set.json``minimumRedisVersion: "7.2"` 를 **선언**하지만, 그것이 배포 환경에서 실제로 지켜지는지는 별개다. 미달이면 D6 의 조건 ①과 `#REDIS-EVAL-C7`(effects replication 유일 모드)이 함께 깨진다 | 배포 대상에서 `INFO server``redis_version` 확인 + startup 시 최소 버전 검증을 둘지 결정 | `needs-confirmation` |
| as-built 의 EVAL 폴백이 Redis 공식 절차와 동등하다 | as-built 는 `NOSCRIPT` 시 **EVAL 직접 실행**으로 복구하고, `#REDIS-EVAL-C3` 은 "`SCRIPT LOAD` 후 EVALSHA 재시도" 를 서술한다 — 서로 다른 경로다. EVAL 이 캐시에 넣는 것은 부수효과이며 코드가 관측하지 않는다 | 실제 Redis lane 에서 `SCRIPT FLUSH` → 실행 → `SCRIPT EXISTS <sha>` 로 재적재 여부 확인 | `needs-confirmation` |
| D3(pipeline plain EVAL)이 공허하지 않다 | as-built `RedisBinaryCommands``get`/`set`/`delete`/`evalSha`/`eval` 5개뿐이고 **pipeline 표면이 없다**. 즉 D3 은 현재 도달 불가능한 조건 위의 결정이다 | pipeline API 를 실제로 추가할지 결정 → 추가하지 않으면 D3 을 "향후 확장 시 제약" 으로 재분류 | `planned` |
| 정적 검사가 bounded execution 위반을 실효적으로 잡는다 | 정규식 텍스트 검사는 문자열 조립(`'S'..'MEMBERS'`)으로 우회된다. "완전한 강제" 라는 인상만 주고 실제로는 부분 강제일 수 있다 | 의도적 우회 fixture 를 넣어 검사기가 놓치는지 확인(비공허성 증명). 놓치면 그 한계를 문서에 명시 | `planned` |
| barrier 로 강제한 인터리빙이 매 실행 race 를 재현한다 | barrier 는 read/write 의 **논리적 순서**만 강제하고 서버가 명령을 수신하는 wire-order 까지 통제하지 못한다 | 동일 test 를 CI 에서 연속 반복 실행해 재현율 측정. 100% 가 아니면 quarantine 대상이 된다 | `planned` |
| atomic 대체가 실제로 경합을 없앤다 | 원자성은 서버가 보장하는 성질이라 fake 로 증명 불가(`#REDIS-EVAL-C1`). in-memory 대체도 기능 결여 위험(`#TC-OFFICIAL-C5`) | 실제 Redis 통합 test — 동시 호출 후 불변식 유지 확인. 이는 통계적(위반 미관측) 증명이지 결정론적 증명이 아님을 명시 | `planned` |
| golden vector 가 descriptor drift 를 잡는다 | `keyCount`·`resultType` 은 수기 선언이고 `.lua` 본문 변경 시 자동 갱신되지 않는다 | descriptor 는 그대로 두고 `.lua` 의 KEYS 사용 개수만 바꾼 fixture 로 test 가 실패하는지 확인 | `planned` |
| 신규 error code 가 registry 절차를 통과한다 | `error-codes.yaml` 에 script/program code 가 하나도 없어 전부 신규 제안이다. `Category` 는 10종 밖으로 못 나간다 | registry-governance 절차 + `contract-verification` test 로 매핑 검증 | `planned` |
| **`maxmemory` 초과 상태에서 program 이 abort 된다** | D4 는 bounded *시간*만 다루고 bounded *메모리*는 다루지 않는다. "maxmemory 초과 시 추가 메모리를 쓰는 첫 write 가 스크립트를 abort 시킨다"는 서술은 수집한 두 자료(`redis-programmability-max-execution-time`·`redis-key-eviction-maxmemory-policy`) 어디에도 없어 **인과관계가 미증명**이다 | 공식 근거를 별도 수집하거나 실제 Redis 에서 `maxmemory` 근접 상태로 write program 실행해 재현. 확인되면 D4 에 메모리 축을 추가하고, `maxmemory-policy` 값 선택은 #061(배포 설정 소유)로 위임 | `needs-confirmation` |
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 소비 branch 4건 중 **2건만** 위임을 선언했고(edge-rate-limit·fenced-lock), idempotency·cache-strategy 는 아직 scaffolding 이라 선언이 없다 | 그 2건 `/branch-spec``/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
## Audit & Findings
> `/branch-spec` 2026-07-28 조사에서 발견한 **상위 문서의 사실 오류**. 대상은 hub / 설계 문서이므로 본 branch 는 정합 권고만 내고 자동 수정하지 않는다.
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 | 처리 |
|---|---|---|---|---|---|
| `GROUND_TRUTH_PATH_DRIFT` | `.claude/commands/branch-spec.md` §참조 + llm-wiki 다수 문서 | ca-tmpl ground truth 경로를 `/home/donghyeon/workspace/ca-tmpl/` 로 지정 | **그 경로는 낡은 clone 이다.** 실제 최신 저장소는 `/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/` 이며, `cache-redis` 가 47개 src 파일을 보유하고 `RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisProgramExecutor`·`RedisLuaProgramExecutor`·`RedisProgramId`·`LettuceRedisRuntime`·`RedisKeyBuilder`·`RedisCacheEnvelopeCodec` + `.lua` 3개 + `program-set.json` 이 모두 존재한다. 낡은 clone 에는 14개뿐이라 "코드 없음" 으로 오판하기 쉽다 | 명령 문서의 ground truth 경로를 정정. 같은 사실을 [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] §Audit A1 과 설계 §13 D2 가 독립적으로 기록했다 | `open` — harness/사용자 소유 |
| `SELF_CORRECTION` (본 세션) | 본 노트 초판의 `CODE_CLAIM_DRIFT` finding | 초판은 "4개 클래스가 어디에도 없고 commit `b3add01` 도 없으므로 hub §35 를 `[ ]`(미시작)로 강등하라" 고 권고했다 | **그 권고는 틀렸다.** 낡은 clone(`/home/donghyeon/workspace/ca-tmpl`)만 조회한 결과이며, 실제 저장소에는 4개 클래스가 모두 존재한다. hub §35 의 `[*]`(부분 구현) 표기가 **맞다**. 설계 §14.2 의 `actually-implemented` 등급도 유효하다(단 파일 수 "43개" 는 현재 47개로 증가) | hub·설계 문서에 대한 강등 권고를 **철회**한다. 남는 정정 대상은 파일 수 숫자와 commit 해시(`b3add01` 은 낡은 clone 기준 미존재)뿐이며 경미하다 | `partially-resolved` 2026-07-28 — D-row(D1·D2·D4·D8·D9·D10·D11)·§구현 가이드 3·8·9·§TODO·§결정 사항·§진행 중 메모·§Claims To Verify 는 as-built 로 재작성했다. **§구현 가이드 1·2·6·7 은 아직 초판 전제가 남아 있다** — §구현 가이드 preamble 의 충돌 표 참조 |
| `SPRING_DATA_REDIS_PREMISE_RESOLVED` | 본 노트 초판 D1·D2 | 초판은 D1·D2 를 Spring Data Redis 의 `RedisScript`/`DefaultRedisScript`/`ScriptExecutor` 전제 위에 세웠다 | **as-built 는 `io.lettuce:lettuce-core` 단독이고 `spring-data-redis` 의존성이 없다**(`cache-redis/build.gradle` 확인). EVALSHA→NOSCRIPT→EVAL 폴백은 `RedisLuaProgramExecutor` 가 직접 구현한다 | D1·D2 를 as-built 기준으로 재작성했다. [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] §Audit A2 가 이 충돌을 `SPRING_DATA_REDIS_PREMISE_CONFLICT` 로 열어두고 본 노트를 가리키는데, **본 노트 쪽은 해소됐으므로 그 항목과 §구현 가이드 4 의 위험 서술이 낡았다** — 그 branch 소유라 여기서 고치지 않고 알림만 남긴다 | `resolved` (본 노트 측) / #061 측 갱신 필요 |
| `NO_VERSION_PIN` **(철회)** | 본 branch D6 의 opt-in 조건 ① | 초판은 "ca-tmpl 에 Redis 버전이 고정된 곳이 없어 7.0+ 판정 불가" 라고 단정했다 | **틀렸다 — 낡은 clone 기준이었다.** 실제 저장소의 `redis/program-set.json:3``"minimumRedisVersion": "7.2"` 가 있다. 즉 D6 의 조건 ①(7.0+)은 **이미 충족**이고, `#REDIS-EVAL-C7`(effects replication 유일 모드, 7.0+ 전제)도 확정으로 승격된다 | D6 게이트를 ②③ 로 좁혔다. 남는 확인은 "배포 이미지의 실제 `redis_version` 이 7.2 이상인가" 라는 운영 확인뿐 | `resolved` 2026-07-28 |
| `DANGLING_DESIGN_REF` **(철회)** | 본 노트 §근거의 "프로젝트 내부 설계 참조" | 초판은 "두 설계 문서가 부재하므로 인용 근거에서 제외" 라고 권고했다 | **틀렸다 — 낡은 clone 기준이었다.** 실제 저장소 `docs/superpowers/specs/``2026-07-26-production-capability-platform-design.md``2026-07-26-redis-production-capability-design.md`(as-built 를 낳은 6,700줄 설계)가 **둘 다 실재**한다 | 두 문서를 `internal-design-doc` 등급으로 §근거에 유지한다. **다만 본 노트의 현재 결정은 아직 이 문서들을 읽고 쓴 것이 아니다** — 다음 세션에서 정독해 §1 golden vector·§9 metric·§3 denylist·BUSY 관측의 `UNSUPPORTED_IMPL_DECISION` 라벨을 항목별로 재판정해야 한다 | `open` — 정독 미완 |
| `STALE_CLONE_ROOT_CAUSE` | 본 세션의 조사 방법 | 위 `SELF_CORRECTION`·`NO_VERSION_PIN`·`DANGLING_DESIGN_REF` 3건이 모두 같은 원인에서 나왔다 | `/branch-spec` 명령 문서가 지정한 `/home/donghyeon/workspace/ca-tmpl` 를 ground truth 로 삼은 결과, **"코드 없음·버전 없음·설계문서 없음" 이라는 3중 오판**이 연쇄했다 | 명령 문서의 ground truth 경로를 정정하기 전까지 이 branch 계열의 조사는 반드시 `desktop-server-git/clean-architecture-backend-template/` 를 쓴다 | `open` — harness/사용자 소유 |
| `EVALSHA_COUNTEREXAMPLE` | 본 branch D2 (EVALSHA 우선) | 완료 조건이 "NOSCRIPT 재적재 test" 를 요구 → EVALSHA 우선을 전제 | 조사 중 LINE engineering 이 Lua 를 쓰되 **EVALSHA 를 의도적으로 거부**한 사례가 확인됐다(노드·키 관리 비용 vs 대역폭 절감을 비교한 결과). 다만 **아직 raw 로 archive 하지 않아 근거로 인용하지 않았다** | 클러스터 노드 수·호출 빈도가 확정되면 EVALSHA 의 실익을 재평가. 필요 시 해당 블로그를 `raw/company-tech-blogs/` 로 수집 후 D2 재검토 | `open` — 근거 미수집, 결정 변경 없음 |
## Coverage / 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> `coverage-auditor` 생성물 — 손으로 유지하지 않는다. 2026-07-28 loop 2/2 판정: **Covered** (Blocking 0 / Should-fix 0 / Advisory 3).
> governing doc = [[raw/project-notes/ca-skeleton-operational-contract]] (§8.0 WI-062 · §25 Owner Map · §36.2 capability card · §11 실패 매트릭스). 코드 대조는 `desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/`.
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| WI-062 ① program descriptor | covered-here | — | — | D1 · `RedisProgramDescriptor.java` 실재 |
| WI-062 ② golden vector | covered-here | — | — | D1 · `RedisProgramCatalogTest.loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature` |
| WI-062 ③ NOSCRIPT 재적재 test | covered-here | — | — | D2 · `RedisLuaProgramExecutorTest.fallsBackToEvalOnlyWhenEvalShaReportsNoScript` |
| WI-062 ④ unsafe recipe 경합 재현 test | covered-here (결정) / 코드 `planned` | — | — | D9 + §구현 가이드 6. 모듈에 latch/thread 코드 0건 — 노트의 "3 구현 / 1 미구현" 자기평가가 코드와 일치함이 검증됨 |
| §36.2 bounded cardinality metric · trace span | covered-here | — | — | **D11** + §구현 가이드 9 (loop 1 의 🔴 Blocking 해소) |
| §36.2 나머지 12항목 (provider ID·readiness·비용모델·rolling upgrade·runbook 등) | delegated | `feature-capability-provider-selection-contract` | — | hub §36 헤더가 그 branch 를 card owner 로 지정. 본 branch 는 Tier T(기술 런타임)이므로 card 전체를 채울 의무가 없다 |
| §11 실패 정책 매트릭스 정합 | covered-here (consumer) | 규칙 owner: `feature-capability-provider-selection-contract` | — | D10 · `Category.java` 10종 enum 과 노트 열거가 100% 일치 확인 |
| 배포 모드 typed descriptor 노출 | delegated + linked | `feature-capability-provider-selection-contract` | — | §엣지·실패·의존 |
| cluster / hash-slot 라우팅 | delegated + linked | `feature-redis-runtime-role-isolation-contract` | — | §엣지·실패·의존 (#061 D5·D6·D8·D10·D11·D16~D18 pin) |
| idempotency branch 의 위임 미선언 | delegated (consumer 쪽 미선언) | `feature-idempotency-ownership-protocol-contract` | ⚪ Advisory | 그 노트가 아직 scaffolding(D-row 0건). 본 branch 는 이미 gap 을 서술·귀속했으므로 `UNLINKED_DELEGATION` 아님 — 그쪽 `/branch-spec` 으로 해소 |
| cache-strategy branch 의 위임 미선언 | delegated (consumer 쪽 미선언) | `feature-cache-strategy-registry-contract` | ⚪ Advisory | 위와 동일 |
## 마주친 문제
아직 없음.
## 묶음 (이 branch에서 파생된 자료)
<!-- GENERATED: branches:start -->
<!-- GENERATED: branches:end -->
## 관련 일일 노트
해당 없음.
## 완료 후 정리
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- **추출하지 않을 항목** (planned / documented-only / abandoned):