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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,190 @@
---
kind: CASE
slug: a08-f005-transferbufferpool-maxborrowedbytes
title: 부재로 판정된 결합이 원본 증거의 출력 안에 있다
topic: file-transfer-and-storage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a08-f005-transferbufferpool-maxborrowedbytes
evidenceCapturedOn: 2026-09-02
assets:
- key: a08-f005-transferbufferpool-maxborrowedbytes
file: ../../../final/evidence/rendered/a08-f005-transferbufferpool-maxborrowedbytes.svg
- key: a08-f005-transferbufferpool-maxborrowedbytes-peak
file: ../../../final/evidence/rendered/a08-f005-transferbufferpool-maxborrowedbytes-peak.svg
evidence:
- ../../../final/evidence/raw/a08-f005-transferbufferpool-maxborrowedbytes.txt
- ../../../final/evidence/raw/a08-f005-transferbufferpool-maxborrowedbytes-peak.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L410 이다. 등급은 P3 이다. 자바독의 문장, 계측이 옳게 동작한다는 확인, 그리고 경계 성질이 다른 방식으로도 검증된다는 서술이 그 절에 있다.
- 그 절은 그 이름이 구현 파일 세 줄에만 나타나고 지목된 시험에 없다고 적는다. 구현 파일은 네 줄이고 시험에 한 줄이 있으며, 그 절이 근거로 댄 증거 파일이 다섯 줄을 전부 출력했다.
- 페이로드를 바꿔도 그 값이 상수라는 것, 반납 없는 반복 대여에서는 단언이 깨진다는 것, 그리고 두 시험의 워크플로 지위가 다르다는 것은 이 기록에서 확인했다.
---
# 부재로 판정된 결합이 원본 증거의 출력 안에 있다
버퍼 풀의 접근자가 자기 자바독에 유계 메모리 회귀 시험이 이 값을 단언한다고 적는다. 원본 절은 그 결합이 없다고 판정했는데, 원본이 근거로 댄 증거 파일이 그 단언 줄을 화면에 출력하고 있다.
## 관계
- **소비자가 없는 fixture 셋**
이쪽도 프로덕션 호출자가 0 이라는 것을 세어 확인했다.
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
단언의 변별력을 묻는 규칙이고, 이 기록의 뒷부분이 그것을 자기 대상에 적용한다.
- **커버리지 gate 둘이 나란히 있고, 하나는 발화할 수 없다**
두 사례 모두 검증이 있는 것처럼 보이지만 그 코드가 실행되지 않는다.
## 문제
존재 이유는 접근자 자바독에 한 줄로 적혀 있다. 동시 대여 바이트의 정점이며 유계 메모리 회귀 시험이 이 값을 단언한다는 것이다.
원본 분석의 판정은 그 결합이 없다는 것이었다. 그 이름이 구현 파일 세 줄에만 나타나고 지목된 시험에는 없다는 것이 근거다.
## 결론
결합은 실재한다. 그 이름은 다섯 줄에 나오고 그중 하나가 시험의 단언이다. 그 시험의 클래스 주석이 자기를 유계 메모리 회귀라고 부르므로, 자바독이 가리킨 대상도 분명하다.
원본의 계수는 두 군데에서 어긋난다. 구현 파일은 세 줄이 아니라 네 줄이고, 시험 한 줄이 통째로 빠졌다.
빠진 이유는 검색 범위가 아니다. 원본이 근거로 댄 증거 파일의 해당 절이 구현 파일과 시험 파일을 함께 인자로 넘겨 검색했고, 다섯 줄을 전부 출력했다. 출력된 줄이 요약 단계에서 없는 것으로 적혔다.
시험은 실제로 돈다. 격리 태그 없이 야간 워크플로가 이름을 적어 돌린다. 풀 리퀘스트 쪽 경계 메모리 잡이 고르는 것은 두 번째 시험 하나다.
여기까지가 원본 판정의 정정이다. 그 뒤에 남는 것은 그 단언이 무엇을 변별하느냐다.
대여 계수가 실제 버퍼 용량이 아니라 고정 버퍼 크기 단위로 누적하므로, 최대 대여 바이트는 언제나 버퍼 크기의 배수다. 그리고 이 경로에서 대여는 한 번뿐이다. 오프셋이 0 이면 접두 재해시가 빌리기 전에 반환하고, 복사와 다이제스트가 한 번 빌려 종료 블록에서 반납한다.
실행으로 확인했다. 페이로드를 1메가, 16메가, 256메가로 바꿔도 최대 대여 바이트는 131072 로 고정된다. 이름은 최대 관측 버퍼가 페이로드를 따라 늘지 않는다고 말하는데, 실제로는 페이로드를 바꿔도 값이 그대로다.
그 단언이 잡는 것은 반납 없는 반복 대여다. 같은 풀에서 반납 없이 두 번 빌리면 값이 두 배가 되고 단언이 깨진다. 풀을 거치지 않는 할당은 세지 못한다.
그 성질을 실제로 재는 것은 두 번째 시험이다. 생성기 채널이 저장소가 채운 가장 큰 단일 버퍼를 세고, 전송 비용이 파일 크기를 따라 늘지 않는다는 것을 그 수로 보인다. 정작 그 시험은 접근자를 건드리지 않는다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : src 자바 트리 식별자 전수 검색, 원본 증거 파일 대조, 실행 탐침
소스 수정 : x
## 재현 조건
1. 접근자의 자바독을 읽는다.
2. 그 이름을 src 아래 자바 파일에서 시험 트리를 포함해 검색하고 줄을 센다.
3. 원본 절이 근거로 댄 증거 파일의 해당 절을 열어 무엇이 출력됐는지 읽는다.
4. 그 시험에 격리 태그가 있는지, 어느 워크플로가 이름으로 지목하는지 확인한다.
5. 대여와 반납이 어떤 단위로 누적하는지 읽는다.
6. 같은 추가 경로를 페이로드 크기를 바꿔 가며 돌리고 최대 대여 바이트를 읽는다.
7. 같은 풀에서 반납 없이 두 번 빌려 그 값을 읽는다.
8. 같은 성질을 재는 두 번째 시험이 이 접근자를 부르는지 확인한다.
## 본문
<!-- body:start -->
접근자의 자바독이 존재 이유를 한 줄로 적는다.
## 자바독이 적은 결합
:::evidence key="a08-f005-transferbufferpool-maxborrowedbytes" alt="버퍼 풀 접근자의 자바독과 구현. 그 이름이 src 아래 자바 파일에 나오는 곳 전수. 원본 절이 근거로 댄 증거 파일의 해당 절이 실행한 검색 명령과 그 출력. 그리고 그 시험의 애너테이션과 두 시험을 이름으로 지목하는 워크플로 줄을 출력한 터미널 기록." caption="자바독은 회귀 시험이 이 값을 단언한다고 적음 · 그 이름은 다섯 줄에 나오고 그중 하나가 시험 · 원본 증거는 구현과 시험 두 파일을 함께 검색해 다섯 줄을 전부 출력함 · 시험에 격리 태그 없음, 야간 워크플로가 이름으로 지목 — 28줄 · exit 0" zoom="true"
:::
```java
/** Peak simultaneously-borrowed bytes; the bounded-memory regression asserts on this. */
long maxBorrowedBytes() {
return maxBorrowedBytes.get();
}
```
원본 분석은 그 결합이 없다고 판정했다. 그 이름이 구현 파일 세 줄에만 나타난다는 것이 근거다.
## 구현 네 줄과 시험 한 줄
```text
test/.../LocalAppendMemoryTest.java:54: assertThat(bufferPool.maxBorrowedBytes()).isLessThanOrEqualTo(BUFFER_BYTES);
main/.../TransferBufferPool.java:20: private final AtomicLong maxBorrowedBytes = new AtomicLong();
main/.../TransferBufferPool.java:38: maxBorrowedBytes.accumulateAndGet(outstanding, Math::max);
main/.../TransferBufferPool.java:56: long maxBorrowedBytes() {
main/.../TransferBufferPool.java:57: return maxBorrowedBytes.get();
```
경로 앞부분을 줄여 옮겼다. 구현 파일도 세 줄이 아니라 네 줄이고, 그 위에 시험 한 줄이 있다.
## 원본 증거는 그 줄을 출력했다
```text
=== 8.4 doc/behaviour: bounded memory claim and its regression test ===
$ grep -n 'maxBorrowedBytes' …/TransferBufferPool.java …/LocalAppendMemoryTest.java
…/TransferBufferPool.java:20: private final AtomicLong maxBorrowedBytes = new AtomicLong();
…/TransferBufferPool.java:38: maxBorrowedBytes.accumulateAndGet(outstanding, Math::max);
…/TransferBufferPool.java:56: long maxBorrowedBytes() {
…/TransferBufferPool.java:57: return maxBorrowedBytes.get();
…/LocalAppendMemoryTest.java:54: assertThat(bufferPool.maxBorrowedBytes()).isLessThanOrEqualTo(BUFFER_BYTES);
```
검색 명령이 구현 파일과 시험 파일을 함께 인자로 받았고, 다섯 줄을 전부 찍었다. 범위 밖이어서 못 본 것이 아니라, 출력된 줄이 요약 단계에서 없는 것으로 적혔다.
시험은 실제로 돈다.
```text
31: @Test
.github/workflows/fileserver-nightly.yml:103: … --tests '*LocalAppendMemoryTest'
.github/workflows/fileserver-pr.yml:161: … --tests '*LargeFileBoundedMemoryTest'
```
격리 태그가 없고 야간 워크플로가 이름으로 지목한다. 다만 풀 리퀘스트 쪽 경계 메모리 잡은 두 번째 시험만 돌린다.
## 그 단언이 변별하는 것
:::evidence key="a08-f005-transferbufferpool-maxborrowedbytes-peak" alt="자바독이 지목한 시험과 같은 추가 경로를 페이로드 1메가·16메가·256메가로 각각 돌려 최대 대여 바이트와 그것이 버퍼 크기 이하인지, 버퍼 크기의 몇 배인지 읽은 결과. 그리고 같은 풀에서 반납 없이 두 번 빌렸을 때의 값과 그 단언의 성립 여부를 출력한 터미널 기록." caption="페이로드를 256배로 늘려도 최대 대여 바이트는 131072 로 고정, 버퍼 크기의 1배 · 반납 없이 두 번 빌리면 262144 가 되어 단언이 깨짐 — 9줄 · exit 0" zoom="true"
:::
대여 계수는 실제 버퍼 용량이 아니라 고정 버퍼 크기 단위로 누적한다.
```java
long outstanding = borrowedNow.addAndGet(bufferSize);
maxBorrowedBytes.accumulateAndGet(outstanding, Math::max);
```
그래서 이 값은 언제나 버퍼 크기의 배수다. 그리고 이 경로에서 대여는 한 번뿐이다 — 오프셋이 0 이면 접두 재해시가 빌리기 전에 반환하고, 복사와 다이제스트가 한 번 빌려 종료 블록에서 반납한다.
같은 추가 경로를 페이로드만 바꿔 돌렸다.
```text
payload maxBorrowedBytes BUFFER_BYTES 이하 버퍼 크기의 배수
1MiB 131072 예 1
16MiB 131072 예 1
256MiB 131072 예 1
```
페이로드를 256배로 늘려도 값이 움직이지 않는다. 시험 메서드의 이름은 최대 관측 버퍼가 페이로드를 따라 늘지 않는다는 것인데, 그 값은 페이로드에 대해 상수다.
자명한 단언은 아니다. 반납 없이 두 번 빌리면 깨진다.
```text
[대조] 반납 없이 두 번 빌리면 그 단언이 깨진다
maxBorrowedBytes = 262144 BUFFER_BYTES 이하 = false
```
잡는 것은 정점 동시 대여가 버퍼 하나를 넘는 경우다. 잡지 못하는 것은 풀을 우회한 페이로드 비례 할당이다.
## 그 성질을 재는 것은 두 번째 시험이다
```java
* Proves that transfer cost does not scale with file size.
*
* <p>The property is measured, not asserted by inspection: a generator channel counts the largest
* single buffer the store ever asked it to fill.
```
생성기 채널이 실제로 채운 단일 버퍼의 최댓값을 기록한다. 이쪽은 접근자를 부르지 않는다.
## 정정 범위
자바독이 서술한 결합은 실재하고, 그 계측은 시험에 연결돼 있다. 정정할 것은 원본 판정이다. 그 단언의 변별 범위가 좁다는 것은 별개의 관찰이고, 원본 절은 그것을 다루지 않았다.
## 확인하지 못한 것
원본 절이 왜 자기 증거가 출력한 줄을 부재로 요약했는지, 그 경위는 확인하지 못했다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: analysis-finding-a08-f002
title: R1과 R2의 설정 취급이 비대칭이고, 검증된 쪽은 하나뿐이다
topic: file-transfer-and-storage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a08-f002
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a08-f002.body.md
assets:
- key: analysis-finding-a08-f002
file: ../../../final/evidence/rendered/analysis-finding-a08-f002.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a08-f002.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L108 이다.
---
# R1과 R2의 설정 취급이 비대칭이고, 검증된 쪽은 하나뿐이다
같은 리프의 두 설정 묶음이 미지의 키를 다르게 다룬다. 한쪽은 거부하고 그것을 지키는 테스트가 있다. 다른 쪽은 조용히 무시하고 그 테스트가 없다. 오타 난 상한이 적용되지 않은 채 큰 기본값이 쓰인다.
## 관계
- **설정 오타는 실패해야 하고 무시되면 안 된다**
이 사례가 그 규칙의 형태다.
- **문서가 지목한 기본값 위치와 test 목록이 실제와 다르다**
같은 리프의 다른 문서 오류다.
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
같은 계열의 상위 규칙이다.
## 문제
같은 리프 안에 설정 묶음이 둘 있다. 하나는 현재 경로이고 하나는 호환용이다.
두 묶음이 설정을 어떻게 다루는지 대조했다.
## 결론
다섯 축에서 다르다.
바인딩 타입이 다르다. 현재 경로는 불변 레코드에 미지 필드 거부를 켠다. 호환 경로는 가변 자바빈이고 기본값이라 미지 키를 무시한다.
루트 경로 취급이 다르다. 현재 경로는 이미 절대이고 정규화된 경로를 요구한다. 호환 경로는 상대 경로를 받아 현재 작업 디렉터리 기준으로 절대화한다.
기본 루트가 다르다. 현재 경로는 필수라 기본값이 없다. 호환 경로는 상대 기본값 둘을 가진다.
디렉터리 생성이 다르다. 현재 경로는 만들지 않고 증명을 별도로 요구한다. 호환 경로는 만든다.
그리고 미지 키 테스트가 현재 경로에만 있다.
결과의 차이가 구체적이다.
현재 경로에서는 설정 키의 오타가 컨텍스트를 실패시킨다. 호환 경로에서는 상한 키의 오타가 조용히 무시되고, 설정했다고 믿는 상한 대신 백만이라는 기본값이 쓰인다.
두 묶음이 같은 리프의 같은 성격 설정인데 한쪽만 닫힌 실패다.
판정은 P3 다. 호환 경로는 문서상 호환 전용이므로 우선순위를 낮춘다.
## 검증 환경
Spring Boot : 4.0.8
확인 방식 : 두 설정 클래스와 각각의 테스트 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/142 계열에 있다.
1. 두 설정 묶음의 바인딩 타입을 확인한다.
2. 미지 필드 거부 설정이 켜져 있는지 각각 확인한다.
3. 루트 경로 정규화 방식을 대조한다.
4. 기본값과 디렉터리 생성 여부를 대조한다.
5. 미지 키 거부 테스트가 어느 쪽에 있는지 확인한다.
## 본문
<!-- body:start -->
R2에서는 `strict-path-securty` 같은 오타가 컨텍스트를 실패시킨다. R1에서는 `app.file-export.maximum-rowz=10` 같은 오타가 조용히 무시되고, 설정했다고 믿는 상한이 적용되지 않은 채 기본값 1,000,000이 쓰인다.
## 같은 오타가 두 등급에서 갈리는 결과
:::evidence key="analysis-finding-a08-f002" alt="분석 문서 analysis/08-adapter-outbound-fileserver.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/08-adapter-outbound-fileserver.md 발췌 — 15줄" zoom="true"
:::
## 같은 성격의 설정인데 한쪽만 fail-closed다
두 selector가 같은 leaf에 있다. P3 — R1은 문서상 "compatibility only"이므로 우선순위를 낮춘다.
## 확인하지 못한 것
호환 경로의 상한 키에 오타를 넣고 실제로 백만이 쓰이는지 실행하지 않았다. 바인딩 설정상 그 결과가 나온다.
<!-- body:end -->
@@ -0,0 +1,96 @@
---
kind: CASE
slug: analysis-finding-a08-f003
title: 문서가 지목한 기본값 위치와 test 목록이 실제와 다르다
topic: file-transfer-and-storage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a08-f003
evidenceCapturedOn: 2026-09-01
assets:
- key: analysis-finding-a08-f003
file: ../../../final/evidence/rendered/analysis-finding-a08-f003.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a08-f003.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L122 이다.
---
# 문서가 지목한 기본값 위치와 test 목록이 실제와 다르다
README 가 선택 스위치의 기본값이 부트스트랩 설정 파일에 꺼짐으로 들어 있다고 적는다. 그 파일에 해당 키가 없다. 그리고 테스트 목록의 첫 항목은 이 리프가 아니라 애플리케이션 코어에 있다.
## 관계
- **두 selector의 설정 취급이 비대칭이고 검증된 쪽은 하나뿐이다**
같은 리프의 설정 사례다.
- **README의 세 가지 사실 오류**
같은 계열의 문서 오류다.
- **동작이 옳아도 문서가 가리킨 자리는 틀릴 수 있다**
이 사례가 그 규칙의 형태다.
## 문제
README 가 두 가지를 적는다. 선택 스위치의 기본값 위치와 이 리프를 검증하는 테스트 목록이다.
## 결론
첫째가 어긋난다.
README 는 선택 스위치가 부트스트랩 설정 파일에서 꺼짐으로 기본값을 갖는다고 적는다.
그 파일에는 현재 경로의 활성화 키도 호환 경로의 활성화 키도 없다. 비슷한 접두사로 걸리는 두 줄은 주석이다.
실효 기본값은 다른 경로로 만들어진다. 속성이 없으면 조건부 애너테이션이 맞지 않고 빈이 만들어지지 않는다.
그러므로 동작 자체는 옳다. 꺼진 것이 기본이다.
틀린 것은 그 결과가 어디서 오는지에 대한 서술이다. 문서가 가리킨 자리에 그 키가 없다.
둘째도 어긋난다.
README 의 테스트 목록 첫 항목이 이 리프에 없다. 애플리케이션 코어에 있는 클래스다.
판정은 P3 다. 둘 다 동작이 아니라 서술의 문제다.
## 검증 환경
Spring Boot : 4.0.8
확인 방식 : 설정 파일 키 검색과 테스트 클래스 위치 확인
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/142 계열에 있다.
1. README 의 기본값 서술 줄을 읽는다.
2. 지목된 설정 파일에서 두 활성화 키를 검색한다.
3. 걸리는 줄이 주석인지 확인한다.
4. 조건부 애너테이션이 속성 부재에서 어떻게 동작하는지 확인한다.
5. 테스트 목록의 첫 항목을 저장소 전체에서 찾는다.
## 본문
<!-- body:start -->
README는 R2 selector가 "`app-bootstrap/application.yml`에서 `false`로 기본값을 갖는다"고 적는다. 그 파일에 `app.fileserver.enabled``app.file-export.enabled`도 없다(`142-...` §8.4e; `app.fileserver`로 걸리는 두 줄은 주석이다).
## FilePublicationContractTest 참조 위치
:::evidence key="analysis-finding-a08-f003" alt="코드베이스에서 FilePublicationContractTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FilePublicationContractTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 동작은 옳고 문서가 가리킨 자리가 비어 있다
실효 기본값은 "속성 부재 → `@ConditionalOnProperty` 미매치 → bean 없음"이다.
## test 목록의 첫 항목도 다른 곳에 있다
README의 Tests 목록 첫 항목 `FilePublicationContractTest`는 이 leaf가 아니라 `application-core`에 있다.
## 확인하지 못한 것
그 키가 과거에 설정 파일에 있었는지 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,113 @@
---
kind: CASE
slug: analysis-finding-a08-f004
title: 발행 rename만 경로 기반이고, 그것을 지키는 것은 이 모듈이 "근사에 불과하다"고 적은 사전검사다
topic: file-transfer-and-storage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a08-f004
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a08-f004.body.md
assets:
- key: analysis-finding-a08-f004
file: ../../../final/evidence/rendered/analysis-finding-a08-f004.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a08-f004.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L376 이다.
---
# 발행 rename만 경로 기반이고, 그것을 지키는 것은 이 모듈이 "근사에 불과하다"고 적은 사전검사다
이 모듈은 디스크립터를 따라 내려가는 방식으로 심볼릭 링크 공격을 구조적으로 막는다. 콘텐츠를 보이게 만드는 마지막 단계만 경로 기반으로 남아 있고, 그 단계를 지키는 사전검사는 모듈 자신이 근사에 불과하다고 적어 둔 메서드다.
## 관계
- **검사를 더 하는 것은 창을 좁힐 뿐 닫지 않는다**
이 사례가 그 규칙의 형태다.
- **두 selector의 설정 취급이 비대칭이고 검증된 쪽은 하나뿐이다**
같은 리프의 다른 사례다.
- **계측 필드가 자기 회귀 test를 지목하는데 그 test가 읽지 않는다**
같은 리프의 문서 결합 사례다.
## 문제
이 모듈의 지역 플랫폼에 남은 파일 시스템 정적 호출을 전수 조사했다.
대부분은 정당하다. 루트 열기는 문서가 피할 수 없는 하나의 경로 해석이라고 적은 것이고, 시작 시 능력 탐침은 격리된 영역에서 돌며, 고아 검사와 상태 조회와 사용량 탐침은 다른 하위 범위다.
## 결론
문제는 쓰기 경로에 남은 다섯 호출이다.
원자적 이동 발행자의 발행 이동과 그 실패 분류를 위한 존재 검사 둘, 발행 검증의 크기 조회, 메타데이터 포인터 발행자의 준비 파일 삭제다.
그중 발행 이동이 핵심이다.
발행자는 그 이동 직전에 루트와 대상 부모 사이에 심볼릭 링크가 없는지 검사한다. 경로 기반 사전검사다.
그런데 그 메서드의 자바독이 스스로를 이렇게 설명한다.
능력 탐침을 위해 남겨 두었으며 탐침은 여전히 경로명으로 추론한다. production 접근은 더 이상 여기에 의존하지 않는다. 디스크립터를 하나씩 따라 내려가며 링크를 따르지 않는 방식이 심볼릭 링크 성분을 구조적으로 거부하고, 사전검사는 그것을 근사할 수 있을 뿐이라는 것이다.
즉 production 이 더 이상 의존하지 않는다고 적힌 메서드를, 콘텐츠를 보이게 만드는 바로 그 단계가 유일한 보호로 쓴다.
같은 모듈의 다른 문서가 겨냥한 패턴 그대로다. 검사를 더 하는 것은 창을 좁힐 뿐 닫지 않는다는 문장이다.
같은 불일치가 파일 길이 조회에서도 보인다.
판정은 P3 다.
실제 악용에는 저장소 루트 안쪽 쓰기 권한이 필요하다. 이 리프가 그 루트의 소유자와 권한을 증명하는 것은 현재 경로뿐이고, 플랫폼 저장소 루트의 증명은 부트스트랩의 시작 검증기 몫이다.
그래서 도달성은 배포 형상에 달려 있다.
그럼에도 기록하는 이유는 모듈 자신의 문서가 이 패턴을 명시적으로 불충분하다고 선언했다는 점이다.
수정은 발행 이동을 디스크립터 기반 도우미로 옮겨 부모 서술자 상대 이동을 쓰고, 크기 조회를 채널의 속성 읽기로 바꾸는 것이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 정적 호출 전수 조사와 자바독 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/145 계열에 있다.
1. 지역 플랫폼의 파일 시스템 정적 호출을 전수 조사한다.
2. 각 호출이 어느 경로에 속하는지 분류한다.
3. 쓰기 경로에 남은 호출을 추린다.
4. 발행 이동 직전의 사전검사가 무엇인지 확인한다.
5. 그 사전검사 메서드의 자바독을 읽는다.
## 본문
<!-- body:start -->
발행 rename만 경로 기반이고, 그것을 지키는 것은 이 모듈이 "a precheck could only ever approximate"라고 적은 사전검사다.
## LocalPersistentRootAttestor 참조 위치
:::evidence key="analysis-finding-a08-f004" alt="코드베이스에서 LocalPersistentRootAttestor 를 검색한 출력 9줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="LocalPersistentRootAttestor 코드베이스 검색 — 9줄 · exit 0" zoom="true"
:::
## 도달성이 배포 형상에 달려 있다
실제 악용에는 스토리지 루트 안쪽 쓰기 권한이 필요하고, 이 leaf가 그 루트의 소유자·권한을 증명하는 것은 R2 경로(`LocalPersistentRootAttestor`)뿐이며, 플랫폼 저장소 루트의 증명은 `app-bootstrap`의 startup validator 몫이다. 심각도를 P3로 두는 이유가 그것이다.
## 그럼에도 기록하는 이유
모듈 자신의 문서가 이 패턴을 명시적으로 불충분하다고 선언했다.
## 수정
발행 rename을 `SecureDirectoryWalk.inParentOf`로 옮겨 부모 서술자 상대 `move`를 쓰고, `sizeOf``channels.readAttributes`로 바꾸는 것이다.
## 확인하지 못한 것
심볼릭 링크를 실제로 심어 발행 단계를 통과시키는 재현을 하지 않았다. 그것은 저장소 루트 안쪽 쓰기 권한을 전제한다.
<!-- body:end -->
@@ -0,0 +1,117 @@
---
kind: CASE
slug: analysis-finding-a09-f001
title: production 판정이 두 개의 리터럴 프로파일 이름에 걸려 있다
topic: file-transfer-and-storage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a09-f001
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a09-f001.body.md
assets:
- key: analysis-finding-a09-f001
file: ../../../final/evidence/rendered/analysis-finding-a09-f001.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a09-f001.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L106 이다.
---
# production 판정이 두 개의 리터럴 프로파일 이름에 걸려 있다
개발용 제공자를 운영에서 쓰지 말라는 금지가 문서에 있고 그것을 강제하는 코드는 한 곳이다. 그 코드는 거부 검사인데 판정 근거가 이름 두 개의 허용 목록이다. 다른 이름을 쓰는 분기는 검사를 통과한다.
## 관계
- **거부 검사의 근거를 허용 목록으로 두면 목록 밖이 통과한다**
이 사례가 그 규칙의 형태다.
- **사라진 가드가 문서에만 남았고 그 부재는 이미 승인된 결정이다**
같은 계열의 강제 수단 사례다.
- **발행 rename만 경로 기반이고 그것을 지키는 것은 모듈 자신이 근사에 불과하다고 적은 사전검사다**
도달성이 형상에 달린 다른 사례다.
## 문제
리프 지침 문서의 금지 목록에 개발용 제공자를 운영에서 쓰는 것이 있다.
그것을 강제하는 코드가 어디 있는지 찾았다.
## 결론
한 곳이다.
활성 프로파일 목록을 소문자로 만들고, 그중 하나가 짧은 운영 이름이거나 긴 운영 이름과 같은지 본다.
이 저장소가 짧은 이름의 프로파일 설정 파일을 싣고 있으므로 현재 형상에서는 맞는다.
그리고 같은 방식으로 운영을 판정하는 리프는 이것 하나뿐이다. 저장소 전체가 공유하는 운영 판별 장치가 없다.
문제는 방향이다.
이것은 거부 검사인데 판정 근거가 허용 목록 두 개다.
지역이나 환경을 이름에 붙이거나 축약형이나 다른 낱말을 쓰는 분기는 이 검사를 통과한다. 그리고 개발용 제공자가 운영에서 조용히 선택된다.
그 제공자는 README 가 현재 경로 전용 개발 제공자라고 적은 것이다.
실패는 시작 시점이 아니라 데이터가 로컬 디스크에 쌓인 뒤에 드러난다.
판정은 P3 다. 이 저장소 형상에서는 도달하지 않는다.
기록하는 이유는 셋이다. 지침 문서가 금지 항목으로 명시했고, 강제 수단이 문자열 둘이며, 분기가 프로파일 이름을 바꾸는 것은 평범한 일이다.
수정은 운영 판별을 뒤집는 것이다. 이름을 묻는 대신 개발용 제공자를 허용한다는 명시적 설정을 요구하는 형태다. 이름이 아니라 의도를 묻는 것이다.
## 검증 환경
Spring Boot : 4.0.8
확인 방식 : 강제 코드 전수 검색과 저장소 전체 운영 판별 장치 확인
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/150 계열에 있다.
1. 지침 문서의 금지 목록을 읽는다.
2. 그 금지를 강제하는 코드를 저장소에서 찾는다.
3. 판정 조건이 무엇과 비교하는지 확인한다.
4. 같은 방식으로 운영을 판정하는 다른 리프가 있는지 센다.
5. 개발용 제공자의 README 서술을 확인한다.
## 본문
<!-- body:start -->
CLAUDE.md의 Forbidden 목록에 "local-dev in production"이 있고, 그것을 강제하는 코드는 이것 하나다.
```java
private boolean productionProfileActive() {
return activeProfiles.stream()
.map(profile -> profile.toLowerCase(Locale.ROOT))
.anyMatch(profile -> profile.equals("prod") || profile.equals("production"));
}
```
## 금지를 강제하는 코드 한 곳
:::evidence key="analysis-finding-a09-f001" alt="분석 문서 analysis/09-adapter-outbound-objectstorage.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/09-adapter-outbound-objectstorage.md 발췌 — 15줄" zoom="true"
:::
## 현재 형상에서는 맞는다
이 저장소가 `application-prod.yml`을 싣고 있다. 그리고 `150-...` §8.2c에서 확인했듯 같은 방식으로 production을 판정하는 leaf는 이것 하나뿐이다 — 저장소 전체가 공유하는 production 판별 장치가 없다.
## 문제는 방향이다
이것은 **거부** 검사인데 판정 근거가 **허용 목록 두 개**다. `prd`, `production-eu`, `live`, `prod-apac` 같은 이름을 쓰는 fork는 이 검사를 통과하고, `filesystem-local-dev`가 production에서 조용히 선택된다 — 그 provider는 README가 "R1-only development provider"라고 적은 것이다. 실패는 startup이 아니라 데이터가 로컬 디스크에 쌓인 뒤에 드러난다.
## 기록하는 세 이유
(a) CLAUDE.md가 금지 항목으로 명시했고 (b) 강제 수단이 두 문자열이며 (c) fork가 프로파일 이름을 바꾸는 것은 평범한 일이다. 수정은 production 판별을 명시적 설정(예: `app.object-storage.allow-local-dev=true`를 요구)으로 뒤집는 것 — 이름이 아니라 의도를 묻는 형태다. P3.
## 확인하지 못한 것
목록 밖 이름의 프로파일로 실제로 띄워 개발용 제공자가 선택되는지 재현하지 않았다. 조건식상 그 결과가 나온다.
<!-- body:end -->
@@ -0,0 +1,112 @@
---
kind: CASE
slug: analysis-finding-a09-f004
title: 그러나 R0 경계가 문서에만 있고 compile 경로에서 닫히지 않는다
topic: file-transfer-and-storage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a09-f004
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a09-f004.body.md
assets:
- key: analysis-finding-a09-f004
file: ../../../final/evidence/rendered/analysis-finding-a09-f004.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a09-f004.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L485 이다.
---
# 그러나 R0 경계가 문서에만 있고 compile 경로에서 닫히지 않는다
직접 전송 능력이 아직 배선되지 않았다고 README 가 선언한다. 그런데 설정은 그 능력을 계속 받아들이고 런타임은 자격증명을 쥔 서명자를 실제로 만든다. 만들어진 제공자를 꺼내 가는 코드는 없다.
## 관계
- **두 개의 outbox 중 하나만 조립되어 있다**
같은 형태의 미조립 사례다.
- **production 판정이 두 개의 리터럴 프로파일 이름에 걸려 있다**
같은 리프의 다른 사례이자 반대 사례다.
- **문서가 선언한 경계는 코드가 닫아야 경계다**
이 사례가 그 규칙의 형태다.
## 문제
README 가 직접 업로드와 직접 다중 파트가 아직 배선되지 않았다고 적는다.
그 선언이 코드로도 강제되는지 확인했다.
## 결론
강제되지 않는다. 네 단계로 확인된다.
첫째, 두 능력은 목적지 요구사항으로 선언할 수 있고 바인딩 컴파일러가 그것을 제공자 능력으로 번역한다.
둘째, 제공자 바인딩의 프로파일 컴파일은 자체 호스팅 구현에 대해서만 이 두 주장을 거부한다. 정확한 판본이 조건부 관리 변형을 기본 지원한다고 주장할 수 없다는 이유다. 클라우드 프로파일에는 대응하는 거부가 없다.
셋째, 그러면 제공자 기여자가 두 스위치 중 하나라도 켜져 있을 때 서명자를 할당하고 직접 전송 제공자와 직접 다중 파트 제공자를 만들어 선택 팩토리에 넣는다.
넷째, 그 둘을 팩토리에서 꺼내 가는 코드가 없다. 전수 검색 결과가 0 이다.
즉 클라우드 바인딩에서 직접 업로드를 요구하는 배포는 시작을 통과하고 서명자를 할당하고 두 제공자를 조립하고, 직접 전송 포트는 여전히 하나도 얻지 못한다.
README 가 말한 미배선은 사실이다. 그 사실을 강제하는 것이 문서뿐이다.
이 리프 안에 정반대의 사례가 있어서 대비가 분명하다.
개발용 제공자는 운영 프로파일에서 컴파일 시점에 거부된다. 자체 호스팅 구현의 능력 과대 주장도 컴파일 시점에 거부된다.
같은 파일이 같은 종류의 자격 없음 판정을 클라우드와 직접 전송 조합에 대해서만 하지 않는다.
판정은 P2 다.
데이터 위험은 없다. 없는 포트는 호출될 수 없다.
위험은 둘이다. 운영자가 켰다고 믿는 기능이 없다는 것과, 아무도 쓰지 않는 서명 자격증명 핸들이 프로세스 수명 동안 살아 있다는 것이다.
수정은 셋 중 하나다. 조정자를 조건부로 조립하거나, 미배선 동안 두 능력 요구를 제공자 종류와 무관하게 컴파일 단계에서 거부하거나, 능력이 켜져도 서명자를 만들지 않도록 조립을 뒤로 미루는 것이다.
## 검증 환경
확인 방식 : 바인딩 컴파일러와 제공자 기여자 코드 확인, 소비자 전수 검색
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/154 계열에 있다.
1. README 의 미배선 선언을 읽는다.
2. 두 능력이 목적지 요구사항으로 선언 가능한지 확인한다.
3. 프로파일 컴파일에서 어느 제공자에 대해 거부가 있는지 확인한다.
4. 제공자 기여자가 어떤 조건에서 서명자를 만드는지 확인한다.
5. 만들어진 두 제공자를 팩토리에서 꺼내는 코드를 검색한다.
## 본문
<!-- body:start -->
R0 경계가 문서에만 있고 compile 경로에서 닫히지 않는다.
## 문서에만 있는 R0 경계
:::evidence key="analysis-finding-a09-f004" alt="분석 문서 analysis/09-adapter-outbound-objectstorage.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/09-adapter-outbound-objectstorage.md 발췌 — 15줄" zoom="true"
:::
## 데이터 위험은 없다
없는 port는 호출될 수 없다. P2.
## 위험 둘
(a) 운영자가 켰다고 믿는 기능이 없다는 것, (b) 아무도 쓰지 않는 서명 자격증명 핸들이 프로세스 수명 동안 살아 있다는 것.
## 수정 방향 셋
coordinator를 조건부로 조립하거나, R0인 동안 `DIRECT_UPLOAD`/`DIRECT_MULTIPART` 요구를 compile 단계에서 provider 종류와 무관하게 거부하거나, capability가 켜져도 presigner를 만들지 않도록 조립을 뒤로 미루는 것.
## 확인하지 못한 것
클라우드 바인딩으로 실제로 띄워 서명자가 살아 있는 것을 확인하지 않았다. 조립 조건상 그 결과가 나온다.
<!-- body:end -->
@@ -0,0 +1,98 @@
---
kind: CASE
slug: analysis-finding-a09-f006
title: nonce replay 경계가 결과를 읽고 버린다
topic: file-transfer-and-storage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a09-f006
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a09-f006.body.md
assets:
- key: analysis-finding-a09-f006
file: ../../../final/evidence/rendered/analysis-finding-a09-f006.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a09-f006.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L589 이다.
---
# nonce replay 경계가 결과를 읽고 버린다
인터페이스가 스스로를 내구적 비교 후 교체 재생 경계라고 부른다. 호출부는 청구 결과를 받아 변수에 담고, 어느 값이든 발행을 그대로 진행한다. 결과가 바꾸는 것은 종결 기록을 쓸지 여부뿐이다.
## 관계
- **검사 결과를 읽고 버리면 검사가 아니다**
이 사례가 그 규칙의 형태다.
- **미배선 경계가 문서에만 있고 compile 경로에서 닫히지 않는다**
같은 리프의 다른 경계 사례다.
- **멱등은 결과를 같게 만들지만 경계를 대신하지는 않는다**
피해가 제한된 이유이자 그것이 변명이 되지 않는 이유다.
## 문제
승인 문서의 논스가 한 번만 쓰인다는 성질을 지키는 저장소가 있다.
그 저장소의 청구 메서드를 호출부가 어떻게 쓰는지 확인했다.
## 결론
청구 결과를 변수에 담고, 다음 줄에서 발행을 부른다.
그다음에야 결과를 본다. 종결 재생이 아니면 종결 기록을 남긴다.
결과 값은 셋이다. 청구됨과 정확한 재생과 종결 재생이다.
어느 값이든 발행은 그대로 진행된다.
즉 청구 결과가 바꾸는 것은 종결 기록을 쓸지 여부뿐이다. 인터페이스 자바독은 자신을 내구적 비교 후 교체 재생 경계라고 부르는데, 경계로서 무엇도 막지 않는다.
실제 피해는 제한적이다.
발행이 연산 키 기반 멱등이다. 이미 소진된 논스로 다시 들어와도 결과는 재생됨이고 두 번째 객체가 생기지 않는다.
그래서 판정은 P3 다.
그럼에도 기록하는 이유는 셋이다.
이름이 약속하는 것과 다르다. 종결 기록에 넘기는 기대 개정 번호가 항상 0 이라 비교 후 교체의 인자로서도 고정값이다. 그리고 승인 문서의 논스가 한 번만 쓰인다는 성질이 이 코드로는 보장되지 않는다.
수정은 종결 재생에서 발행 전에 거부하는 것이다.
## 검증 환경
확인 방식 : 호출부와 인터페이스 자바독 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/154 계열에 있다.
1. 재생 저장소 인터페이스의 자바독을 읽는다.
2. 청구 결과 열거값 셋을 확인한다.
3. 호출부에서 결과가 어떻게 쓰이는지 확인한다.
4. 발행 호출이 결과보다 앞인지 뒤인지 본다.
5. 종결 기록에 넘기는 기대 개정 번호가 무엇인지 확인한다.
## 본문
<!-- body:start -->
`ClaimResult``CLAIMED` / `EXACT_REPLAY` / `TERMINAL_REPLAY` 셋인데, 어느 값이든 발행은 그대로 진행된다. claim 결과가 바꾸는 것은 terminal 기록을 쓸지 여부뿐이다.
## ClaimResult 참조 위치
:::evidence key="analysis-finding-a09-f006" alt="코드베이스에서 ClaimResult 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClaimResult 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 경계라고 부르지만 아무것도 막지 않는다
인터페이스 javadoc은 자신을 "Durable compare-and-set nonce replay boundary"라고 부른다.
## 확인하지 못한 것
소진된 논스로 두 번 들어와 결과가 재생됨이 되는지 실행하지 않았다. 멱등 키 구현상 그 결과가 나온다.
<!-- body:end -->