Files
document-haness/docs/clean-architecture-backend-template/analysis/10-adapter-outbound-cache-redis.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

1103 lines
113 KiB
Markdown
Raw 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.
# 10 · adapter-outbound-cache-redis
## SSOT identity — 2026-08-31 재검증
- registered leaf id: `adapter-outbound-cache-redis`
- canonical state `analysisFile`: `analysis/10-adapter-outbound-cache-redis.md` (이 문서) — 이 leaf의 단일 SSOT
- source path: `src/adapter/outbound/cache-redis` · Gradle `:adapter:outbound:cache-redis`
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
- coverage ledger: `FULL_READ` **390** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
---
> 상태: IN_PROGRESS
> revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
> 경로: `src/adapter/outbound/cache-redis` · Gradle: `:adapter:outbound:cache-redis`
## 0. Denominator와 coverage ledger
tracked file **390개** — main 316 (Java 314 / 32,082 LOC + resource 2), test 70 (Java 69 / 15,244 LOC + golden 1), governance 4. 총 약 47.3k LOC.
```json
{ "id": "adapter-outbound-cache-redis",
"gradle_path": ":adapter:outbound:cache-redis",
"allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"],
"runtime_memberships": ["app-bootstrap"] }
```
이 leaf는 다른 leaf와 모양이 다르다. **자체 Redis SDK**를 품고 있고, build.gradle 첫 줄이 그 사실과 이유를 적는다 — 설계 문서는 SDK를 여러 Gradle 모듈로 나누지만 "This repository's fail-closed module registry outranks that layout, so the module boundaries are packages under `dev.caskeleton.adapter.outbound.cache.redis.sdk` and `RedisSdkModuleBoundaryTest` enforces them."
main 패키지 배치(Java 314):
| 계층 | 패키지 | 파일 | LOC |
|---|---|---|---|
| 공개 API | `sdk/api/operations` | 69 | 3,048 |
| | `sdk/api/reactive` | 17 | 1,630 |
| | `sdk/api/key` | 17 | 702 |
| | `sdk/api/error` | 14 | 510 |
| | `sdk/api/command` | 13 | 501 |
| | `sdk/api` · `sdk/api/codec` | 9 | 754 |
| Lettuce 구현 | `sdk/lettuce/operations` | 67 | 10,718 |
| | `sdk/lettuce/command` | 14 | 1,825 |
| | `sdk/lettuce/connection` | 9 | 1,473 |
| | `sdk/lettuce/codec` · `observability` | 9 | 996 |
| 부가 기능 | `sdk/programmability` | 17 | 1,504 |
| | `sdk/extensions/**` | 17 | 1,508 |
| | `sdk/raw` · `cluster` · `admin` | 16 | 1,403 |
| 조립 | `sdk/config` | 12 | 2,215 |
| 의미 포트 어댑터 | `cache` · `idempotency` · `lease` · `ratelimit` · `realtime` · `keyspace` | 14 | 3,295 |
`build.gradle`이 **부재를 명시적으로 선언**한다 — `spring-data-redis`는 "routing through Spring Data would reintroduce the untyped, unguarded command surface the catalog exists to prevent. Zero imports.", `micrometer-core`는 "binding it to a meter registry belongs to the composition root, not here. Zero imports." 두 부재 모두 근거와 함께 적혀 있다.
topology lane(`redisTopologyTest`)은 이 저장소에서 본 것 중 가장 방어적으로 작성된 Gradle 태스크다 — mode allowlist(`standalone`·`sentinel`·`cluster`·`tls`), lane별 필수 프로퍼티, lane 태그가 실제 소스에 존재하는지의 사전 확인, `failOnNoDiscoveredTests`, 실행 0건 실패, lane별 **필수 클래스 이름 목록**, lane별 **최소 test 수 하한**, 그리고 skip 1건이라도 있으면 실패. 각 방어에 그것이 막는 과거 실패가 주석으로 붙어 있다("`-Predis.topology.mode=TYPO` built the tag `lane-typo`, matched nothing, ran zero tests and exited 0").
### 하위 범위 ledger
| # | 범위 | main | test | 기타 | 합 | 상태 |
|---|---|---|---|---|---|---|
| 1 | governance + `sdk/config/**` — 설정 · auto-configuration · startup probe · 조립 | 12 | 8 | 6 | 26 | **COMPLETE** |
| 2 | `sdk/api/operations` + `sdk/api` 루트 + `sdk/api/codec` — 타입 있는 명령 표면 | 78 | 5 | | 83 | **COMPLETE** |
| 3 | `sdk/api/key` + `error` + `command` + `reactive` — 키·오류·명령 기술·리액티브 | 61 | 3 | | 64 | **COMPLETE** |
| 4 | `sdk/lettuce/operations` — gateway · request builder · in-memory double | 67 | 28 | | 95 | **COMPLETE** |
| 5 | `sdk/lettuce/command` + `connection` + `codec` + `observability` — 정책 가드 · 연결 · 코덱 | 32 | 11 | 1 | 44 | **COMPLETE** |
| 6 | `sdk/programmability` + `extensions/**` + `raw` + `cluster` + `admin` | 50 | 4 | | 54 | **COMPLETE** |
| 7 | 의미 포트 어댑터 6종 + live/topology test 기반 | 14 | 10 | | 24 | **COMPLETE** |
| | **TOTAL** | **314** | **69** | **7** | **390** | **7 / 7** |
manifest: `evidence/raw/158-cache-redis-module-inventory.txt`.
---
## 1. Sub-scope 01 범위와 denominator
> 내부 상태: COMPLETE — **26 / 26 FULL_READ**
> 범위: governance 4 + `sdk/config/**` main 12 (2,215 LOC) + 전용 test 8 + main resource 2
> 역할: `app.redis.enabled` 하나로 열리는 조립 지점 — 설정 bind · cross-field 검증 · 자격증명 해석 · 클라이언트/연결/health 조립
manifest와 probe: `evidence/raw/159-cache-redis-config-activation-probes.txt`.
## 2. 조립의 순서가 클래스 하나에 고정돼 있다
`RedisSdkAutoConfiguration`의 javadoc이 규칙을 적는다 — "`app.redis.enabled` is the whole switch. While it is false this class contributes nothing, and because `RedisSdkSettings` is registered here rather than by the application-wide `@ConfigurationPropertiesScan`, **'contributes nothing' is literal**: the properties are not bound, the cross-field rules are not run, no credential is resolved, and no policy resource, TLS material, client, connection or thread is created."
그 문장이 구조로 뒷받침된다. `RedisSdkSettings``@ConfigurationPropertiesScan` 대상이 아니라 이 클래스의 `@Bean` + `@ConfigurationProperties`로만 존재한다. 그래서 Redis를 쓰지 않는 배포는 Redis 설정을 들고 다니지 않고, **켠 적 없는 잘못된 Redis 설정 때문에 벌을 받지도 않는다**. test가 그 넷을 이름으로 고정한다 — `absentSwitchRegistersNothing`, `disabledRegistersNothing`, `disabledIgnoresMalformedRedisConfiguration`, `disabledNeverAsksForASecretOrAConnection`.
순서도 bind → validate → build로 고정된다. 검증이 `@PostConstruct`나 리스너가 아니라 **bean factory 메서드 안**에 있어서, 설정 오류가 "그 bean을 만들지 못했다"는 실패로 보고되고 그 아래 어떤 것도 검증되지 않은 settings를 잡을 수 없다. 그리고 `redisSdkSettingsValidation`이 별도 bean인 이유도 적혀 있다 — Spring은 factory 메서드가 **반환한 뒤에** binder를 돌리므로 검증이 `redisSdkSettings()` 안에 있을 수 없다.
`RedisSdkSettings.validate()`가 값이 아니라 값들 사이의 관계를 본다.
- Cluster이면 database는 0만, Sentinel이면 `master-name` 필수
- TLS 클라이언트 인증서가 있으면 개인키 참조 필수("a certificate without its private key cannot be presented")
- 다섯 timeout 프로파일 전부 양수·상한 이하, `fast`가 500ms 가이드를 넘으면 **warning**(실패 아님)
- blocking 명령은 무한 대기 금지, 전용 연결 레인은 양수 상한
- raw gateway와 admin plane은 **각자의 자격증명 참조**를 요구
- advanced가 꺼져 있는데 advanced 정책이 설정돼 있으면 실패
- 마지막에 `authentication.validate(warnings)` — 그 순서에 이유가 적혀 있다("a deployment with both a structural mistake and a missing credential should be told about the structural one first")
자격증명은 값이 아니라 **참조**만 설정에 들어간다(`secret://<source>/<name>`). `RedisCredentialResolver`가 startup에 해석하고, 스킴이 아니면 리터럴 비밀번호로 통과시키지 않고 거부한다. `secret://<user>@<source>/<name>` 형태로 ACL 계정 이름을 지정할 수 있고 없으면 `default`인데, javadoc이 그 기본값의 의미를 적는다 — "`default` — which is exactly the account a hardened deployment disables." `RedisCredentials.toString()`은 비밀번호를 `***`로 만든다.
그리고 **설정된 모든 역할이 해석된다** — application뿐 아니라 advanced·pubsub·admin·raw·sentinel까지. 이유가 적혀 있다: "A deployment that named an advanced or pub/sub account and got a client that silently authenticated as the application account has the privilege separation it configured on paper and nowhere else."
## 3. Confirmed — raw allowlist 기본값은 없는 리소스를 가리키고, 그것이 의도다
`RedisSdkSettings.Raw.policyResource` 기본값은 `classpath:redis-sdk/raw-command-allowlist.yml`인데, 저장소에 그 파일은 **없다**(`159-...` §8.3b, `git ls-files` 매치 0. 이 leaf의 main resource는 `AutoConfiguration.imports`와 `redis-sdk/redis-command-policy.yml` 둘뿐).
이것은 결함이 아니라 이미 잡혀 있는 함정이다. `requireRawPolicyResource`가 그 사실과 과거 증상을 함께 적는다 — "`validate()` only checks that the setting is non-blank, and the default points at … a resource this module does not ship. So enabling the raw gateway passed configuration validation and then **failed at the first raw command, from inside a request, against a live connection.** The allowlist is the entire authorisation model for that gateway; not being able to read it is a startup failure." test `enabledRejectsAMissingRawAllowlistResource`와 `enabledAcceptsAReadableRawAllowlistResource`가 양쪽을 고정한다.
## 4. Confirmed — "하나의 상수, 두 독자"가 실제로 지켜진다
`RedisCorrectnessRoles`의 javadoc은 이 클래스가 존재하는 이유를 과거 장애로 설명한다 — `redisRequired` health contributor를 만들지 결정하는 조건과 readiness 그룹이 그 이름을 넣어도 되는지 결정하는 post-processor가 서로 어긋나자, Boot가 존재하지 않는 contributor를 named group에서 발견하고 **모든 Redis-off 배포가 startup에서 실패**했다("Included health contributor 'redisRequired' in group 'readiness' does not exist").
주장을 검증했다. `app-bootstrap`의 `RedisReadinessGroupPostProcessor`가 이 leaf의 상수를 **실제로 import해서 읽는다** — `:45`에서 `RedisCorrectnessRoles.anySelected(environment)`, `:54`에서 `RedisCorrectnessRoles.REQUIRED_HEALTH_CONTRIBUTOR`. 술어가 복제돼 있지 않다. 주장대로다.
두 contributor의 분리도 그 자체로 규칙이다 — `redisOptional`은 도달 불가일 때 `DOWN`이 아니라 커스텀 `DEGRADED`를 반환하고("a status the readiness group understands as failure would remove a healthy pod from service — shrinking capacity during the exact incident that needs it most"), `redisRequired`는 correctness 역할(session·idempotency·rate-limit·lease) 중 하나가 Redis를 선택했을 때만 존재하며 `DOWN`을 낸다. probe는 플래그가 아니라 실제 왕복이고("`isOpen()` on a driver connection reports what the client believes, which stays true for as long as it takes TCP to notice"), 실패 detail에는 예외 **클래스 이름만** 넣는다("a driver message can carry an endpoint, a username, or a key").
## 5. P2 — README readiness 표와 build.gradle 주석이 실제 소스와 어긋난다
README의 readiness 표(README:2329)와 그 아래 두 문단이 이 leaf의 현재 상태를 서술한다. 네 행 중 **세 행이 사실과 다르다.**
| README가 말하는 것 | 실제 |
|---|---|
| "Topology client / connection lifecycle — API 구현 **없음**" | `sdk/lettuce/connection` **9 파일 1,473 LOC** — `RedisTopologyClientFactory` 600, `RedisRuntimeOwner` 316, `SentinelFailoverObserver` 153, `RedisConnectionRegistry` 142 … |
| "cache / session / idempotency / rate limit / lease semantic port — API 구현 **없음**" | `cache`·`idempotency`·`lease`·`ratelimit`·`realtime`·`keyspace` **14 파일 3,295 LOC** — `RedisIdempotencyStoreAdapter` 529, `RedisCacheRegionAdapter` 428, `RedisDistributedLeaseAdapter` 403 … 각각 전용 test 보유 |
| "role-aware health·readiness contributor — **없음**" | `RedisHealthContributor` 88 + `RedisCorrectnessRoles` 62, 그리고 auto-configuration이 `redisOptional`·`redisRequired` 두 bean을 만든다 |
| "아래 절들은 이전 세대 semantic adapter 세트의 설계 결정을 기록한 것이며, **그 코드는 현재 이 leaf에 없다**" | 위 14 파일이 그 코드다 |
두 번째 열("Spring composition 구현")도 마찬가지다. README:3032는 "현재 `APP_REDIS_ENABLED=true`가 하는 일은 `RedisSdkSettings`를 bind하고 cross-field 규칙을 fail-fast로 검증하는 것까지다. **client, connection, gateway, semantic adapter, health contributor는 아직 조립되지 않는다**"라고 적는다. 그러나 `RedisSdkAutoConfiguration`은 **@Bean 메서드 일곱 개**를 갖는다 — settings(83), validation(101), resolved credentials(156), **runtime client**(226), **runtime owner**(273, `destroyMethod="close"`), **redisOptional**(297), **redisRequired**(324). test가 그것을 확인한다 — `clusterBuildsAClusterClient`, `theRuntimeOwnerFollowsTheContext`, `theOptionalContributorReportsUp`, `closingTheContextTearsEverythingDown`, `aLeaseReachesTheServer`. 실제로 조립되지 않는 것은 gateway와 semantic adapter 둘뿐이다.
`build.gradle`의 주석도 같은 방향으로 틀렸다.
> "The SDK itself imports nothing from them today (**0 imports across main source**) — the semantic cache/session/idempotency/rate-limit adapters that did **were removed** and are restored by Phase E …"
실측은 다르다(`159-...` §8.4b).
```
import dev.caskeleton.application.* → 7 파일
RedisIdempotencyStoreAdapter 18 · RedisDistributedLeaseAdapter 10 · RedisCacheRegionAdapter 9
RedisConnectionRegistryAdapter 5 · RegistrationCodec 4 · RedisEphemeralFanoutAdapter 3 · RealtimeKeys 2
import dev.caskeleton.shared.* → 3 파일 (ratelimit 3종)
import …adapter.outbound.support → 0 파일
```
같은 주석의 다른 절반은 맞다 — `spring-data-redis`와 `io.micrometer` import는 실제로 0이다(exit=1).
**판정: P2.** 코드 결함이 아니라 문서 결함이지만 이 저장소 기준으로는 무겁다. 첫째, 이 leaf의 README는 "readiness는 서로 다른 세 가지 질문이며 하나로 합치면 안 된다"는 문장으로 시작하는, **정직한 readiness 보고를 자기 주제로 삼는 문서**다. 둘째, 방향이 이례적이다 — 보통의 drift는 없는 것을 있다고 하는데 여기는 **있는 것을 없다고** 한다. fork가 이미 있는 4,900 LOC를 다시 구현하거나, 조립되지 않은 채 존재하는 코드의 존재 자체를 모르게 된다. 셋째, `RedisSdkAutoConfiguration`의 javadoc이 "until this class existed the method had no production caller at all"이라고 적는 것으로 보아 이 클래스가 README 문장보다 나중이다 — 조립이 진행됐는데 서술이 따라가지 않았다.
## 6. P2 — startup probe가 production에서 한 번도 실행되지 않는다
`RedisStartupProbe`의 javadoc은 자신을 이렇게 소개한다 — "Asks the server what it is, **once, at startup**. Configuration says what the deployment intends; only the server says what is true." 그것이 확인하는 것은 넷이다.
1. 서버 버전이 7.2.0 baseline 이상인가
2. Cluster인데 database가 0이 아닌가
3. **명시적으로 켠 capability를 서버가 실제로 갖고 있는가** — "A capability the deployment explicitly enabled but the server does not have is a startup failure, never a silently disabled feature." 버전이 아니라 `COMMAND` 목록으로 확인한다(`SPUBLISH`·`FCALL`·`HEXPIRE`·`HGETEX`·`XACKDEL`·`XNACK`·`JSON.SET`·`FT.SEARCH`·`TS.ADD`·`BF.ADD` 10종 witness)
4. **복제 배포의 쓰기 내구성** — `min-replicas-to-write ≥ 1` **그리고** `min-replicas-max-lag ≥ 1`
네 번째의 javadoc은 이 저장소에서 본 가장 구체적인 사고 기록이다.
> "When a primary is superseded by a promotion it does not find out immediately, and until it does it keeps answering `+OK` to writes that are discarded when it resyncs from the new primary. **The Sentinel lane measured eleven seconds and 2,086 acknowledged-then-discarded writes, with exactly one command failing.** No client can see it: the server answered, so the driver, this SDK, and the caller all record a success. There is no metric to add, no failure to retry, and no certainty value that describes it."
그리고 결론을 못박는다 — "So a replicated deployment without it is **a startup failure rather than a warning**."
**그 startup failure는 일어나지 않는다.** `RedisStartupProbe`와 `RedisCapabilityProbe`를 참조하는 `main` 코드는 `RedisSdkSettings:888`의 javadoc `{@link}` 하나뿐이고, 생성하거나 `confirm(...)`을 부르는 곳은 **전부 test**다(`159-...` §8.1d). `RedisSdkAutoConfiguration`은 클라이언트와 runtime owner와 두 health contributor를 만들지만 startup probe는 부르지 않는다. 그래서 조립된 애플리케이션에서
- 7.2 미만 서버로 시작해도 startup은 통과하고,
- `app.redis.capabilities`로 켠 capability가 서버에 없어도 startup은 통과하며,
- `min-replicas-to-write`가 없는 Sentinel/Cluster 배포도 startup은 통과한다 — 위 인용이 설명한 정확히 그 상황에서.
**판정: P2.** 로직은 완성돼 있고 test 두 파일(`RedisCapabilityProbeTest` 6케이스 · `RedisStartupProbeTest`)이 각 규칙을 고정한다. 없는 것은 호출 지점 하나다. 수정은 `RedisSdkAutoConfiguration`에 `RedisStartupProbe`를 실행하는 bean을 추가하는 것이고, `RedisStartupProbe`가 연결을 들고 있지 않도록 설계된 것(`ServerFacts`를 인자로 받는다) 자체가 그 조립을 쉽게 만들어 둔 형태다. 다만 `INFO`/`CONFIG GET`이 admin-plane이라는 javadoc의 지적대로, 그 조립은 admin 계정이 설정된 배포에서만 완전하다 — `ServerFacts.from`이 `min-replicas-*`가 없으면 "grant the admin account +config|get"이라고 실패하는 것도 그 때문이다.
## 7. P3/기록 — permit 발급 권한도 production 생성 0
`ConfiguredRedisPolicyAuthority`(정책 이름별 HMAC 서명 permit 발급)와 `ConfiguredRedisPermitVerifier`(구현 타입·발급자 신원·서명·정책 이름 네 가지를 모두 확인)는 설계가 촘촘하다 — permit 구현 record 3종이 **package-private**이라 애플리케이션 코드가 인터페이스는 구현할 수 있어도 그 타입을 만들 수 없고, 검증기가 서명뿐 아니라 **구체 타입**도 본다. 서명 비교는 `MessageDigest.isEqual`로 상수시간이다. javadoc이 경계도 정직하게 긋는다 — "It is not a security boundary against a hostile process — the Redis ACL account is, and a permit never widens it."
그러나 이 둘을 `main`에서 생성하는 코드는 없다(`159-...` §8.1d). 참조는 전부 test다. 지금은 gateway 자체가 조립되지 않으므로(§5) 모순은 아니다 — permit은 gateway가 요구하는 것이고 gateway가 없으면 발급자도 필요 없다. 기록해 두는 이유는 §5·§6과 같은 모양이기 때문이다: **완성된 절반과 조립되지 않은 절반**.
## 8. Negative-space probes — sub-scope 01
- **8.1 reachability**: auto-configuration의 @Bean 7개 확인. startup probe·capability probe·permit authority·verifier는 production 생성 0(§6, §7).
- **8.1b 교차 모듈 계약**: `RedisCorrectnessRoles`를 app-bootstrap이 실제로 읽는지 확인 — 읽는다(§4).
- **8.2 조건부 형제**: `redisOptional`(DEGRADED, readiness 밖)과 `redisRequired`(DOWN, readiness 안, 역할 조건부)의 대비가 양쪽 모두 구현·test됨(§4).
- **8.3 기본값이 가리키는 리소스**: raw allowlist 기본값이 없는 파일을 가리키고, 그것이 startup 실패로 닫혀 있음(§3).
- **8.4 문서/수치 drift**: README 4행 중 3행, build.gradle "0 imports" 주장(§5).
## 9. Sub-scope 01 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P2** | README readiness 표 3행과 "그 코드는 이 leaf에 없다"가 사실과 다르고, build.gradle의 "0 imports across main source"도 실측 7+3 파일과 어긋난다. auto-configuration은 client·runtime owner·health contributor 2종을 실제로 만든다 | 이 leaf를 읽는 모든 fork |
| **P2** | `RedisStartupProbe`/`RedisCapabilityProbe`의 production 호출자 0 — 7.2 baseline·Cluster db0·명시 capability·복제 쓰기 내구성 네 guardrail이 조립된 애플리케이션에서 실행되지 않는다 | Redis를 켠 모든 배포, 특히 복제 배포 |
| **P3/기록** | `ConfiguredRedisPolicyAuthority`·`ConfiguredRedisPermitVerifier` production 생성 0 (gateway 미조립과 일관되나 발급 경로가 없다) | gateway 조립 시점 |
## 10. Sub-scope 01 완료 조건
- denominator 26 / 26 FULL_READ (`159-...` OWNED FILES)
- §8.1~§8.4 네 종 probe 수행, 교차 모듈 계약 1건 검증
- 후보 finding 1건(raw allowlist 기본 리소스 부재)을 startup 검사로 추적해 결함 아님으로 판정(§3)
- 소스 미변경
---
## 11. Sub-scope 02 범위와 denominator
> 내부 상태: COMPLETE — **83 / 83 FULL_READ**
> 범위: `sdk/api/operations` 69 + `sdk/api` 루트 6 + `sdk/api/codec` 3 (main 78, 3,802 LOC) + `sdk/api` 전용 test 5
> 역할: 이 SDK가 **무엇을 표현할 수 있게 하는가** — 타입 있는 명령 표면과 그 위의 값 타입
manifest와 probe: `evidence/raw/160-cache-redis-api-surface-probes.txt`.
## 12. 설계의 중심은 "위험한 명령을 부를 수 없게 만드는 것"
이 계층의 규칙은 검증이 아니라 **표현 불가능성**이다. 네 가지 장치가 서명에 박혀 있다.
**(a) 모든 쓰기가 만료를 들고 다닌다.** `Expiration`은 sealed interface고 변형이 셋뿐이다 — `After(Duration)`(양수 강제), `At(Instant)`, 그리고 `Persistent(PersistentKeyPermit permit)`. 즉 **TTL 없는 쓰기는 permit 없이 표현할 수 없고**, javadoc이 그 의도를 적는다("cannot be reached by omission"). `RedisValueOperations`의 javadoc이 이어 설명한다 — `SETNX`·`SETEX`·`PSETEX`가 없는 이유는 "so a caller cannot accidentally pick the variant that forgets the TTL."
**(b) 다중 키·고비용·비가역 연산이 permit이나 budget을 요구한다.**
| 요구 | 연산 |
|---|---|
| `MultiKeyPermit` | `values.multiGet` · `keys.exists/delete/unlink/rename` · `lists.move` · `sets.move` · blocking `move` · `hyperLogLogs.count/merge` · `bitmaps.bitOperation` · `geo.searchStore` |
| `PersistentKeyPermit` | `keys.persist` · `hashes.persistFields` · `Expiration.Persistent` |
| `AdvancedOperationPermit` | `keys.scan`(키 공간) · `hashes.entries` · `sets.difference/intersection/union` |
| `OperationBudget` | 범위·페이지·전체 읽기 계열 전부 — `values.append/getRange/setRange` · `lists.range` · `sortedSets.rangeBy*` · `geo.search` · `bitmaps.bitOperation` · `bitFields.execute` · `hashes.entries` |
**(c) 폐기·위험 명령이 아예 없다.** `KEYS`는 "not here and never will be", `ZREVRANGE` 계열은 `SortDirection` 인자로 흡수, `RPOPLPUSH`/`BRPOPLPUSH`는 명시적 source/destination side를 가진 `move`로, 폐기된 `GEORADIUS`는 bounded search로, 그리고 `RedisSetOperations`에는 `members()`가 없다 — "the size of a set is a runtime property and an API that ignores it is an outage waiting for the right key."
**(d) 값 타입이 모순된 조합을 거부한다.** `SortedSetAddOptions`는 absent+present, greater+less, absent+score비교 세 조합을 거부한다("a score comparison is meaningless for a member that must be absent"). `GeoSearchRequest`는 origin/fromMember 정확히 하나, radius/box 정확히 하나, box는 width와 height 둘 다를 강제한다. `BatchItemResult`는 값과 실패 중 정확히 하나. `BitFieldSubcommand`는 signed 64 / unsigned 63 비트 상한과 "읽기는 피연산자를 갖지 않는다"를 강제한다. `GeoPoint`는 위도를 Redis의 실제 한계인 ±85.05112878로 제한한다.
정직한 한정도 각 인터페이스 머리에 붙어 있다 — HyperLogLog은 "roughly 0.81% standard error… unsuitable for billing, quota enforcement, or anything a user can dispute", Pub/Sub은 "at-most-once… Do not build order, payment, or retry workflows on this", 스트림 소비자 그룹은 "at-least-once… the SDK does not hide that."
## 13. Confirmed — "설계상 부재" 주장 6건이 구현·정책 계층까지 일치한다
주장이 API 문서에만 있는지 확인했다(`160-...` §8.4).
- `sdk/api` 전체에서 `SETNX`·`SETEX`·`PSETEX`·`ZREVRANGE`·`RPOPLPUSH`·`BRPOPLPUSH`·`GEORADIUS`가 등장하는 곳은 **"없다"고 적는 javadoc 네 줄뿐**이다.
- Lettuce 구현 계층에서 걸린 둘은 무해하다 — `HashOperationRequests:128`의 `HSETNX`(다른 명령이다), `WritePresence:6`의 주석("This is what replaces `SETNX` and `SETEX`").
- 명령 정책 SSOT(`redis-command-policy.yml`, 1,406줄)에서 `KEYS`는 **`risk: R4`, `support: BLOCKED`**이고, 파일 머리의 표에 따르면 `BLOCKED`의 access는 `NONE`이다. 같은 자리에 `RANDOMKEY`(R2 BLOCKED)·`DUMP`·`RESTORE`·`MIGRATE`·`SELECT`·`SWAPDB`도 BLOCKED다. 즉 raw gateway로도 `KEYS`에 닿을 수 없다.
정책 파일 자체의 구조는 sub-scope 05에서 다룬다 — 머리 주석이 "Official server metadata … decides what a command *is*. This file decides what this SDK is willing to *do* with it. **The catalog drift gate compares the two and fails the build when the server grows a command this file has not judged.**"라고 적는다.
## 14. Confirmed — 두 프로그래밍 모델의 대칭이 기계 검사되고, 검사기 자신도 검사된다
`ReactiveRedisOperations`는 "Mirrors `RedisOperations` method for method"라고 주장한다. `ApiParityTest`가 그것을 반사로 강제한다 — `PAIRS` 맵에 14쌍의 sync/reactive 인터페이스를 놓고 `everySyncOperationHasReactiveCounterpart`, `everyTypedSurfaceIsInParity`, `theTwoEntryPointsExposeTheSameStructureAccessors`, `everyReactiveMethodReturnsAPublisher`를 돌린다. 두 facade의 접근자 12개는 실제로 동일하다(diff 공백).
두 가지가 특히 좋다. 첫째, **예외가 이유와 함께 목록에서 빠져 있다** — Pub/Sub은 sync가 핸들러+closeable subscription이고 reactive는 publisher 자신이 전달하며 취소로 구독을 끊으므로 "different shapes on purpose, so mechanical parity would be the wrong check for them". 둘째, `theInspectorDetectsADivergentReturnShape`라는 **검사기에 대한 메타 test**가 있다 — 대칭 검사기가 고장 나 항상 통과하는 상태를 잡는다.
## 15. P2 — SDK가 선언한 두 진입점에 구현이 없다
`RedisOperations`는 자신을 "Synchronous entry point to the typed Redis API"라고 소개하고, `ReactiveRedisOperations`가 그 reactive 짝이다. 두 인터페이스는 각각 12개 접근자(`values()`·`hashes()`·`lists()`·`sets()`·`sortedSets()`·`bitmaps()`·`bitFields()`·`hyperLogLogs()`·`geo()`·`streams()`·`keys()`·`batches()`)를 선언한다.
**둘 다 구현체가 없다.** leaf 전체의 `implements` 전수 조사(`160-...` §8.1 후속)에서 개별 표면은 전부 구현돼 있다 — sync 26종(`LettuceRedisValueOperations`·`LettuceRedisHashOperations`·… `LettuceRedisAdminOperations`·`LettuceRedisSearchOperations`까지), reactive 17종. 그런데 `implements RedisOperations` / `implements ReactiveRedisOperations`는 **0건**이다.
`main` 안에서 두 타입을 이름으로 부르는 곳도 없다. 유일한 참조가 `ReactiveRedisOperations:19`의 javadoc `{@link}` 하나와, test인 `ApiParityTest:97-98`의 반사 두 줄이다.
결과적으로 이 SDK를 쓰는 코드는 `RedisOperations`를 **얻을 수 없다**. 12개 표면을 각각 어디선가 따로 받아야 하고, 진입점이 약속하는 "하나의 객체에서 타입 있는 표면 전체" 는 존재하지 않는다. facade에 접근자를 추가하고 reactive 짝을 맞추는 규율(§14)은 실행되고 있는데, 그 facade를 만드는 코드는 없다.
**판정: P2.** 데이터 위험은 없다 — 없는 타입은 잘못된 답을 주지 않는다. 위험은 API 계약의 신뢰다: 이 leaf의 공개 표면 중 **가장 먼저 읽히는 두 타입**이 구현되지 않은 상태이고, 대칭 test가 그 사실을 가리지 못한다(인터페이스끼리만 비교하므로). sub-scope 01의 §5와 방향이 반대이면서 원인은 같다 — 조립이 절반이다. 수정은 이미 존재하는 26개 구현을 묶는 `LettuceRedisOperations` / `LettuceReactiveRedisOperations` 두 클래스를 추가하고, `ApiParityTest`에 "두 facade는 구현을 가진다"는 검사를 더하는 것이다.
## 16. P3 — Pub/Sub 채널만 렌더 크기 검증을 받지 않는다
`RedisKeyRules.requireRenderedSize(...)`는 조립된 키 문자열이 설정된 최대 바이트를 넘지 않는지 본다. 세 형제 중 하나만 그것을 부른다.
| 타입 | 렌더 형태 | `requireRenderedSize` |
|---|---|---|
| `PubSubPattern` | `namespace:suffixPattern` | **있음** (`:30`) |
| `PubSubChannel` | `namespace:entity:identifier` | 없음 |
| `ShardedPubSubChannel` | `namespace:entity:identifier` | 없음 |
`QualifiedRedisKey` 쪽에는 `rejectsARenderedKeyAboveTheConfiguredSize` test가 있으므로 키 경로에서는 강제된다. 채널 이름은 Redis에서 키와 같은 문자열 공간을 쓰고 같은 상한을 받는데, 두 채널 타입만 조립 결과를 재지 않는다. 구성 요소(`RedisNamespace`·`RedisKeyName`)가 각자 토큰 길이를 제한하므로 현실적인 초과는 어렵지만, 검사가 **패턴에는 있고 채널에는 없다**는 비대칭 자체가 의도로 보이지 않는다. **P3.**
덧붙여 `PubSubChannel.render()`와 `ShardedPubSubChannel.render()`는 본문이 **완전히 동일**하다(같은 세 조각을 같은 구분자로 잇는다). 두 타입이 분리된 이유는 전송 경로(cluster에서 슬롯 소유 shard로만 전달)이지 렌더링이 아니므로 분리 자체는 옳지만, 렌더 규칙이 두 벌로 복제돼 있어 §16의 비대칭이 생길 자리를 만들었다.
## 17. P3 — 다중 키 fan-in 중 HyperLogLog `merge`만 budget이 없다
같은 성격(여러 키를 읽어 하나에 쓰는, 비용이 입력 크기에 비례하는 연산)의 세 형제를 비교하면 요구가 다르다.
| 연산 | permit | budget |
|---|---|---|
| `sets.difference/intersection/union` | Advanced **+** MultiKey | **있음** |
| `bitmaps.bitOperation` | MultiKey | **있음** |
| `geo.searchStore` | MultiKey | **있음** |
| `hyperLogLogs.merge` | MultiKey | **없음** |
| `hyperLogLogs.count`(다중 키) | MultiKey | **없음** |
`PFMERGE`/`PFCOUNT`는 레지스터를 병합·추정하므로 입력 레지스터 수에 비례해 비용이 든다. 이 계층이 "비용이 입력에 비례하면 budget을 받는다"를 다른 네 곳에서 일관되게 적용한 뒤 HyperLogLog 두 곳에서만 빠뜨린 형태다. **P3.** 실제 위험은 낮다 — HLL 레지스터는 크기가 12KB로 고정이라 폭발 범위가 좁다. 그래서 P3이고, 기록하는 이유는 규칙의 예외가 이유 없이 존재하기 때문이다.
## 18. Negative-space probes — sub-scope 02
- **8.1 reachability**: 26개 sync + 17개 reactive 표면은 전부 구현되어 있고, 그것을 묶는 두 facade만 구현 0(§15).
- **8.2 조건부 형제 ①**: sync/reactive 대칭이 기계 검사되고 예외가 이유와 함께 목록화됨(§14).
- **8.2b 조건부 형제 ②**: 다중 키 fan-in 4종 중 HLL 2종만 budget 없음(§17).
- **8.3 중복 mechanism**: `PubSubChannel`/`ShardedPubSubChannel`의 동일한 `render()`와 그중 하나에도 없는 크기 검증(§16).
- **8.4 문서 주장 대조**: "설계상 부재" 6건을 API·구현·정책 yml 세 계층에서 확인(§13).
## 19. Sub-scope 02 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P2** | `RedisOperations`·`ReactiveRedisOperations` 두 진입점의 구현이 0이고 production 참조도 0 — 개별 표면 43종은 전부 구현돼 있다 | SDK를 쓰려는 모든 fork |
| **P3** | `PubSubChannel`·`ShardedPubSubChannel`이 렌더 크기 검증을 받지 않는다(형제 `PubSubPattern`은 받는다) | 긴 namespace/entity/identifier 조합 |
| **P3** | `hyperLogLogs.merge`·`count`가 다중 키 fan-in인데 `OperationBudget`을 받지 않는다(형제 3종은 받는다) | 많은 레지스터를 병합하는 호출 |
## 20. Sub-scope 02 완료 조건
- denominator 83 / 83 FULL_READ (`160-...` OWNED FILES)
- §8.1~§8.4 네 종 probe 수행, 조건부 형제 비교 2건
- "설계상 부재" 주장 6건을 API·Lettuce 구현·명령 정책 yml 세 계층에서 대조(§13)
- 소스 미변경
---
## 21. Sub-scope 03 범위와 denominator
> 내부 상태: COMPLETE — **64 / 64 FULL_READ**
> 범위: `sdk/api/key` 17 + `error` 14 + `command` 13 + `reactive` 17 (main 61, 3,343 LOC) + 전용 test 3
> 역할: 키가 무엇일 수 있는가 · 실패가 무엇을 말하는가 · 명령이 어떻게 기술되는가 · reactive 짝
manifest와 probe: `evidence/raw/161-cache-redis-key-error-command-probes.txt`.
## 22. 키: 렌더된 문자열을 받는 API가 존재하지 않는다
`QualifiedRedisKey`의 javadoc이 이 계층의 규칙이다 — "This is the only key shape the SDK accepts. **There is no API that takes an already rendered key string**, so namespace, slot, and size rules cannot be bypassed."
구조가 그것을 강제한다. `RedisTypedKey`는 9종만 허용하는 sealed interface고(`ValueKey`·`HashKey`·`ListKey`·`SetKey`·`SortedSetKey`·`BitmapKey`·`HyperLogLogKey`·`GeoKey`·`StreamKey`), 전부 `QualifiedRedisKey` + 코덱으로 구성된다. `QualifiedRedisKey`는 `RedisNamespace`(토큰 3개) + `RedisKeyName`(entity 토큰 + identifier) + 선택적 `RedisSlotTag`다. 그리고 `RedisKeyRenderer`가 **중괄호를 쓰는 유일한 장소**라서 Cluster 해시 태그가 "the tag and nothing else"를 덮는다.
`RedisKeyRules`의 자기 한정도 정직하다 — 규칙은 "mechanical"이며 "Values that are indistinguishable from an ordinary surrogate identifier, such as a bare digit string, cannot be rejected here; those must be fingerprinted by the caller before they become a key part." 즉 이 검사가 PII 방지의 완결이 아님을 명시한다.
## 23. 실패: 재시도 가능성과 모호성이 배타로 강제된다
`RedisFailureMetadata`는 "Low-cardinality, payload-free description"이고, 불변식 하나가 이 SDK의 재시도 규칙 전체다.
```java
if (retryable && ambiguousExecution) {
throw new IllegalArgumentException("an ambiguous execution must never be marked retryable");
}
```
그리고 팩토리 두 개가 그 규칙을 실제 상황에 적용한다. `notSent(...)`는 `retryable = readOperation`으로 유도한다 — 서버에 닿지 않은 읽기는 재시도해도 안전하다. `storedDataCorruption(...)`은 **일부러 `notSent`가 아니고**, javadoc이 그 이유를 적는다.
> "that factory derives `retryable` from `readOperation`, so a corrupt or unreadable stored value came back marked "safe to retry" purely because reading it was a read. **Retrying a decode of the same bytes produces the same failure — the value is wrong, not the attempt** — and a caller that treats it as transient turns one bad key into a retry loop instead of surfacing the corruption."
그 팩토리는 실제로 쓰인다 — `JsonEnvelopeFraming:202`, `VersionedJsonCodec:103` 두 곳(sub-scope 05 범위)이 디코딩 실패에서 호출한다.
예외 계층은 12종이고 전부 `RedisOperationException`을 상속한다. 메시지는 reason + `command=` 계열 + `mode=` + `ambiguous=`만 조립하고, javadoc이 경계를 적는다 — "keys, fields, members, values, arguments, and authentication material never appear."
## 24. 명령 기술: 정책 파일과 서버 메타데이터의 접합점
`RedisCommandDescriptor`는 "the join between official server metadata and organization policy"이고, 그 아래를 못박는다 — "Nothing downstream of the guard is allowed to re-derive risk, access, or timeout from a command name." 생성자가 그 접합의 모순 네 가지를 거부한다.
| 불변식 | 의미 |
|---|---|
| `BLOCKED` ⇒ `access == NONE` | 차단된 명령은 ACL 계정을 갖지 않는다 |
| `R4` ⇒ `BLOCKED` | 최고 위험은 반드시 차단 |
| `R3` ⇒ `ADMIN_ONLY` 또는 `BLOCKED` | 관리 위험은 애플리케이션에 열리지 않는다 |
| 쓰기 ∧ `retrySafe` ∧ `mayBeAmbiguous` ⇒ 거부 | 모호할 수 있는 쓰기를 재시도 안전으로 선언 불가 |
마지막 하나가 §23의 런타임 불변식과 같은 규칙의 **선언 시점** 짝이다 — 하나는 정책 파일이 거짓말하지 못하게 하고, 하나는 실패 객체가 거짓말하지 못하게 한다.
`KeySpec`은 공식 Redis 규약(1-based, 음수 lastKey는 뒤에서부터, `movable`은 정적 유도 불가)을 그대로 따르고, `movable`이면 `resolvePositions`가 던진다 — "movable key specification must be resolved by the server". `CommandId`는 항상 대문자로 정규화해 "a policy file, a server metadata reply, and an SDK call site cannot disagree because of casing."
permit 세 종은 인터페이스이고 javadoc이 경계를 명확히 한다 — "Application code may implement this interface, but a self-made instance never passes `RedisPermitVerifier`… The final enforcement boundary remains the Redis ACL account, which a permit never widens." `PersistentKeyPermit`은 한 줄 더 붙인다: "Cache, session, lock, idempotency, and rate-limit APIs never accept this permit."
`OperationBudget`도 규칙을 문서로 못박는다 — "Every R2 API requires a budget. The budget is never optional and never defaulted, because the whole point is that the caller states the cost it is prepared to pay before Redis is asked."
## 25. Confirmed — sync/reactive 대칭이 값 타입 수준까지 유지된다
§14에서 확인한 facade 대칭에 더해, 개별 인터페이스 하나를 메서드 이름 집합으로 직접 대조했다 — `RedisValueOperations` vs `ReactiveRedisValueOperations`의 메서드 이름 diff가 **공백**이다(`161-...` §8.2). `byte[] getRange(...)`가 `Mono<byte[]> getRange(...)`로 정확히 대응한다.
반환 형태의 번역 규칙도 일관된다 — `Optional<V>` → `Mono<V>`(비어 있음이 부재), `List<V>` → `Flux<V>`, `void` → `Mono<Void>`, `OptionalDouble`/`OptionalLong` → `Mono<Double>`/`Mono<Long>`. Pub/Sub만 형태가 다르고 그 예외가 `ApiParityTest`의 `PAIRS`에서 이유와 함께 빠져 있다(§14).
## 26. P3 — `requireIdentifier`의 다섯 검사 중 둘은 도달할 수 없다
```java
if (value == null || !IDENTIFIER.matcher(value).matches()) { throw …생략된 문자 클래스 메시지 }
String lowerCase = value.toLowerCase(Locale.ROOT);
if (value.indexOf('@') >= 0) { throw "…must not contain a mail address"; }
if (JSON_WEB_TOKEN.matcher(value).matches()) { throw "…must not contain a JSON web token"; }
if (INTERNATIONAL_PHONE.matcher(value).matches()) { throw "…must not contain a phone number"; }
if (lowerCase.startsWith("bearer") || lowerCase.startsWith("eyj")) { throw "…authentication material"; }
```
`IDENTIFIER`는 `^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`다. 이 문자 클래스에는 **`@`도 `+`도 없다.**
- `@`를 포함한 값은 첫 검사에서 이미 탈락한다 → **메일 주소 분기는 도달 불가**.
- `INTERNATIONAL_PHONE`은 `^\+\d[\d.~-]{7,}$`로 반드시 `+`로 시작하는데, `+`는 첫 문자로도 이후 문자로도 허용되지 않는다 → **전화번호 분기도 도달 불가**.
나머지 셋은 살아 있다 — JWT 패턴의 문자(`A-Za-z0-9_-`와 `.`)는 모두 `IDENTIFIER`가 허용하므로 128자 이하 JWT 형태는 문자 클래스를 통과해 전용 검사에 닿고, `bearer`/`eyj` 접두 검사도 마찬가지다.
test가 이 사실을 가리지 않는다는 점이 중요하다. `rejectsEmailInIdentifier`는 `new RedisKeyName("user", "person@example.com")`이 `IllegalArgumentException`이기만 하면 통과하고, `rejectsInternationalPhoneNumbersInIdentifier`도 `"+821012345678"`에 대해 타입만 본다. 둘 다 **문자 클래스 검사 때문에** 초록이고, 전용 분기가 지워져도 초록으로 남는다.
**판정: P3.** 보안 효과는 그대로다 — 두 형태 모두 거부된다. 잃는 것은 진단 품질(운영자가 "must not contain a mail address" 대신 일반적인 문자 클래스 메시지를 본다)과, 두 분기가 실제로는 아무 일도 하지 않으면서 검증이 다섯 겹인 것처럼 보이게 만드는 점이다. 수정은 두 분기를 지우고 문자 클래스 메시지에 그 의도를 포함시키거나, 검사 순서를 뒤집어 구체적 형태를 먼저 판정하는 것이다.
## 27. P3/기록 — 선언되었으나 읽히지 않는 것 셋
- **`RedisRiskLevel.requiresPermit()`** — 저장소 전체에서 호출자 0(`161-...` §8.1b). javadoc은 "the level requires an issued permit and an operation budget"라는 R2 규칙을 선언하지만, 실제 강제는 정책 파일의 `required-policy` 필드와 `CommandPolicyGuard`가 한다. 형제 `deniedToApplications()`는 `CommandPolicyGuard:126`과 `RawCommandApprovals:116` 두 곳에서 읽힌다 — 같은 enum의 두 술어 중 하나만 배선돼 있다.
- **`RedisOperationException.metadata`가 `transient`** — 예외는 `serialVersionUID`를 선언해 직렬화 가능한데, `RedisFailureMetadata`는 `Optional` 필드를 갖는 record라 직렬화 대상이 될 수 없어 `transient`가 맞는 선택이다. 결과로 직렬화 왕복을 거친 예외는 `metadata()`가 **null**을 돌려주고, 그 사실이 javadoc에 없다. 원격 호출로 예외를 전달하는 fork에서만 문제가 된다.
- **`TypedRedisKeys`의 slot-tag 변형이 두 종뿐** — `valueWithSlot`·`setWithSlot`만 있고 hash·list·sortedSet·bitmap·hyperLogLog·geo·stream에는 없다. `QualifiedRedisKey.tagged(...)`와 각 record 생성자로 우회할 수 있으므로 표현 불가능은 아니지만, Cluster에서 다중 키 연산을 쓰려면 슬롯 고정이 필요한 타입(예: 두 list 사이의 `move`, 여러 HLL의 `merge`)에 편의 생성자가 없다.
## 28. Negative-space probes — sub-scope 03
- **8.1 도달 가능성**: `requireIdentifier` 다섯 분기 중 둘이 선행 문자 클래스에 가려 도달 불가(§26).
- **8.1b 선언 대비 독자**: `requiresPermit()` 0, `deniedToApplications()` 2(§27).
- **8.2 조건부 형제**: sync/reactive 메서드 이름 집합 직접 diff — 차이 없음(§25).
- **8.2b 선언 시점 ↔ 런타임 불변식**: descriptor의 "모호한 쓰기는 retry-safe 불가"와 metadata의 "모호 실행은 retryable 불가"가 같은 규칙의 두 시점(§24, §23).
- **8.3 팩토리 분화**: `notSent`와 `storedDataCorruption`의 분리가 실제 호출자 2곳으로 이어짐(§23).
- **8.4 문서 주장**: "렌더된 키를 받는 API가 없다"를 sealed 계층과 renderer 단일 지점으로 확인(§22).
## 29. Sub-scope 03 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P3** | `RedisKeyRules.requireIdentifier`의 메일·전화 분기가 선행 문자 클래스 때문에 도달 불가 — test는 타입만 보므로 분기를 지워도 초록 | 진단 메시지 품질 |
| P3/기록 | `RedisRiskLevel.requiresPermit()` 호출자 0 (형제 `deniedToApplications()`는 2곳) | 규칙 강제 위치의 분산 |
| P3/기록 | `RedisOperationException.metadata`가 `transient`라 직렬화 왕복 후 `metadata()`가 null이고 그 사실이 문서에 없다 | 예외를 원격 전달하는 fork |
| P3/기록 | `TypedRedisKeys`의 slot-tag 편의 생성자가 value·set 두 종뿐 | Cluster 다중 키 사용 시점 |
## 30. Sub-scope 03 완료 조건
- denominator 64 / 64 FULL_READ (`161-...` OWNED FILES)
- §8.1~§8.4 probe 수행, 조건부 형제 비교 2건(sync/reactive · 두 술어)
- 후보 finding 1건(reactive `getRange` 누락 의심)을 직접 diff로 추적해 **오탐으로 판정**(§25)
- 소스 미변경
---
## 31. Sub-scope 04 범위와 denominator
> 내부 상태: COMPLETE — **95 / 95 FULL_READ**
> 범위: `sdk/lettuce/operations` main 67 (10,718 LOC) + 전용 test 28 (약 8.3k LOC)
> 역할: 타입 있는 API를 실제 명령으로 바꾸는 층 — driver seam · request builder · 실행자 · in-memory double
manifest와 probe: `evidence/raw/162-cache-redis-gateway-probes.txt`.
## 32. 이 층의 구조 — 네 겹이 각자 하나씩만 안다
```
Lettuce{,Reactive}Redis*Operations ← 공개 인터페이스 구현. executor 선택만 다르다
↓ (같은 인스턴스)
*OperationRequests ← 키 렌더 · 값 인코딩 · permit · budget · CommandRequest 조립
Sync/ReactiveRedisCommandExecutor ← CommandPolicyGuard · 타임아웃 · 관측 (sub-scope 05)
RedisCommandGateway (114 메서드) ← 명령당 하나. 이미 렌더된 키와 인코딩된 값만 받는다
LettuceRedisCommandGateway ← Lettuce 타입이 갇히는 유일한 지점
```
**금지된 표면이 실제로 없다.** `RedisCommandGateway`의 javadoc이 "There is deliberately no method that accepts a command name, so the forbidden `execute(String, byte[]...)` surface cannot appear here either"라고 적고, 전수 검색 결과 `execute(String`·`command(String`·`dispatch(String` 매치는 **그 문장 자신뿐**이다(`162-...` §8.1). 인터페이스는 `CompletionStage`를 반환하는 **114개 명령별 메서드**로만 이루어져 있다.
seam의 존재 이유도 적혀 있다 — "so the typed operations can be proven against a deterministic in-memory server without Docker, and so the Lettuce types stay inside `LettuceRedisCommandGateway`."
## 33. Confirmed — 두 프로그래밍 모델이 같은 request builder를 공유한다
`ValueOperationRequests`의 javadoc이 불변식을 적는다 — "Both the blocking and the reactive string operations call exactly these methods, so **a change to a permit, a budget, an encoding, or a command choice cannot apply to one API and not the other.**"
구조가 그것을 보장한다. `LettuceRedisValueOperations`와 `LettuceReactiveRedisValueOperations`는 둘 다 생성자에서 `new ValueOperationRequests(gateway, context, counters)`를 만들고, 차이는 `SyncRedisCommandExecutor` vs `ReactiveRedisCommandExecutor` 하나뿐이다. 각 메서드는 `executor.execute(requests.xxx(...))` 한 줄이고, reactive 쪽은 그 위에 `flatMap`/`then` 같은 형태 변환만 얹는다.
11개 계열 전부에서 확인했다(`162-...` §8.2) — Value·Hash·List·Set·SortedSet·Key·Geo·Bitmap·Stream·HyperLogLog·PubSub 모두 sync와 reactive 양쪽이 같은 `*OperationRequests`를 생성한다.
## 34. Confirmed — 규칙이 `RedisOperationContext` 한 곳에 모여 있다
javadoc이 역할을 정확히 적는다 — "It renders keys, encodes values against the value ceiling, verifies the permits **the guard does not see because their command is R1**, and issues the SDK-side half of an R2 admission. It holds no connection and no driver type, so both the blocking and the reactive operations share exactly one copy of these rules."
세부가 촘촘하다.
- **`parseKey`의 엄격함.** SCAN이 돌려준 키를 다시 해석하는 유일한 지점이고, 네임스페이스 밖이거나 슬롯 태그가 안 닫혔거나 키 문법을 안 따르면 거부한다. 이유가 적혀 있다 — "an anomaly, not something to silently drop from a page and let a caller mistake for an empty result."
- **`scanBudget`의 overshoot 허용.** `COUNT`는 힌트라서 Redis가 버킷 단위로 더 돌려줄 수 있고, 실제로 "a real server returned 501 for exactly that request"였다. 그래서 예산을 요청 count에 맞추면 **정상 응답을 거부**하게 된다. 대신 `설정된 scan 상한 + 512`를 허용하되 "a server returning an order of magnitude more than it was asked for is still refused."
- **`requirePersistentKeyPermit`.** TTL 없는 쓰기와 `PERSIST`는 R1이라 guard가 permit을 보지 못한다. 그래서 여기서 검증하는 것이 "a key without an expiry is a deliberate, authorized decision"을 **advisory가 아니라 참으로** 만든다.
- **`sdkMultiKeyPermit`의 이중 permit 처리.** `BLMOVE`처럼 본질적으로 다중 키인 명령은 caller가 `multi-key-write`를 증명하지만 guard는 `blocking-pop` 정책으로 admit한다. caller의 permit을 guard에 넘기면 **엉뚱한 이유로** provenance가 실패하므로, caller의 권한은 context에서 검증하고 guard에는 SDK 자신의 permit을 준다. javadoc이 그 설계를 그대로 적고 마지막 줄에 안전성을 못박는다 — "The caller still cannot reach the fan-out without holding a real multi-key permit."
응답 예산은 실제로 강제된다 — `requireReplyWithinBudget`이 main에서 12곳, 8개 request builder(Bitmap·Geo·Hash·Key·List·Set·SortedSet·Value)에서 호출된다.
## 35. Confirmed — guard를 지나지 않는 경로가 하나 있고, 그것이 선언돼 있다
`PubSubOperationRequests`의 javadoc이 예외를 스스로 밝힌다.
> "Publishing is an ordinary command and goes through the guard. **Subscribing is not**: it has no reply to bound and no timeout to apply, so what the guard would have checked is checked here — the namespace of every target, and the R2 permit a pattern subscription needs because its fan-out is decided by the server, not by the request."
대체 검사가 실제로 있다. `channelTargets`·`shardTargets`·`patternTargets` 셋 다 빈 컬렉션을 거부하고 **모든 대상의 네임스페이스가 이 프로세스의 것과 같은지** 확인한다(`requireNamespace`, 다르면 "channel belongs to a namespace this process may not use"). `patternTargets`는 추가로 `context.sdkPermit(PATTERN_SUBSCRIBE)`를 호출한다.
## 36. P3 — 패턴 구독의 R2 승인만 호출자가 아니라 배포에 대해 이루어진다
§35의 마지막 줄이 다른 R2 연산과 다르다.
| R2 연산 | 호출자가 permit을 들고 오는가 |
|---|---|
| `sets.difference/intersection/union` | **예** — 서명이 `AdvancedOperationPermit`을 요구 |
| `keys.scan` · `hashes.entries` | **예** |
| `bitFields.execute` | budget 필수, permit은 guard가 catalog의 `required-policy`로 요구 |
| `pubSub.patternSubscribe` | **아니오** — 서명에 permit 인자가 없다 |
`patternTargets`가 부르는 `context.sdkPermit(PATTERN_SUBSCRIBE)`는 **SDK가 자기 자신에게 발급**하는 경로다. `ConfiguredRedisPolicyAuthority.issueAdvanced`는 정책 이름이 배포의 `enabledPolicies`에 없으면 던지므로, 실제 효과는 "이 배포가 `pattern-subscribe`를 켰는가"를 확인하는 것이다. 그리고 반환된 permit은 **버려진다**.
즉 다른 R2 연산은 *호출 지점*이 승인을 증명하는데, `patternSubscribe`는 *배포*가 켜 두었는지만 본다. javadoc이 permit이 필요한 이유("its fan-out is decided by the server")는 적지만, 그 permit이 호출자가 아니라 SDK가 스스로 발급한 것이라는 **약해진 보증**은 적지 않는다. `AdvancedOperationPermit`의 계약이 "proving that an R2 operation was explicitly approved"인 것과 견주면 차이가 있다.
**판정: P3.** 배포 수준 게이트는 실재하고 네임스페이스 봉쇄도 있으므로 열린 구멍은 아니다. 기록하는 이유는 같은 위험 등급에 두 가지 다른 승인 모델이 적용되고 그 차이가 문서화돼 있지 않기 때문이다. 수정은 `patternSubscribe` 서명에 `AdvancedOperationPermit`을 추가하거나, javadoc에 "배포 수준 승인"임을 명시하는 것이다.
## 37. P3 — permit 정책 이름이 세 곳에 문자열로 존재하고 교차 검사가 없다
정책 이름의 출처가 셋이다.
| 출처 | 개수 |
|---|---|
| `RedisOperationContext`의 `public static final String` 상수 | 18 |
| `redis-command-policy.yml`의 `required-policy:` 값 | 18 |
| `LettuceRedisSearchOperations:31`의 private 상수 `SEARCH_INDEX` | 1 |
두 집합의 차분은 정확히 둘이고 **양쪽 다 설명이 있다**(`162-...` §8.3).
- `persistent-key`는 Java에만 있다 — 해당 명령들이 R1이라 catalog의 `required-policy`가 아니라 `RedisOperationContext.requirePersistentKeyPermit`이 강제한다(§34).
- `search-index`는 YAML에만 있다 — `FT.CREATE`(R2, ADVANCED_TYPED, 최소 8.0)의 `required-policy`이고, Java 쪽 짝은 `RedisOperationContext`가 아니라 `sdk/extensions/search`의 private 상수다.
문제는 차분이 아니라 **차분을 감지하는 장치가 없다**는 것이다. YAML에 `required-policy: bounded-collectoin-read`처럼 오타가 들어가면 그 명령은 아무도 발급받을 수 없는 정책을 요구하게 되고, Java 상수 쪽에 오타가 들어가면 `issueAdvanced`가 "policy is not enabled"로 던진다. 어느 쪽도 빌드를 깨지 않는다. catalog drift gate는 YAML을 **서버 메타데이터**와 대조하지, Java 상수 집합과 대조하지 않는다.
**판정: P3.** 확정은 sub-scope 05로 이월한다 — `RedisCommandPolicyLoaderTest`가 정책 이름 집합을 검사하는지 그 sub-scope에서 확인한다.
## 38. Confirmed — in-memory double이 같은 인터페이스를 구현한다
`InMemoryRedisCommandGateway`(test, 2,456 LOC)는 `implements RedisCommandGateway`다. 즉 114개 메서드를 **컴파일러가 강제**하므로, 실제 gateway에 명령이 추가되면 double도 따라가지 않을 수 없다. seam의 목적("proven against a deterministic in-memory server without Docker")이 타입으로 보장된다.
이것이 이 leaf의 test 전략을 설명한다 — `sdk/lettuce/operations` 아래 test 28개 중 대부분이 계약 test(`Redis*OperationsContractTest`)이고, 같은 계약을 live lane(`LiveRedis*Test`)이 실제 서버에 대해 다시 돌린다.
## 39. Negative-space probes — sub-scope 04
- **8.1 금지 표면**: 명령 이름을 받는 메서드 0, 114개 명령별 메서드만 존재(§32).
- **8.2 조건부 형제**: 11개 계열 전부에서 sync/reactive가 같은 request builder를 생성(§33).
- **8.2b 승인 모델 형제**: R2 중 `patternSubscribe`만 호출자 permit이 아닌 배포 permit(§36).
- **8.3 중복 mechanism**: 정책 이름이 Java 상수 18 + YAML 18 + 별도 private 상수 1, 교차 검사 없음(§37).
- **8.4 선언된 예외**: guard를 지나지 않는 구독 경로가 선언돼 있고 대체 검사가 실재(§35). 응답 예산 강제 12곳(§34). double이 인터페이스 구현으로 강제됨(§38).
## 40. Sub-scope 04 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P3** | `patternSubscribe`가 R2인데 호출자 permit을 요구하지 않고 SDK 자기 발급 permit으로 배포 수준만 확인한다 — 다른 R2 연산과 승인 모델이 다르고 문서에 그 차이가 없다 | 패턴 구독을 쓰는 배포 |
| **P3** | permit 정책 이름이 Java 상수 18 · YAML 18 · 별도 private 상수 1로 흩어져 있고 두 집합의 일치를 검사하는 장치가 없다 (sub-scope 05에서 확정) | 정책 이름 오타 |
## 41. Sub-scope 04 완료 조건
- denominator 95 / 95 FULL_READ (`162-...` OWNED FILES) — main 10,718 LOC 전수 판독
- §8.1~§8.4 probe 수행, 조건부 형제 비교 2건, 중복 mechanism 1건
- 후보 finding 1건(`InMemoryRedisCommandGateway`의 메서드 누락 의심)을 `implements` 관계로 추적해 **오탐으로 판정**(§38)
- 정책 이름 교차 검사 여부는 sub-scope 05로 이월(§37)
- 소스 미변경
---
## 42. Sub-scope 05 범위와 denominator
> 내부 상태: COMPLETE — **44 / 44 FULL_READ**
> 범위: `sdk/lettuce/command` 14 + `connection` 9 + `codec` 7 + `observability` 2 (main 32, 4,294 LOC) + 전용 test 11 + golden resource 1
> 역할: 명령 입장 심사 · 연결 레인과 수명 · 코덱 · 관측. 그리고 1,406줄짜리 명령 정책 SSOT를 읽는 파서
manifest와 probe: `evidence/raw/163-cache-redis-guard-connection-codec-probes.txt`.
## 43. `CommandPolicyGuard` — 순서가 고정된 단일 입장 지점
javadoc이 순서와 그 이유를 적는다 — "Validation order is fixed and **each step is cheaper than the one after it**, so an obviously inadmissible command is refused before anything is encoded or sent."
```
capability → risk/permit provenance → namespace → slot → request budget
→ connection lane → timeout/retry → invocation → reply budget → translation → telemetry
```
각 단계가 구체적이다. `requireReachable`은 `BLOCKED`거나 `access == NONE`이면 거부하고 R3/R4를 애플리케이션 경로에서 배제한다. `requireCapability`는 명령의 최소 버전을 프로브된 서버 버전과 대조한다. `requireNamespace`는 모든 키의 네임스페이스를 확인하고 렌더까지 수행한다. `requireSameSlot`은 Cluster에서 두 개 이상 슬롯이면 `RedisCrossSlotException`을 **서버를 부르기 전에** 던진다. `effectiveTimeout`은 블로킹 명령이 유한한 server block을 선언하지 않으면 거부하고, 설정 상한을 넘으면 거부하며, 통과하면 `BLOCKING_MARGIN`(2초)을 더한다.
이 클래스에는 두 개의 수정 이력이 주석으로 남아 있고, 둘 다 이 저장소에서 반복해 본 종류다.
**(a) 죽은 중복 mechanism을 지운 기록.** `validateReply(...)`가 있었고 아무도 부르지 않았다.
> "Two mechanisms for one rule, with the more visible one dead, is worse than one: a reader finds the guard's method, assumes replies are bounded during admission, and writes an operation that never bounds its own. **Admission cannot do this job anyway.** The guard runs before the command is sent, so the only reply size available to it is the estimate the request declared. The authority has to sit where the bytes actually arrive."
**(b) 절대 발화하지 못하던 조건.** 다중 키 permit 검사가 advanced permit 검사와 한 조건으로 접혀 있었고, "둘 다 없음"이 위에서 이미 던지므로 다중 키 절은 도달 불가였다 — "set algebra over any number of keys was admitted on an advanced permit alone." 지금은 `request.keys().size() > 1 && request.multiKeyPermit().isEmpty()`가 독립 조건이다. test `rejectsAMultiKeyCommandCarryingOnlyAnAdvancedPermit`과 `aSingleKeyAdvancedCommandStillNeedsNoMultiKeyPermit`가 양쪽을 고정한다.
## 44. 정책 문서를 일반 YAML 파서로 읽지 않는다
`RedisCommandPolicyLoader`의 javadoc이 이유를 적는다.
> "It is parsed by an explicit reader rather than a general YAML engine because a general engine would silently accept **anchors, merges, nested structures, and duplicate keys** — exactly the constructs that could hide a risk downgrade inside a security policy file."
파서는 그만큼 좁다 — 탭 금지, 들여쓰기 0/2/4만 허용, `commands:` 루트 정확히 하나, 명령 블록 중복 금지, 필드 이름 allowlist(12종) 밖이면 거부, 빈 값 거부, 필드 중복 거부. test가 `rejectsUnknownFieldsEnumsAndDuplicates`로 잡는다.
`RedisCommandPolicy`/`RedisCommandDescriptor`의 교차 필드 불변식(§24)이 로딩 시점에 적용되므로, "R4인데 BLOCKED이 아닌" 정책 파일은 **읽히지 않는다**. test가 `everyDestructiveCommandIsBlockedAndUnreachable`·`deprecatedCommandNamesAreNotReachable`·`arbitraryScriptSourceExecutionIsBlocked`·`theCatalogFailsClosedForAnUnclassifiedCommand`로 그 집합을 고정한다.
## 45. 연결: 레인이 계정과 함께 유도되고, 종료가 순서다
`RedisConnectionKind`가 레인 6종(REGULAR·BLOCKING·TRANSACTION·SCRIPT·PUBSUB·ADMIN)을 정의하고, javadoc이 분리 이유를 적는다 — 실패 양식이 서로 호환되지 않기 때문이다. 더 중요한 것은 **레인이 계정도 결정한다**는 점이다.
> "Least privilege only means something if the accounts differ. The application account runs ordinary data commands and **must not be able to execute a script**; the advanced account can, and exists so that a compromised request path cannot reach `EVALSHA`."
`forCommand`는 blocking이면 BLOCKING, `ADMIN_READONLY`면 ADMIN, `NONE`이면 예외다. test `routesEachCommandFamilyToItsOwnLane`·`aBlockedCommandHasNoLane`이 고정한다.
`RedisRuntimeOwner`는 종료를 상태 기계로 만든다 — `OPEN → DRAINING → CLOSED`. javadoc이 그 필요성을 과거 증상으로 설명한다 — "Without them 'closed' was a counter reset: a lease taken during shutdown still succeeded, its connection was never returned, and a second close double-counted." 지금은 draining이 시작되는 순간 lease가 거부되고, close는 멱등이며, 실패한 연결은 다음 호출자에게 넘기지 않고 무효화한다.
레인 포화는 **대기가 아니라 거부**다 — "An unbounded wait on a saturated lane converts one slow dependency into every caller of this process waiting on it." 그리고 Cluster에서 라우팅된 lease는 절대 풀에 넣지 않는다("a lane's pooled connection on Cluster is pinned to whichever node the *previous* caller routed to").
test가 이 성질들을 이름으로 잡는다 — `exhaustingALaneRejectsImmediatelyInsteadOfQueueing`, `aSaturatedBlockingLaneNeverConsumesTheRegularLane`, `closingALeaseTwiceReleasesItOnlyOnce`, `anInvalidatedConnectionIsNotReused`, `closingStopsAdmissionFirst`, `theClientShutsDownLast`, `aDeadPooledConnectionIsReplaced`.
## 46. Confirmed — 두 실행자가 같은 네 협력자를 갖는다
`SyncRedisCommandExecutor`와 `ReactiveRedisCommandExecutor`는 생성자 인자가 동일하다 — `CommandPolicyGuard`, `LettuceExceptionTranslator`, deployment mode, `Consumer<RedisObservation>`. 둘 다 `guard.validate(request)`로 시작하고 `admission.timeout()`을 적용하며 `NoThrowObservationSink.wrap(...)`으로 sink를 감싼다.
sink를 감싸는 이유가 test 이름에 있다 — `throwingSinkDoesNotFailASuccessfulSynchronousCommand`, `throwingSinkDoesNotReplaceTheOriginalSynchronousFailure`, 그리고 reactive 짝 둘. **관측이 명령의 결과를 바꾸지 못한다**는 규칙이 네 방향 모두에서 고정돼 있다.
실패 번역도 확실성 보존이 중심이다 — `marksWriteTimeoutAsAmbiguousAndNotRetryable`, `marksReadTimeoutAsRetryableAndNotAmbiguous`, `marksConnectionLossAroundAWriteAsAmbiguous`, `treatsAnUnrecognisedWriteFailureAsAmbiguousRatherThanAsNotApplied`, `keepsAnUnrecognisedReadFailureRetryable`, `neverLeaksServerMessageDetailIntoTheSdkMessage`.
관측 태그는 닫혀 있다 — `neverAddsRawKeyToMetricTags`, `exposesTheClosedLowCardinalityTagSet`, `projectsTheSlotIntoALowCardinalityBucket`.
코덱은 golden payload로 고정된다 — `readsVersionOneGoldenPayload`/`writesTheSameBytesAsTheGoldenPayload`(`src/test/resources/redis-sdk/golden/order-summary-v1.json`), 그리고 `rejectsAFutureVersionInsteadOfSilentlyMisreadingIt`·`rejectsCorruptFraming`·`aDecodeFailureIsNotRetryableAndReportsTheBoundDeploymentMode`가 §23의 `storedDataCorruption` 규칙을 실제 경로에서 확인한다.
## 47. P2 — "build gate"라고 불리는 catalog drift 검사가 어디에서도 실행되지 않는다
명령 정책 파일의 머리 주석이 이 leaf의 가장 강한 governance 주장을 한다.
> "Official server metadata (COMMAND DOCS / COMMAND INFO / COMMAND GETKEYSANDFLAGS) decides what a command *is*. This file decides what this SDK is willing to *do* with it. **The catalog drift gate compares the two and fails the build when the server grows a command this file has not judged.**"
`RedisCommandMetadataDiff`의 javadoc도 자신을 "**The build gate**"라고 부르고, 다섯 버킷 각각이 무엇을 막는지 적는다 — 아무도 분류하지 않은 새 명령, 사라진 명령, **키 추출이 엉뚱한 인자를 가리키게 만드는 key spec 이동**, 계정을 조용히 넓히는 ACL 카테고리 변경, 타입 있는 API가 아직 노출하는 deprecation.
비교 로직은 완성돼 있고 test 여섯 개가 다섯 버킷을 전부 덮는다 — `reportsACommandTheServerGrewAndThePolicyNeverClassified`, `reportsAKeySpecificationThatMoved`, `reportsAnAclCategoryChangeAgainstTheReviewedBaseline`, `reportsADeprecationThatIsStillApplicationReachable`, `reportsACommandThatDisappearedFromTheServer`, `reportsNoDriftWhenPolicyMatchesTheServer`.
**그런데 실제 서버 메타데이터를 넣는 곳이 없다.** `RedisCommandMetadataDiff`를 참조하는 `main` 파일은 **0개**이고(`163-...` §8.1), test 참조도 자신의 단위 test 하나뿐이다 — 토폴로지 lane 어디에서도 쓰이지 않는다(`RedisServerCommandMetadata`를 포함한 검색에서 lane 매치 0). `build.gradle`에도 이 비교를 실행하는 태스크가 없다(§0의 lane 정의는 전부 `redisTopologyTest`다).
즉 **빌드를 깨는 gate는 존재하지 않는다.** 존재하는 것은 gate가 쓸 비교 함수와 그 함수의 단위 test다. Redis가 명령을 추가하거나 key spec을 옮겨도 이 저장소의 어떤 실행 경로도 알려주지 않는다.
**판정: P2.** 데이터 위험은 즉각적이지 않다 — 분류되지 않은 명령은 `RedisCommandCatalog.require`가 fail-closed로 거부하므로(test `theCatalogFailsClosedForAnUnclassifiedCommand`) 새 명령이 조용히 통과하지는 않는다. 위험은 반대편 셋이다: **key spec이 이동하면** 이 SDK의 네임스페이스·슬롯 검사가 잘못된 인자를 키로 보게 되고, **ACL 카테고리가 넓어지면** 계정 분리 가정이 조용히 약해지며, **deprecation**은 타입 있는 API가 사라질 명령을 계속 노출한다. 셋 다 fail-closed 카탈로그가 잡지 못하는 종류다. 수정은 토폴로지 lane에서 `COMMAND DOCS`/`COMMAND INFO`를 읽어 `RedisCommandMetadataDiff.compare(...)`를 돌리고 비어 있지 않으면 실패시키는 것 — lane은 이미 실제 서버에 붙어 있고 비교 함수도 완성돼 있으므로 없는 것은 연결 한 줄이다.
## 48. P3/기록 — 정책 문서가 자기 필드를 하나 적지 않는다
`redis-command-policy.yml` 머리의 "Fields and their defaults:" 표는 **11개** 필드를 적는다(`risk`·`support`·`minimum-version`·`access`·`blocking`·`read-only`·`retry-safe`·`may-be-ambiguous`·`timeout-profile`·`key-spec`·`required-policy`).
`RedisCommandPolicyLoader.FIELDS`는 **12개**를 받는다 — 위 11개에 **`optional-block`**이 더 있다. 그리고 그 필드는 실제로 쓰인다(파일 :666, :673 두 명령). 파서는 allowlist 밖 필드를 거부하므로 문서만 뒤처져 있다.
이 sub-scope에서 sub-scope 04 §37의 이월도 확정한다. `RedisCommandPolicyLoaderTest.everyAdvancedCommandDeclaresThePermitPolicyItRequires`는 모든 `ADVANCED_TYPED` 명령이 `required-policy`를 **갖는지**만 확인하고, 그 이름이 Java 쪽에서 발급 가능한 정책인지는 확인하지 않는다. `RedisOperationContext`를 참조하는 이 sub-scope의 test도 없다. **§37은 확정된 P3이다** — 정책 이름 집합의 일치를 검사하는 장치가 저장소에 없다.
## 49. P3/기록 — production에 있으나 production 소비자가 없는 타입 셋
| 타입 | LOC | main 참조 | 실제 소비자 |
|---|---|---|---|
| `RedisCommandMetadataDiff` | 145 | **0** | 자기 단위 test뿐(§47) |
| `RedisCodecRegistry` | 203 | **0** | `RedisCodecRegistryTest`뿐 |
| `SentinelFailoverObserver` | 153 | **0** | Sentinel 토폴로지 lane test |
셋 다 sub-scope 01 §5가 확정한 "조립이 절반"의 결과다. `SentinelFailoverObserver`는 lane의 계측 도구라는 역할이 javadoc에 분명하므로 성격이 다르고, 그 javadoc은 이 저장소에서 본 가장 정직한 자기 한정 중 하나다 — 자신이 **측정할 수 없는** 손실 창을 명시하고("no counter here can be made to include it"), 그 창을 닫는 것은 서버 설정(`min-replicas-to-write`)임을 적으며, 같은 promotion을 그 설정으로 다시 돌렸을 때 2,086 → 1로 줄었다는 실측을 남긴다. §6의 미실행 startup probe가 강제하려던 바로 그 설정이다.
## 50. Negative-space probes — sub-scope 05
- **8.1 reachability**: 9종 타입의 main 참조 수 계수 — 셋이 0(§49). drift gate가 그중 하나(§47).
- **8.2 조건부 형제**: 두 실행자의 협력자 4종 동일, 관측 예외 격리가 sync/reactive 각 2 test(§46).
- **8.3 중복 mechanism**: 죽은 `validateReply`를 지운 기록과 그 이유(§43). 정책 이름 3중 기재의 교차 검사 부재 확정(§48).
- **8.4 문서/수치 drift**: 정책 파일 필드 목록 11 vs loader allowlist 12(§48). "build gate"라는 서술과 실행 경로 부재(§47).
## 51. Sub-scope 05 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P2** | catalog drift gate(`RedisCommandMetadataDiff`)가 "fails the build"라고 선언돼 있으나 실제 서버 메타데이터를 넣는 실행 경로가 없다 — main 참조 0, lane 참조 0, Gradle 태스크 0 | key spec 이동 · ACL 카테고리 확대 · deprecation |
| **P3** | permit 정책 이름 집합(Java 18 + 별도 상수 1 vs YAML 18)의 일치를 검사하는 장치가 없다 (sub-scope 04 §37 확정) | 정책 이름 오타 |
| P3/기록 | 정책 문서의 필드 표가 11개를 적지만 loader는 `optional-block`을 포함해 12개를 받고 그 필드가 실제로 쓰인다 | 문서 |
| P3/기록 | `RedisCodecRegistry`(203 LOC)·`SentinelFailoverObserver`(153 LOC)의 production 소비자 0 | 조립 완료 시점 |
## 52. Sub-scope 05 완료 조건
- denominator 44 / 44 FULL_READ (`163-...` OWNED FILES)
- §8.1~§8.4 probe 수행, 조건부 형제 비교 1건, 중복 mechanism 2건
- sub-scope 04 §37의 이월을 확정(§48)
- 소스 미변경
---
## 53. Sub-scope 06 범위와 denominator
> 내부 상태: COMPLETE — **54 / 54 FULL_READ**
> 범위: `sdk/programmability` 17 + `extensions/**` 17 + `raw` 7 + `cluster` 5 + `admin` 4 (main 50, 4,415 LOC) + 전용 test 4
> 역할: 스크립트·트랜잭션·함수, 확장 모듈 4종, 승인된 raw 명령, 슬롯 계산, 관리 평면
manifest와 probe: `evidence/raw/164-cache-redis-programmability-extensions-probes.txt`.
이 sub-scope의 전용 test는 4개뿐이지만 실제 계약 test는 sub-scope 04의 test 패키지에 있다 — `RedisExtensionModulesContractTest`, `RedisAdminPlaneContractTest`, `RedisTransactionContractTest`, `RedisTransactionSlotContractTest`. 계층이 아니라 계약 단위로 test를 배치한 결과다.
## 54. raw gateway — "escape hatch"가 두 겹의 사전 승인으로 닫혀 있다
`RedisRawGateway`의 javadoc이 존재 이유와 한계를 함께 적는다 — "There is no `execute(String, byte[]...)` here or anywhere else in the SDK. The escape hatch exists because **some commands genuinely have no typed form worth building**, not because arbitrary command execution is acceptable; every one of them is named, bounded, and audited before it can be sent."
승인이 **두 개의 독립된 문**을 모두 통과해야 한다(`RawCommandApprovals`).
1. 명령이 정책 카탈로그에서 `RAW_ONLY`로 분류돼 있어야 한다 — "the organization's decision about which commands may ever leave through this door"
2. 배포가 그 명령에 대한 승인(`ApprovedRawCommand`)을 등록해야 한다
"Neither alone is enough, and neither is decided at request time." 그리고 R3/R4는 어느 쪽이든 거부된다.
`ApprovedRawCommand`는 **배포 산출물**이다 — 명령 identity, 최대 인자 수, 요청/응답 바이트 상한, 타임아웃, 응답 디코더를 프로세스 시작 전에 고정한다. 토큰은 `RawCommandApprovals`만 발급하고, 검증은 (a) 토큰 타입이 내부 record인지, (b) **발급 레지스트리 인스턴스가 같은지**(`issued.origin != this`), (c) 정책 id가 일치하는지, (d) 제시된 승인이 등록된 것과 같은지 넷을 본다.
**`RawMovableKeys`가 이 패키지에서 가장 흥미롭다.** movable key spec(예: `SORT`)은 키 위치를 인자 목록이 결정하므로 정적으로 알 수 없고, 그러면 네임스페이스 검사를 할 수 없다. 기본은 여전히 거부다. 예외로 `SORT`/`SORT_RO` 파서 하나가 등록돼 있는데, 그 설계가 명시적이다 — "a parser that knows **exactly one command shape** and refuses everything else. Refusing is the important half: a parser that guesses at an unknown option would mis-locate the keys, and a mis-located key is a key the namespace check never sees."
그리고 유효한 Redis 문법인 `BY`/`GET`을 **의도적으로 거부**한다 — 둘은 키가 아니라 **패턴**을 받고 서버가 키 공간 전체에 대해 확장하므로 SDK가 검사할 키가 없다. "That is a deliberate capability limit, not an oversight." `SORT_RO`에 `STORE`가 오면 거부하고, `STORE` 목적지는 실제 키이므로 위치 목록에 넣어 검사받게 한다. test 8개가 이 파서의 수용/거부를 전부 고정한다(`aPlainSortHasOneKey`·`storeAddsTheDestination`·`readOnlySortCannotStore`·`patternsAreRefused`·`anUnknownOptionIsRefused`·`aMalformedLimitIsRefused`·`anUnparsableCommandIsRefused`·`anEmptySortIsRefused`).
## 55. 스크립트와 트랜잭션 — 등록이 배포 단계이고, 창(window)은 노드에 고정된다
`RedisScriptRegistry`의 규칙 — "Registration is a deployment step, not a request-time one. A script that was never registered has no digest and therefore **no way to reach the server**, which is what makes 'only reviewed scripts run' a structural property rather than a convention." 같은 identity에 다른 body를 등록하면 거부하고, 실행 시점에도 body가 등록본과 같은지 다시 본다.
README가 주장하는 복구 사슬 `EVALSHA → NOSCRIPT → SCRIPT LOAD → digest verify → EVALSHA`는 **실재한다** — `LettuceRedisScriptOperations:26` javadoc이 "NOSCRIPT is the one failure retried automatically"라고 적고, `:129-133`이 `RedisNoScriptException` 또는 메시지 접두 `NOSCRIPT`를 잡아 `:100`에서 `registry.forget(script.id())`를 호출한다. 다음 호출이 `digest(...)`에서 다시 `SCRIPT LOAD`한다.
`RedisTransactionRunner`는 Cluster에서의 `MULTI` 문제를 정면으로 다룬다. javadoc이 문제와 해법을 적는다 — 다른 레인은 명령마다 슬롯 소유 노드로 라우팅하는데 "that is exactly what a `MULTI` window must not do: the queued commands would be spread across nodes and none of them would be part of the same window." 해법은 연결이 아니라 **라우팅 결정**이었다 — 감시 키(또는 명시적 슬롯 태그)에서 노드를 정해 레인을 고정한다. 감시 키가 없는 Cluster 트랜잭션은 거부하고 그 이유를 적는다 — "the keys the callback will queue are not known until the callback runs, which is after the window is open."
실패한 창의 연결은 **풀에 넣지 않는다** — "the next borrower would queue their command into somebody else's MULTI." test가 `aSameSlotTransactionExecutes`·`aCrossSlotTransactionIsRefusedBeforeSend`·`anUnroutableTransactionIsRefused`·`anExplicitSlotTagRoutesTheWindow`로 네 갈래를 고정한다.
확장 4종(JSON·Search·TimeSeries·Probabilistic)은 `ExtensionCommandRunner` 하나를 공유한다 — "Sharing it is also what guarantees they cannot drift apart on the parts that matter: **every extension command declares its key**, so the guard namespace-checks and slot-checks it exactly like a classic one."
## 56. P3 — NOSCRIPT 복구가 다섯 벌로 구현돼 있고 넷은 스크립트 레지스트리를 지나지 않는다
README는 이 메커니즘을 하나로 서술한다 — "closed-catalog `EVALSHA -> NOSCRIPT -> SCRIPT LOAD -> digest verify -> EVALSHA` recovery".
실제로는 `NOSCRIPT` 문자열을 스스로 판정하고 자기 digest 캐시를 무효화하는 코드가 **다섯 곳**에 있다(`164-...` §8.2).
| 위치 | 자체 digest 캐시 | `RedisScriptRegistry` 경유 |
|---|---|---|
| `sdk/programmability/LettuceRedisScriptOperations:129-133` | 아니오 — 레지스트리 사용 | **예** |
| `sdk/lettuce/operations/AtomicCounterScripts:190-199` | 예 | 아니오 |
| `idempotency/IdempotencyScripts:263` | 예 | 아니오 |
| `lease/LeaseScripts:161` | 예 | 아니오 |
| `ratelimit/RateLimitScripts:241` | 예 | 아니오 |
넷은 모두 같은 판정식(`message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT")`)을 각자 복사해 갖고 있고, 각자 digest를 캐시하며, `RedisScriptRegistry`가 강제하는 두 성질 — **동일 identity에 다른 body 등록 거부**, **실행 시 body와 등록본 대조** — 를 받지 않는다.
**판정: P3.** 넷 다 스크립트 원본이 자기 클래스 안의 상수라 지금 body가 갈라질 여지는 좁다. 기록하는 이유는 두 가지다. (a) README가 하나의 닫힌 메커니즘이라고 서술하는 것이 실제 구조와 다르다. (b) 판정식이 다섯 벌이므로 Redis가 오류 문자열을 바꾸면 다섯 곳을 고쳐야 하고, 그중 하나를 놓치면 그 스크립트만 조용히 복구되지 않는다 — 증상은 `NOSCRIPT`가 애플리케이션 실패로 새어 나가는 것이다. 수정은 판정을 `LettuceExceptionTranslator`가 이미 만드는 `RedisNoScriptException` 하나로 통일하고(그 번역은 이미 존재한다: `:92-93`, `:190-191`), 네 곳이 자기 캐시 대신 레지스트리를 쓰게 하는 것이다.
## 57. Confirmed — 슬롯 검사 두 곳은 중복이 아니라 서로 다른 범위다
`CommandPolicyGuard.requireSameSlot`(모든 명령, admission 시점)과 `cluster/SameSlotValidator`(트랜잭션 창) 둘 다 존재한다. 중복처럼 보이지만 범위가 다르다 — guard는 **한 명령의 키 집합**을 보고, `SameSlotValidator`는 **창이 감시·큐잉할 키 집합 전체**를 본다. 창 안에서 큐잉되는 명령은 `MULTI`가 열린 뒤에야 정해지므로 guard의 명령별 검사로는 창 전체의 슬롯 일치를 보장할 수 없다. `RedisTransactionRunner`의 javadoc이 그 관계를 적는다 — "Cross-slot is still refused before anything is sent, by the same `SameSlotValidator` the window itself uses. Pinning a lane does not make a transaction that spans slots legal; it makes a transaction that does not span them possible."
`RedisSlotCalculator`는 test로 CRC16 슬롯 규칙을 고정한다 — `matchesPublishedSlots`, `bracesControlSlotCalculation`, `emptyBracesHashTheWholeKey`(빈 중괄호는 키 전체를 해시하는 Redis 규칙), `slotsStayInRange`, `sameSlotRequiresAHashTag`.
## 58. P3/기록 — 이 sub-scope의 진입 타입 다섯이 production 소비자 0
| 타입 | LOC | main 참조 |
|---|---|---|
| `LettuceRedisRawGateway` | 160 | 0 |
| `RedisTransactionRunner` | 139 | 0 |
| `LettuceRedisAdminOperations` | 337 | 0 |
| `ClusterTopologyObserver` | 99 | 0 |
| `ClusterScanCursor` | 115 | 0 |
내부 협력자들은 배선돼 있다 — `RawCommandApprovals` 2, `ExtensionCommandRunner` 4, `RedisSlotCalculator` 3, `SameSlotValidator` 2, `RedisScriptRegistry` 1. 즉 **부품은 서로 연결돼 있고 진입점만 아무도 만들지 않는다.** sub-scope 01 §5·02 §15와 정확히 같은 모양이고, 같은 원인(조립 절반)의 세 번째 관측이다.
## 59. Negative-space probes — sub-scope 06
- **8.1 reachability**: 10종 진입/협력 타입의 main 참조 계수 — 진입 5종이 0(§58).
- **8.2 문서 주장 대조**: README의 EVALSHA 복구 사슬이 실재함을 `forget(...)` 호출 지점으로 확인(§55), 그러나 구현이 다섯 벌임을 발견(§56).
- **8.3 중복 mechanism**: NOSCRIPT 판정과 digest 캐시 5중(§56). 슬롯 검사 2곳은 범위가 달라 중복 아님으로 판정(§57).
- **8.4 test 배치**: 이 sub-scope 전용 test 4개지만 계약 test는 sub-scope 04 패키지에 있음을 확인 — 확장/관리 평면 모두 계약 test 보유(§53).
## 60. Sub-scope 06 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P3** | NOSCRIPT 복구가 5벌로 구현되고 그중 4벌이 `RedisScriptRegistry`의 body 대조를 받지 않는다. README는 하나의 닫힌 메커니즘으로 서술한다 | Redis 오류 문자열 변경 · 스크립트 body 관리 |
| P3/기록 | raw gateway·transaction runner·admin operations·cluster observer·cluster scan cursor 다섯 진입 타입의 production 소비자 0 (내부 협력자는 배선됨) | 조립 완료 시점 |
## 61. Sub-scope 06 완료 조건
- denominator 54 / 54 FULL_READ (`164-...` OWNED FILES)
- §8.1~§8.4 probe 수행, 중복 mechanism 2건 조사(하나는 중복 아님으로 판정)
- README의 복구 사슬 주장을 호출 지점으로 추적해 **실재 확인**하되 구조는 다름을 기록(§55, §56)
- 소스 미변경
---
## 62. Sub-scope 07 범위와 denominator
> 내부 상태: COMPLETE — **24 / 24 FULL_READ**
> 범위: `cache` 2 + `idempotency` 2 + `lease` 2 + `ratelimit` 3 + `realtime` 4 + `keyspace` 1 (main 14, 3,295 LOC) + 전용 test 5 + live/topology 기반 5
> 역할: `application-core`·`shared-contract`의 의미 포트를 Redis로 구현하는 층
manifest와 probe: `evidence/raw/165-cache-redis-semantic-adapters-probes.txt`.
## 63. 여섯 개의 의미 포트가 실제로 구현돼 있다
```
RedisCacheRegionAdapter<K,V> implements CacheRegionPort<K,V>
RedisIdempotencyStoreAdapter implements IdempotencyStorePortV2
RedisDistributedLeaseAdapter implements DistributedLeasePort
RedisEdgeRateLimitAdapter implements EdgeRateLimitPort
RedisConnectionRegistryAdapter implements ConnectionRegistryPort
RedisEphemeralFanoutAdapter implements EphemeralFanoutPort
```
각각이 자기 포트의 실패 정책을 명시적으로 다르게 정한다. 그 대비가 이 sub-scope의 중심이다.
| 포트 | 실패 시 | 근거(javadoc) |
|---|---|---|
| cache | **degrade** — miss 또는 `DEGRADED_UNAVAILABLE` | "a cache exists to make things faster… That licence is **specific to this port and must never be copied** to session, idempotency, rate limit, or lease" |
| rate limit | **fail-closed** — `Unavailable` | "a limiter that allows traffic when its store is unreachable removes the bound at exactly the moment it matters… an in-process count during a Redis outage is not a global limit, **it is N times the limit**" |
| idempotency | **INDETERMINATE** | "a caller told 'failed' retries and duplicates the effect, while a caller told 'indeterminate' inspects with the same attempt and discovers what actually happened" |
| connection registry | **"nothing found"** | 라우팅 힌트이므로 "Throwing would turn a Redis blip into a failed user-visible operation" |
| ephemeral fanout | publish 실패는 오류 아님 | 메시지가 본래 ephemeral이라 "'the broker did not accept it' and 'it reached nobody' are the same outcome" |
세부도 정직하다.
- **`RedisDistributedLeaseAdapter`는 이름이 계약이다** — "Efficiency only… There is **no fencing token**, so a holder that is paused past its expiry cannot be stopped from acting; anything correctness-sensitive needs a conditional write at the point of effect, not a lock in front of it. Saying so in the type name is the only durable way to keep the next caller from reaching for it as a mutex." 유효성은 서버 TTL이 아니라 **요청을 보낸 시각부터 monotonic 시계로** 재고, 왕복 시간만큼 의도적으로 비관적이다.
- **`IdempotencyScripts`는 owner와 revision을 함께** 확인한다 — owner만 보면 만료된 보유자가 새 보유자의 작업을 덮고, revision만 보면 같은 revision의 다른 owner가 덮는다. 레코드가 문자열이 아니라 해시인 이유도 적혀 있다("a read-modify-write of a serialized blob would reintroduce exactly the race the programs remove").
- **`RateLimitScripts`는 서버 `TIME`을 쓰지 않는다** — 스크립트가 비결정적이 되고, 판정이 caller의 deadline과 같은 시계로 측정돼야 하기 때문이다. 시계 역행은 정책의 clock-regression bound로 다룬다.
- **`RateLimitKeys`는 정책 revision을 키에 넣는다** — 한도를 100/분에서 10/분으로 바꿨을 때 옛 카운터가 남아 있으면 이미 50을 쓴 주체가 10짜리 예산으로 계속하게 되고, 반대 방향이면 새 할당을 받는다. "A revision in the key means a policy change starts new counters, which is the only interpretation that is correct in both directions."
- **주체·행위자는 digest로만 들어온다** — "a Redis key reaches MONITOR output, the slow log, `KEYS` during an incident and every backup — none of which has the access controls the application has, and all of which outlive the request."
- **`RegistrationCodec`가 JSON이 아닌 이유**는 롤링 배포다 — 필드를 추가한 JSON 리더는 구버전 노드가 계속 쓰는 항목마다 실패하므로, 선행 버전 토큰으로 "감지하고 건너뛰기"를 가능하게 한다.
- **`CapabilityKeyspace`는 과거의 실제 사고를 고친 결과다** — 각 capability가 자기 순서로 토큰을 이어 붙여 `ca-skeleton:prod:cache:…`와 `prod:ca-skeleton:shared:…`가 공존했고, "An account restricted to `~prod:*` could not touch a single cache entry, and nothing said so until a real server refused the write." 지금은 SDK와 같은 `RedisNamespace.prefix()`에서 시작한다.
## 64. P2 — 의미 어댑터 다섯이 `CommandPolicyGuard`를 지나지 않는다
이 leaf의 아키텍처 주장은 두 javadoc에 있다.
> `CommandPolicyGuard`: "**The single admission point every command passes through.**"
> `RedisCommandGateway`: "Policy, permits, budgets, timeouts, and observability are not this interface's concern: **everything routed through it has already passed `CommandPolicyGuard`**."
의미 어댑터 다섯은 그 전제를 만족하지 않는다(`165-...` §8.1).
- `SyncRedisCommandExecutor`·`ReactiveRedisCommandExecutor`·`CommandPolicyGuard`·`CommandRequest`를 참조하는 파일 **0**(exit=1)
- 타입 있는 API(`RedisValueOperations`·`RedisHashOperations`·`RedisKeyOperations`·`RedisOperations`)를 참조하는 파일 **0**(exit=1)
- 대신 `RedisRuntimeOwner`(5) → `RedisLease`(5) → **`lease.gateway()`를 직접 호출**한다 — cache 6곳, idempotency 6곳, lease 4곳, ratelimit 1곳, realtime 13곳
즉 이 다섯 어댑터가 보내는 모든 명령에 대해 다음이 **실행되지 않는다**.
| guard 단계 | 이 경로에서 |
|---|---|
| 카탈로그 분류(BLOCKED·R3·R4 거부) | 없음 |
| capability / 최소 버전 확인 | 없음 |
| permit provenance 검증 | 없음 |
| 네임스페이스 검사 | 없음 — 다만 §63의 `CapabilityKeyspace`가 같은 `RedisNamespace`에서 키를 조립하므로 **구성으로는 유지된다** |
| Cluster 동일 슬롯 검사 | 없음 |
| 요청 예산 | 없음 |
| 정책 기반 레인·타임아웃 유도 | 없음 — 어댑터가 자기 `commandTimeout`을 `.get(...)`에 직접 적용 |
| 실패 번역(`LettuceExceptionTranslator`) | 없음 — 어댑터가 `Exception`을 직접 잡아 자기 결과 타입으로 접는다 |
| 관측(`RedisObservation`) | 없음 |
**두 번째 결과: 키 렌더 경로가 둘이다.** sub-scope 03 §22에서 확인한 주장 — "There is no API that takes an already rendered key string, so namespace, slot, and size rules cannot be bypassed" — 은 타입 있는 API에 대해서는 참이다. 그러나 `CapabilityKeyspace.key(...)`는 **`byte[]`를 직접 만들어** gateway에 넘기고, `RedisKeyRenderer`를 거치지 않으므로 `RedisKeyRules.requireRenderedSize(...)`가 적용되지 않는다(`165-...` §8.2: `CapabilityKeyspace`에 `requireRenderedSize`·`MAX_KEY_BYTES` 매치 0). 슬롯 태그 중괄호 규칙("The renderer is the only place braces are written")도 이 경로에는 없다.
**판정: P2.** 완화 요인이 실재한다 — (a) 현재 이 어댑터들은 bean으로 조립되지 않아 노출이 없고, (b) 키는 네임스페이스에서 조립되며, (c) 명령은 caller가 주는 것이 아니라 어댑터가 고정한 소수이고, (d) 각 어댑터가 자기 타임아웃과 실패 정책을 명시적으로 갖는다. 그래서 즉각적 데이터 위험은 없다.
위험은 구조적이다. 이 leaf 전체가 "모든 명령이 지나는 단일 입장 지점"이라는 주장 위에 서 있고, 그 주장을 강제하는 test도 없다 — `RedisSdkModuleBoundaryTest`가 패키지 경계를 강제하지만 "gateway를 부르는 것은 executor뿐"은 강제하지 않는다. 조립이 완료되는 시점(§5)에 이 다섯 어댑터는 카탈로그·permit·슬롯·예산·번역·관측 없이 도는 다섯 개의 경로가 된다. 특히 Cluster에서 **동일 슬롯 검사 부재**는 실제 실패로 이어진다 — `realtime` 어댑터는 세 구조(actor 해시·node 집합·heartbeat sorted set)를 함께 쓰는데 그 셋이 같은 슬롯에 있다는 보장이 코드 어디에도 없다.
수정 방향은 둘 중 하나다. 어댑터를 타입 있는 API 위로 올리거나(그러면 permit·budget 서명을 만족시켜야 한다), 최소한 `SyncRedisCommandExecutor`를 통과시켜 카탈로그·슬롯·번역·관측을 얻는 것. 그리고 어느 쪽이든 "gateway의 유일한 호출자는 executor다"를 강제하는 ArchUnit 규칙 하나가 이 종류의 재발을 막는다.
## 65. Confirmed — README의 "그 코드는 이 leaf에 없다"가 결정적으로 반증된다
sub-scope 01 §5에서 제기한 P2를 여기서 확정한다. README:35–37은 이렇게 적는다.
> "아래 절들은 이전 세대 semantic adapter 세트의 설계 결정을 기록한 것이며, **그 코드는 현재 이 leaf에 없다.** 복구 범위는 위 plan의 Phase E가 소유한다."
그리고 readiness 표는 "cache / session / idempotency / rate limit / lease semantic port | API 구현 **없음**"이다.
실제로는 `application-core`/`shared-contract`의 **여섯 포트가 구현돼 있고**(§63), 3,295 LOC이며, 각 어댑터에 전용 test가 있고(`RedisCacheRegionAdapterTest` 333 · `RedisIdempotencyStoreAdapterTest` 337 · `RedisDistributedLeaseAdapterTest` 292 · `RedisEdgeRateLimitAdapterTest` 321 · `RedisConnectionRegistryAdapterTest` 262), 토폴로지 lane의 `LiveRedisSemanticPortsTest`(364 LOC)가 실제 서버에 대해 다시 검증한다. README 자신이 §0에서 인용한 standalone lane 서술("세 rate-limit 프로그램, 각 프로그램의 exact-boundary/denial-no-consume, clock-regression state 불변, token refill remainder와 malformed hash 분류를 검증한다")도 **바로 이 코드**를 가리킨다 — 같은 문서 안에서 한 절은 이 코드의 검증 범위를 설명하고 다른 절은 이 코드가 없다고 말한다.
## 66. Negative-space probes — sub-scope 07
- **8.1 reachability / 계층 통과**: 다섯 어댑터의 guard·executor·타입 API 참조 0, gateway 직접 호출 30곳(§64).
- **8.1b 문서화된 전제 대조**: gateway와 guard의 javadoc이 주장하는 "모든 명령"이 이 경로에서 거짓임을 확인(§64).
- **8.2 두 번째 키 렌더 경로**: `CapabilityKeyspace`가 `requireRenderedSize`를 적용하지 않음(§64).
- **8.3 조건부 형제**: 여섯 포트의 실패 정책이 서로 다르고 각각 근거가 적혀 있음 — cache만 degrade, rate limit은 fail-closed(§63).
- **8.4 문서 drift 확정**: README의 네 행과 "코드가 없다"는 서술을 구현·test·lane 세 겹으로 반증(§65).
## 67. Sub-scope 07 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P2** | 의미 어댑터 다섯이 `CommandPolicyGuard`와 executor를 지나지 않고 `lease.gateway()`를 직접 호출한다 — 카탈로그·permit·슬롯·예산·실패번역·관측이 모두 적용되지 않고, `CapabilityKeyspace`가 두 번째 키 렌더 경로로 `requireRenderedSize`를 건너뛴다 | 현재 미배선; 배선 시 다섯 경로 전부, 특히 Cluster 슬롯 |
| — | §5(README drift)를 이 sub-scope의 증거로 **확정** — 별도 finding으로 세지 않고 §5에 귀속 | — |
## 68. Sub-scope 07 완료 조건
- denominator 24 / 24 FULL_READ (`165-...` OWNED FILES)
- §8.1~§8.4 probe 수행, 조건부 형제 비교 1건(여섯 포트의 실패 정책)
- sub-scope 01 §5의 P2를 구현·test·lane 세 겹으로 확정(§65)
- 소스 미변경
---
## 69. 모듈 ledger 정합
| # | 범위 | main | test | 기타 | 합 | FULL_READ | probe |
|---|---|---|---|---|---|---|---|
| 1 | governance + `sdk/config/**` | 12 | 8 | 6 | 26 | 26 | `158`, `159` |
| 2 | `sdk/api/operations` + `api` 루트 + `codec` | 78 | 5 | | 83 | 83 | `160` |
| 3 | `sdk/api/key` + `error` + `command` + `reactive` | 61 | 3 | | 64 | 64 | `161` |
| 4 | `sdk/lettuce/operations` | 67 | 28 | | 95 | 95 | `162` |
| 5 | `sdk/lettuce/{command,connection,codec,observability}` | 32 | 11 | 1 | 44 | 44 | `163` |
| 6 | `programmability` + `extensions` + `raw` + `cluster` + `admin` | 50 | 4 | | 54 | 54 | `164` |
| 7 | 의미 포트 어댑터 6종 + live/topology 기반 | 14 | 10 | | 24 | 24 | `165` |
| | **TOTAL** | **314** | **69** | **7** | **390** | **390** | **7 / 7** |
coverage ledger: `FULL_READ` **390** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / 미분류 **0**.
## 70. 모듈 findings
| # | 우선순위 | finding | 위치 | reachability |
|---|---|---|---|---|
| 1 | **P2** | README readiness 표 4행 중 3행과 "그 코드는 이 leaf에 없다"가 사실과 다르다. 의미 포트 **6종 3,295 LOC**가 전용 test·live lane과 함께 실재하고, auto-configuration은 client·runtime owner·health contributor 2종을 실제로 만든다. build.gradle의 "0 imports across main source"도 실측 `application.*` 7파일 / `shared.*` 3파일과 어긋난다 | §5, §65 | 이 leaf를 읽는 모든 fork |
| 2 | **P2** | 의미 어댑터 다섯이 `CommandPolicyGuard`·executor·타입 API를 지나지 않고 `lease.gateway()`를 직접 호출한다(30곳). 카탈로그·permit·슬롯·예산·실패번역·관측 미적용, `CapabilityKeyspace`가 두 번째 키 렌더 경로 | §64 | 현재 미배선; 배선 시 다섯 경로, 특히 Cluster 슬롯 |
| 3 | **P2** | `RedisStartupProbe`/`RedisCapabilityProbe`의 production 호출자 0 — 7.2 baseline·Cluster db0·명시 capability·**복제 쓰기 내구성** 네 guardrail이 조립된 애플리케이션에서 실행되지 않는다 | §6 | Redis를 켠 모든 배포, 특히 복제 배포 |
| 4 | **P2** | catalog drift gate(`RedisCommandMetadataDiff`)가 "fails the build"라고 선언돼 있으나 실제 서버 메타데이터를 넣는 실행 경로가 없다 — main 참조 0, lane 참조 0, Gradle 태스크 0 | §47 | key spec 이동 · ACL 카테고리 확대 · deprecation |
| 5 | **P2** | `RedisOperations`·`ReactiveRedisOperations` 두 진입점의 구현이 0 — 개별 표면 43종은 전부 구현돼 있다 | §15 | SDK를 쓰려는 모든 fork |
| 6 | **P3** | `patternSubscribe`가 R2인데 호출자 permit이 아니라 SDK 자기 발급 permit으로 배포 수준만 확인한다 | §36 | 패턴 구독 사용 배포 |
| 7 | **P3** | permit 정책 이름이 Java 상수 18 · YAML 18 · 별도 private 상수 1로 흩어져 있고 두 집합의 일치를 검사하는 장치가 없다 | §37, §48 | 정책 이름 오타 |
| 8 | **P3** | NOSCRIPT 복구가 5벌로 구현되고 그중 4벌이 `RedisScriptRegistry`의 body 대조를 받지 않는다 | §56 | Redis 오류 문자열 변경 |
| 9 | **P3** | `RedisKeyRules.requireIdentifier`의 메일·전화 분기가 선행 문자 클래스 때문에 도달 불가 — test는 타입만 보므로 분기를 지워도 초록 | §26 | 진단 메시지 |
| 10 | **P3** | `PubSubChannel`·`ShardedPubSubChannel`이 렌더 크기 검증을 받지 않는다(형제 `PubSubPattern`은 받는다) | §16 | 긴 namespace/entity/identifier |
| 11 | **P3** | `hyperLogLogs.merge`·`count`가 다중 키 fan-in인데 `OperationBudget`을 받지 않는다(형제 3종은 받는다) | §17 | 많은 레지스터 병합 |
| 12 | P3/기록 | `ConfiguredRedisPolicyAuthority`·`ConfiguredRedisPermitVerifier` production 생성 0 | §7 | gateway 조립 시점 |
| 13 | P3/기록 | `RedisRiskLevel.requiresPermit()` 호출자 0 (형제 `deniedToApplications()`는 2곳) | §27 | 규칙 강제 위치 분산 |
| 14 | P3/기록 | `RedisOperationException.metadata`가 `transient`라 직렬화 왕복 후 `metadata()`가 null이고 문서에 없다 | §27 | 예외 원격 전달 |
| 15 | P3/기록 | `TypedRedisKeys`의 slot-tag 편의 생성자가 value·set 두 종뿐 | §27 | Cluster 다중 키 |
| 16 | P3/기록 | 정책 문서의 필드 표가 11개를 적지만 loader는 `optional-block`을 포함해 12개를 받고 그 필드가 실제로 쓰인다 | §48 | 문서 |
| 17 | P3/기록 | production 소비자 0인 타입 8종 — `RedisCodecRegistry`·`SentinelFailoverObserver`·`LettuceRedisRawGateway`·`RedisTransactionRunner`·`LettuceRedisAdminOperations`·`ClusterTopologyObserver`·`ClusterScanCursor`·`RedisCommandMetadataDiff` | §49, §58 | 조립 완료 시점 |
**결함 아님으로 판정한 후보 4건** — raw allowlist 기본 리소스 부재(§3: startup 실패로 닫혀 있음), reactive `getRange` 누락 의심(§25: 오탐), `InMemoryRedisCommandGateway` 메서드 누락 의심(§38: `implements`가 강제), 슬롯 검사 2곳(§57: 범위가 다름).
## 71. 이 모듈에서 반복해서 나타난 패턴
- **표현 불가능성이 검증을 대체한다.** TTL 없는 쓰기는 permit 없이 **타입으로** 만들 수 없고(`Expiration.Persistent`), `KEYS`는 API에도 카탈로그에도 없으며(R4 BLOCKED), 렌더된 키를 받는 API가 존재하지 않고, 명령 이름을 받는 gateway 메서드가 없다.
- **정책이 코드가 아니라 데이터이고, 그 데이터의 파서가 좁다.** 1,406줄 명령 카탈로그를 일반 YAML 엔진으로 읽지 않는 이유가 "anchors, merges, nested structures, and duplicate keys… could hide a risk downgrade inside a security policy file"로 적혀 있다.
- **주석이 사고 기록이다.** "the Sentinel lane measured eleven seconds and 2,086 acknowledged-then-discarded writes", "`-Predis.topology.mode=TYPO` built the tag `lane-typo`, matched nothing, ran zero tests and exited 0", "a real server returned 501 for exactly that request", "An account restricted to `~prod:*` could not touch a single cache entry". 각 방어에 그것이 막는 과거 실패가 붙어 있다.
- **죽은 중복을 지운 기록까지 남긴다.** `CommandPolicyGuard`의 `validateReply` 제거 주석 — "Two mechanisms for one rule, with the more visible one dead, is worse than one."
- **그리고 다섯 개의 P2는 전부 같은 모양이다** — **선언과 조립의 간극**. 문서가 없다고 말하는 코드가 있고(1), 모든 명령이 지난다는 입장 지점을 지나지 않는 경로가 있으며(2), startup에 한 번 묻는다는 probe가 불리지 않고(3), 빌드를 깬다는 gate가 실행되지 않으며(4), 진입점이라고 선언된 타입에 구현이 없다(5). 개별 구성요소의 품질(설계·주석·test)과 **그것들을 하나로 잇는 배선** 사이에 일관된 격차가 있고, 이 모듈에서는 그 격차가 앞선 아홉 모듈보다 크다.
## 72. 모듈 완료 조건
- denominator 390 / 390 FULL_READ, `STRUCTURAL_ONLY` 0, `EXCLUDED` 0, 미분류 0 (§69)
- 7개 하위 범위 전부 §8.1~§8.4 네 종 negative-space probe 수행, evidence `158`~`166` 9건 생성
- 후보 finding 4건을 코드로 추적해 결함 아님으로 판정, sub-scope 04의 이월 1건을 sub-scope 05에서 확정
- 소스 미변경 — 이 분석은 어떤 애플리케이션 코드도 수정하지 않았다
## 73. 검증
`evidence/raw/166-cache-redis-suite-verification.txt`.
```
$ cd src && LANG=C.UTF-8 LC_ALL=C.UTF-8 ./gradlew :adapter:outbound:cache-redis:test --console=plain -q
GRADLE_EXIT=0
classes=52 tests=435 failures=0 errors=0 skipped=0
$ git status --short
changed=0
```
skip 0이다. `redisTopologyTest` lane 4종(standalone·sentinel·cluster·tls)은 `:test`에서 태그로 제외되며(§0), 각 lane은 실제 서버와 `-Predis.topology.*` 프로퍼티를 요구하므로 이 분석에서 실행하지 않았다. lane 자신의 fail-closed 장치들(mode allowlist·필수 클래스·최소 test 수·skip 0)은 정적으로 확인했다.
작업 트리는 변경 0이다 — 이 분석 과정에서 애플리케이션 소스를 수정하거나 임시 파일을 남기지 않았다.
## Source anchors
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **125개** (main 106 · test 18 · 기타 1).
```
src/adapter/outbound/cache-redis/build.gradle
src/config/architecture/modules.json (adapter-outbound-cache-redis 항목)
main:
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/keyspace/CapabilityKeyspace.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitKeys.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RegistrationCodec.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/LettuceRedisAdminOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/ReactiveRedisOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/RedisOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/AdvancedOperationPermit.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/CommandId.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/KeySpec.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/MultiKeyPermit.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/OperationBudget.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/PersistentKeyPermit.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/RedisCommandDescriptor.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/RedisPermitVerifier.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/RedisRiskLevel.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/error/RedisCrossSlotException.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/error/RedisFailureMetadata.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/error/RedisNoScriptException.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/error/RedisOperationException.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/BitmapKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/GeoKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/HashKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/HyperLogLogKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/ListKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/QualifiedRedisKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyName.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRenderer.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRules.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisNamespace.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisSlotTag.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisTypedKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/SetKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/SortedSetKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/StreamKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/TypedRedisKeys.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/ValueKey.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/BatchItemResult.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/BitFieldSubcommand.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/Expiration.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/GeoPoint.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/GeoSearchRequest.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/PubSubChannel.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/PubSubPattern.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/RedisHashOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/RedisKeyOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/RedisSetOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/RedisValueOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/ShardedPubSubChannel.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/SortDirection.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/SortedSetAddOptions.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/reactive/ReactiveRedisValueOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/cluster/ClusterScanCursor.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/cluster/ClusterTopologyObserver.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/cluster/RedisSlotCalculator.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/cluster/SameSlotValidator.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/ConfiguredRedisPermitVerifier.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/ConfiguredRedisPolicyAuthority.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCapabilityProbe.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCorrectnessRoles.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCredentialResolver.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisHealthContributor.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisStartupProbe.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/extensions/ExtensionCommandRunner.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/extensions/search/LettuceRedisSearchOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/codec/RedisCodecRegistry.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/CommandPolicyGuard.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/CommandRequest.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/LettuceExceptionTranslator.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/ReactiveRedisCommandExecutor.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandCatalog.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandMetadataDiff.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandPolicyLoader.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisServerCommandMetadata.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/SyncRedisCommandExecutor.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionKind.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionRegistry.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisLease.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisTopologyClientFactory.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/SentinelFailoverObserver.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/observability/NoThrowObservationSink.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/observability/RedisObservation.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LettuceReactiveRedisValueOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LettuceRedisCommandGateway.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LettuceRedisHashOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LettuceRedisValueOperations.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/PubSubOperationRequests.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisCommandGateway.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisOperationContext.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/ValueOperationRequests.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/programmability/RedisScriptRegistry.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/programmability/RedisTransactionRunner.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/ApprovedRawCommand.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/LettuceRedisRawGateway.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/RawCommandApprovals.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/RawMovableKeys.java
src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/RedisRawGateway.java
src/main/resources/redis-sdk/redis-command-policy.yml
test:
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapterTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapterTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapterTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisConnectionRegistryAdapterTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSdkModuleBoundaryTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/ApiParityTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCapabilityProbeTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisStartupProbeTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/codec/RedisCodecRegistryTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandPolicyLoaderTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/InMemoryRedisCommandGateway.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisAdminPlaneContractTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisExtensionModulesContractTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisTransactionContractTest.java
src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisTransactionSlotContractTest.java
src/test/resources/redis-sdk/golden/order-summary-v1.json
기타:
src/build.gradle
해석되지 않은 인용 (10종) — 외부 타입·문서상 약칭 등:
evidence/raw/158-cache-redis-module-inventory.txt
evidence/raw/159-cache-redis-config-activation-probes.txt
classpath:redis-sdk/raw-command-allowlist.yml
evidence/raw/160-cache-redis-api-surface-probes.txt
evidence/raw/161-cache-redis-key-error-command-probes.txt
evidence/raw/162-cache-redis-gateway-probes.txt
evidence/raw/163-cache-redis-guard-connection-codec-probes.txt
evidence/raw/164-cache-redis-programmability-extensions-probes.txt
evidence/raw/165-cache-redis-semantic-adapters-probes.txt
evidence/raw/166-cache-redis-suite-verification.txt
```