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>
113 KiB
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_READ390 /STRUCTURAL_ONLY0 /EXCLUDED0 /UNCLASSIFIED0 - 최초 분석 revision
a24ece9c→ 재검증 revision21234e38· 이 리프의 변경 파일 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.
{ "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:23–29)와 그 아래 두 문단이 이 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:30–32는 "현재 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." 그것이 확인하는 것은 넷이다.
- 서버 버전이 7.2.0 baseline 이상인가
- Cluster인데 database가 0이 아닌가
- 명시적으로 켠 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.ADD10종 witness) - 복제 배포의 쓰기 내구성 —
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
+OKto 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/operations69 +sdk/api루트 6 +sdk/api/codec3 (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 replacesSETNXandSETEX"). - 명령 정책 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/key17 +error14 +command13 +reactive17 (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의 재시도 규칙 전체다.
if (retryable && ambiguousExecution) {
throw new IllegalArgumentException("an ambiguous execution must never be marked retryable");
}
그리고 팩토리 두 개가 그 규칙을 실제 상황에 적용한다. notSent(...)는 retryable = readOperation으로 유도한다 — 서버에 닿지 않은 읽기는 재시도해도 안전하다. storedDataCorruption(...)은 일부러 notSent가 아니고, javadoc이 그 이유를 적는다.
"that factory derives
retryablefromreadOperation, 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의 다섯 검사 중 둘은 도달할 수 없다
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/operationsmain 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/command14 +connection9 +codec7 +observability2 (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/programmability17 +extensions/**17 +raw7 +cluster5 +admin4 (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).
- 명령이 정책 카탈로그에서
RAW_ONLY로 분류돼 있어야 한다 — "the organization's decision about which commands may ever leave through this door" - 배포가 그 명령에 대한 승인(
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 범위:
cache2 +idempotency2 +lease2 +ratelimit3 +realtime4 +keyspace1 (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,
KEYSduring 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 passedCommandPolicyGuard."
의미 어댑터 다섯은 그 전제를 만족하지 않는다(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=TYPObuilt the taglane-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_ONLY0,EXCLUDED0, 미분류 0 (§69) - 7개 하위 범위 전부 §8.1~§8.4 네 종 negative-space probe 수행, evidence
158~1669건 생성 - 후보 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