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>
86 KiB
09 · adapter-outbound-objectstorage
SSOT identity — 2026-08-31 재검증
- registered leaf id:
adapter-outbound-objectstorage - canonical state
analysisFile:analysis/09-adapter-outbound-objectstorage.md(이 문서) — 이 leaf의 단일 SSOT - source path:
src/adapter/outbound/objectstorage· Gradle:adapter:outbound:objectstorage - registry
allowed_dependencies:["application-core", "shared-contract"] - registry
runtime_memberships:["sample-portfolio"] - coverage ledger:
FULL_READ206 /STRUCTURAL_ONLY0 /EXCLUDED0 /UNCLASSIFIED0 - 최초 분석 revision
a24ece9c→ 재검증 revision21234e38· 이 리프의 변경 파일 0 - 재검증 증거:
EVD-333(소스 드리프트 0),EVD-334(lane 재실행)
재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
상태: COMPLETE
revision:a24ece9cf797f7ea647e33bf846b115208ed1ba5
경로:src/adapter/outbound/objectstorage· Gradle::adapter:outbound:objectstorage
0. Denominator와 coverage ledger
tracked file 206개 — main 147 (14,336 LOC), test 48 + resource 1 (6,753 LOC), 별도 qualification source set 3개 (6 files, 546 LOC), governance 4. 총 약 21.6k LOC.
{ "id": "adapter-outbound-objectstorage",
"gradle_path": ":adapter:outbound:objectstorage",
"allowed_dependencies": ["application-core", "shared-contract"],
"runtime_memberships": ["sample-portfolio"] }
build.gradle이 strictTestLanes로 세 개의 별도 source set을 선언한다 — objectStorageMinioContractTest, objectStorageMinioFaultTest, objectStorageAwsQualificationTest. AWS SDK v2 BOM은 Spring Boot BOM이 관리하지 않으므로 모듈 범위로 import되고, 그 이유가 주석에 적혀 있다("keeps the strict-locking blast radius to this module").
패키지 배치(main): s3 26 · control 24 · kernel 23 · config 19 · direct 13 · readiness 8 · maintenance 8 · codec 7 · filesystem 6 · multipart 5 · provider 4 · 루트 4.
하위 범위 원장
| # | 범위 | main | test | 기타 | 합 | 상태 |
|---|---|---|---|---|---|---|
| 1 | governance + config/** — opt-in · binding compiler · routing · capability |
19 | 5 | 4 | 28 | COMPLETE |
| 2 | control/** — canonical JSON codec + durable record 타입 |
24 | 1 | – | 25 | COMPLETE |
| 3 | kernel/** + codec/** — operation kernel · state machine · epoch · key/fingerprint codec |
30 | 9 | – | 39 | COMPLETE |
| 4 | s3/** — provider binding · client policy · async bridge · provider 구현 |
26 | 14 | – | 40 | COMPLETE |
| 5 | direct/** + multipart/** — direct transfer · multipart coordinator |
18 | 7 | – | 25 | COMPLETE |
| 6 | filesystem/** + maintenance/** + readiness/** + provider/** + 루트 |
30 | 12 | 1 | 43 | COMPLETE |
| 7 | qualification source set 3종 (minio contract / minio fault / aws) | – | – | 6 | 6 | COMPLETE |
| TOTAL | 147 | 48 | 11 | 206 | 7 / 7 |
manifest: evidence/raw/149-objectstorage-module-inventory.txt.
1. Sub-scope 01 범위와 denominator
내부 상태: COMPLETE — 28 / 28 FULL_READ 범위: governance 4 +
config/**main 19 + 전용 test 5 역할:app.object-storage를 비활성 기본값에서 정확한 불변 바인딩으로 컴파일하고, 선택된 provider만 자원을 만들게 하며, 폐기된 alias를 격리한다
manifest와 probe: evidence/raw/150-objectstorage-config-activation-probes.txt.
2. Confirmed — "컴파일이 먼저, 생성은 나중"이 실제 순서다
ObjectStorageProviderContribution이 두 메서드의 계약을 나눈다.
describemust not resolve credentials, create files, clients, threads, or schedulers.createowns cleanup of every partial allocation before it throws; after a successful return the assembler owns the returned lifecycle exactly once.
ObjectStorageCapabilityAssembler.assemble이 그 순서를 지킨다 — compiler.compile(settings)가 전부 끝난 뒤(:25)에야 선택된 destination을 돌며 contribution.create(provider)를 부른다(:48). 그리고 도중에 실패하면 이미 만든 것을 역순으로 닫는다(:53–56). AssembledCapability.close()도 역순이고 AtomicBoolean으로 정확히 한 번만 실행된다.
README의 "Settings compile fully before any selected provider creates a directory, client, thread, scheduler, or credential lookup"이 코드 구조로 성립한다.
컴파일러 자체가 fail-closed다. 비활성이면 빈 바인딩을 돌려주고, 활성인데 provider·destination·default destination 중 하나라도 비면 거부한다. provider마다 describe가 돌려준 서술자와 설정을 대조한다 — providerType 일치, version 일치, maximumObjectBytes가 서술자 상한 이하, chunkBytes가 서술자 상한 이하. chunk는 추가로 1 ≤ chunk ≤ min(maxObject, 16 MiB)이고 Integer.MAX_VALUE를 넘지 못한다. destination은 route token 중복을 거부하고, 요구한 capability를 provider가 SUPPORTED로 신고하지 않으면 거부하며, SCAN_CLEAN을 요구하는데 scanner seam이 없으면 이름을 대며 거부한다.
식별자 검증도 좁다 — canonicalId는 64자 이내, [a-z0-9][a-z0-9_-]*, 소문자, 그리고 0x20–0x7e 밖 문자를 전부 거부한다.
3. Confirmed — README가 "등록되지 않는다"고 적은 것들이 실제로 등록되지 않는다
150-... §8.2의 네 주장을 각각 추적했다.
| README 주장 | 확인 |
|---|---|
| "no direct-grant port is registered" | RoutingObjectDirectGrantAdapter는 private 생성자만 가진 빈 클래스이고, leaf 안에서 자기 파일 밖 참조 0(grep exit=1) |
| "Scanner and privileged purge composition remain separate and empty" | ObjectStorageMaintenanceCapabilityConfig는 본문이 없는 @Configuration. scanner는 별도 ObjectStorageScanMaintenanceConfig에 있고 app.object-storage.scan-maintenance.enabled=true로만 켜진다 |
"filesystem-local-dev is rejected under prod/production" |
ObjectStorageBindingCompiler:125에 존재 |
| "Mixing any old alias with canonical settings fails startup without echoing values" | LegacyObjectStorageActivationGuard가 두 prefix가 동시에 있으면 IllegalStateException을 던지고, 메시지에 값이 없다 |
마지막 것의 구현이 특히 조심스럽다 — hasPrefix가 EnumerablePropertySource를 순회해 prefix로 시작하는 이름이 있는지만 보고, 열거 불가능한 source에 대비해 알려진 키 목록으로 fallback한다. 어느 경로에서도 값을 읽지 않는다.
적재 경로는 fileserver와 같다 — AutoConfiguration.imports가 없고(150-... §8.1, exit=1), CaSkeletonApplication의 명시적 @ComponentScan이 dev.caskeleton.adapter.outbound.objectstorage를 목록에 올린다(:76). 그리고 app.object-storage.*는 어느 application.yml에도 없으므로 실효 기본값은 속성 부재다.
4. Confirmed — legacy가 세 겹으로 격리돼 있다
폐기 경로가 셋인데 서로 다른 스위치를 쓰고 서로를 배제한다.
| 경로 | 스위치 | 성격 |
|---|---|---|
| 선호 임시 활성화 | app.object-storage.legacy.enabled=true + 명시적 backend |
ObjectStoragePort(whole-byte[]) 노출 |
| 구 alias | ca-skeleton.objectstorage.* |
LegacyObjectStorageActivationGuard 조건, canonical과 혼용 시 실패 |
| 채택(adoption) | app.object-storage.legacy-adoption.enabled=true |
raw locator 유지보수 전용, 별도 config 클래스 |
ObjectStorageBindingCompiler.rejectLegacyOverlap가 legacy filesystem 루트와 canonical provider 루트가 어느 방향으로든 포함 관계면 거부한다. LegacyObjectAdoptionSettings는 APPLY 모드일 때 검토된 manifest 경로와 64자리 SHA-256을 요구하고, batch size 1–1000, timeout 5분 이내를 강제한다.
legacy runtime은 AutoCloseable holder로 감싸 S3 client 수명을 정확히 소유하고, @Bean(destroyMethod = "close")로 등록된다.
5. P3 — production 판정이 두 개의 리터럴 프로파일 이름에 걸려 있다
CLAUDE.md의 Forbidden 목록에 "local-dev in production"이 있고, 그것을 강제하는 코드는 이것 하나다.
private boolean productionProfileActive() {
return activeProfiles.stream()
.map(profile -> profile.toLowerCase(Locale.ROOT))
.anyMatch(profile -> profile.equals("prod") || profile.equals("production"));
}
이 저장소가 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이 아니라 데이터가 로컬 디스크에 쌓인 뒤에 드러난다.
판정: P3. 이 저장소 형상에서는 도달하지 않는다. 기록하는 이유는 (a) CLAUDE.md가 금지 항목으로 명시했고 (b) 강제 수단이 두 문자열이며 (c) fork가 프로파일 이름을 바꾸는 것은 평범한 일이기 때문이다. 수정은 production 판별을 명시적 설정(예: app.object-storage.allow-local-dev=true를 요구)으로 뒤집는 것 — 이름이 아니라 의도를 묻는 형태다.
6. P3/기록 — readiness registry가 build의 test 입력인데 leaf 소스가 그 파일명을 참조하지 않는다
build.gradle:45–46이 docs/registries/object-storage-readiness.yaml을 test task의 inputs.file로 선언한다. 그 파일의 헤더는 소유 test 둘을 이름으로 적는다.
# Repository owner test: dev.caskeleton.bootstrap.contract.ContractRegistrySchemaGovernanceTest
# Semantic owner test: dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageReadinessRegistryTest
그런데 leaf 소스에서 object-storage-readiness라는 문자열을 검색하면 매치가 없다(150-... §8.4c, exit=1). 즉 semantic owner test는 파일명을 상수로 갖지 않고 다른 방식(경로 조립 등)으로 찾는다. 실제 결합 여부는 readiness 패키지를 읽는 sub-scope 06에서 확정한다 — 여기서는 이월 항목으로만 남긴다.
7. Confirmed — 후보로 본 unguarded split은 값 타입이 막고 있다
RoutingObjectReadAdapter.load가 reference.canonicalText().split("\\.", -1)[1]로 route token을 꺼낸다. 인덱스 검사가 없어 처음에는 ArrayIndexOutOfBoundsException 후보로 봤다.
ObjectReference를 확인한 결과 생성자가 ObjectIdentitySupport.requireRouted(canonicalText, "osr1")로 형태를 강제하므로, 유효하게 만들어진 참조에는 항상 route 구획이 있다(150-... §8.4d). 결함이 아니다.
8. Negative-space probes — sub-scope 01
- 8.1 reachability: auto-configuration 등록 metadata 0, 적재는 명시적 component scan, 두 selector 모두
application.yml에 부재(속성 부재가 실효 기본값). - 8.2 계약 ↔ 구현:
describe/create의 부작용 계약과 assembler의 실제 호출 순서(§2). README의 네 가지 "등록되지 않는다" 주장 전수 확인(§3). - 8.2b 조건부 형제: production 판정이 이 leaf에만 있고 저장소 공용 장치가 없음(§5).
- 8.3 중복 mechanism: legacy 경로 셋이 서로 다른 스위치를 쓰고 겹침을 명시적으로 거부(§4).
- 8.4 문서/빌드 drift: readiness registry의 build 입력 선언과 leaf 소스의 참조 부재(§6, sub-scope 06으로 이월).
9. Sub-scope 01 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P3 | filesystem-local-dev production 거부가 prod/production 두 리터럴에만 걸려 있다 — 다른 이름을 쓰는 fork는 R1 개발용 provider를 production에서 받는다 |
프로파일 이름을 바꾼 fork |
| P3/기록 | readiness registry가 build의 test 입력인데 leaf 소스에 그 파일명 참조가 없다 — 실제 결합은 sub-scope 06에서 확정 | 이월 |
10. Sub-scope 01 완료 조건
- denominator 28 / 28 FULL_READ (
150-...OWNED FILES) - §8.1~§8.4 네 종 probe 수행, README의 네 가지 조립 주장을 각각 코드로 추적
- 후보 finding 1건(unguarded split)을 값 타입 검증으로 추적해 결함 아님으로 판정(§7)
- 소스 미변경
11. Sub-scope 02 범위와 denominator
내부 상태: COMPLETE — 25 / 25 FULL_READ 범위:
control/**main 24 (2,470 LOC) + 전용 test 1 (339 LOC) 역할: 열한 종의 durable 제어 레코드를 닫힌 sealed 계열로 두고, canonical-json-v1 + 외곽 SHA-256 봉투로 인코딩하며, 불변식을 레코드 생성자에서 강제한다
manifest와 probe: evidence/raw/151-objectstorage-control-probes.txt.
12. Confirmed — 계열이 닫혀 있고 스키마가 fail-closed다
ObjectControlRecord는 열한 개 구현만 허용하는 sealed interface이고, javadoc이 규칙을 적는다 — "Unknown families and schemas fail closed." codec의 payload(...)가 그 계열에 대해 exhaustive switch를 쓰므로, 새 레코드를 추가하면 컴파일이 강제로 codec을 갱신하게 만든다.
스키마 버전은 ControlRecordSupport.header가 schemaVersion != 1을 거부한다 — "only control schema version 1 is writable". 더 새로운 스키마를 만나면 덮어쓰지 않고 UnsupportedObjectControlSchemaException으로 격리한다("A newer or unknown durable schema that must be quarantined rather than overwritten").
objectstorage.control 패키지를 leaf 밖에서 참조하는 코드는 0이다(151-... §8.1, exit=1). CLAUDE.md의 "control-record types leaking into application-core" 금지가 가시성으로 성립한다.
13. Confirmed — canonical 표현이 "우리가 쓴 것과 바이트가 같은가"로 강제된다
CanonicalJsonReader는 관용을 두지 않는다.
- 필드 순서 고정:
field(expectedName)이 읽은 이름과 기대 이름을 비교한다 — 재배열은 실패. - 공백 불허:
expect(char)가 정확히 그 문자만 소비한다. 공백을 건너뛰는 코드가 없다. - 이스케이프 두 개만:
\"와\\외의 이스케이프는 실패. - printable ASCII만: 0x20–0x7e 밖 문자는 reader·writer·
ControlRecordSupport세 곳 모두에서 거부. - 숫자 정규형: 선행 0(
00,01)과-0을 거부. - 후행 콘텐츠 불허:
end()가cursor != input.length()면 실패.
reader가 new String(bytes, UTF_8)로 관용 디코딩하는 것은 그 자체로는 malformed 바이트를 U+FFFD로 바꾸지만, U+FFFD(0xFFFD)는 0x7e를 넘으므로 printable ASCII 검사에서 걸린다. 즉 관용 디코딩이 뚫리지 않는다 — fileserver R1 저널(sub-scope 03, 08번 문서 §26)에서 같은 관용 디코딩이 열려 있던 것과 대비된다.
봉투도 이중으로 잠긴다. decodeUnchecked가 Base64를 디코딩한 뒤 다시 인코딩해 문자열이 같은지 확인하고(alias 거부), payload의 SHA-256을 MessageDigest.isEqual(상수시간)로 비교한다. 크기 상한이 계열별로 셋이다 — 봉투 64 KiB, terminal receipt 16 KiB, part 4 KiB — 그리고 encode·decode 양쪽에서 enforceFamilySize가 적용된다.
14. Confirmed — 레코드가 값을 믿지 않고 관계를 다시 계산한다
ObjectOperationRecord의 compact 생성자가 대표적이다. 넘겨받은 policySnapshotDigest를 그대로 쓰지 않고 스냅샷에서 다시 계산해 대조한다 — "policySnapshotDigest does not match the snapshot". expectedContentIdentity가 있으면 얼어붙은 정책 상한을 넘는지도 본다.
상태 짝도 강제된다.
(pendingEffect == null) != (effectCertainty == NOT_SENT)→ "pending effect and certainty do not agree"- 미해결 pending effect가 있는데 새 것을 만들면 → "an unresolved pending effect already exists"
DATA_UPLOADED로 전진하려면 업로드 증거가 확인돼 있어야 함- terminal 전이는
ABORTED/QUARANTINED/FAILED만 허용
ObjectPublicationHandoffRecord는 lease를 fence와 함께 다룬다 — fence는 1 이상, released && abortAuthorized 동시 참 금지, 그리고 쓰기 시점에 이미 만료된 활성 lease를 거부한다("active handoff lease is expired at write time"). claimant는 지문(hexDigest)으로만 저장된다.
ObjectStagedObjectRecord는 scan 증거의 짝을 강제한다 — (scanOperationId == null) != (scannerPolicyRevision == null)이면 "scan verdict evidence is incomplete", 그리고 스캔은 integrityVerified && publicationRequirement == SCAN_CLEAN일 때만 시작할 수 있다.
ObjectControlStore 인터페이스에는 list가 없다. javadoc이 그 부재를 명시한다 — "exact-lookup/create/CAS control storage. LIST is deliberately absent." 열거가 없으면 제어 평면을 훑어 다른 테넌트의 키를 발견하는 경로가 구조적으로 없다.
15. Negative-space probes — sub-scope 02
- 8.1 reachability:
control패키지의 leaf 밖 참조 0(§12). - 8.2 계약 ↔ 구현: canonical 주장과 reader/writer의 실제 거부 목록 대조(§13). 관용 UTF-8 디코딩이 ASCII 검사로 닫히는지 확인.
- 8.2b 봉투 무결성: Base64 재인코딩 대조 + 상수시간 digest 비교 + 계열별 크기 상한(§13).
- 8.3 닫힌 계열: sealed interface와 exhaustive switch가 새 레코드 추가 시 codec 갱신을 강제(§12).
ObjectControlStore에list부재(§14). - 8.4 불변식: 레코드 생성자가 digest를 재계산하고 상태 짝을 강제하는 지점 전수 확인(§14).
16. Sub-scope 02 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| — | 없음. canonical 강제가 reader·writer·봉투 세 겹이고, 레코드 불변식이 값이 아니라 관계를 검증하며, 열거 API가 존재하지 않는다 | — |
17. Sub-scope 02 완료 조건
- denominator 25 / 25 FULL_READ (
151-...OWNED FILES) — main 2,470 LOC 전수 판독 - §8.1~§8.4 네 종 probe 수행
- 관용 UTF-8 디코딩 후보를 ASCII 검사로 추적해 결함 아님으로 판정
- 소스 미변경
18. Sub-scope 03 범위와 denominator
내부 상태: COMPLETE — 39 / 39 FULL_READ 범위:
kernel/**23 (1,463 LOC) +codec/**7 (539 LOC) + 전용 test 9 역할: provider SDK 없이 예약 → 보류 효과 → 확인 → 단계 전진을 정확한 CAS 위에서 돌리고, 모든 네임스페이스 키를 단일 인코더로 만든다
manifest와 probe: evidence/raw/152-objectstorage-kernel-codec-probes.txt.
19. Confirmed — 다섯 개의 닫힌 전이표가 있고 terminal이 진짜 terminal이다
ObjectOperationStateMachine이 publication·scan·reference·direct-grant·multipart 다섯 계열의 전이를 각각 switch로 적는다. terminal 처리가 계열마다 명시적이다.
| 계열 | terminal 처리 |
|---|---|
| publication | current.terminal()이면 즉시 거부 |
| scan | case CLEAN, MALICIOUS -> false; NOT_REQUIRED는 어디로도 못 감 |
| reference | case PURGED -> false (PUBLISHED → RETIREMENT_PENDING → RETIRED → PURGE_ELIGIBLE → PURGED) |
| direct grant | terminal 집합 4종을 먼저 계산해 !currentTerminal 요구 |
| multipart | terminal 집합 5종에 대해 동일 |
뒤 둘은 branch 전이(EXPIRED/ABORTED/FAILED/CORRUPT)를 별도로 허용해, 정상 사슬 어디서든 실패로 빠질 수 있되 terminal에서는 나올 수 없게 한다.
requireNextRevision(current, next)가 next == current + 1을 강제한다 — revision은 건너뛰지도 되돌아가지도 못한다. ObjectOperationStateMachineTest가 그 셋을 이름으로 고정한다: publicationFollowsScanFreeAndScanRequiredPaths, terminalOutOfOrderAndStaleRevisionTransitionsFailClosed, independentStateFamiliesDoNotImplyEachOther.
20. Confirmed — 응답 유실을 "의도를 먼저 적는" 방식으로 다룬다
StagedObjectPublicationKernel.stage의 순서가 핵심이다.
operations.reserve(...)— 제어 저장소에 조건부 create. 충돌하면 기존 레코드의requestFingerprint를 비교해 CONFLICT / REPLAY_TERMINAL / REPLAY_NON_TERMINAL로 분류한다.pendingEffect == null이면markEffectSent(...)로 외부 mutation 전에 의도를 durable하게 적는다 — kind(DATA_PUT), attemptId, 대상 증거 해시, 원하는 상태, precondition(create-if-absent), 요청 증거 다이제스트.resolveOrCreate(providerOperation, producer)— provider 호출.- 성공하면
confirmEffect(...), 그 다음에야advancePublication(DATA_UPLOADED).
PendingObjectEffect의 javadoc이 그 성격을 못박는다 — "Bounded, non-secret exact intent persisted before external mutation". 모든 필드가 길이 제한이 있고 대상은 해시로만 적힌다.
ObjectOperationKernel.replace는 read → stored.record().equals(expected) 비교 → compareAndSet(key, mutation(stored.version(), replacement)) 순서다. 즉 낙관적 비교와 저장소 CAS를 겹쳐 쓴다. test pendingEffectIsDurableBeforeIoAndResponseLossRemainsPhaseSpecific와 everyPendingMutationIsResolvedOnceWithoutBlindMutationReplay가 이 성질을 고정한다 — 후자의 이름이 규칙을 그대로 말한다.
21. Confirmed — 모든 키가 단일 인코더에서 나오고 route를 벗어날 수 없다
ObjectControlKeyCodec과 ObjectDataKeyCodec이 각각 "Sole encoder"를 자칭하고, 저장소에서 "control/v1/"·"data/v1/" 리터럴은 이 두 파일에만 있다(152-... §8.3). 키 형태는 control/v1/<family>/<route>/<shard>/<identity>이고 shard는 identity의 SHA-256 앞 두 자리다.
route 격리가 구조적이다 — requireMatchingRoute(route, routedIdentity)가 routed identity의 route 구획을 파싱해 현재 route와 다르면 거부한다("routed identity belongs to a different route"). reference·session·stage handle 키 모두 이 검사를 지난다.
핸들 계열도 접두사로 분리된다 — osh1(stage), osu1(direct upload), osm1(multipart), osv1(version), osr1(reference). ObjectNamespaceCodecTest.referenceAndHandleFamiliesRemainSeparated가 그 분리를 고정하고, dataKeyApiHasNoRawNameStringParameter는 API 서명 자체에 raw 이름 문자열이 없음을 단언한다.
CrockfordBase32는 소문자 정규 알파벳(0123456789abcdefghjkmnpqrstvwxyz — I·L·O·U 제외)을 쓰고, 인코딩 후 남은 값이 있으면 거부한다("base32 output length is too small") — 잘림을 조용히 넘기지 않는다.
test에 property-based 검사가 있다 — routeParserRejectsArbitraryNonCanonicalText(@ForAll String candidate)(jqwik), namespaceRejectsAliasesAndTraversalInputs, 그리고 fingerprint codec에는 golden vector가 고정돼 있다(canonicalIntentHasAFrozenGoldenVector, sameIntentIsStableAndEverySemanticChangeChangesTheFingerprint).
policySnapshotCodecIsCanonicalAndContainsNoCredentialSurface는 정책 스냅샷에 자격증명 표면이 없음을 test로 고정한다.
22. P3/기록 — 보류 효과 전이가 updatedAt을 전진시키지 않는다
ObjectOperationKernel의 두 메서드가 새 시각 대신 기존 값을 쓴다(152-... §8.2d).
markEffectSent → current.withPendingEffect(effect, current.updatedAt())
markResponseLost → current.withEffectCertainty(INDETERMINATE, current.updatedAt())
confirmEffect와 advancePublication·terminate는 now를 받는다. 즉 의도를 적은 시각과 응답 유실을 기록한 시각이 durable 레코드에 남지 않는다 — revision은 올라가지만 updatedAt은 이전 단계의 값 그대로다.
기능상 문제는 없다. revision이 순서를 주고, lease 만료 판정은 ObjectPublicationHandoffRecord가 자기 leaseExpiresAt로 따로 한다. 다만 "언제 이 mutation을 보냈는가"는 응답 유실 조사에서 가장 먼저 묻는 값이고, 지금은 레코드에서 답할 수 없다. P3/기록.
23. Negative-space probes — sub-scope 03
- 8.1 reachability:
kernel·codec패키지의 leaf 밖 참조 0(exit=1). - 8.2 계약 ↔ 구현: 다섯 전이표의 terminal 처리 전수 확인(§19). 보류 효과의 기록-호출-확인 순서를 호출 지점으로 추적(§20).
- 8.2b 조건부 형제:
confirmEffect·advancePublication은now를 받고markEffectSent·markResponseLost는 받지 않음(§22). - 8.3 중복 mechanism: 키 리터럴이 두 인코더에만 존재하고 route 격리가 파싱으로 강제됨(§21).
- 8.4 test 커버리지: 상태 기계·kernel·codec 각각에 이름이 규칙을 말하는 test가 있고, property-based 검사와 golden vector가 포함됨.
24. Sub-scope 03 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P3/기록 | markEffectSent·markResponseLost가 updatedAt을 전진시키지 않아, 의도를 적은 시각과 응답 유실 시각이 durable 레코드에 남지 않는다 |
응답 유실 조사 |
25. Sub-scope 03 완료 조건
- denominator 39 / 39 FULL_READ (
152-...OWNED FILES) - §8.1~§8.4 네 종 probe 수행
- 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능
- 소스 미변경
26. Sub-scope 04 범위와 denominator
내부 상태: COMPLETE — 40 / 40 FULL_READ 범위:
s3/**main 26 (3,581 LOC) + 전용 test 14 (약 2,300 LOC) 역할: AWS SDK v2를 이 패키지 안에 가두고, 클라이언트 정책·바인딩·조건부 제어 저장소·비동기 브리지를 컴파일 시점에 전부 검증된 값으로 만든다
manifest와 probe: evidence/raw/153-objectstorage-s3-probes.txt.
27. Confirmed — SDK 타입이 production에서 leaf를 벗어나지 않는다
software.amazon.awssdk를 참조하는 main 파일은 leaf 안에 19개이고, 그중 16개가 s3/**이다. 밖의 셋은 ObjectStorageConfig·S3ObjectStorageAdapter(둘 다 루트의 legacy 어댑터)와 config/ObjectStorageCapabilityConfig(legacy runtime이 S3 client를 만드는 지점)뿐 — 전부 README가 "deprecated compatibility only"로 선언한 경로다.
leaf 밖에서 SDK를 참조하는 파일은 아키텍처 test 카탈로그와 빌드 파일뿐이다(153-... §8.1) — GraphQlReturnTypePolicy, WebForbiddenTypeCatalog, NotificationArchitectureTest, 그리고 application-core의 ObjectStorageArchitectureContractTest. 즉 production 코드에서의 유출은 없고, 유출 금지가 다른 leaf의 금지 타입 목록으로도 지켜지고 있다.
28. Confirmed — 클라이언트 정책이 시간 예산의 정합성을 검사한다
S3ClientPolicy.validate()가 개별 값의 양수 여부를 넘어 값들 사이의 관계를 본다.
- per-attempt timeout < parent call timeout
- 다섯 전송 timeout(connection·TLS·acquire·read·write)이 모두 per-attempt timeout 이하
retryBaseDelay ≤ retryMaximumBackoff- 재시도 최악 예산이 부모 호출 예산 안에 드는지:
attemptTimeout × maxAttempts + maxBackoff × (maxAttempts − 1) ≤ apiCallTimeout그리고 그 곱셈은ArithmeticException을 잡아 "retry budget overflows"로 거부한다.
즉 "재시도를 다 해도 부모 예산을 못 넘는다"가 설정 검증으로 강제된다 — 설정만 보고는 알 수 없는 종류의 모순이다.
엔드포인트도 좁다 — scheme은 http/https만, userinfo·query·fragment 금지, 그리고 평문 AWS 엔드포인트 금지(http + *.amazonaws.com → 거부). 정적 자격증명은 access/secret가 둘 다 있거나 둘 다 없어야 한다. toString()은 자격증명을 [REDACTED]로 대체한다.
S3AsyncClientFactoryTest가 이 규칙들을 이름으로 고정한다 — rejectsMissingNonPositiveAndContradictoryTimeoutPoolAndRetryPolicy, rejectsUnsafeEndpointAndPartialStaticCredentialsWithoutExposingSecrets.
29. Confirmed — provider 타입마다 신원 규칙이 다르고, 둘 다 좁다
S3ProviderBinding.compile이 AWS_S3_GENERAL_PURPOSE와 MINIO_COMMUNITY_2024_01_16을 다르게 검증한다.
| AWS | MinIO | |
|---|---|---|
| expectedOwner | 정확히 12자리 숫자 필수 | 설정하면 거부("cannot claim an AWS expected owner") |
| endpoint override | 금지("not part of the qualified profile") | 필수, canonical HTTPS만 |
| addressing | virtual-hosted 강제 |
virtual-hosted 또는 path-style |
| credentials | default-chain 허용 |
환경변수 참조 필수("MinIO requires explicit environment credential references") |
그리고 provider 타입과 무관하게 autoCreateBucket || publicAcl이면 거부한다 — "runtime provisioning and public ACLs are forbidden". 런타임이 버킷을 만들거나 공개 ACL을 붙이는 경로가 설정 단계에서 닫힌다.
30. Confirmed — mutation의 불확실성이 보존된다
S3ProviderErrorMapper.map(failure, mutation)의 첫 분기가 규칙이다. AwsServiceException이 아니면(즉 서버가 답하지 않았으면) 그리고 mutation이면 → Failure.INDETERMINATE, authoritative=false. 서버가 답한 경우에만 error code/status로 분류하고 authoritative = normalized != INDETERMINATE로 표시한다.
상태 코드 매핑의 기본값도 같은 방향이다 — 알 수 없는 status는 mutation ? INDETERMINATE : UNKNOWN. 즉 "실패했으니 재시도"라는 순진한 독법이 구조적으로 불가능하다.
S3ConditionalObjectControlStore도 같은 규칙을 제어 평면에 적용한다 — INDETERMINATE면 즉시 실패하지 않고 정확한 GET으로 실제 상태를 읽어 기대값과 비교한 뒤 결정한다. test droppedCreateResponseResolvesByExactGetAndDigestComparison과 staleWriterConflictAndCorruptControlNeverBecomeAbsence, exact404IsTheOnlyAbsentRead가 그 세 갈래를 고정한다. 마지막 이름이 특히 중요하다 — 부재로 해석되는 유일한 신호가 정확한 404다.
31. Confirmed — 논리 다이제스트와 provider 체크섬을 분리해 둘 다 대조한다
S3ChecksumPolicy가 사용자 메타데이터 키 ca-logical-sha256에 논리 SHA-256을 넣고, provider의 네이티브 checksumSHA256도 함께 요청한다. requireMatchingEvidence가 둘 다 기대값과 같은지 확인하고 하나라도 어긋나면 CONTENT_MISMATCH다.
S3ObjectEvidenceMapper.fromHead는 그 위에 세 가지를 더 요구한다 — content length가 바인딩 상한 안, serverSideEncryption == AES256, ETag 존재. 그리고 provider version id와 ETag는 HeadEvidence에 담겨 adapter-private로 남는다(S3ConditionalRequestMapper가 ifMatch/versionId 조건으로만 쓴다).
32. Confirmed — 비동기 브리지가 단일 구독·유계 버퍼·역압을 지킨다
S3AsyncRequestBodyBridge는 subscribed.compareAndSet(false, true)로 단일 구독을 강제하고, downstream 수요를 기다린 뒤에야 한 청크를 보유한다. 누적 길이가 선언 길이를 넘으면 즉시 ObjectChunkWriteException, 완료 시 관측 identity가 기대와 다르면 provider 실패다. 취소와 예산 만료를 매 청크 경계에서 확인한다.
S3AsyncResponseBodyBridge는 SDK 콜백을 논블로킹으로 두고 동기 소비자를 adapter 소유 worker에서 호출한다. S3ConditionalObjectControlStore의 BoundedControlTransformer도 누적 바이트가 64 KiB를 넘으면 중단한다.
test가 이 성질을 직접 잡는다 — streamsOnceOffTheSubscriberThreadWithOneChunkOfProducerLead, cancellationAndDigestMismatchFailWithoutProducerReplay, truncatedAndOversizedSdkChunksFailClosed.
33. Negative-space probes — sub-scope 04
- 8.1 reachability: SDK 참조를 leaf 안팎으로 전수 조사(§27). 밖은 아키텍처 test 카탈로그뿐.
- 8.2 계약 ↔ 구현: 정책의 시간 예산 상호 검증(§28), provider 타입별 신원 규칙(§29).
- 8.2b 조건부 형제: AWS와 MinIO가 같은 필드에 대해 반대 방향 규칙을 갖고 둘 다 강제됨(§29).
- 8.3 중복 mechanism: 논리 다이제스트와 provider 체크섬을 의도적으로 이중화하고 둘 다 대조(§31) — 중복이 아니라 교차 검증.
- 8.4 불확실성 보존: 비권위적 실패의 분류와 정확한 GET을 통한 해소(§30). 단일 구독·유계 버퍼(§32).
34. Sub-scope 04 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| — | 없음. SDK가 패키지에 갇혀 있고, 정책이 값이 아니라 값들 사이의 관계를 검증하며, mutation 불확실성이 분류에서 보존되고 정확한 GET으로만 해소된다 | — |
35. Sub-scope 04 완료 조건
- denominator 40 / 40 FULL_READ (
153-...OWNED FILES) — main 3,581 LOC 전수 판독 - §8.1~§8.4 네 종 probe 수행
- 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능하고 test가 각 성질을 이름으로 고정
- 소스 미변경
36. Sub-scope 05 범위와 denominator
내부 상태: COMPLETE — 25 / 25 FULL_READ 범위:
direct/**13 +multipart/**5 (main 18, 1,988 LOC) + 전용 test 7 역할: 브라우저가 provider와 직접 주고받는 bearer grant의 상태 기계와, part 원장·완료 검증
manifest와 probe: evidence/raw/154-objectstorage-direct-multipart-probes.txt.
37. 이 sub-scope의 설계 — 비밀은 durable하지 않고, 승인은 명시적으로 닫힌다
durable record가 비밀을 담지 않는다는 것이 출발점이다. DirectTransferSessionRecord의 한 줄 javadoc이 그것이다 — "Durable non-secret direct-transfer session state; bearer material is deliberately absent." 저장되는 것은 generation·제약 다이제스트·서명 시각·만료·credential revision·reference revision뿐이고, presigned URI와 서명 헤더는 process-local 캐시에만 남는다. 프로세스가 재시작하면 이미 발급된 grant는 재현되지 않고 "issued direct grant bearer material is unavailable after process restart"로 명시적으로 실패한다 — 조용히 새로 서명해서 두 번째 bearer를 만드는 대신이다.
상태 전이도 CAS로 순서가 고정된다. SESSION_RESERVED → GRANT_PREPARED → GRANT_ISSUED → DATA_UPLOADED이고, test 이름이 그 순서를 그대로 못박는다 — preparedCasPrecedesSigningAndIssuedCasPrecedesReturningTheBearerGrant. 서명 전에 prepared가 durable해야 하고, bearer를 반환하기 전에 issued가 durable해야 한다.
multipart 쪽에서 가장 흥미로운 것은 완료 시점의 admission drain이다. DirectMultipartCompletionVerifier.requireAdmissionDrained는 provider가 "controlled ingress가 비었다"고 권위 있게 말해 주지 않으면, 마지막 grant 만료 + 검증된 시계 오차 + 최대 in-flight 지평 이 지나기 전에는 완료를 거부한다. 이미 발급된 part PUT이 아직 날아가고 있을 수 있기 때문이다. test 이름이 completionHorizonRejectsWhileIssuedPartRequestsMayStillArrive와 lateGrantAdmissionRejectsAfterCompletionFence — 양방향 모두 고정돼 있다.
part 원장은 provider의 LIST 순서에 의존하지 않는다. MultipartPartLedger.ordered(n)은 1..n을 정확한 키로 하나씩 읽고 하나라도 없으면 "ledger has a gap"이다. record(...)는 같은 증거면 REPLAYED, 다른 증거면 conflict — 재시도가 원장을 다시 쓰지 못한다. 그리고 DirectMultipartCompletionVerifier.requireExactLedger가 클라이언트가 제출한 receipt token 목록과 원장을 위치까지 일치시키고 논리 길이 합계를 기대값과 대조한다.
로그 유출 차단도 한 곳에 모여 있다 — PresignedGrantRedactor.redact(URI)는 인자를 아예 쓰지 않고 [REDACTED_PRESIGNED_URI]를 돌려주며, 헤더는 키 이름만 정렬해 노출한다. test가 bearerUriAndSignedValuesAreNeverRendered로 잡는다.
38. P2 — 직접 multipart의 마지막 part는 grant를 받을 수 없다
S3ClientPolicy.requirePartSize(long partBytes, boolean finalPart)는 마지막이 아닌 part에만 5 MiB 하한을 적용한다(MINIMUM_NON_FINAL_PART_BYTES = 5 * 1024 * 1024). S3의 실제 규칙과 같다.
leaf 안에 이 정책의 호출 지점이 셋인데, 마지막 하나만 다르다.
| 호출 지점 | finalPart 인자 |
|---|---|
MultipartUploadPlan.partBytes:61 |
partNumber == partCount — 계산된 값 |
S3ManagedMultipartProvider:80 |
finalPart — 호출자가 넘긴 값 |
DirectMultipartCoordinator.createPartGrant:163 |
false 하드코딩 |
그리고 PartUploadGrantRequest에는 "이것이 마지막 part"라는 필드가 없다(operationKey · sessionId · partNumber · exactPartLength · expectedPartDigest · requestedTtl · budget · cancellation). 그러므로 coordinator가 그 사실을 알아낼 방법도 없다 — maximumParts()는 상한일 뿐 실제 part 수가 아니고, 실제 수는 completeMultipart에서 request.partTokens().size()로 비로소 정해진다.
결과. DirectMultipartUploadPort.createPartGrant는 5 MiB 미만의 part에 대해 항상 "S3 multipart part size is outside the supported range"로 거부한다. 즉
- 총 크기가 5 MiB 미만인 객체는 직접 multipart로 전혀 올릴 수 없다(part 1개 = 마지막 part).
- 고정 part 크기 + 나머지라는 통상적인 클라이언트 분할(예: 12 MiB를 5+5+2로)은 마지막 grant 요청에서 실패한다. 성공하려면 클라이언트가 모든 part를 5 MiB 이상으로 재분할해야(5+7) 하는데, 그 요구는 port 계약 어디에도 적혀 있지 않다.
같은 파일이 MultipartUploadPlan에서 마지막 part 구분을 정확히 계산하고 있으므로 규칙을 모르는 상태가 아니다. 직접 경로에서만 정보가 요청 타입에 실려 오지 않아 보수적인 상수로 대체된 것이다. 판정: P2. 수정은 PartUploadGrantRequest에 마지막 part 표시를 추가하고 그 값을 넘기는 것, 또는 start 요청에 정확한 part 수를 고정해 partNumber == exactPartCount로 유도하는 것이다.
test는 이 경계를 건드리지 않는다 — DirectMultipartCoordinatorTest의 세 케이스는 모두 5 MiB 이상 part만 쓴다. 반대로 S3SdkApiCharacterizationTest:93-94는 requirePartSize(MIN, false)와 requirePartSize(MIN - 1, true) 둘 다 통과함을 확인한다 — 정책 자체는 옳고, 직접 경로의 호출만 어긋나 있다는 것을 이 test가 오히려 증명한다.
39. P2 — 서명된 grant의 endpoint 검증이 upload 경로에만 있다
DirectTransferPolicy의 host allowlist는 이 sub-scope의 유일한 endpoint 방어다. 그런데 실제 provider가 서명해 돌려준 URI를 그 allowlist에 대조하는 호출은 한 곳뿐이다.
// createUploadGrant
DirectGrantProvider.DirectGrantMaterial material = provider.signUpload(current.session());
policy.validateSignedGrant(material.requestUri(), current.session().expiresAt()); // ← 검증
// createDownloadGrant
DirectGrantProvider.DirectGrantMaterial material = provider.signDownload(prepared.session(), published);
// ← 대응하는 validateSignedGrant 없음
// createPartGrant (DirectMultipartCoordinator)
DirectGrantProvider.DirectGrantMaterial material = provider.signPart(session, grant.record());
// ← 대응하는 validateSignedGrant 없음. 이 coordinator는 DirectTransferPolicy를 아예 갖고 있지 않다
planGrant(...)도 validateEndpoint를 부르지만, 두 호출자 모두 policy.planningEndpoint()를 넘긴다. 그 메서드는 "https://" + allowedHosts 중 사전순 첫 host를 조립한 값이므로 정의상 항상 통과한다. 결국 allowlist가 실제 URI에 대해 힘을 갖는 지점은 upload 경로 한 곳뿐이고, 브라우저에 그대로 건네지는 download bearer와 multipart part bearer는 host·scheme·userinfo 검사를 통과하지 않는다.
덧붙여 validateSignedGrant(URI, Instant expectedExpiry)는 expectedExpiry를 null 검사만 하고 쓰지 않는다. 서명 재료(DirectGrantMaterial.expiresAt())와 세션이 선언한 만료가 어긋나도 걸리지 않으며, 호출자는 세션 쪽 만료를 응답에 실어 보낸다. 즉 "서명된 grant를 검증한다"는 이름이 실제로는 host 검사 하나다.
판정: P2. 현재 노출은 없다(§40: 미배선). 그러나 이 세 경로는 모두 같은 종류의 값 — 브라우저에 넘길 bearer URI — 를 다루는 형제이고, 방어가 하나에만 있다. 수정은 validateSignedGrant를 세 경로 모두에서 호출하고(그러려면 DirectMultipartCoordinator도 policy를 받아야 한다), expectedExpiry를 실제로 material.expiresAt()과 대조하는 것이다.
40. Confirmed — 직접 전송 subsystem은 미배선이고, README가 그 사실을 정확히 적는다
DirectTransferCoordinator·DirectMultipartCoordinator·DirectTransferPolicy를 이름으로 부르는 곳은 direct/** 패키지 밖에 하나도 없다(154-... §8.1b, 매치 0). DirectObjectUploadPort·DirectObjectDownloadGrantPort·DirectMultipartUploadPort 세 application port는 production 구현이 등록되지 않는다.
README가 이것을 두 문장으로 선언한다 — "Direct transfer, multipart, quarantine, retention, and production reconciliation cards remain R0."와 "no direct-grant port is registered." 이 leaf에서 반복해 확인한 정직함이고, 미배선 자체는 결함이 아니다.
41. P2 — 그러나 R0 경계가 문서에만 있고 compile 경로에서 닫히지 않는다
미배선이 선언돼 있는데도, 설정은 그 capability를 계속 받아들이고 런타임은 자격증명을 쥔 presigner를 실제로 만든다.
ObjectCapabilityRequirement.DIRECT_UPLOAD/DIRECT_MULTIPART는 destination 요구사항으로 선언 가능하고,ObjectStorageBindingCompiler:206-207이 그것을 provider capability로 번역한다.S3ProviderBinding.compileProfiles는 MinIO에 대해서만 이 두 capability 주장을 거부한다("the exact MinIO release cannot claim native conditional managed mutation support"). AWS profile에는 대응하는 거부가 없다.- 그러면
S3ObjectStorageProviderContribution:112-150이directUploadEnabled || directMultipartEnabled일 때presignerFactory.apply(clientPolicy)로S3Presigner를 할당하고S3DirectTransferProvider/S3DirectMultipartProvider를 만들어SelectedObjectStorageProviderFactory에 넣는다. - 그리고 그 둘을 factory에서 꺼내 가는 코드가 없다(
154-...§8.1c, 매치 0).
즉 AWS binding에서 direct-upload를 요구하는 배포는 — startup을 통과하고, presigner를 할당하고, 두 provider를 조립하고, 직접 전송 port는 여전히 하나도 얻지 못한다. README가 말한 R0는 사실이지만, 그 사실을 강제하는 것은 문서뿐이다.
이 leaf 안에 정반대의 사례가 있어서 대비가 분명하다 — filesystem-local-dev는 prod/production 프로파일에서 compile 시점에 거부되고, MinIO의 capability 과대 주장도 compile 시점에 거부된다. 같은 파일이 같은 종류의 "이 조합은 자격이 없다"를 AWS + DIRECT_* 에 대해서만 하지 않는다.
판정: P2. 데이터 위험은 없다 — 없는 port는 호출될 수 없다. 위험은 (a) 운영자가 켰다고 믿는 기능이 없다는 것과 (b) 아무도 쓰지 않는 서명 자격증명 핸들이 프로세스 수명 동안 살아 있다는 것이다. 수정은 셋 중 하나다: coordinator를 조건부로 조립하거나, R0인 동안 DIRECT_UPLOAD/DIRECT_MULTIPART 요구를 compile 단계에서 provider 종류와 무관하게 거부하거나, capability가 켜져도 presigner를 만들지 않도록 조립을 뒤로 미루거나.
42. P3/기록 — 선언만 되고 강제되지 않는 정책 항목
DirectTransferPolicy.maximumOutstandingGenerations. 생성자가 1..16 범위를 검사하지만 읽는 곳이 없다(154-...§8.4, 매치 2 = 선언과 검증뿐).DirectTransferSessionRecord.grantGeneration도 항상 1이다 —planGrant가new DirectGrantGeneration(1, ...)로 고정하고 generation을 올리는 경로가 없다. "outstanding generation을 몇 개까지 허용한다"는 정책이 표현돼 있으나 generation 자체가 재발급되지 않으므로 지금은 의미를 갖지 않는다.DirectTransferCorsPolicy. "infrastructure must apply before direct admission"이라고 적혀 있으나 production 소비자가 없고, 이 값을 어디에 어떻게 반영해야 하는지 알려 주는 배선도 없다. 유일한 참조는DirectTransferCorsContractTest다. 브라우저 계약을 코드로 고정해 둔 것 자체는 유용하나, 적용 주체가 코드 밖(인프라)이라는 사실은 README에 없다.
43. Negative-space probes — sub-scope 05
- 8.1 reachability: coordinator·policy 모두 패키지 밖 참조 0(§40). README가 그 상태를 선언(§40). 그러나 capability는 설정에서 살아 있고 presigner는 실제로 할당된다(§41).
- 8.2 조건부 형제 ①:
requirePartSize의 세 호출 지점 중 하나만finalPart를 하드코딩(§38). - 8.2b 조건부 형제 ②:
validateSignedGrant가 upload 경로에만 있고 download·part 경로에는 없다(§39). - 8.2c 조건부 형제 ③:
S3ProviderBinding이 DIRECT_* 주장을 MinIO에서만 거부(§41). - 8.3 중복 mechanism: 두 coordinator가 각자 독립된 process-local
ConcurrentHashMapbearer 캐시를 갖는다. 둘 다 경계가 없고(무한 증가 가능) 만료된 항목을 청소하지 않으며, 제거는 성공적인 완료/확인 경로에서만 일어난다. 발급 후 완료되지 않은 세션의 재료는 프로세스 수명 동안 남는다 — 미배선이므로 지금 노출은 없고, 두 곳이 같은 방식으로 같은 성질을 갖는다는 점에서 우연이 아니라 공통 설계다. P3/기록. - 8.4 문서/선언 drift: 강제되지 않는 정책 항목 2종(§42).
44. Sub-scope 05 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P2 | DirectMultipartCoordinator:163이 requirePartSize(..., false)를 하드코딩하고 PartUploadGrantRequest에 마지막 part 표시가 없어, 5 MiB 미만 part에 grant를 발급할 수 없다 — 총 5 MiB 미만 객체는 직접 multipart 불가 |
현재 미배선; 배선 시 모든 통상적 클라이언트 분할 |
| P2 | 서명된 bearer URI의 host allowlist 검증이 upload 경로에만 있고 download·multipart part 경로에는 없다. validateSignedGrant의 expectedExpiry는 검사되지 않는다 |
현재 미배선; 배선 시 브라우저로 나가는 두 bearer |
| P2 | AWS binding에서 DIRECT_UPLOAD/DIRECT_MULTIPART 요구가 compile을 통과해 presigner를 할당하지만, 그것을 쓰는 port가 없다. MinIO에는 같은 주장을 막는 검사가 있다 |
AWS provider + direct capability 요구 배포 |
| P3/기록 | maximumOutstandingGenerations가 검증만 되고 읽히지 않으며 grant generation은 항상 1이다 |
정책/조립 |
| P3/기록 | DirectTransferCorsPolicy는 production 소비자 0이고, 적용 주체가 인프라라는 사실이 README에 없다 |
문서 |
| P3/기록 | 두 coordinator의 process-local bearer 캐시가 무경계·무만료이며, 미완료 세션의 재료는 프로세스 수명 동안 남는다 | 배선 시 |
45. Sub-scope 05 완료 조건
- denominator 25 / 25 FULL_READ (
154-...OWNED FILES) - §8.1~§8.4 네 종 probe 수행, 조건부 형제 비교는 3건 독립 수행
- 세 finding 모두 정적으로 결정 가능(호출 인자 상수 · 호출 부재 · 조립 경로)하여 실행 probe 불필요
- 소스 미변경
46. Sub-scope 06 범위와 denominator
내부 상태: COMPLETE — 43 / 43 FULL_READ 범위:
filesystem/**6 +maintenance/**8 +readiness/**8 +provider/**4 + 루트 4 (main 30, 2,420 LOC) + test 12 + resource 1 역할: local-dev provider, legacy 채택(adoption) 경로, readiness 증거 타입, provider-neutral 계약, 그리고 deprecated 루트 어댑터
manifest와 probe: evidence/raw/155-objectstorage-platform-readiness-probes.txt.
47. §6의 forward reference 해소 — readiness 레지스트리는 실재하고 test가 강제한다
sub-scope 01에서 build.gradle:46이 docs/registries/object-storage-readiness.yaml을 test 태스크의 입력으로 선언하는데 leaf 소스에 그 파일명을 부르는 곳이 없다는 점을 미해결로 남겼다. 해소한다.
파일은 저장소 루트에 실재한다(docs/registries/object-storage-readiness.yaml). build.gradle은 파일명을 시스템 프로퍼티 objectstorage.readiness.registry로 전달하고, leaf test ObjectStorageReadinessRegistryTest:131이 그 프로퍼티를 읽어 YAML을 파싱한다. 그래서 소스 grep에 파일명이 잡히지 않았다.
그 test가 하는 일이 이 sub-scope에서 가장 강한 governance 장치다.
- 레지스트리 키가
schema_version과claims둘뿐임을 확인하고, - claim의 card id 집합이
ObjectStorageCapabilityCard.cardIds()(고정된 9종)와 정확히 일치함을 확인하고, - 각 claim에
validate(knownProviders, availableTasks)를 돌려 provider·필수 태스크·한계 문구·R2/R3 만료를 검사하고, - R1인 카드가 정확히 두 개(
managed-upload-single,managed-download)임을 확인하고, - direct 3종 · quarantine · retention · reconciliation 여섯 카드가 모두 R0임을 확인한다.
README가 산문으로 적은 "Direct transfer, multipart, quarantine, retention, and production reconciliation cards remain R0"가 여기서 기계 검사가 된다. YAML 파일 자신도 헤더 주석에 소유 test 두 개(ContractRegistrySchemaGovernanceTest = 저장소 소유, ObjectStorageReadinessRegistryTest = 의미 소유)를 적어 둔다.
ObjectStorageCapabilityEvidence.validate의 개별 규칙도 좁다 — filesystem-local-dev는 R2/R3를 주장할 수 없고, requiredTasks는 비어 있을 수 없으며 전부 실재하는 태스크여야 하고, limitations도 비어 있을 수 없다. "한계를 적지 않은 readiness 주장"이 표현 불가능하다.
48. §41 보강 — 레지스트리는 문서 주장을 얼어붙히지만 런타임 설정 경로는 덮지 않는다
§41에서 "R0 경계가 문서에만 있다"고 적었다. §47을 반영해 정확히 다시 말한다.
R0 경계는 문서 주장에 대해서는 기계 검사된다(§47). 그러나 그 검사의 대상은 docs/registries/object-storage-readiness.yaml이고, KNOWN_PROVIDERS는 filesystem-local-dev 하나다. 운영자가 app.object-storage 설정에 AWS provider용 qualification profile을 쓰면서 DIRECT_UPLOAD capability를 주장하는 경로는 이 레지스트리를 거치지 않는다. S3ProviderBinding.compileProfiles가 그 주장을 MinIO에 대해서만 거부하므로, AWS + DIRECT_* 조합은 여전히 compile을 통과하고 presigner를 할당한다(§41).
따라서 §41의 판정은 유지되고 오히려 선명해진다 — 이 저장소에는 "이 카드는 R0"를 강제하는 장치가 이미 있는데, 런타임 설정 경로가 그 장치의 사정권 밖에 있다.
49. P2 — APPLY를 켜는 설정은 있고, 승인을 검증하는 bean은 없다
legacy 채택은 이 leaf에서 가장 권한이 센 동작이다. 원시 locator로 legacy 네임스페이스를 읽어 관리 네임스페이스에 발행한다. 그래서 설계가 detached 2인 승인을 요구한다 — Ed25519LegacyAdoptionApprovalVerifier가 서로 다른 두 승인자의 Ed25519 서명을 검증하고, 문서의 destination/epoch/operationId/manifest·네임스페이스 다이제스트가 요청과 일치하는지, 유효창이 최대 7일 안인지까지 본다. 구현은 촘촘하다 — LegacyAdoptionApprovalCodec.decode는 길이 프레이밍 이진 코덱이고 decode 후 재인코딩해 원본 바이트와 같아야만 통과한다(비정규 인코딩 거부).
그런데 조립이 비대칭이다.
| 켜는 방법 | bean | |
|---|---|---|
| APPLY 실행 경로 | app.object-storage.legacy-adoption.enabled=true, mode=APPLY |
ObjectStorageLegacyMigrationConfig:30이 LegacyObjectAdoptionPort를 만든다 |
| 승인 검증 | 없음 | 없음 — Ed25519LegacyAdoptionApprovalVerifier를 main에서 생성하는 코드가 저장소 전체에 0(155-... §8.1) |
LegacyObjectAdoptionSettings는 enabled·mode·reportPath·reviewedManifestPath·reviewedManifestSha256·batchSize·operationTimeout을 노출하고, APPLY일 때 reviewed manifest 경로와 sha256을 요구한다. 그러나 신뢰 승인자 키, 키 id, 승인 유효기간에 해당하는 설정 항목이 하나도 없다. 검증기를 켤 방법이 설정에 없다.
adapter 쪽 LegacyObjectAdoptionService.requireApproval은 request.approval() 객체의 필드 동등성만 검사한다 — operationKey · manifest · 네임스페이스 다이제스트 · destination · 유효창. 서명은 보지 않는다. 그리고 LegacyObjectAdoptionApproval은 application-core의 공개 생성자를 가진 값 타입이다(검증기 자신이 new로 만든다).
의도된 조립은 sample-portfolio가 보여 준다 — AdoptLegacyPosterImageUseCase.authorizeApply가 호출자가 준 approval을 버리고 approvals.verify(document, request)의 결과로 요청을 다시 만든다. 즉 "application에서 검증하고 adapter에서 적용한다"가 설계다. 그 use case 역시 어디에도 배선돼 있지 않다.
판정: P2. 지금 우회가 열려 있는 것은 아니다 — 검증기 bean이 없으면 AdoptLegacyPosterImageUseCase를 쓰는 fork의 컨텍스트는 시작에 실패하고, 그것은 조용하지 않은 실패다. 위험은 다른 쪽이다: 설정 한 줄로 켜지는 절반과 손으로 배선해야 하는 절반이 있고, 켜지는 쪽이 권한이 센 쪽이다. enabled=true, mode=APPLY를 켠 fork가 use case를 쓰지 않고 port를 직접 부르면 adapter의 검사는 필드 동등성뿐이다. 수정은 ObjectStorageLegacyMigrationConfig가 승인자 키를 설정에서 읽어 Ed25519LegacyAdoptionApprovalVerifier bean도 함께 만들되, APPLY 모드에서 그 bean이 없으면 startup을 거부하는 것이다.
LegacyObjectAdoptionServiceTest에 test가 하나뿐이고 그것이 reportOnlyInspectsExactEvidenceAndPerformsNoMutation이라는 점도 같은 방향을 가리킨다 — APPLY 경로에는 서비스 수준 test가 없다. 검증기 자체는 LegacyAdoptionApprovalVerifierTest가 두 케이스(verifiesCanonicalExactBindingWithTwoDistinctTrustedApprovers, rejectsDuplicateApproverAndAnyBindingTamper)로 덮는다.
50. P3 — nonce replay 경계가 결과를 읽고 버린다
LegacyAdoptionApprovalReplayStore.ClaimResult claim = replayStore.claim(replay);
var published = publications.publish(request.publicationRequest(), inspected.producer());
if (claim != ClaimResult.TERMINAL_REPLAY) { replayStore.markTerminal(...); }
ClaimResult는 CLAIMED / EXACT_REPLAY / TERMINAL_REPLAY 셋인데, 어느 값이든 발행은 그대로 진행된다. claim 결과가 바꾸는 것은 terminal 기록을 쓸지 여부뿐이다. 인터페이스 javadoc은 자신을 "Durable compare-and-set nonce replay boundary"라고 부르지만, 경계로서 무엇도 막지 않는다.
실제 피해는 제한적이다 — 발행이 operation-key 기반 멱등이므로 이미 소진된 nonce로 다시 들어와도 결과는 REPLAYED이고 두 번째 객체가 생기지 않는다. 그래서 P3다. 그러나 (a) 이름이 약속하는 것과 다르고, (b) markTerminal에 넘기는 expectedRevision이 항상 replay.revision() = 0이라 CAS 인자로서도 고정값이며, (c) 승인 문서의 nonce가 "한 번만 쓰인다"는 성질은 이 코드로는 보장되지 않는다. 수정은 TERMINAL_REPLAY에서 발행 전에 거부하는 것이다.
51. Confirmed — local-dev provider의 경로 방어와 publication
LocalObjectPathGuard는 이 저장소에서 반복해 본 강한 형태다 — root 정규화 + startsWith 봉쇄 + root 자신 거부에 더해, 부모 경로를 root부터 한 세그먼트씩 내려가며 심링크와 비디렉터리를 거부하고(createParentsWithoutLinks / rejectExistingLinks), 대상 자신도 심링크면 거부한다. control key는 control/v1/ 접두사 + [a-z0-9._/-]+ + //·/./·/../ 금지 + 세그먼트별 재검사다. 그리고 control 레코드는 물리 파일명에 .record를 붙인다 — 객체 저장소가 허용하는 reference와 reference/lifecycle 쌍이 파일시스템에서 파일/디렉터리 충돌을 일으키지 않도록. 논리 키는 그대로 유지된다.
발행은 배타적 하드링크다. LocalDevObjectDataStore.create가 임시 파일에 쓰고 channel.force(true) 후 Files.createLink(target, temporary)를 하며, FileAlreadyExistsException을 CONFLICT로, UnsupportedOperationException을 "local filesystem cannot prove immutable create"로 번역한다 — 하드링크를 지원하지 않는 파일시스템에서 조용히 약한 방식으로 내려가지 않는다. 앞선 Files.exists(NOFOLLOW) 검사는 빠른 경로일 뿐이고 배타성은 createLink가 준다. POSIX면 소유자 읽기 전용 권한을 씌운다.
test가 이것들을 이름으로 잡는다 — traversalAbsoluteUnicodePercentAndSymlinkEscapesAreRejected, exclusiveCreateRaceHasOneWinner, injectedDiskFailureLeavesNoFinalOrTemporaryData, restartInspectsCommittedDataWithoutReplayingProducer, corruptControlRecordRemainsPresentAndNeverAppearsAbsent, createsRestrictivePermissionsWherePosixIsSupported. 마지막에서 두 번째가 특히 이 leaf의 규칙이다 — 손상된 control 레코드는 부재로 보이지 않는다.
LocalDevObjectControlStore도 자신의 한계를 클래스 javadoc에 적는다 — "Single-process local create/CAS store; it deliberately does not claim multi-node linearizability." 그리고 descriptor의 capability 표가 MULTI_NODE_LINEARIZABLE_CAS와 POWER_LOSS_DURABILITY를 UNSUPPORTED로 명시한다. ObjectStorageProviderContract:184의 unsupportedOptionalCapabilitiesAreDeclaredRatherThanSkipped가 그 선언을 강제한다 — 지원하지 않는 것을 test에서 건너뛰는 대신 선언하게 만든다.
52. P3/기록 — 같은 capability 표가 두 벌 있다
filesystem-local-dev의 capability 표가 두 곳에 독립적으로 하드코딩돼 있다.
FilesystemLocalDevProviderContribution.capabilitySupport()—describe(settings)가 쓰는 것. sub-scope 01에서 확인했듯 이것이 compile 시점 권위다.LocalDevObjectStorageProvider.capabilitySupport()— 런타임descriptor()가 쓰는 것.
현재 둘은 같다(각각 Support.SUPPORTED 6개, 같은 여섯 capability). 그러나 공유하는 상수도, 한쪽이 다른 쪽을 부르는 구조도 없다. 한쪽만 고치면 compile이 허용한 capability와 런타임이 주장하는 capability가 갈라지고, 그 불일치를 잡는 test는 없다. P3/기록. 수정은 표를 한 곳에 두고 양쪽이 그것을 부르는 것이다.
53. P3/기록 — deprecated 루트 어댑터에는 형제에게 있는 방어가 없다
루트의 네 파일(ObjectStorageSettings · ObjectStorageConfig · FilesystemObjectStorageAdapter · S3ObjectStorageAdapter)은 README가 "deprecated compatibility only... never back the new semantic ports"로 선언한 경로이고, LegacyObjectStorageActivationGuard가 명시적 opt-in을 요구하며 canonical 네임스페이스와의 혼용을 예외로 막는다. 그 선까지는 규율이 있다.
그 아래에서는 canonical 경로에 있는 방어가 하나씩 없다.
| canonical | legacy 루트 | |
|---|---|---|
autoCreateBucket |
S3ProviderBinding:268이 autoCreateBucket || publicAcl을 거부 |
ObjectStorageSettings:46 기본값 true, startup에서 실제로 버킷 생성 |
| 평문 엔드포인트 | AWS 도메인 + http 거부 |
기본값 http://localhost:9000 |
| 심링크 | LocalObjectPathGuard가 root·부모·대상 전부 거부 |
FilesystemObjectStorageAdapter.resolve는 normalize() + startsWith만 — 심링크 검사 0 |
| production 프로파일 | filesystem-local-dev를 prod/production에서 거부 |
LegacyObjectStorageActivationGuard에 프로파일 검사 없음 |
LegacyObjectStorageConfigTest.autoCreateBucketTrueProvisionsTheBucketWhileTheS3BeanIsCreated가 그 기본값을 의도된 동작으로 고정하고 있으므로 우발적 잔재는 아니다. 그래서 P3/기록이다 — deprecated 경로가 자신의 과거 의미를 보존하는 것은 정당하고, 위험한 것은 그 경로가 production 프로파일에서 아무 제지 없이 켜진다는 점 하나다. FilesystemObjectStorageAdapter의 심링크 미검사는 baseDir 안에 심링크를 심을 수 있는 로컬 접근을 전제하므로 노출이 좁다.
54. Negative-space probes — sub-scope 06
- 8.1 reachability:
maintenance/**는 설정 flag로 배선된다(§49).readiness/**의 세 타입은 leaf test가 소비한다(§47) — 앞선 grep이 test 패키지까지 제외해 0으로 보였던 것을 바로잡았다. - 8.1b forward reference 해소: build.gradle → 시스템 프로퍼티 → leaf test → 루트 YAML(§47).
- 8.2 조건부 형제 ①: 켜지는 절반과 손배선 절반의 비대칭(§49).
- 8.2b 조건부 형제 ②: legacy 루트 vs canonical의 네 가지 방어 차이(§53).
- 8.3 중복 mechanism: capability 표 두 벌(§52).
- 8.4 선언 대비 강제: nonce replay 경계가 결과를 쓰지 않음(§50). readiness 주장의 한계 문구 강제(§47).
55. Sub-scope 06 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P2 | legacy-adoption.enabled=true, mode=APPLY는 설정으로 켜지는데 Ed25519LegacyAdoptionApprovalVerifier bean은 저장소 어디에도 없고 승인자 키 설정 항목도 없다. adapter의 승인 검사는 필드 동등성뿐 |
APPLY를 켜고 port를 직접 부르는 fork |
| P3 | LegacyAdoptionApprovalReplayStore.claim 결과가 발행을 막지 않는다 — TERMINAL_REPLAY도 그대로 발행 |
APPLY 경로 |
| P3/기록 | filesystem-local-dev capability 표가 contribution과 provider에 두 벌로 하드코딩 |
표가 갈라질 때 |
| P3/기록 | deprecated 루트 경로에 production 프로파일 검사가 없고 autoCreateBucket 기본값이 true, 평문 엔드포인트가 기본값, 심링크 검사 없음 |
legacy opt-in 배포 |
56. Sub-scope 06 완료 조건
- denominator 43 / 43 FULL_READ (
155-...OWNED FILES: main 30 / test+resource 13) - §8.1~§8.4 probe 수행, 조건부 형제 비교 2건, 중복 mechanism 1건
- §6의 readiness 레지스트리 forward reference 해소(§47) 및 그에 따른 §41 보강(§48)
- 소스 미변경
57. Sub-scope 07 범위와 denominator
내부 상태: COMPLETE — 6 / 6 FULL_READ 범위: 전용 qualification source set 3종 6파일 (546 LOC) 역할: 실제 provider를 상대로 "무엇이 되고 무엇이 안 되는지"를 증거로 고정하는 lane
manifest와 probe: evidence/raw/156-objectstorage-qualification-lanes-probes.txt.
| source set | 파일 | 성격 |
|---|---|---|
objectStorageMinioContractTest |
MinioManagedObjectContractTest 291 · MinioDirectTransferContractTest 25 |
전자는 실제 MinIO 컨테이너 기동 |
objectStorageMinioFaultTest |
MinioManagedObjectFaultTest 147 · MinioDirectTransferFaultTest 32 |
전자는 MinIO + Toxiproxy |
objectStorageAwsQualificationTest |
AwsS3ManagedCommonSubsetQualificationTest 27 · AwsS3DirectTransferQualificationTest 24 |
환경변수 권한 검사만 |
세 lane 모두 registerStrictQualificationTest로 등록되고 requiredClasses로 클래스 이름이 고정된다 — 클래스를 지우거나 이름을 바꾸면 lane 등록이 실패한다. 설명 문구도 "non-skipping"을 명시한다. 즉 "Docker 없으면 조용히 skip"이 이 lane들에는 없다.
58. Confirmed — MinIO의 조건부 create가 작동하지 않는다는 것을 실측으로 증명한다
MinioManagedObjectContractTest가 digest로 고정된 MinIO 이미지를 띄우고 다음을 순서대로 확인한다.
PutObject+If-None-Match: *+ SHA-256 체크섬 + AES256 → 성공.- 같은 키에 다시
PutObject+If-None-Match: *→ 또 성공하고 내용이 덮인다. 변수 이름이 결론이다:overwrittenDespiteCreateOnlyCondition. HeadObject로 길이·ca-logical-sha256메타데이터·SSE가 보존됨을 확인,GetObject+If-Match+Range로 부분 읽기가 정확함을 확인.- control 키에 대해서도 같은 일이 일어남을 확인(
overwrittenControlCreate). - 그러나 stale
If-Match는 HTTP 412로 정확히 거부됨을 확인. - 두 번째 test에서 low-level multipart가 동작하되
CompleteMultipartUpload+If-None-Match: *가 기존 객체를 덮는다는 것을 확인.
이 여섯 줄이 sub-scope 04에서 본 S3ProviderBinding의 MinIO 거부 규칙 — "the exact MinIO release cannot claim native conditional managed mutation support" — 의 근거다. 즉 코드의 거부가 의견이 아니라 이 lane의 실측 결과다.
결과는 src/test/resources/object-storage/minio-provider-evidence.json에 고정돼 있고, 그 한계 문구가 그대로 사람이 읽을 문장이다 — "PutObject If-None-Match was accepted and overwrote an existing object", "CompleteMultipartUpload accepted If-None-Match despite a pre-existing target and overwrote it", 그리고 범위를 좁히는 세 줄("local single-node container evidence only", "not AWS evidence", "not production TLS or deployment-topology evidence").
MinioManagedObjectFaultTest는 Toxiproxy로 양방향 대역폭을 0으로 만들고, 실패가 5초 안에 유계로 발생하는지와 복구 후 같은 객체가 그대로인지를 본다 — connectionCutProducesABoundedFailureAndRecoveryWithoutMutationReplay. 컨테이너 두 개가 모두 try-with-resources 안에 있고 @SuppressWarnings("resource")에 그 이유가 주석으로 적혀 있다.
MinioDirectTransfer* 두 파일이 provider를 부르지 않는 것도 근거가 있다 — javadoc이 "No bearer or multipart mutation is attempted because qualification proved that create-only PUT and create-only multipart completion are ignored by this provider identity." 즉 이미 증명된 결과를 근거로 mutation 행렬에 들어가지 않겠다는 선언이고, 두 파일은 그 결정을 evidence 파일에 대한 assertion으로 얼려 둔다.
59. P3/기록 — AWS lane은 환경변수만 검사하고 통과한다
AwsS3ManagedCommonSubsetQualificationTest와 AwsS3DirectTransferQualificationTest의 본문은 전부 다음 형태다.
Map<String, String> environment = System.getenv();
assertThat(environment.get("OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED")).isEqualTo("true");
assertThat(environment.get("OBJECT_STORAGE_AWS_BUCKET")).isNotBlank();
assertThat(environment.get("OBJECT_STORAGE_AWS_REGION")).isNotBlank();
assertThat(environment.get("OBJECT_STORAGE_AWS_EXPECTED_OWNER")).matches("[0-9]{12}");
AWS를 호출하는 코드는 한 줄도 없다. 권한이 없으면 실패한다는 점에서 fail-closed이지만, 권한이 있어도 아무것도 증명하지 않는다. lane 이름은 objectStorageAwsQualificationTest이고 설명은 "Runs only with explicit protected AWS sandbox authority and exact inputs"이므로, CI에서 이 lane이 초록으로 통과한 것을 AWS가 자격 검증되었다는 증거로 읽을 여지가 있다.
지금 실제 위험은 낮다 — readiness 레지스트리의 KNOWN_PROVIDERS는 filesystem-local-dev 하나뿐이라 AWS 주장 row 자체가 없고(§47), README도 "It does not prove … production credentials/TLS/IAM/encryption, S3 response-loss behavior, or R2 readiness"라고 적는다. 그래서 P3/기록이다. 다만 CapabilityEvidenceSource.CI_QUALIFICATION이라는 값이 존재하고 §41에서 본 대로 AWS profile은 capability를 주장할 수 있으므로, fork가 이 초록 lane을 근거로 삼는 경로가 구조적으로 열려 있다. 수정은 lane 본문을 실제 sandbox 호출로 채우거나, 채우기 전까지 클래스/lane 이름에 "authority-gate"임이 드러나게 하는 것이다.
60. P3/기록 — provider 신원 문자열이 세 곳에 독립적으로 적혀 있다
정확한 MinIO 신원이 세 곳에 문자열로 존재한다.
| 위치 | 값 |
|---|---|
S3ProviderType.MINIO_COMMUNITY_2024_01_16:8 |
s3-compatible-minio-community-release-2024-01-16t16-07-38z |
S3ProviderVersion:7 |
release-2024-01-16t16-07-38z-sdk-2.30.0 |
minio-provider-evidence.json:3-4 |
위 두 값과 정확히 동일 |
컨테이너 이미지 digest도 세 곳(contract test, fault test, evidence JSON)에 같은 값으로 적혀 있고 현재 모두 일치한다. 그러나 이 일치를 검사하는 test는 없다 — evidence JSON에 대한 assertion은 status와 profile 이름에 대한 부분 문자열 확인뿐이다(§58). production 바인딩이 인정하는 신원과 자격 검증이 실제로 돌아간 신원이 갈라져도 아무도 알려주지 않는다. P3/기록. 수정은 evidence JSON을 파싱해 S3ProviderType/S3ProviderVersion의 canonical 값 및 lane의 이미지 digest와 대조하는 assertion 하나를 추가하는 것이다.
61. Negative-space probes — sub-scope 07
- 8.1 lane 등록: 세 lane 모두 strict·non-skipping·
requiredClasses고정(§57). - 8.2 조건부 형제: 여섯 파일 중 provider를 실제로 부르는 것은 둘(
MinioManagedObject*). 나머지 넷 중 둘은 근거 있는 freeze(§58), 둘은 근거 없는 통과(§59). - 8.3 중복 신원: 신원 문자열·이미지 digest 3중 기재, 교차 검사 없음(§60).
- 8.4 증거의 자기 한정: evidence JSON의
limitations7줄이 범위를 스스로 좁힌다(§58).
62. Sub-scope 07 완료 조건
- denominator 6 / 6 FULL_READ (
156-...OWNED FILES) - §8.1~§8.4 probe 수행
- lane 실행은 Docker와 보호된 AWS sandbox 권한이 필요하므로 이 분석에서 실행하지 않음 — 대신 lane이 무엇을 주장하고 그 주장이 어디에 소비되는지를 정적으로 추적
- 소스 미변경
63. 모듈 ledger 정합
| # | 범위 | main | test | 기타 | 합 | FULL_READ | probe |
|---|---|---|---|---|---|---|---|
| 1 | governance + config/** |
19 | 5 | 4 | 28 | 28 | 149, 150 |
| 2 | control/** |
24 | 1 | – | 25 | 25 | 151 |
| 3 | kernel/** + codec/** |
30 | 9 | – | 39 | 39 | 152 |
| 4 | s3/** |
26 | 14 | – | 40 | 40 | 153 |
| 5 | direct/** + multipart/** |
18 | 7 | – | 25 | 25 | 154 |
| 6 | filesystem/**+maintenance/**+readiness/**+provider/**+루트 |
30 | 12 | 1 | 43 | 43 | 155 |
| 7 | qualification source set 3종 | – | – | 6 | 6 | 6 | 156 |
| TOTAL | 147 | 48 | 11 | 206 | 206 | 7 / 7 |
coverage ledger: FULL_READ 206 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / 미분류 0.
64. 모듈 findings
| # | 우선순위 | finding | 위치 | reachability |
|---|---|---|---|---|
| 1 | P2 | 직접 multipart의 마지막 part에 grant를 발급할 수 없다 — requirePartSize(..., false) 하드코딩 + 요청 타입에 마지막 part 표시 없음 |
§38 | 미배선; 배선 시 통상적 클라이언트 분할 전부 |
| 2 | P2 | 서명된 bearer URI의 allowlist 검증이 upload 경로에만 있고 download·multipart part 경로에는 없다. expectedExpiry는 검사되지 않는다 |
§39 | 미배선; 배선 시 브라우저로 나가는 두 bearer |
| 3 | P2 | AWS binding에서 DIRECT_UPLOAD/DIRECT_MULTIPART 요구가 compile을 통과해 presigner를 할당하지만 그것을 쓰는 port가 없다. MinIO에는 같은 주장을 막는 검사가 있다 |
§41, §48 | AWS provider + direct capability 요구 배포 |
| 4 | P2 | legacy-adoption.enabled=true, mode=APPLY는 설정으로 켜지는데 Ed25519 승인 검증기 bean과 승인자 키 설정이 없다 — adapter의 승인 검사는 필드 동등성뿐 |
§49 | APPLY를 켜고 port를 직접 부르는 fork |
| 5 | P3 | filesystem-local-dev production 거부가 prod/production 두 리터럴에만 걸려 있다 |
§5 | 프로파일 이름을 바꾼 fork |
| 6 | P3 | nonce replay 경계가 ClaimResult를 읽고 버린다 — TERMINAL_REPLAY도 그대로 발행 |
§50 | APPLY 경로 |
| 7 | P3/기록 | markEffectSent·markResponseLost가 updatedAt을 전진시키지 않는다 |
§22 | 응답 유실 조사 |
| 8 | P3/기록 | maximumOutstandingGenerations가 검증만 되고 읽히지 않으며 grant generation은 항상 1 |
§42 | 정책/조립 |
| 9 | P3/기록 | DirectTransferCorsPolicy의 production 소비자 0, 적용 주체가 인프라라는 사실이 README에 없음 |
§42 | 문서 |
| 10 | P3/기록 | 두 coordinator의 process-local bearer 캐시가 무경계·무만료 | §43 | 배선 시 |
| 11 | P3/기록 | filesystem-local-dev capability 표가 두 벌로 하드코딩 |
§52 | 표가 갈라질 때 |
| 12 | P3/기록 | deprecated 루트 경로에 production 프로파일 검사 없음, autoCreateBucket 기본 true, 평문 엔드포인트 기본, 심링크 검사 없음 |
§53 | legacy opt-in 배포 |
| 13 | P3/기록 | AWS qualification lane이 환경변수만 검사하고 통과한다 | §59 | 초록 lane을 증거로 삼는 fork |
| 14 | P3/기록 | provider 신원 문자열·이미지 digest가 세 곳에 독립 기재, 교차 검사 없음 | §60 | 신원이 갈라질 때 |
결함 아님으로 판정한 후보 3건 — RoutingObjectReadAdapter의 무방비 split(§7: ObjectReference 생성자 검증이 막는다), control 레코드의 관용 UTF-8 디코딩(§16: printable ASCII 검사가 닫는다), sub-scope 02·04의 zero-finding 결과 자체.
이월 해소 1건 — sub-scope 01의 readiness registry forward reference를 §47에서 확정(파일은 저장소 루트에 실재하고 leaf test가 시스템 프로퍼티로 읽어 9장 카드와 R0/R1 수준을 강제한다).
65. 이 모듈에서 반복해서 나타난 패턴
- 불확실성을 보존한다. mutation의 비권위적 실패는
INDETERMINATE로 분류되고 정확한 GET으로만 해소된다(§30). 손상된 control 레코드는 부재로 보이지 않는다(§51). 부재로 읽히는 유일한 신호는 정확한 404다. - 값이 아니라 값들 사이의 관계를 검증한다. 재시도 최악 예산이 부모 호출 예산 안에 드는지(§28), provider 타입마다 반대 방향의 신원 규칙(§29), 레코드 상태와 증거 완전성의 결합(§37).
- 표현 불가능성으로 막는다. canonical key 문법, 논리 다이제스트와 provider 체크섬의 이중 대조(§31), 길이 프레이밍 이진 코덱의 재인코딩 검사(§49).
- 자기 한정을 문서에 적는다. README의 R0 선언과 evidence JSON의
limitations7줄(§58), "does not claim multi-node linearizability"(§51). - 그리고 이 모듈의 P2 넷은 전부 같은 모양이다 — 설정 표면이나 계약이 절반만 조립돼 있다. 마지막 part 표시가 요청 타입에 없고(§38), 검증이 세 경로 중 하나에만 있고(§39), capability가 compile을 통과하는데 소비자가 없고(§41), APPLY는 설정으로 켜지는데 검증기는 손배선이다(§49). 개별 구현의 품질과 조립의 대칭성 사이에 일관된 격차가 있다.
66. 모듈 완료 조건
- denominator 206 / 206 FULL_READ,
STRUCTURAL_ONLY0,EXCLUDED0, 미분류 0 (§63) - 7개 하위 범위 전부 §8.1~§8.4 네 종 negative-space probe 수행, evidence
149~1568건 생성 - 후보 finding 3건을 코드로 추적해 결함 아님으로 판정, 이월 1건 해소
- 소스 미변경 — 이 분석은 어떤 애플리케이션 코드도 수정하지 않았다
67. 검증
evidence/raw/157-objectstorage-suite-verification.txt.
$ cd src && LANG=C.UTF-8 LC_ALL=C.UTF-8 ./gradlew :adapter:outbound:objectstorage:test --console=plain -q
GRADLE_EXIT=0
classes=47 tests=140 failures=0 errors=0 skipped=0
$ git status --short
changed=0
skip 0이라는 점을 기록해 둔다 — 이 leaf의 :test에는 조건부로 비활성화되는 test가 없다. Docker나 보호된 자격이 필요한 것들은 애초에 별도 source set으로 분리돼 있고(§57), :test에 섞여 들어와 조용히 건너뛰지 않는다. 그 세 lane(objectStorageMinioContractTest·objectStorageMinioFaultTest·objectStorageAwsQualificationTest)은 이 분석에서 실행하지 않았다.
작업 트리는 변경 0이다 — 이 분석 과정에서 애플리케이션 소스를 수정하거나 임시 파일을 남기지 않았다.
Source anchors
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 82개 (main 64 · test 10 · 기타 8).
src/adapter/outbound/objectstorage/build.gradle
src/config/architecture/modules.json (adapter-outbound-objectstorage 항목)
main:
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/CrockfordBase32.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectControlKeyCodec.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectDataKeyCodec.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectAdoptionSettings.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectStorageActivationGuard.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityAssembler.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfig.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderContribution.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectDirectGrantAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectReadAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/SelectedObjectStorageProviderFactory.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonReader.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ControlRecordSupport.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecord.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlStore.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectOperationRecord.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectStagedObjectRecord.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/UnsupportedObjectControlSchemaException.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCompletionVerifier.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinator.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinator.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferSessionRecord.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactor.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/FilesystemLocalDevProviderContribution.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectControlStore.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectDataStore.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProvider.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalObjectPathGuard.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernel.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachine.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PendingObjectEffect.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernel.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/Ed25519LegacyAdoptionApprovalVerifier.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalCodec.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalReplayStore.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartPartLedger.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartUploadPlan.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidenceSource.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityCard.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityEvidence.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridge.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridge.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ChecksumPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ClientPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalRequestMapper.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProvider.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProvider.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectEvidenceMapper.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBinding.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapper.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderType.java
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderVersion.java
test:
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectNamespaceCodecTest.java
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinatorTest.java
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsContractTest.java
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachineTest.java
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalVerifierTest.java
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionServiceTest.java
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessRegistryTest.java
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java
src/test/resources/object-storage/minio-provider-evidence.json
기타:
docs/registries/object-storage-readiness.yaml
src/build.gradle
src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3DirectTransferQualificationTest.java
src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ManagedCommonSubsetQualificationTest.java
src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferContractTest.java
src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectContractTest.java
src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferFaultTest.java
src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java
해석되지 않은 인용 (11종) — 외부 타입·문서상 약칭 등:
application.yml
evidence/raw/149-objectstorage-module-inventory.txt
evidence/raw/150-objectstorage-config-activation-probes.txt
application-prod.yml
evidence/raw/151-objectstorage-control-probes.txt
evidence/raw/152-objectstorage-kernel-codec-probes.txt
evidence/raw/153-objectstorage-s3-probes.txt
evidence/raw/154-objectstorage-direct-multipart-probes.txt
evidence/raw/155-objectstorage-platform-readiness-probes.txt
evidence/raw/156-objectstorage-qualification-lanes-probes.txt
evidence/raw/157-objectstorage-suite-verification.txt