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

Follows the import procedure in README.md.

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

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

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

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

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

74 KiB
Raw Blame History

08 · adapter-outbound-fileserver

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-fileserver
  • canonical state analysisFile: analysis/08-adapter-outbound-fileserver.md (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/fileserver · Gradle :adapter:outbound:fileserver
  • registry allowed_dependencies: ["application-core", "shared-contract"]
  • registry runtime_memberships: ["app-bootstrap"]
  • coverage ledger: FULL_READ 119 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행) — EVD-334의 로케일 finding이 이 리프의 것이다

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: COMPLETE
revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
경로: src/adapter/outbound/fileserver · Gradle: :adapter:outbound:fileserver

0. Denominator와 coverage ledger

tracked file 119개 — main 78 (12,707 LOC), test 37 (12,043 LOC), governance 4. 총 약 24.7k LOC. build.gradle에 별도 source set이나 test lane 선언이 없다(main/test뿐).

레지스트리:

{ "id": "adapter-outbound-fileserver",
  "gradle_path": ":adapter:outbound:fileserver",
  "allowed_dependencies": ["application-core", "shared-contract"],
  "runtime_memberships": ["app-bootstrap"] }

leaf 밖 소비자는 app-bootstrap 하나다 — CaSkeletonApplication + autoconfigure/fileserver/** 6개 config 클래스, 그리고 test 3개.

패키지 배치(main): 루트 fileserver 31 · platform/local 33 · platform/verification 10 · platform/security 2 · platform/audit 2.

하위 범위 원장

# 범위 main test 상태
1 governance / build / config / activation (+ governance 4) 7 2 13 COMPLETE
2 control plane + control record codec + recovery verifier 3 3 6 COMPLETE
3 publication — provider · adapter · journal · attestor · binding 19 7 26 COMPLETE
4 platform/local IO primitive · gateway · store/publisher 22 8 30 COMPLETE
5 platform/local failure·probe·health·orphan + verification + security + audit 24 5 29 COMPLETE
6 payload operations · CSV export · testkit 계약 · crash matrix 3 12 15 COMPLETE
TOTAL 78 37 119 (governance 4 포함) 6 / 6

manifest: evidence/raw/141-fileserver-module-inventory.txt.


1. Sub-scope 01 범위와 denominator

내부 상태: COMPLETE — 13 / 13 FULL_READ 범위: governance 4 + config/activation main 7 + 전용 test 2 역할: R1(CSV export)과 R2(local-persistent publication) 두 개의 opt-in 선택자를 서로 혼동될 수 없게 분리하고, 잘못된 조합을 파일시스템에 손대기 전에 거부한다

manifest와 probe: evidence/raw/142-fileserver-config-activation-probes.txt.

2. 선택자 세 개가 각자 다른 것을 켠다

이 leaf는 이름이 비슷한 세 능력을 명시적으로 갈라 둔다(CLAUDE.md).

namespace 무엇을 켜는가 소유
app.fileserver.* R2 publication (local-persistent) 이 leaf
app.file-export.* R1 CSV export (+ legacy-enabled로 덮어쓰기 가능 legacy port) 이 leaf
app.fileserver-platform.* HTTP Fileserver 플랫폼(업로드/다운로드/수명주기 라우트) app-bootstrap

셋 다 기본 off이고, R1과 R2 동시 활성화는 파일시스템 초기화 전에 실패한다. FileserverActivationValidator.rejectAmbiguous(environment)가 세 bean factory 메서드의 첫 줄에서 호출되고(FileExportConfig:33·:49, FileserverR2Config:31), Binder로 두 selector를 직접 읽으므로 bean 정의 순서에 의존하지 않는다. test가 그 순서를 고정한다 — enablingLegacyR1AndR2TogetherFailsBeforeEitherFilesystemIsMutated는 실패 후 R2 루트의 .ca-fileserver·data와 R1/legacy 루트가 모두 존재하지 않음을 단언한다.

R2 쪽 조립은 fail-closed가 촘촘하다. FileserverR2Config.routingFilePublicationPort는 destination을 컴파일하고, 서로 다른 provider ID가 같은 루트를 소유하는 조합을 거부하고(rejectSharedRootAcrossProviderIds), provider ID별로 하나의 attestor/control plane/payload 런타임을 만들어 같은 provider를 지목한 모든 destination이 공유하게 한다. test 둘이 그 공유/분리를 각각 확인한다(destinationsBoundToOneProviderReuseOneProviderRuntime, destinationsBoundToDifferentProvidersUseDifferentProviderRuntimes).

FileserverR2Validation은 값 검증을 한곳에 모은다 — ID는 [a-z][a-z0-9-]{0,62}이고 이미 정규화돼 있어야 하며, 경로는 이미 절대·정규화돼 있어야 하고, sentinel 이름은 ./../구분자/제어문자를 거부한 뒤 UTF-8 인코딩 길이 255바이트와 getNameCount()==1까지 확인한다. maximum-root-mode는 네 자리 8진수만 받고 group/world write를 별도로 거부한다((group & 2) != 0 || (others & 2) != 0).

3. Confirmed — 비활성 상태에서 부작용이 없다는 것을 test가 실제로 확인한다

disabledR2CreatesNoPortOrFilesystemSideEffect는 bean 부재만이 아니라 설정된 루트가 생성되지 않았음(assertThat(absentRoot).doesNotExist())까지 단언한다. unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored도 실패 후 .ca-fileserver·data 부재를 확인한다. "비활성이면 아무 일도 없다"를 bean 목록이 아니라 파일시스템으로 검증하는 형태다.

rejectsLegacyRootThatAliasesThePublicationRootThroughASymbolicLink는 심볼릭 링크로 우회한 루트 겹침까지 본다 — canonicalDirectorytoRealPath()로 정규화한 뒤 startsWith로 양방향 포함을 검사하기 때문에 잡힌다.

4. P2 — README가 "노출된 setting도 bean도 없다"고 적은 능력들에 production bean이 있다

README:103105의 guarantee boundary 문단이 이렇게 끝난다.

Cross-node producer fencing, background reconciliation/reaping, retention, quota/backpressure, readiness/health, metrics, tracing, and audit are also not implemented. No setting or bean for those capabilities is exposed.

app-bootstrap이 그중 넷에 대해 이 leaf의 타입으로 bean을 만든다(142-... §8.4f).

README가 "없다"고 한 것 실제 bean 만드는 곳
audit StructuredAdminAuditAdapter, StructuredFileserverAuditAdapter FileserverSecurityConfiguration:71·:77
readiness/health LocalStorageHealthAdapter FileserverStorageConfiguration:179
background reconciliation/reaping LocalOrphanScanAdapter, LocalReconciliationContentProbe FileserverStorageConfiguration:189·:203
quota LocalStorageUsageProbe FileserverStorageConfiguration:196

공정하게 볼 지점. 코드 배치 자체는 앞뒤가 맞는다. 이것들은 R2 publication이 아니라 HTTP Fileserver 플랫폼(app.fileserver-platform.*, CLAUDE.md가 "owned by app-bootstrap"이라 적는 별개 능력)의 부품이고, build.gradle의 description도 이 leaf가 "the local filesystem content platform behind the HTTP Fileserver"를 함께 담는다고 밝힌다. main 78개 중 **67개가 platform/****라는 사실이 그 비중을 보여 준다.

잘못된 것은 문단의 범위다. "No setting or bean for those capabilities is exposed"에는 한정어가 없고, 이 문단은 독자가 이 모듈이 무엇을 제공하고 무엇을 제공하지 않는지 확인하러 오는 자리다. 그 자리에서 "audit은 구현돼 있지 않다"를 읽은 사람은 감사 기록이 없다고 결론짓는데, 같은 저장소가 두 개의 audit adapter를 bean으로 만든다. 판정: P2. 수정은 문단을 R2 publication 범위로 한정하고, 같은 leaf가 담는 플랫폼 부품이 별도 namespace로 조립된다는 사실을 그 자리에 적는 것이다.

(이 finding의 나머지 절반 — 그 bean들이 실제로 무엇을 보장하는가 — 은 platform/**을 읽는 sub-scope 05에서 다룬다.)

5. P3 — R1과 R2의 설정 취급이 비대칭이고, 검증된 쪽은 하나뿐이다

같은 leaf 안의 두 selector가 설정을 다르게 다룬다.

R2 app.fileserver.* R1 app.file-export.*
바인딩 타입 record + ignoreUnknownFields = false 가변 JavaBean, 기본값(미지의 키 무시)
루트 경로 requireAbsoluteNormalizedPath — 이미 절대·정규화여야 함 Path.of(v).toAbsolutePath().normalize() — 상대 경로 허용, CWD 기준 절대화
기본 루트 없음(필수) ./.data/fileserver, ./.data/fileserver-legacy
디렉터리 생성 하지 않음(attestation이 별도로 요구) Files.createDirectories(root)생성
미지 키 test unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored 없음

R2에서는 strict-path-securty 같은 오타가 컨텍스트를 실패시킨다. R1에서는 app.file-export.maximum-rowz=10 같은 오타가 조용히 무시되고, 설정했다고 믿는 상한이 적용되지 않은 채 기본값 1,000,000이 쓰인다. 두 selector가 같은 leaf의 같은 성격 설정인데 한쪽만 fail-closed다. P3 — R1은 문서상 "compatibility only"이므로 우선순위를 낮춘다.

6. P3 — 문서가 지목한 기본값 위치와 test 목록이 실제와 다르다

  • README는 R2 selector가 "app-bootstrap/application.yml에서 false로 기본값을 갖는다"고 적는다. 그 파일에 app.fileserver.enabledapp.file-export.enabled없다(142-... §8.4e; app.fileserver로 걸리는 두 줄은 주석이다). 실효 기본값은 "속성 부재 → @ConditionalOnProperty 미매치 → bean 없음"이고 동작은 옳지만, 문서가 가리킨 자리에는 그 키가 없다.
  • README의 Tests 목록 첫 항목 FilePublicationContractTest는 이 leaf가 아니라 application-core에 있다.

7. Confirmed — 적재 경로는 auto-configuration이 아니라 명시적 component scan이다

이 leaf에는 META-INF/spring/…AutoConfiguration.imports없다(142-... §8.1). FileExportConfig/FileserverR2Config를 leaf 밖에서 이름으로 참조하는 production 코드도 없고, 유일한 외부 참조는 app-bootstrap의 test(OptionalAdapterBeanGatingTest)다.

실제 적재는 CaSkeletonApplication의 명시적 @ComponentScandev.caskeleton.adapter.outbound.fileserver를 목록에 올려서 이루어진다(:75). 즉 CLAUDE.md의 "never activates unexpectedly when merely present on the classpath"는 classpath 존재만으로 bean이 생기지 않는다는 뜻으로는 정확하지만, 기전은 import filter가 아니라 "@Configuration은 스캔되고 bean 생성만 @ConditionalOnProperty로 막힌다"이다. fail-closed는 성립한다 — 기록해 두는 이유는 mongo leaf의 4중 opt-in(§sub-scope 01, 06번 문서)과 기전이 다르기 때문이다.

8. Negative-space probes — sub-scope 01

  • 8.1 reachability: auto-configuration 등록 metadata 0, 적재는 명시적 component scan(§7). 세 bean 모두 @ConditionalOnProperty 게이트.
  • 8.2 조건부 형제: R1 vs R2의 설정 엄격도·경로 규칙·디렉터리 생성·test 커버리지 비대칭(§5).
  • 8.3 중복 mechanism: rejectAmbiguous 호출 3곳은 중복이 아니라 각 진입점의 첫 줄이라는 배치다 — Binder로 환경을 직접 읽으므로 bean 순서에 무관하고, app-bootstrap의 FileserverStartupValidator는 R1/R2 selector가 아니라 플랫폼 저장소 probe 결과를 검증하는 별개 장치다(중복 아님).
  • 8.4 문서/개수 drift: §4(가장 무거움) · §6(기본값 위치, test 목록).

9. Sub-scope 01 findings backlog

우선순위 finding reachability
P2 README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reconciliation/reaping·quota 넷에 대해 사실과 다르다 — 모두 app-bootstrap이 이 leaf의 타입으로 bean을 만든다 이 문단을 근거로 능력 유무를 판단하는 독자
P3 FileExportSettingsignoreUnknownFields=false가 없어 app.file-export.* 오타가 조용히 무시된다(R2는 거부하고 test도 있다) R1을 켠 배포의 설정 오타
P3 R1 루트는 상대 경로를 허용해 CWD 기준으로 절대화하고 디렉터리를 생성하는데, R2는 이미 절대·정규화된 경로만 받는다 — 같은 leaf의 두 selector가 다른 규칙 R1 배포
P3 README가 지목한 selector 기본값 위치(app-bootstrap/application.yml)에 해당 키가 없고, Tests 목록의 FilePublicationContractTestapplication-core 소속이다 문서

10. Sub-scope 01 완료 조건

  • denominator 13 / 13 FULL_READ (142-... OWNED FILES)
  • §8.1(적재 경로)·§8.2(R1/R2 형제)·§8.3(중복 아님 확인)·§8.4(문서 drift) 네 종 probe 수행
  • §4는 app-bootstrap의 bean 생성 지점을 직접 확인해 판정했고, 그 bean들이 무엇을 보장하는지는 sub-scope 05로 이월
  • 소스 미변경

11. Sub-scope 02 범위와 denominator

내부 상태: COMPLETE — 6 / 6 FULL_READ 범위: LocalPersistentControlPlane 1,470 + FileserverControlRecordCodec 855 + LocalPersistentRecoveryVerifier 210 (main 3, 2,535 LOC) + 전용 test 3 (3,240 LOC) 역할: R2의 강제된(forced) 제어 평면 — 세 종류의 canonical 제어 레코드를 저장·검증하고, 상태 전이를 인접 행렬로 강제하며, 협력 프로세스를 JVM+OS 락으로 직렬화한다

manifest와 probe: evidence/raw/143-fileserver-control-plane-probes.txt.

test/main 비율이 1.28이다. 이 sub-scope에서 찾은 결함은 없고, 아래는 왜 없는지에 대한 기록이다.

12. Confirmed — codec이 "canonical"을 왕복으로 강제한다

FileserverControlRecordCodec은 세 레코드와 receipt snapshot에 대해 decode 직후 재encode해 바이트를 비교한다(requireCanonical(bytes, encodeOperation(record))). 그래서 "파싱은 되지만 우리가 쓰지 않았을 형태"가 전부 거부된다 — 공백, 필드 재배열, A 같은 이스케이프, -0/선행 0 같은 숫자 표기, 후행 콘텐츠. 파서 자체도 좁다.

  • 필드 집합을 정확히 일치시킨다(values.keySet().equals(allowedFields)) — 누락도 미지 필드도 거부.
  • 중복 키를 거부한다(putIfAbsent).
  • UTF-8 디코딩이 REPORT 모드라 malformed 바이트가 대체문자로 조용히 바뀌지 않는다.
  • \b \f \n \r \t 이스케이프를 문법 수준에서 거부한다("control characters are forbidden") — 제어문자가 이스케이프로 밀입되는 경로를 닫는다.
  • 짝 없는 서로게이트를 거부한다(requireWellFormedUnicode).
  • Instant.parseresult.toString().equals(value)canonical UTC 표기만 받는다.
  • receipt snapshot은 rsv1. 접두사 + unpadded base64url이고, 디코딩 후 재인코딩 문자열 비교로 alias(후행 비트가 0이 아닌 변형)를 거부한다.

test가 그 하나하나를 이름으로 고정한다 — canonicalDecoderRejectsWhitespaceReorderingEscapesNumbersUtf8AndTrailingContent, receiptSnapshotRejectsBase64urlAliasWithNonZeroTrailingBits, canonicalCodecRoundTripsSupplementaryUnicodeInOpaqueText, formulaMitigationCountCannotExceedCellsAndUsesOverflowSafeBounds.

마지막 것은 코드에서도 확인된다. requireFormulaCountWithinCellsrowCount * columnCount 곱을 하기 전에 rowCount <= Long.MAX_VALUE / columnCount를 먼저 본다 — 오버플로가 상한 검사를 무력화하는 경로를 닫는다.

13. Confirmed — 상태 전이가 인접 행렬이고 terminal이 진짜 terminal이다

validateOperationTransition이 여섯 가지를 순서대로 강제한다: requestFingerprint 불변 → 불변 identity 10개 필드 불변 → stateRevision 감소 금지 → 동일 revision 다른 내용 금지 → 정확히 +1 증가 → 인접 전이 행렬. 행렬은 README가 적은 사슬과 일치하고, 모든 비terminal 상태에서 QUARANTINED로만 이탈할 수 있으며 PUBLISHED·QUARANTINED는 후속 전이가 없다(case PUBLISHED, QUARANTINED -> false).

봉인 이후 사실은 얼어붙는다 — requireSealedFactsUnchanged가 byteSize·rowCount·columnCount·sha256·formulaMitigatedCount·sealedAt을 고정하고, MANIFEST_PUBLISHED 이후에는 manifestDigest, REFERENCE_PUBLISHED 이후에는 referenceDigest도 고정된다.

current.equals(candidate)는 전이가 아니라 **복구(repair)**로 취급된다 — 부모 디렉터리를 다시 force하고 정확 read-back만 수행한다. 크래시 후 같은 레코드를 다시 쓰는 재시도가 conflict가 되지 않게 하는 처리이고, parentForcedCallbackFailureIsRepairedByIdenticalOperationRetry가 이를 고정한다.

14. Confirmed — 두 개의 락 형태가 각자의 쓰기 원시연산에 맞춰져 있다

한 클래스 안에 락이 두 종류다. 얼핏 비대칭으로 보이지만 각자의 커밋 방식이 다르다.

operation 레코드 manifest / reference 레코드
커밋 원시연산 Files.move(ATOMIC_MOVE, REPLACE_EXISTING)배타적이지 않음 Files.createLink — 이미 있으면 FileAlreadyExistsException, OS 수준 배타
JVM 락 OPERATION_LOCK_STRIPES, 키에 root 범위 포함(operationLockRootKey + "\0" + token) IMMUTABLE_LOCK_STRIPES, 키는 "manifest:"+fileId — root 범위 없음
OS 락 FileChannel.lock() (.lock 파일, 0600, 소유자·FileStore 검증) 없음

즉 배타성이 필요한 쪽(replace)에는 OS 락을 두고, 원시연산 자체가 배타적인 쪽(create-link)에는 JVM 스트라이프만 둔 것이다. 후자의 root 미포함은 과잉 직렬화 방향이라(다른 root의 같은 fileId가 같은 스트라이프를 공유) 배타 누락으로는 이어지지 않고, fileIdSecureRandom 16바이트라 실질 충돌도 없다. 결함이 아니라 설계로 기록한다.

collision 경로도 닫혀 있다 — createLink가 충돌하면 임시 파일을 정확히 지우고, 기존 레코드를 읽어 identity와 내용 동등성을 확인한 뒤 같으면 repair, 다르면 CONFLICT다. concurrentCrossInstanceImmutableCollisionIsNeverClassifiedAsStorage가 그 분류를 고정한다.

15. Confirmed — poisoning은 root 범위이고, 읽기를 막지 않는 것이 의도다

OS 언락을 증명하지 못한 경우에만 POISONED_OPERATION_LOCK_ROOTS에 root 키가 들어간다. release와 close 중 하나라도 성공하면 poison하지 않는다(releaseProvedUnlock || closeProvedUnlock).

requireOperationLockRootHealthy()는 6곳에서 호출되는데 전부 쓰기 경로(storeOperation·acquireOperationLock×storeManifest·storeReference)이고, findOperation/findStoredOperation/findManifest/findReference 어디에도 없다. 처음에는 누락으로 보였으나 test 이름이 그것이 의도임을 못박는다 — poisonedRootBlocksEveryWriteIncludingHeldLockFastPathButAllowsReads. 이미 획득한 락의 fast path(heldTokens.contains(...))조차 poison에 걸린다는 것까지 이름에 들어 있다.

poison을 해제하는 경로는 없다(집합은 static이고 제거 호출이 없다). 프로세스 수명 동안 그 root는 쓰기 불가로 남는다 — "OS 락이 풀렸는지 증명할 수 없다"에 대한 fail-closed 응답이고, operationLockClosePoisonsOnlyTheAttestedRootWhenUnlockCannotBeProven이 범위가 해당 root에 한정됨을 확인한다. 두 개의 형제 test(releaseFailureWithSuccessfulChannelCloseReportsStorageWithoutPoisoning, successfulReleaseWithChannelCloseFailureReportsStorageWithoutPoisoning)가 "증명 하나면 충분" 규칙을 양쪽에서 고정한다.

16. Confirmed — 파일시스템 접근이 전부 SecureDirectoryStream 상대 연산이다

SystemSecureRecordOperations의 다섯 연산이 모두 openSecure(topDirectory)newDirectoryStream(shard, NOFOLLOW_LINKS)를 거친다. SecureDirectoryStream이 아니면 스트림을 닫고 IOException을 던진다 — TOCTOU 우회 경로를 열어 두지 않는다.

세부가 촘촘하다.

  • 읽기는 maximumBytes + 1 버퍼로 읽어 한 바이트 초과분을 감지하고, 읽기 전후 fileKeysize를 비교해 "읽는 중 신원이 바뀐" 경우를 integrity 실패로 만든다.
  • 임시 파일 생성은 CREATE_NEW + NOFOLLOW_LINKS + 0600이고, 쓴 뒤 force(true), 그 다음 크기와 fileKey를 생성 시점과 대조한다.
  • 커밋 전후로 requireCreatedTemporaryIdentity정확히 그 fileKey만 지운다 — 다른 프로세스가 같은 이름으로 바꿔 둔 파일을 지우지 않는다. cleanupPreservesAReplacementWhoseNoFollowFileKeyDiffersFromCreatedTemp가 그 경계를 고정한다.
  • shard 디렉터리는 매번 소유자·0700 권한·FileStore 동일성을 재검증하고, 좌표는 [0-9a-f]{2}와 세 허용 디렉터리로 제한된다.
  • 모든 쓰기/읽기 단계 사이에 verifyAttestedIdentity()가 끼어 있다 — root가 도중에 바뀌면 즉시 멈춘다.

forceDirectory는 디렉터리를 READ로 열어 force(true)한다. README가 FILE_AND_DIRECTORY_SYNC를 "attested local file/directory force boundary only"로 한정하는 것과 일치한다.

17. Confirmed — 세 타입 모두 leaf 밖으로 새지 않는다

LocalPersistentControlPlane·FileserverControlRecordCodec·LocalPersistentRecoveryVerifier는 전부 package-private이고, 저장소에서 이 leaf 밖 참조는 0이다(143-... §8.1). production 생성 지점은 FileserverR2Config:51 하나다. CLAUDE.md의 "Leaking filesystem, stream, framework, or provider types across FilePublicationPort" 금지가 타입 가시성으로 뒷받침된다.

R1 하위호환도 좁게 열려 있다 — decodeStoredOperation은 R2 codec을 먼저 시도하고, 실패하면 R1 journal codec으로 넘어가되 terminal PUBLISHED 허용한다. CLAUDE.md의 "schema v1 is strict read-only compatibility"와 일치하고, typedOperationLookupDispatchesCanonicalR2AndTerminalR1FromTheSameHashedPathtypedOperationLookupRejectsMalformedNonCanonicalNonTerminalAndWrongIdentityR1이 양쪽을 고정한다.

18. Negative-space probes — sub-scope 02

  • 8.1 reachability: 세 타입 모두 package-private, leaf 밖 참조 0, production 진입점 1개(§17).
  • 8.2 조건부 형제: 한 클래스 안의 두 락 형태(§14) — 커밋 원시연산 차이로 설명됨. requireOperationLockRootHealthy의 쓰기/읽기 비대칭(§15) — test 이름이 의도임을 명시.
  • 8.3 중복 mechanism: poison 집합에 해제 경로 없음(§15, 의도된 fail-closed). R1/R2 두 codec 경로는 dispatch 순서와 terminal 제약으로 분리(§17).
  • 8.4 문서/동작 대조: README의 상태 사슬(WRITING → SEALED → DATA_PUBLISHED → MANIFEST_PUBLISHED → REFERENCE_PUBLISHED → PUBLISHED)과 isAllowedAdjacentTransition의 행렬이 일치. FILE_AND_DIRECTORY_SYNC의 한정 서술과 forceDirectory 구현이 일치.

19. Sub-scope 02 findings backlog

우선순위 finding reachability
없음. 후보로 본 세 가지(immutable 락의 root 미포함, poison 해제 경로 부재, 읽기 경로의 health 게이트 부재)는 각각 커밋 원시연산·fail-closed 설계·명시적 test로 의도임이 확인됐다

20. Sub-scope 02 완료 조건

  • denominator 6 / 6 FULL_READ (143-... OWNED FILES) — main 2,535 LOC 전수 판독
  • §8.1~§8.4 네 종 probe 수행, 후보 finding 3건을 각각 코드·test로 추적해 결함 아님으로 판정
  • 실행 probe 불필요 — 세 후보 모두 소스와 test 이름으로 정적 결정 가능
  • 소스 미변경

21. Sub-scope 03 범위와 denominator

내부 상태: COMPLETE — 26 / 26 FULL_READ 범위: publication main 19 (provider·adapter·journal·attestor·binding·record, 약 4,400 LOC) + 전용 test 7 역할: 요청 → 스테이지 → 데이터 → manifest → reference → terminal 사슬을 재개 가능한 상태 기계로 만들고, 루트를 startup에 증명하며, R1 아티팩트를 읽기 전용으로만 복원한다

manifest와 probe: evidence/raw/144-fileserver-publication-probes.txt.

22. Confirmed — 19개 production 타입 중 leaf를 벗어나는 것이 하나도 없다

전수 검색 결과 LocalPersistentPublicationProvider·LocalFilePublicationAdapter·RoutingFilePublicationAdapter·LocalPersistentRootAttestor·FileserverBindingCompiler·DurablePublicationRecord·PrivateFileManifest·PublishedReferenceRecord·LocalPublicationJournal 어느 것도 이 leaf 밖에서 참조되지 않는다(144-... §8.1, exit=1). 전부 package-private이고, 애플리케이션이 보는 것은 FilePublicationPort와 그 값 타입뿐이다.

CLAUDE.md의 금지 조항 — "Leaking filesystem, stream, framework, or provider types across FilePublicationPort" — 이 문서가 아니라 타입 가시성으로 뒷받침된다. FilePublicationProvider(adapter 내부 provider 인터페이스)도 package-private이라 provider 개념 자체가 포트를 건너지 않는다.

23. Confirmed — 복구가 "어디서 끊겼든 그 자리에서" 재개하는 루프다

recoverR2는 저장된 상태에 따라 분기하는 while(true) 루프다. 각 단계가 증거를 다시 검증하고, 성공하면 다음 상태로 전이하며, 루프가 PUBLISHED에 도달하면 receipt를 복원한다.

저장 상태 재개 동작
WRITING 격리(UNSEALED_WRITING) — 봉인 전에 끊긴 것은 재개하지 않는다
SEALED stage/data를 조사해 둘 다 없으면 integrity 실패, data가 있으면 디렉터리만 force, 없으면 stage를 hard-link로 publish
DATA_PUBLISHED manifest를 찾거나 생성해 저장
MANIFEST_PUBLISHED reference를 찾거나 생성해 저장
REFERENCE_PUBLISHED 모든 증거를 재대조하고 receipt snapshot을 넣어 terminal 기록
PUBLISHED 전 필드 재검증 후 저장된 receipt를 그대로 반환
QUARANTINED indeterminate

핵심은 producer를 다시 부르지 않는다는 점이다. publishNewstreamRequest를 호출하고, 그 이후의 모든 재개 경로는 이미 봉인된 바이트에서 진행한다. README의 "resumes from verified sealed bytes without replaying the producer"가 코드 구조로 성립한다.

resumeData의 stage/data 이중 조사가 특히 촘촘하다. 둘 다 존재하면 fileKey가 같은지 확인해 — 즉 같은 exclusive hard-link인지 — 확인하고, 다르면 RecoveryIntegrityException이다. hard-link 발행이 성공한 뒤 stage 삭제 전에 죽은 경우와, 전혀 다른 파일이 그 자리에 있는 경우를 구분한다.

실패 분류도 갈라져 있다. RecoveryIntegrityException과 payload의 INTEGRITY/CAPACITY격리 후 indeterminate가 되고, 그 밖의 payload 실패는 격리 없이 indeterminate다. quarantineAndIndeterminate는 이미 PUBLISHED/QUARANTINED인 기록은 건드리지 않는다.

24. Confirmed — 루트 증명이 "설정을 믿지 않는" 형태다

LocalPersistentRootAttestor.attestChecked가 순서대로 확인한다: 절대·정규화 경로 → 심볼릭 루트/조상 거부 → toRealPath()가 설정 경로와 정확히 일치 → 소유자 → 권한 상한 → FileStore 이름·타입 → mount sentinel의 SHA-256 → 내부 디렉터리 8개 생성/검증 → SecureDirectoryStream 가용성 → 실제 capability probe.

마지막이 특징적이다. runCapabilityProbe는 실제로 파일을 만들고(CREATE_NEW+NOFOLLOW_LINKS+0600), 쓰고, force하고, hard-link를 만들고, 디렉터리를 force한 다음, 원본과 링크의 fileKey가 같은지 확인한다. 즉 "이 파일시스템이 배타적 hard-link 발행과 file/directory force를 실제로 할 수 있는가"를 startup에 시험한다 — 첫 publication에서 발견하지 않는다.

내부 디렉터리 생성에는 롤백이 붙어 있다. rollbackCreatedDirectory는 삭제 전에 부모 identity와 디렉터리 자신의 fileKey를 대조하고, 하나라도 바뀌었으면 삭제를 거부한다("refusing rollback because internal directory identity changed"). 실패 정리가 남의 디렉터리를 지우지 않는다.

verifyIdentity는 attest가 끝난 뒤에도 control plane의 거의 모든 단계에서 재호출된다(§16). 증명은 시점이 아니라 불변식이다.

25. Confirmed — canonical digest가 길이 프레이밍이고, route token 충돌을 명시적으로 검사한다

FilePublicationCanonicalDigests.digestOrderedValues는 값 개수를 먼저 넣고, 값마다 길이(4바이트) + 엄격 UTF-8 바이트를 넣는다. 구분자를 쓰지 않으므로 값 안에 어떤 문자가 있어도 경계가 흐려지지 않는다. FilePublishRequestFingerprint도 같은 방식이다.

routeToken은 정책 다이제스트의 앞 31자에 r을 붙인 것이라 잘린 값이다. 그래서 FileserverBindingCompiler.deriveUniqueRouteTokens가 컴파일 시점에 토큰 충돌을 검사하고, 충돌하면 두 destination 이름을 모두 담아 거부한다. 잘림이 만들 수 있는 유일한 문제를 그 자리에서 닫는다. 컴파일 후에도 compiled.forEach로 각 destination의 토큰이 레지스트리와 같은지 다시 확인한다.

CompiledFileDestination의 compact 생성자는 넘겨받은 effectivePolicyDigest다시 계산해 대조하고, routeToken이 그 다이제스트에서 유도됐는지, formatPolicyDigest가 정본과 같은지도 확인한다. 값이 아니라 관계를 검증한다.

26. Confirmed — R1과 R2가 같은 일을 다른 엄격도로 하고, 그 사실이 선언돼 있다

두 계층이 나란히 있어 비교가 가능하다(144-... §8.2).

R2 LocalPersistentControlPlane R1 LocalPublicationJournal
파일시스템 접근 SecureDirectoryStream 상대 연산 (17회) Files.exists/isRegularFile/readAllBytes (0회)
읽기 디코딩 엄격 UTF-8 REPORT + canonical 바이트 재대조 new String(bytes, UTF_8) — malformed는 U+FFFD로 대체
제어문자 이스케이프 \b \f \n \r \t문법에서 거부 다섯 개를 모두 수용해 디코드
POSIX 권한 정확히 0700이 아니면 실패 UnsupportedOperationException을 삼키고 진행
임시 파일명 SecureRandom 16바이트 hex UUID.randomUUID()
ReentrantLock 스트라이프 + OS FileLock + poison 래치 Semaphore 스트라이프 + OS FileLock

이것은 결함이 아니라 선언된 상태다 — CLAUDE.md는 R1을 "compatibility only"로, README는 "must not be used as R2 durability or cluster-safety evidence"로 못박는다.

중요한 것은 두 계층이 만나는 한 지점이다. R2 control plane이 같은 해시 경로에서 R1 저널을 읽을 때(decodeStoredOperation) 쓰는 것은 관대한 decode가 아니라 **엄격한 decodeCanonical**이고, 그 위에 state == PUBLISHED까지 요구한다(LocalPersistentControlPlane:634-638). 즉 R1의 느슨함이 R2 경로로 흘러들지 않는다. 이 한 줄이 위 표 전체를 안전하게 만든다.

R1 복원이 등급을 올리지 않는 것도 코드로 확인된다 — restoreR1은 receipt에 DurabilityGuarantee.PROCESS_LOCAL_SYNC를 그대로 넣고, 참조도 R2의 fsr1.… 형식이 아니라 R1의 filepub:<destination>:<token> 형식을 쓴다. README의 "never writes schema v1, creates an R2 manifest/reference for that artifact, or promotes its durability guarantee"와 일치한다.

27. Negative-space probes — sub-scope 03

  • 8.1 reachability: 19개 production 타입 전부 package-private, leaf 밖 참조 0(§22). production 진입점은 FileserverR2Config(R2)와 FileExportConfig(R1) 둘.
  • 8.2 조건부 형제: R1/R2의 6개 축 엄격도 대조(§26), 그리고 두 계층의 접점이 엄격 경로를 쓰는지 확인.
  • 8.3 중복 mechanism: 참조 형식 둘(filepub: / fsr1.)과 락 구현 둘 — 각각 R1/R2 경계에 대응하고 서로 침범하지 않음. LocalPersistentPublicationProviderLocalFilePublicationAdapter가 같은 filepub: 형식을 쓰는 것은 R1 receipt 호환을 위한 의도된 공유.
  • 8.4 문서/동작 대조: README의 여섯 단계 사슬 ↔ recoverR2 분기, "producer를 재생하지 않는다" ↔ publishNewstreamRequest 호출, R1 등급 비승격 ↔ PROCESS_LOCAL_SYNC 고정, route token 잘림 ↔ 컴파일 시 충돌 검사.

28. Sub-scope 03 findings backlog

우선순위 finding reachability
없음. R1/R2 엄격도 격차는 문서가 선언한 상태이고, 두 계층이 만나는 유일한 지점(decodeStoredOperation)은 엄격 경로를 쓴다

29. Sub-scope 03 완료 조건

  • denominator 26 / 26 FULL_READ (144-... OWNED FILES) — main 약 4,400 LOC 전수 판독
  • §8.1~§8.4 네 종 probe 수행, R1/R2 접점을 코드로 추적해 느슨함이 전파되지 않음을 확인
  • 실행 probe 불필요 — 판정 지점이 모두 정적으로 결정 가능
  • 소스 미변경

30. Sub-scope 04 범위와 denominator

내부 상태: COMPLETE — 30 / 30 FULL_READ 범위: platform/local IO 원시연산 9 + gateway 7 + store·publisher 6 (main 22) + 전용 test 8 역할: HTTP Fileserver 플랫폼의 로컬 콘텐츠 저장소 — 스테이징·추가·발행·읽기·삭제를 경로가 아니라 디렉터리 서술자 상대 연산으로 수행한다

manifest와 probe: evidence/raw/145-fileserver-local-io-probes.txt.

31. Confirmed — TOCTOU를 "검사를 더 하는" 방식으로 풀지 않는다

SecureDirectoryWalk의 클래스 javadoc이 이 sub-scope의 설계 명제를 그대로 적는다.

The pathname approach cannot be made safe by adding checks. Proving that no component of ${root}/content/ab/cd is a symbolic link and then calling FileChannel.open on that string re-resolves every component from scratch… More checks only narrow the window; they never close it.

그래서 각 단계가 이전 디렉터리의 서술자를 기준으로 다음 디렉터리를 NOFOLLOW_LINKS로 연다. 열어 둔 서술자는 나중에 그 디렉터리가 교체돼도 영향받지 않는다 — "an attacker who swaps a component afterwards has swapped something nothing is looking at any more".

세부도 논리적이다.

  • fallback을 두지 않는다. SecureDirectoryStream이 없으면 startup capability probe가 실패로 처리한다 — "a silent fall back to pathnames would restore exactly the window this class exists to close".
  • 거부와 장애를 구분한다. NOFOLLOW_LINKS 거부는 플랫폼이 일반 FileSystemException으로 보고하므로, 실패 시 같은 부모 서술자로 그 컴포넌트를 다시 읽어 심볼릭 링크인지 확인하고 SymbolicComponentException(영구 거부)과 스토리지 장애(재시도 가능한 503)를 나눈다.
  • FileChannel이 아니면 거부한다. requireFileChannel은 positional write·truncate·force·transferTo가 전부 FileChannel 연산이고 "cannot be emulated"라고 적으며 거부한다.
  • 경로 해석은 한 곳뿐. DefaultPhysicalPathResolver가 유일하게 식별자를 경로로 바꾸고, 세 겹으로 막는다 — 서버 생성 형태 정규식([a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}), 정규화, 영역 루트 startsWith 재확인. 클라이언트 파일명은 어느 단계에도 들어오지 않는다(resolutionNeverDependsOnAClientFilename가 고정).

LocalAppendEngine의 롤백 설계도 촘촘하다. 실패하면 누산 다이제스트를 먼저 버리고(이미 버려질 바이트를 흡수했으므로), truncateforcesize 재확인으로 물리 길이가 append 이전으로 돌아왔음을 증명한 뒤에야 원래 실패를 그대로 던진다. 증명하지 못하면 AmbiguousCompletionException으로 격상해 reconciliation에 넘긴다. 선언된 content length는 사후 검사가 아니라 읽기 상한으로 쓰이고(buffer.limit(min(capacity, contentLength - appended))), 잉여는 1바이트 probe read로 감지해 버린다.

LocalBlockingContentStore는 20곳 전부 channels.*(서술자 상대)를 쓰고 Files.*를 한 번도 부르지 않는다(145-... §8.2).

32. P3 — 발행 rename만 경로 기반이고, 그것을 지키는 것은 이 모듈이 "근사에 불과하다"고 적은 사전검사다

platform/local에 남은 java.nio.file.Files.* 호출을 전수 조사했다(145-... §8.2). 대부분은 정당하다 — SecureDirectoryWalk.openRoot(문서가 "the one unavoidable pathname resolution"이라 적는 루트 열기), LocalStorageCapabilityProbe(startup probe, 격리된 probe 영역), LocalOrphanScanAdapter·LocalStorageHealthAdapter·LocalStorageUsageProbe(sub-scope 05).

문제는 쓰기 경로에 남은 다섯 호출이다.

AtomicMoveContentPublisher:53   Files.move(staging, target, ATOMIC_MOVE)      ← 발행 rename
AtomicMoveContentPublisher:113  Files.exists(staging, NOFOLLOW_LINKS)         ← 실패 분류
AtomicMoveContentPublisher:114  Files.exists(target, NOFOLLOW_LINKS)          ← 실패 분류
ContentPublishVerification:53   Files.size(target)                            ← 발행 크기
MetadataPointerContentPublisher:107  Files.deleteIfExists(staging)

그리고 AtomicMoveContentPublisher:50이 그 rename 직전에 부르는 것은 channels.requireNoSymlinkBetween(root, target.getParent()) — 즉 경로 기반 사전검사다. 그 메서드의 javadoc이 스스로를 이렇게 설명한다.

Retained for the capability probe, which still reasons about pathnames. Production access no longer relies on it: descending descriptor by descriptor with NOFOLLOW_LINKS refuses a symlinked component by construction, which a precheck could only ever approximate.

즉 "production은 더 이상 이것에 의존하지 않는다"고 적힌 메서드를, 콘텐츠를 보이게 만드는 바로 그 단계가 유일한 보호로 쓴다. SecureDirectoryWalk의 "More checks only narrow the window; they never close it"이 겨냥한 패턴 그 자체다.

같은 불일치가 파일 길이에서도 보인다. LocalAppendEngine.currentLength는 여덟 줄짜리 javadoc으로 왜 Files.size가 틀렸는지 설명하고 channels.readAttributes(root, staging)를 쓴다 — "an attacker who swaps the parent for a symlink gets this check to report the size of their own file". ContentPublishVerification.sizeOf는 같은 질문에 Files.size(target)으로 답한다.

판정: P3. 실제 악용에는 스토리지 루트 안쪽 쓰기 권한이 필요하고, 이 leaf가 그 루트의 소유자·권한을 증명하는 것은 R2 경로(LocalPersistentRootAttestor)뿐이며 플랫폼 저장소 루트의 증명은 app-bootstrap의 startup validator 몫이다. 그래서 도달성은 배포 형상에 달려 있다. 심각도를 P3로 두는 이유는 그것이고, 그럼에도 기록하는 이유는 모듈 자신의 문서가 이 패턴을 명시적으로 불충분하다고 선언했다는 점이다. 수정은 발행 rename을 SecureDirectoryWalk.inParentOf로 옮겨 부모 서술자 상대 move를 쓰고, sizeOfchannels.readAttributes로 바꾸는 것이다.

33. Confirmed — 두 발행 전략이 probe 결과로 선택되고, 각자 다른 실패를 다르게 분류한다

selectPublisher는 설정이 아니라 probe가 증명한 것에서 전략을 고른다. ATOMIC_MOVE_REQUIRED는 원자적 이동을 증명하지 못하면 fail-closed, ATOMIC_MOVE_PREFERRED는 pointer 발행으로 강등된다. ContentPublisherTest가 양쪽을 고정한다(requiredAtomicModeFailsClosedWhenTheProbeCouldNotProveIt, preferredModeDegradesToPointerPublishWhenAtomicMoveIsUnproven).

두 전략 모두 REPLACE_EXISTING을 쓰지 않는다 — 기존 대상은 조용한 덮어쓰기가 아니라 충돌이다. 그리고 결과를 증명할 수 없으면 성공도 실패도 아닌 AmbiguousCompletionException이다.

AtomicMoveContentPublisher.forceDirectoryEntries의 근거가 특히 정확하다 — 스테이징 파일을 force하는 것은 내용을 지속시킬 뿐 그것을 가리키는 디렉터리 엔트리에 대해서는 아무 말도 하지 않는다. 크래시 후 객체가 완전히 쓰였으면서 동시에 두 디렉터리 어디에도 없고 메타데이터는 READY라고 말하는 상태가 가능하다. rename은 두 디렉터리를 바꾸므로 둘 다 sync하고, sync 실패는 무시가 아니라 ambiguous로 격상한다.

MetadataPointerContentPublisher는 복사 후 디스크에서 다시 다이제스트를 계산해 스테이지 다이제스트와 비교한다. ContentPublishVerification의 javadoc이 그 원칙을 적는다 — "recomputed from the bytes actually on disk rather than trusted from the streaming accumulator, so a publish can never advertise a hash the stored object does not have".

34. P3 — TransferBufferPool.maxBorrowedBytes()가 자기 회귀 test를 지목하는데 그 test가 읽지 않는다

TransferBufferPool은 대여 중 바이트의 최대치를 추적하고 javadoc에 이렇게 적는다.

Peak simultaneously-borrowed bytes; the bounded-memory regression asserts on this.

145-... §8.4의 전수 검색에서 maxBorrowedBytesTransferBufferPool.java 세 줄에만 나타나고, LocalAppendMemoryTest에도 LargeFileBoundedMemoryTest에도 없다. 즉 회귀 test는 이 값을 읽지 않는다.

기능 자체는 옳게 동작한다 — borrowbufferSize만큼 증가시키고 release가 되돌리며, 최대치를 accumulateAndGet(_, Math::max)로 누적한다. 그리고 경계 자체(전송이 파일 크기에 비례해 메모리를 쓰지 않음)는 다른 방식으로 검증되고 있다. 문제는 javadoc이 존재하지 않는 결합을 서술한다는 것이고, 그 서술 때문에 이 계측이 지켜지고 있다고 읽힌다. P3.

35. Negative-space probes — sub-scope 04

  • 8.1 reachability: platform/local의 15개 타입이 public이고, leaf 밖에서는 app-bootstrap의 fileserver autoconfigure 5개 클래스가 참조한다. 나머지(SecureDirectoryWalk·SafeFileChannelFactory·DefaultPhysicalPathResolver·publisher 3종·LocalUploadHandle 등)는 package-private — Path가 SPI를 건너지 않는다는 주장이 가시성으로 성립.
  • 8.2 조건부 형제: 파일 길이를 묻는 두 방식(§32), 서술자 상대 vs 경로 기반 쓰기(§32).
  • 8.3 중복 mechanism: 두 발행 전략은 중복이 아니라 probe 결과로 배타 선택되고 usesAtomicMove()로 자기 성격을 보고한다(§33).
  • 8.4 문서/동작 대조: maxBorrowedBytes javadoc의 회귀 test 결합 부재(§34). LocalCopyContentGateway의 "A copy is not a link" 근거와 실제 스테이징 경유 복사 구현 일치. LocalZeroCopyDownloadGateway의 짧은 전송 재시도와 부분 전송 보고 일치.

36. Sub-scope 04 findings backlog

우선순위 finding reachability
P3 발행 rename(Files.move)과 그 실패 분류(Files.exists), 발행 크기(Files.size)가 경로 기반이고, 유일한 보호는 이 모듈이 "a precheck could only ever approximate"라고 적은 requireNoSymlinkBetween이다 스토리지 루트 안쪽에 쓰기 권한을 가진 주체 — 루트 증명은 배포 형상에 달려 있다
P3 TransferBufferPool.maxBorrowedBytes()의 javadoc이 "the bounded-memory regression asserts on this"라고 적지만 어떤 test도 읽지 않는다 계측/문서

37. Sub-scope 04 완료 조건

  • denominator 30 / 30 FULL_READ (145-... OWNED FILES)
  • §8.1~§8.4 네 종 probe 수행, platform/localFiles.* 호출을 전수 조사해 정당한 것과 남은 것을 분리
  • 두 finding 모두 정적으로 결정 가능(호출 지점과 javadoc 대조)하여 실행 probe 불필요
  • 소스 미변경

38. Sub-scope 05 범위와 denominator

내부 상태: COMPLETE — 29 / 29 FULL_READ 범위: platform/local 실패분류·probe·health·orphan 10 + platform/verification 10 + platform/security 2 + platform/audit 2 (main 24) + 전용 test 5 역할: 콘텐츠 검증 사슬, 역할 기반 인가, 감사 기록, 그리고 파일시스템 실패를 "일어났는가"로 분류하는 계층

manifest·정적 probe·실행 probe: evidence/raw/146-fileserver-verification-security-audit-probes.txt.

39. P2 확정 — §4의 README 주장이 여덟 개의 port 구현과 여덟 개의 bean 앞에서 성립하지 않는다

sub-scope 01(§4)에서 이월한 판정을 여기서 닫는다. README:103105는 audit·readiness/health·reconciliation/reaping·quota가 "not implemented"이고 "No setting or bean for those capabilities is exposed"라고 적는다. 실제로는 이 sub-scope의 타입들이 application-core port를 구현하고, app-bootstrap이 그 전부를 bean으로 만든다(146-... §8.1).

port 구현 bean 생성
AdminAuditPort StructuredAdminAuditAdapter FileserverSecurityConfiguration:71
FileserverAuditPort StructuredFileserverAuditAdapter :77
FileAccessPolicy RoleBasedFileAccessPolicy / UnenforcedFileAccessPolicy :51 / :90
StorageHealthPort LocalStorageHealthAdapter FileserverStorageConfiguration:179
OrphanScanPort LocalOrphanScanAdapter :189
StorageUsageProbe LocalStorageUsageProbe :196
ReconciliationContentProbe LocalReconciliationContentProbe :203

스텁이 아니다. 감사 어댑터는 전용 로거 카테고리(dev.caskeleton.fileserver.audit)로 쓰고, 실패한 동작을 성공과 같은 레벨로 남긴다("a refused force-delete is the entry a reviewer most needs to find"). health 어댑터는 원자적 이동 가능 여부를 설정이 아니라 probe가 증명한 사실에서 보고한다. usage probe는 매 호출마다 FileStore를 다시 읽고, 읽을 수 없으면 0%도 100%도 아닌 빈 답을 낸다("a synthetic 0% would silently disable the high-water guard, and a synthetic 100% would take the capability down over a failed syscall").

즉 코드 쪽은 잘 만들어져 있고, 틀린 것은 README 한 문단이다. §4에서 적은 대로 이것들은 R2 publication이 아니라 HTTP Fileserver 플랫폼의 부품이지만, 그 문단에는 한정어가 없다. P2 확정.

40. P2 — scriptable 콘텐츠 탐지가 접두사 시작에만 고정돼 있어 BOM·NUL·주석으로 우회된다

ScriptableContentPolicy의 javadoc은 이 검사의 목적을 분명히 적는다.

Guards content that a browser would execute if it were ever served inline. Detection is on content, not on the claimed type or the extension, because both are attacker controlled.

구현은 1,024바이트 접두사를 소문자로 만든 뒤 stripLeading()하고, 여섯 마커(<!doctype html, <html, <script, <svg, <?xml, <!entity) 중 하나로 시작하는지만 본다.

hermetic 실행 probe로 실제 판정을 측정했다(146-... EXECUTION PROBE, inlineSafeProfile=false 강제 프로파일).

PROBE plain <script>                 -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE plain <html>                   -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE leading whitespace + <html>    -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE uppercase <SCRIPT>             -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE <svg onload>                   -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE UTF-8 BOM + <html>             -> ACCEPT / NO_SCRIPTABLE_CONTENT     ←
PROBE HTML comment then <script>     -> ACCEPT / NO_SCRIPTABLE_CONTENT     ←
PROBE NUL byte then <html>           -> ACCEPT / NO_SCRIPTABLE_CONTENT     ←
PROBE plain text                     -> ACCEPT / NO_SCRIPTABLE_CONTENT

세 가지가 통과한다. String.stripLeading()Character.isWhitespace만 제거하므로 UTF-8 BOM(U+FEFF)도 NUL도 지우지 않고, 선행 HTML 주석은 어떤 마커로도 시작하지 않는다. 셋 다 브라우저는 HTML로 렌더링한다 — BOM 접두 HTML은 이국적인 우회가 아니라 여러 편집기의 기본 출력이다.

형제 검증기와의 대비가 판정을 굳힌다. MediaTypeVerifier는 매직바이트를 접두사 시작에서 비교하는데, 그것은 시그니처의 정의가 파일 선두이므로 옳다. scriptable 마커는 시그니처가 아니라 브라우저가 스니핑하는 패턴이고, 브라우저는 선두 고정 매칭을 하지 않는다. 같은 "접두사 시작 비교"가 한쪽에서는 정확하고 다른 쪽에서는 우회 가능하다.

판정: P2. inlineSafeProfile=false인 배포에서 도달 가능하고, 그 프로파일이 바로 격리를 강제하려는 설정이다. 수정은 startsWith를 접두사 탐색으로 바꾸고, 매칭 전에 BOM·NUL·제어바이트를 제거하는 것이다. (완화 요인: MediaTypeVerifier가 claimed 타입과 감지 타입의 불일치를 별도로 격리하므로, Content-Type: text/html을 선언한 업로드는 다른 경로로 걸린다. 타입을 선언하지 않거나 application/octet-stream을 선언하면 걸리지 않는다.)

41. Confirmed — 검증 사슬의 합성이 fail-closed다

VerificationCoordinator는 검증기마다 시한을 두고, timeout·interrupt·예외를 전부 RETRY 만든다 — ACCEPT가 아니다. javadoc이 이유를 적는다: "an unavailable scanner can never publish content by failing open."

VerificationPolicyCombiner의 우선순위는 REJECT > QUARANTINE > RETRY > ACCEPT이고, RETRYACCEPT보다 높다는 것이 핵심이다 — 답하지 못한 검증기가 답한 검증기들에게 조용히 덮이지 않는다. 결과가 비면 NO_VERIFIER_ANSWEREDRETRY다. REJECT에 도달하면 뒤 검증기를 건너뛴다("A reject cannot be overturned").

개별 검증기도 방향이 옳다. LengthVerifier가 먼저 돌아 정책이 이미 배제한 콘텐츠에 뒤 검증기가 일하지 않게 하고, MediaTypeVerifier는 시그니처 일치를 안전 판정으로 쓰지 않고 기록할 타입만 정하며 claimed와 detected의 불일치를 격리한다. FilenamePolicyVerifier는 저장된 이름을 다시 sanitize해서 달라지면 거부가 아니라 격리한다 — 미정제 텍스트가 메타데이터 저장소에 들어갔다는 뜻이므로 콘텐츠 문제가 아니라 결함이기 때문이다.

LocalVerificationContentReader는 스테이징(업로드 중)과 발행 콘텐츠(재검증) 양쪽을 읽는다 — "otherwise re-verifying a quarantined file would silently inspect nothing and accept it".

42. Confirmed — 인가와 감사가 정보를 흘리지 않는다

RoleBasedFileAccessPolicy는 열 개 연산을 READ/WRITE/ADMIN 세 계층으로 접는다. 근거가 적혀 있다 — 연산별 역할 맵은 COPY를 주고 CREATE를 안 주는 조합을 허용하는데 "a copy creates a file"이므로 제한처럼 보이고 제한이 아니다. admin은 write를 상속하지 않는다 — 삭제할 수 있다는 이유로 force-delete까지 되면 감사되는 관리 평면이 일반 데이터 평면으로 도달 가능해진다. 빈 admin 역할 집합은 생성자가 거부한다("would leave the management plane unreachable rather than protected").

거부 메시지는 필요한 역할도 주체의 역할도 말하지 않는다 — "a denial that reported what was missing would turn every 403 into a readable description of the role model".

UnenforcedFileAccessPolicy의 설계도 기록할 만하다. 이름 자체가 장치다 — composition root가 타입 이름으로 매치해 production startup을 거부한다. "A permissive default that looked like a real policy would ship as one."

실패 메시지 위생도 일관된다. LocalStorageFailures의 어떤 메시지도 경로·마운트·루트를 담지 않고, 감사 어댑터가 쓰는 필드는 전부 지문·코드·불투명 식별자다.

43. Confirmed — 실패를 "재시도 안전한가"로 분류한다

AmbiguousFilesystemOperationDetectorIOException을 네 결과로 나눈다(NOT_SENT / DEFINITELY_REJECTED / AMBIGUOUS_COMPLETION / RECONCILIATION_REQUIRED). 기본값이 보수적이다 — 인식하지 못한 실패는 변경 연산이면 ambiguous다. javadoc이 비대칭을 적는다: "the cost of a wrong 'safe to retry' is a corrupted object, while the cost of a wrong 'ambiguous' is one reconciliation entry."

mutating 인자로 순수 읽기는 결코 ambiguous가 되지 않게 하고, stale handle은 변경 연산일 때 RECONCILIATION_REQUIRED로 격상한다 — 에러만으로는 결과를 알 수 없으므로 물리 증거를 다시 읽어야 한다.

다만 isStaleHandle·isLostResponseFilesystemFailureClassifier.isOutOfSpace메시지 텍스트 매칭에 의존한다("stale file handle", "estale", "timed out", "No space left on device", "Disk quota exceeded"). 후자에는 주석이 붙어 있다 — "The JDK has no dedicated exception for this, so the reason text is the only available signal." 로케일이나 JDK 판본에 따라 문구가 달라지면 분류가 기본값으로 떨어지는데, 기본값이 보수적(변경 연산 → ambiguous)이므로 안전한 방향이다. 기록만 한다.

44. Negative-space probes — sub-scope 05

  • 8.1 reachability: 8개 port 구현과 app-bootstrap의 8개 bean 생성 지점을 직접 확인해 §4를 확정(§39).
  • 8.2 계약 ↔ 구현: scriptable 탐지의 선언("detection is on content")과 실제 매칭 범위(접두사 시작 고정)의 격차 — 실행 probe로 확정(§40).
  • 8.2b 조건부 형제: 같은 "접두사 시작 비교"가 MediaTypeVerifier에서는 정확하고 ScriptableContentPolicy에서는 우회 가능(§40).
  • 8.3 fail-closed 합성: coordinator의 timeout/예외 → RETRY, combiner의 RETRY > ACCEPT 우선순위(§41).
  • 8.4 위생/문서: 실패·감사 메시지에 경로 없음, 거부 메시지에 역할 없음(§42). 메시지 텍스트 매칭 의존과 그 보수적 기본값(§43).

45. Sub-scope 05 findings backlog

우선순위 finding reachability
P2 README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reaping·quota 네 능력에 대해 사실과 다르다 — 8개 port 구현과 app-bootstrap의 8개 bean으로 확정 이 문단으로 능력 유무를 판단하는 독자
P2 ScriptableContentPolicy가 마커를 접두사 시작에서만 찾아, UTF-8 BOM·NUL·선행 HTML 주석이 붙은 실행 가능 콘텐츠를 ACCEPT한다 (실행 probe 3건) inlineSafeProfile=false이고 claimed 타입을 선언하지 않는 업로드
P3/기록 실패 분류가 예외 메시지 텍스트("stale file handle", "timed out", "No space left on device")에 의존한다 — 문구가 달라지면 보수적 기본값으로 떨어지므로 안전한 방향 로케일/JDK 판본이 다른 배포

46. Sub-scope 05 완료 조건

  • denominator 29 / 29 FULL_READ (146-... OWNED FILES)
  • §8.1~§8.4 네 종 probe 수행, sub-scope 01에서 이월한 P2를 port 구현·bean 생성 지점으로 확정
  • scriptable 탐지 우회를 hermetic 실행 probe 3건으로 확정
  • 임시 probe class 1개 추가 후 제거, git status --short = 0, 소스 미변경

47. Sub-scope 06 범위와 denominator

내부 상태: COMPLETE — 15 / 15 FULL_READ 범위: LocalPersistentPayloadOperations 1,101 + StreamingCsvEncoder 190 + FilesystemCsvExportAdapter 188 (main 3) + 전용 test 3 + testkit 9 역할: R2 payload의 보안 경계, RFC-4180 스트리밍 인코딩(수식 완화 포함), 그리고 store 계약·크래시 행렬·NFS 모호성 testkit

manifest와 probe: evidence/raw/147-fileserver-payload-testkit-probes.txt.

48. Confirmed — payload 계층이 자신의 잔여 위험을 먼저 선언한다

LocalPersistentPayloadOperations의 클래스 javadoc이 무엇이 서술자 상대이고 무엇이 아닌지를 앞에서 밝힌다.

Reads, writes, and exact deletes use SecureDirectoryStream; the JDK's missing relative hard-link, directory-create, and directory-force primitives are bracketed by attested identity checks in this class.

전수 검사가 그 서술과 일치한다(147-... §8.2). 이 파일의 Files.* 호출은 정확히 그 셋 — Files.createLink(:941), Files.createDirectory(:802), 그리고 force/stat/FileStore 조회 — 뿐이고, 각각 앞뒤로 fileKey·소유자·권한·FileStore 재확인이 붙는다. JDK가 linkat/mkdirat를 노출하지 않으므로 서술자 상대 대응물이 없고, 그 사실을 숨기는 대신 적었다.

이것이 §32와의 차이다. 여기서는 잔여 경로 연산이 (a) 문서에 선언되고 (b) identity 검사로 감싸인다. AtomicMoveContentPublisher의 발행 rename은 (a) 어디에도 선언되지 않고 (b) 같은 모듈이 "a precheck could only ever approximate"라고 적은 사전검사 하나로만 보호된다. 같은 저장소가 같은 문제를 한 번은 정직하게, 한 번은 그렇지 않게 다룬 대비다.

49. Confirmed — CSV 인코더가 스트리밍이고 세 가지 상한을 동시에 건다

StreamingCsvEncoder는 행 단위로 쓰고 즉시 다이제스트에 넣는다. 상한이 셋이다 — 행 수(maximumRows), 누적 바이트(maximumBytes, bytesWritten > maximumBytes - bytes.length로 오버플로 없이 검사), 그리고 컬럼별 UTF-8 바이트(column.maximumUtf8Bytes()). 셀 타입이 스키마와 다르면 거부하고, null은 컬럼이 nullable일 때만 빈 문자열이 된다.

수식 주입 완화는 세 정책으로 갈린다 — ALLOW/MITIGATE(' 접두, 카운트 증가)/REJECT. 후보 판정은 첫 문자가 =, +, -, @, \t, \r인지다(OWASP 권고 집합). formulaMitigatedCount가 receipt와 control record까지 전달되므로(§13의 requireFormulaCountWithinCells) 완화가 일어났다는 사실이 감사 가능한 값으로 남는다.

checkpoint()가 매 행 앞뒤로 스레드 인터럽트를 확인해 협력적 취소를 지원한다.

R1 legacy 어댑터도 두 결함을 이미 고쳤다고 주석에 남긴다 — 전체를 StringBuilder에 모으던 방식(백만 행이면 OOM)을 bounded writer 스트리밍으로, Files.write의 조용한 truncate를 CREATE_NEW로. 다만 R1이 "a stand-in for NFS/SFTP"라고 자칭하는 것은 CLAUDE.md의 "Advertising shared-mounted/NFS, SFTP … as implemented" 금지와 나란히 두면 표현이 조심스럽다("stand-in"은 구현 주장이 아니다). 결함으로 올리지 않는다.

50. Confirmed — testkit이 크래시 지점을 열거해 전수 검증한다

CrashRecoveryMatrixTest@EnumSource(CrashPoint.class)모든 크래시 지점에 대해 두 불변식을 건다.

  • aPublishedObjectIsAlwaysCompleteAndDigestMatched
  • aCrashNeverLeavesAPartialObjectUnderThePublishedKey

즉 "어떤 지점에서 죽어도 발행된 키 아래에 부분 객체가 없다"를 지점별로 확인한다. AFTER_CREATE·DURING_APPEND·AFTER_APPEND_COMMIT·BEFORE_PUBLISH·AFTER_METADATA_BEFORE_QUOTA 등이 열거돼 있어, 새 지점을 추가하면 두 test가 자동으로 그것을 포함한다.

ContentStoreContract는 추상 계약이고 LocalContentStoreContractTest(원자적 이동)와 MetadataPointerContentStoreContractTest(포인터 발행)가 각각 상속한다 — 두 발행 전략이 같은 계약을 통과해야 한다는 것을 구조로 강제한다. 계약 항목도 성질 중심이다: 왕복, 다중 append의 다이제스트가 모든 바이트를 덮는지, 오프셋 불일치가 객체를 건드리지 않고 거부되는지, 범위 읽기가 정확히 요청 바이트만 반환하는지, 선언 다이제스트/길이 불일치가 finalize에서 거부되는지, 부재 객체 삭제가 멱등 성공이면서 divergence를 보고하는지, store가 증명한 capability만 보고하는지, 두 업로드가 물리 키를 공유하지 않는지.

FileserverCrashScenarioMain은 별도 프로세스로 fork되는 진입점이고(§sub-scope 02의 LocalPersistentCrashRecoveryTest가 사용), NfsAmbiguityIntegrationTest·NfsTestEnvironment·PvcCertificationDescriptor는 환경이 있을 때만 도는 자격 검증 fixture다.

51. Negative-space probes — sub-scope 06

  • 8.1 reachability: 세 main 타입 모두 leaf 밖 참조 0(147-... §8.1). FilesystemCsvExportAdapterpublic class인데(다른 것은 package-private/final) 외부 참조가 없으므로 가시성이 필요보다 넓다 — 기록만 한다.
  • 8.2 계약 ↔ 구현: payload 계층의 잔여 경로 연산 선언과 실제 호출 지점 일치(§48).
  • 8.3 중복 mechanism: R1 legacy 인코딩과 R2 스트리밍 인코더 — 별개 포트, 별개 selector, 공유 없음(§49).
  • 8.4 문서/커버리지: 크래시 지점 enum 전수 순회(§50), 두 발행 전략의 공통 계약 상속(§50).

52. Sub-scope 06 findings backlog

우선순위 finding reachability
없음. payload 계층은 잔여 위험을 선언하고 감쌌고, 인코더의 상한·수식 정책·취소는 전부 값으로 관측 가능하며, testkit은 크래시 지점을 열거해 전수 검증한다

53. Sub-scope 06 완료 조건

  • denominator 15 / 15 FULL_READ (147-... OWNED FILES) — main 1,479 LOC 전수 판독
  • §8.1~§8.4 네 종 probe 수행
  • 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능
  • 소스 미변경

54. 모듈 원장 대조

§0의 denominator 119를 하위 범위 실측과 대조한다.

# 하위 범위 main test 근거
1 governance / config / activation 7 2 13 (governance 4 포함) 142
2 control plane + record codec + recovery verifier 3 3 6 143
3 publication 19 7 26 144
4 platform/local IO · gateway · store/publisher 22 8 30 145
5 platform/local 실패·probe·health·orphan + verification + security + audit 24 5 29 146
6 payload · CSV · testkit 3 12 15 147
합계 78 37 119
  • main 78 (12,707 LOC), test 37 (12,043 LOC), governance 4. 총 119 tracked files.
  • unclassified 0, structural-only 0, excluded 0. 6개 하위 범위 모두 FULL_READ.

55. 모듈 findings 종합

우선순위 개수 항목
P2 3 §4·§39 README:105의 "노출된 setting도 bean도 없다"가 audit·health·reaping·quota 넷에 대해 사실과 다름 (port 구현 8, bean 8) · §40 ScriptableContentPolicy가 BOM·NUL·선행 주석으로 우회됨 (실행 probe 3건)
P3 5 §5 R1의 ignoreUnknownFields 부재 · §5 R1/R2 루트 경로 규칙 비대칭 · §6 문서의 기본값 위치·test 목록 오류 · §32 발행 rename의 경로 기반 연산 · §34 maxBorrowedBytes의 서술과 실제 결합 부재
P3/기록 1 §43 실패 분류의 예외 메시지 텍스트 의존

결함이 없는 하위 범위가 셋이다(02·03·06). 이 leaf의 코드 품질은 지금까지 분석한 모듈 중 가장 높은 축에 속한다 — canonical 왕복 검증, 인접 전이 행렬, 서술자 상대 파일시스템 접근, 크래시 지점 전수 순회, fail-closed 검증 합성이 모두 실제로 구현돼 있고 test가 그것을 성질로 고정한다.

반복된 형태는 둘이다.

  1. 문서가 코드보다 좁게 또는 넓게 말한다. README의 guarantee-boundary 문단이 같은 저장소가 만드는 bean을 "없다"고 하고(§4·§39), 기본값의 위치와 test 목록이 어긋나며(§6), 계측의 javadoc이 존재하지 않는 결합을 서술한다(§34). 코드는 대체로 옳고 서술이 뒤처졌다.
  2. 자기 규칙의 예외가 선언될 때와 그렇지 않을 때. LocalPersistentPayloadOperations는 JDK가 서술자 상대 원시연산을 주지 않는 세 곳을 먼저 선언하고 identity 검사로 감쌌다(§48). AtomicMoveContentPublisher의 발행 rename은 같은 성격의 예외인데 선언되지 않고, 보호는 이 모듈이 스스로 "근사에 불과하다"고 적은 사전검사 하나다(§32). 규칙이 아니라 예외를 다루는 방식이 두 곳에서 다르다.

56. 모듈 완료 조건

  • denominator 119 / 119 FULL_READ — 6개 하위 범위 전부 COMPLETE(§54)
  • 하위 범위마다 §8.1~§8.4 네 종 negative-space probe 수행, 증거는 evidence/raw/141147
  • 정적으로 결정 불가한 지점을 실행 probe로 확정: 146(scriptable 탐지 우회 3건)
  • sub-scope 01에서 제기한 P2를 sub-scope 05에서 port 구현·bean 생성 지점으로 확정 — 이월과 종결을 원장에 남김
  • 임시 probe class 1개 추가 후 제거, git status --short = 0, 소스 미변경

57. 실행 검증과 분석 환경 제약

HEAD에서 focused suite를 돌린 결과와 그 해석을 남긴다(evidence/raw/148-fileserver-suite-verification.txt).

컨테이너 기본 로케일에서 :adapter:outbound:fileserver:test398 tests / 1 failed / 3 skipped로 끝난다. 실패한 것은 LocalPersistentPayloadOperationsTest.inspectsLegacyRootArtifactByBoundedNoFollowStreamingWithoutRewritingIt() 하나이고, 원인은 다음이다.

java.nio.file.InvalidPathException: Malformed input or input contains unmappable
  characters: 월간 export -- legacy 01.csv
    at java.base/sun.nio.fs.UnixPath.encode(UnixPath.java:129)
    at LocalPersistentPayloadOperationsTest.java:331

분석 컨테이너의 로케일이 POSIX이고 sun.jnu.encoding=ANSI_X3.4-1968이라, test fixture가 자기 경로를 만드는 단계(Path.resolve, 331행)에서 한국어 파일명을 인코딩하지 못한다. adapter production 코드는 실행되지도 않는다.

같은 revision을 LANG=C.UTF-8로 다시 돌리면 BUILD SUCCESSFUL이다. 소스는 한 줄도 바꾸지 않았다.

판정: 분석 환경 제약이지 저장소 결함이 아니다. (JPA scope에서 PostgreSQL TLS lane을 같은 방식으로 분류한 것과 동일한 형태다. 다만 이 leaf는 비-ASCII 소스를 test fixture에 쓰므로, adapter/outbound/identifierbuild.gradle에서 UTF-8 인코딩을 명시적으로 고정한 것과 같은 조치를 이 leaf는 하지 않았다는 점은 기록해 둔다 — 컴파일 인코딩과 런타임 sun.jnu.encoding은 별개 문제이므로 결함으로 올리지는 않는다.)

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 69개 (main 55 · test 13 · 기타 1).

src/adapter/outbound/fileserver/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-fileserver 항목)

main:
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Validation.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournal.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/audit/StructuredAdminAuditAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/audit/StructuredFileserverAuditAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/AmbiguousFilesystemOperationDetector.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/AtomicMoveContentPublisher.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/ContentPublishVerification.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/DefaultPhysicalPathResolver.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/FilesystemFailureClassifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalAppendEngine.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalBlockingContentStore.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalCopyContentGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalOrphanScanAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalReconciliationContentProbe.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageCapabilityProbe.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageFailures.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageHealthAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageUsageProbe.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalUploadHandle.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalZeroCopyDownloadGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/MetadataPointerContentPublisher.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/SafeFileChannelFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/SecureDirectoryWalk.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/TransferBufferPool.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/security/RoleBasedFileAccessPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/security/UnenforcedFileAccessPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/FilenamePolicyVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/LengthVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/LocalVerificationContentReader.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/MediaTypeVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/ScriptableContentPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/VerificationCoordinator.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/VerificationPolicyCombiner.java

test:
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/ContentPublisherTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalAppendMemoryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/ContentStoreContract.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/CrashRecoveryMatrixTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/LargeFileBoundedMemoryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/LocalContentStoreContractTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/MetadataPointerContentStoreContractTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/NfsAmbiguityIntegrationTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/NfsTestEnvironment.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/PvcCertificationDescriptor.java

기타:
  src/build.gradle

해석되지 않은 인용 (9종) — 외부 타입·문서상 약칭 등:
  app-bootstrap/application.yml
  evidence/raw/141-fileserver-module-inventory.txt
  evidence/raw/142-fileserver-config-activation-probes.txt
  evidence/raw/143-fileserver-control-plane-probes.txt
  evidence/raw/144-fileserver-publication-probes.txt
  evidence/raw/145-fileserver-local-io-probes.txt
  evidence/raw/146-fileserver-verification-security-audit-probes.txt
  evidence/raw/147-fileserver-payload-testkit-probes.txt
  evidence/raw/148-fileserver-suite-verification.txt