Files
document-haness/docs/clean-architecture-backend-template/analysis/05-adapter-outbound-persistence-jpa.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

4951 lines
288 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# adapter-outbound-persistence-jpa 상세 분석
## SSOT identity — 2026-08-31 재검증
- registered leaf id: `adapter-outbound-persistence-jpa`
- canonical state `analysisFile`: `analysis/05-adapter-outbound-persistence-jpa.md` (이 문서) — 이 leaf의 단일 SSOT
- source path: `src/adapter/outbound/persistence-jpa` · Gradle `:adapter:outbound:persistence-jpa`
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]`
- registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]`
- coverage ledger: `FULL_READ` **605** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
---
> 상태: COMPLETE
> 기준 revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
> 분석 범위: `src/adapter/outbound/persistence-jpa`
> Gradle path: `:adapter:outbound:persistence-jpa`
## 0. 왜 내부 sub-scope로 나누는가
이 leaf는 하나의 Gradle module이지만 실제로는 JPA platform의 여러 capability를 package boundary로 합쳐 놓은 큰 구현체다. tracked file denominator는 605개이며 production Java만 350개다. 따라서 module 전체를 한 번에 훑지 않고, 파일이 정확히 하나의 내부 bounded sub-scope에 귀속되도록 ledger를 먼저 고정하고 각 sub-scope를 exhaustive-reading unit으로 처리한다.
### 전체 denominator
- tracked module files: **605**
- `src/main`: 381 files / 350 Java / 31 resources / 약 27,744 Java LOC
- `src/test`: 101 files / 100 Java / 1 `.gitkeep` / 약 9,511 Java LOC
- `src/testkit`: 41 Java / 약 3,070 LOC
- `src/postgresqlIntegrationTest`: 75 files / 71 Java / 4 SQL / 약 13,958 Java LOC
- `src/jpaPlatformPerformanceTest`: 3 Java / 약 268 LOC
- leaf top-level: `CLAUDE.md`, `README.md`, `build.gradle`, `gradle.lockfile`
### 내부 bounded sub-scope ledger
| # | sub-scope | denominator | status |
|---:|---|---:|---|
| 1 | governance / build / docs / root boundary | 11 | COMPLETE |
| 2 | API contracts (`api/**`) | 55 | COMPLETE |
| 3 | transaction + persistence failure | 51 | COMPLETE |
| 4 | Spring Data + Hibernate + Querydsl | 53 | COMPLETE |
| 5 | PostgreSQL vendor implementation + vendor migrations | 73 | COMPLETE |
| 6 | baseline capability stores/config/audit/cache/H2/etc. | 87 | COMPLETE |
| 7 | Fileserver persistence + migrations | 29 | COMPLETE |
| 8 | Notification persistence + migrations | 68 | COMPLETE |
| 9 | Experimental platform | 38 | COMPLETE |
| 10 | testkit + testkit fixture tests | 62 | COMPLETE |
| 11 | PostgreSQL integration/readiness lane | 75 | COMPLETE |
| 12 | pool/performance contract lane | 3 | COMPLETE |
| | **TOTAL** | **605** | |
이 ledger는 module completion 전까지 모든 tracked file의 최종 disposition(`FULL_READ` / `STRUCTURAL_ONLY` / `EXCLUDED`)을 추적하기 위한 내부 작업 단위다. module-level `state.json`은 이 12개가 모두 닫힐 때만 COMPLETE로 전환한다.
12개 sub-scope는 모두 닫혔다. 최종 disposition은 **605 FULL_READ / 0 STRUCTURAL_ONLY / 0 EXCLUDED / 0 UNCLASSIFIED**이며, ledger 재조정과 module 완료 조건은 §142에 있다.
## 1. 모듈 구조의 1차 관찰
`CLAUDE.md``docs/jpa/repository-adaptation.md`에 따르면 원래 별도의 Stable library module들로 설계된 JPA platform을 이 저장소의 fail-closed leaf registry에 맞춰 **하나의 Gradle leaf 내부 package boundary**로 적응시켰다. 그래서 이 module의 package graph 자체가 사실상 내부 모듈 그래프 역할을 한다.
실제 build는 production project dependency로 `application-core`, `shared-contract`를 선언하고 Spring Data JPA, Spring Integration JDBC, Flyway, Micrometer 등을 사용한다. PostgreSQL/H2 driver는 runtimeOnly, Querydsl/Envers는 compileOnly다. 별도 `testkit`, `postgresqlIntegrationTest`, `jpaPlatformPerformanceTest` source set과 fail-closed lane을 갖는다.
이 문서는 각 sub-scope를 닫아가며 위 설계 문서의 주장과 실제 source/build/test/runtime evidence를 계속 대조한다.
---
## 2. Sub-scope 02 — API contracts (`api/**`)
> 내부 상태: COMPLETE — 49 production + 6 test, **55 / 55 FULL_READ**
> 범위: `src/main/java/dev/caskeleton/adapter/outbound/persistence/api/**` + matching dedicated tests
> 역할: provider/framework implementation보다 안쪽에서 persistence platform의 외부 계약, 실패 의미, query safety, transaction/retry algebra를 고정한다.
### 2.1 숫자 지도와 package map
| package | production | dedicated test | 역할 |
|---|---:|---:|---|
| `api` root | 1 | 1 | bounded persistence operation identity |
| `api.capability` | 3 | 0 | capability/support-level report vocabulary |
| `api.error` | 23 | 2 | provider-neutral persistence failure algebra |
| `api.query` | 10 | 2 | keyset/cursor/query-observation contract |
| `api.transaction` | 12 | 1 | transaction profile/retry/completion-evidence algebra |
| **합계** | **49** | **6** | **55** |
public top-level production type도 정확히 49개다. `docs/architecture/jpa-api-surface.txt`의 committed API baseline 역시 `api` namespace에서 49개를 기록하고 있어 현재 이름 목록 drift는 없다. Gradle `verifyJpaApiSurface`가 이 surface의 추가/삭제를 fail-closed로 검증한다.
이 package에는 Spring/JPA/Repository/Entity/Configuration annotation이 하나도 없다. 즉 JPA adapter 안에 위치하지만 **API vocabulary 자체는 Spring bean discovery나 JPA mapping으로 활성화되지 않는다.** 실제 composition은 `app-bootstrap` 및 implementation package가 소유한다.
### 2.2 이 API가 “adapter 내부 DTO”와 다른 이유
`api/**`는 implementation package와 달리 의도적으로 외부 adopter surface다. committed API baseline 상단도 `api`를 intended external package로 명시한다. 따라서 다음 두 사실을 구분해야 한다.
1. repository 내부 production consumer가 있는가
2. public library contract로 존재할 이유가 있는가
예를 들어 `JpaEntityNotFoundException`은 현재 repository production에서 자신을 제외한 참조 파일이 0개다. 하지만 이 한 사실만으로 dead type이라고 판정하지 않았다. external API surface는 repository 내부에서 직접 생성되지 않더라도 adopter가 catch/translate하는 계약일 수 있기 때문이다.
반대로 public API라는 이유로 내부 invariant 결함까지 “미사용이라 안전”으로 넘기지는 않는다. `SignedJsonCursorCodec`처럼 codec 자체가 public contract이고 자기 encode/decode algebra가 불일치하면 repository 내부 consumer 유무와 무관하게 API defect다.
### 2.3 `PersistenceOperationName`: 자유 문자열 대신 등록 가능한 identity를 타입으로 만든다
`PersistenceOperationName``[a-z][a-z0-9.-]{2,95}` 형식만 허용한다.
목적은 단순 validation이 아니다. 이 값은 다음 구현 계층에서 실제로 사용된다.
- retry observation
- transaction observation
- migration gate
- PostgreSQL failure translation
- JSON/lock observation
- transaction executor/coordinator
즉 persistence operation의 이름이 metric/trace/policy lookup으로 퍼지기 전에 **cardinality와 데이터 유출 가능성을 가장 안쪽 public type에서 제한**한다.
전용 테스트는 다음을 확인한다.
- 동적 identifier를 붙인 이름 거부
- raw/invalid 형태 거부
- uppercase/길이 초과 거부
- registered dotted name 허용
이 구조는 이후 query 쪽 `QueryName`과 동일한 방향을 가진다. “관측 이름을 호출자가 자유 문자열로 만드는 것”을 허용하지 않는 것이 공통 원칙이다.
## 3. Capability API — 실행 기능과 지원 등급을 reportable contract로 분리
### 3.1 `JpaCapability`
현재 enum은 16개 capability id를 갖는다. app-bootstrap의 `JpaPlatformAutoConfiguration.capabilities()` 역시 16개를 선언하므로 **enum catalog와 current composition count는 일치**한다.
Stable composition은 대표적으로 다음을 기본 지원으로 보고한다.
- transaction retry
- completion evidence
- keyset pagination
- batch
- schema gate
- runtime-role verification
- observability
Advanced capability는 PostgreSQL native write/work claim/JSONB/array-range, bulk DML, stateless session, COPY, L2 cache, Envers 등을 constraints와 함께 보고한다.
이 분리는 “classpath에 코드가 있다”와 “현재 composition이 기본 지원한다고 약속한다”를 동일시하지 않는다. capability enum은 vocabulary이고, `CapabilitySupport`가 support level을 결합하며, app-bootstrap composition이 실제 현재 report를 구성한다.
### 3.2 `CapabilitySupport`
record는:
```text
capability
level
constraints[]
```
을 가진다.
constructor가 보장하는 것은:
- capability non-null
- level non-null
- constraints list defensive copy
- 각 constraint non-null / non-blank
이다.
`usableByDefault()`는 STABLE만 true다. Advanced/Experimental이 “존재하므로 기본 사용 가능”으로 오해되지 않게 support level을 코드에 남긴다.
### 3.3 actuator까지 이어지는 실제 consumer
`CapabilitySupport`는 단순 문서용 record가 아니다.
실제 production 흐름은:
```text
JpaPlatformAutoConfiguration.capabilities()
-> List<CapabilitySupport>
-> JpaPlatformReport.capabilities
-> JpaPlatformEndpoint @ReadOperation
-> management endpoint "jpaplatform"
```
이다.
`JpaPlatformReport`는 JDBC URL/user/password/SQL/entity catalog를 필드로 갖지 않도록 설계되어 있고, privilege detail도 boolean으로 축약한다. 즉 management endpoint의 reconnaissance surface를 줄이려는 의도가 source에 명시돼 있다.
### 3.4 API invariant gap — “bounded constraint”는 타입이 강제하지 않는다
`CapabilitySupport` javadoc은 constraints를 actuator report에 게시할 수 있는 **plain, bounded string**으로 설명한다. 그러나 constructor는 길이/형식 상한을 두지 않는다.
focused constructor probe에서 100,000-character constraint가 그대로 accepted/copy되는 것을 확인했다.
```text
constraintLength=100000
```
다만 current app-bootstrap composition이 만드는 constraints는 모두 source에 고정된 짧은 literal이다. 따라서 현재 shipped composition에서 즉시 100KB user-controlled value가 endpoint에 노출된다고 주장하지 않는다.
판정:
- **Observed:** public API type의 “bounded” invariant는 constructor에서 강제되지 않는다.
- **Observed:** type은 actuator report의 실제 element type이다.
- **Observed:** current composition은 bounded static literal만 생성한다.
- **Conclusion:** current exploit/incident가 아니라 **P2 API-contract hardening gap**이다.
향후 이 type을 외부 composition/fork가 직접 사용하거나 dynamic constraint source가 생기면 report bound가 호출자의 규율에 의존한다. public API가 “safe to publish”를 자기 계약으로 주장하려면 max length/accepted vocabulary를 타입에서 고정하거나, report projection 단계에서 별도 sanitization/bounding이 필요하다.
## 4. Error API — provider exception을 stable failure algebra로 변환
### 4.1 `FailureCategory`가 retry보다 먼저 존재한다
error hierarchy의 핵심은 “예외 class를 많이 만든 것”이 아니라 provider-specific signal을 bounded semantic category로 변환하는 것이다.
대표 category는:
- serialization failure
- deadlock
- optimistic conflict
- lock not available
- connection unavailable
- timeout 계열
- unique/FK/not-null/check constraint
- entity not found
- schema mismatch
- data corruption
- completion unknown
등이다.
이 category는 뒤의 retry policy/metric이 SQLSTATE/provider message를 직접 해석하지 않게 하는 중간 vocabulary다.
### 4.2 `JpaFailureContext`: telemetry-safe failure metadata
`JpaFailureContext`가 가지는 정보는 operation, SQLSTATE/constraint, attempt, retryability, completion-unknown, elapsed, trace 등으로 제한된다.
중요한 invariant는 다음이다.
- arbitrary identifier는 그대로 담지 않고 bounded/redacted form으로 축약
- malformed SQLSTATE는 `redacted`
- absent SQLSTATE는 sentinel로 표현
- completion unknown과 retryable=true를 동시에 표현할 수 없음
- completion-unknown factory는 항상 automatic retry를 차단하는 형태를 만든다
즉 “exception이 발생한 뒤 로그에서 실수하지 말자”보다 앞선 위치에서 **failure context가 위험한 shape 자체를 표현하기 어렵게** 만든다.
### 4.3 `JpaPersistenceException`: bounded message와 raw cause의 역할을 분리
base exception message는 provider cause message를 그대로 복사하지 않고 category + bounded context로 만든다. dedicated test도 provider cause에 email marker를 넣었을 때 top-level exception message에 노출되지 않는 것을 검증한다.
동시에 raw `Throwable cause`는 보존한다. 이는 중요한 구분이다.
```text
exception.getMessage() -> bounded platform message
exception.getCause() -> original provider failure
```
따라서 API 자체의 message contract는 안전하게 설계되어 있지만 **downstream logger가 stacktrace/cause message까지 출력해도 안전하다는 뜻은 아니다.** 이 여부는 `observation`/transaction failure logging consumer를 읽을 때 별도 검증해야 한다.
현재 API sub-scope에서는 이를 defect로 확대하지 않고 후속 observation/transaction trace 항목으로 넘긴다.
### 4.4 constraint exception은 raw constraint name을 외부 meaning으로 쓰지 않는다
`ConstraintCode`/`ConstraintViolationDetails`는 DB constraint의 raw name이 application-visible meaning이 되지 않도록 stable code/details로 변환하기 위한 계약이다. 실제 PostgreSQL catalog/translator가 consumer다.
Unique/FK/NotNull/Check exception은 이 details를 결합한다. 즉 application이 `uk_user_email_2026_v2` 같은 physical identifier를 분기 조건으로 쓰는 대신 stable platform vocabulary에 의존하게 한다.
구체적인 catalog mapping 및 SQLSTATE correctness는 PostgreSQL vendor sub-scope에서 exhaustive하게 검증한다.
### 4.5 completion unknown을 exception type으로 분리
`TransactionCompletionUnknownException`은 단순 `ConnectionUnavailableException`의 한 종류로 흡수되지 않는다.
이 타입은:
- failure category = completion unknown
- retryable=false
- transaction completion evidence
- bounded optional transaction key
를 결합한다.
constructor도 들어온 context가 retryable 형태라 해도 completion-unknown-safe context로 변환한다. 결과적으로 뒤의 retry policy가 실수하더라도 “commit됐을 수 있는 work를 다시 실행”하는 경로를 만들기 어렵다.
이 contract는 transaction implementation의 evidence frame/commit classifier가 실제로 언제 UNKNOWN을 선택하는지 확인해야 완성된다. 그 실행 의미는 다음 sub-scope의 핵심 대상이다.
### 4.6 `JpaEntityNotFoundException`: current repository consumer 0
exact-ish repository production reachability probe에서 이 public type만 현재 source 외 production reference가 0이었다.
이 type은 initial JPA platform commit부터 존재하고 committed API baseline에도 명시적으로 포함된다. 따라서 현 단계 판정은:
```text
confirmed dead code ✗
current repository production consumer 없음 ✓
committed intended external API ✓
```
이다.
향후 public API budget을 줄이는 refactor를 할 때는 “실제 external adopter가 존재하는지”를 확인할 후보지만, source tree만으로 제거 가능하다고 결론 내리지 않는다.
## 5. Query API — pagination 비용과 trust boundary를 type shape로 제한
### 5.1 `KeysetPageRequest`: offset 자체가 없다
record는:
```text
after: Optional<C>
size: 1..500
direction
```
만 가진다.
offset/page number를 아예 표현하지 않으므로 keyset API를 사용하는 consumer가 실수로 large offset pagination으로 회귀하기 어렵다.
`fetchSize()`는 요청 size + 1을 반환한다. 즉 별도 count query 없이 한 row를 더 읽어 `hasNext`를 판단하는 계약이다.
### 5.2 `KeysetSlice`: total count를 contract에서 제거
slice는:
```text
items
nextCursor
hasNext
```
만 가진다.
invariant:
- hasNext=true -> nextCursor 필수
- terminal slice -> nextCursor 금지
- items defensive copy
이다.
page number/total count가 없다는 것은 API omission이 아니라 의도된 성능 정책이다. “keyset을 쓰면서 매번 count(*)도 수행”하는 모순을 contract shape에서 제거한다.
### 5.3 `QueryName`과 `QueryObservation`
`QueryName`도 bounded registry key다. raw SQL을 metric/trace identity로 사용할 수 없다.
`QueryObservation.start(QueryName)``QueryScope` 구조에서 scope는:
- rows(count)
- failure(Throwable)
- close()
를 제공한다.
특히 `QueryScope.failure` 문서가 “throwable message를 log하지 말 것”을 직접 계약한다. Micrometer implementation이 이를 실제로 지키는지는 observation sub-scope에서 확인한다.
`NoopQueryObservation`은 backend가 없을 때도 caller control flow가 갈라지지 않게 singleton no-op scope를 제공한다. app-bootstrap `JpaObservabilityAutoConfiguration`에서 actual fallback consumer가 존재한다.
## 6. `SignedJsonCursorCodec`: 좋은 trust-boundary 설계와 경계값 결함이 동시에 존재
### 6.1 의도된 security properties
codec은 다음 token을 만든다.
```text
v1.<base64url(payload)>.<base64url(HMAC-SHA256(version + '.' + payload))>
```
확인한 방어는 다음과 같다.
- signing key 최소 32 bytes
- URL-safe Base64 / no padding
- version까지 MAC input에 포함
- token 전체 길이 4096-character cap
- payload 2048-byte cap
- presented MAC 32-byte exact length 확인
- `MessageDigest.isEqual` constant-time comparison
- MAC 검증 전에 application payload decoder를 호출하지 않음
- oversized public input을 substring/decode/MAC allocation 전에 거부하려는 선행 check
기존 dedicated tests도 tampering, foreign key, unknown version, short key, oversized token, wrong-length MAC, oversized payload 등을 폭넓게 검증한다.
### 6.2 Confirmed P2 — encode가 발급한 2046~2048-byte cursor를 decode가 거부한다
문제는 decoded payload size를 decode **전에** 추정하는 helper다.
```java
private static int decodedLengthOf(int encodedLength) {
return encodedLength / 4 * 3 + 3;
}
```
이 함수는 “최대 decoded size”를 빠르게 계산하려는 의도로 commit `2f5d2fc`에서 hostile-input bounds와 함께 추가됐다. 그러나 codec은 **unpadded Base64URL**을 사용한다.
실제 self-round-trip probe:
```text
size=2045 -> encode OK / decode OK
size=2046 -> encode OK / decode rejects as oversized
size=2047 -> encode OK / decode rejects as oversized
size=2048 -> encode OK / decode rejects as oversized
size=2049 -> encode itself rejects
```
즉 현재 accepted encode domain과 accepted decode domain이 다르다.
형식적으로:
```text
encode accepts payload bytes <= 2048
decode precheck effectively accepts only a smaller subset
there exists x:
encode(x) succeeds
decode(encode(x)) fails
```
이건 hostile token을 더 엄격히 거부하는 정도가 아니다. **codec의 자기 round-trip contract를 깨는 boundary defect**다.
실행 evidence:
- `evidence/raw/035a-jpa-cursor-boundary-probe.java`
- `evidence/raw/035-jpa-cursor-boundary-probe.txt`
### 6.3 왜 기존 테스트가 못 잡았는가
현재 `SignedJsonCursorCodecTest`는:
- ordinary round-trip
- 2049-byte encode rejection
- decode 쪽 arbitrary oversized payload segment rejection
을 각각 검증한다.
하지만 `MAX_PAYLOAD_BYTES` 바로 아래와 정확히 같은 크기에 대해:
```text
decode(encode(payload)) == payload
```
을 검증하지 않는다.
따라서 security-bound 테스트는 많지만 **양쪽 bound가 같은 집합을 표현하는지**에 대한 property가 빠져 있다.
수정 후보는 두 방향이다.
1. unpadded Base64URL의 decoded length를 remainder까지 반영해 정확히 계산
2. MAC 검증 전에 encoded segment의 최대 허용 길이를 exact bound로 계산하고, 실제 decode 후 byte length도 재확인
어느 구현을 택하든 regression criterion은 최소:
```text
payload sizes: 2045, 2046, 2047, 2048 -> round-trip success
2049 -> encode reject
forged oversized segment -> pre-decode reject
```
이어야 한다.
Tech-Log 후보: **CASE** — “DoS 방어용 Base64 사전 크기 검사가 codec의 자기 round-trip을 깨뜨린 경계값 문제”.
## 7. Transaction API — 실행체보다 먼저 retry 가능 상태를 제한한다
### 7.1 `TransactionProfile`
profile은:
- name
- propagation
- isolation
- timeout
- readOnly
- retryProfile
을 결합한다.
write profile은 positive timeout이 필수다. read-only는 zero timeout을 “connection default” 의미로 허용한다.
지원 propagation을 REQUIRED / MANDATORY / REQUIRES_NEW로 좁혀 SUPPORTS/NESTED/NOT_SUPPORTED/NEVER처럼 “실제로 transaction 안에 있는가”를 흐리는 mode를 surface에서 제거했다.
isolation 역시 PostgreSQL에서 의미가 겹치는 READ_UNCOMMITTED를 expose하지 않는다.
### 7.2 `RetryProfile`: completion unknown을 config로 다시 살릴 수 없다
retryable category allowlist는 다음 contender 계열로 제한된다.
- serialization failure
- deadlock
- optimistic conflict
- lock not available
- connection unavailable
`COMPLETION_UNKNOWN`을 넣으면 constructor가 즉시 거부한다. Unique constraint 같은 ineligible category도 거부한다.
즉 failure translator가 retryability를 판단하고, profile이 category allowlist를 가진다고 해서 “어떤 failure도 설정으로 retry 가능하게” 만들 수 없다.
### 7.3 `RetryDecision`: retry / reconcile / fail을 별도 algebra로 둔다
decision은:
- RETRY_FULL_TRANSACTION
- RECONCILE
- FAIL
세 가지이며 retry만 non-zero delay를 가질 수 있다.
이 분리 덕분에 completion unknown이 `delay=0 retry`처럼 표현되지 않는다. “모르겠음”을 “즉시 한 번 더”와 구분한다.
### 7.4 `reason`의 bounded 주석과 현재 사용
`RetryDecision.reason`은 javadoc상 bounded diagnostic/low-cardinality-safe string으로 설명된다. 그러나 constructor는 non-null/nonblank만 확인하고 길이/형식 상한은 없다.
runtime constructor probe에서는 100,000-character reason도 accepted됐다.
```text
retryDecisionReasonLength=100000
```
다만 actual `JpaRetryObservation`은 decision.reason을 metric tag로 사용하지 않는다. metric tag는:
- persistence unit
- operation
- failure category
- retry disposition
으로 구성된다. current repository의 retry-decision reason accessor도 retry policy test 외 실질 telemetry consumer가 확인되지 않았다.
따라서 현재 판정은:
- docs/type invariant mismatch: **observed**
- current metric cardinality incident: **not observed**
- 우선순위: **P3 API hardening/document precision candidate**
이다.
### 7.5 `maxAttempts`에는 타입-level upper bound가 없다
`RetryProfile.maxAttempts`는 최소 1만 강제한다. probe에서 `Integer.MAX_VALUE`도 구성 가능했다.
이것만으로 retry storm defect라고 판정하지 않는다. 실제 coordinator에는 elapsed-time budget도 있고 backoff와 profile binding이 별도로 존재한다. 따라서 maxAttempts의 effective runtime bound는 `RetryBudget`, settings, composition을 포함해 **다음 transaction sub-scope에서 판단**한다.
### 7.6 cross-scope candidate — fallback policy branch의 도달 가능성
API consumer trace 과정에서 `FullTransactionRetryCoordinator`는:
```java
profile.retryProfile() == null
? fallbackRetryPolicy
: DefaultJpaRetryPolicy.forProfile(profile.retryProfile())
```
로 분기한다.
그런데 `TransactionProfile` compact constructor는 `retryProfile``Objects.requireNonNull`로 강제한다. public constructors/factory도 모두 non-null profile을 만든다.
따라서 current type algebra만 보면 fallback branch는 도달 불가능해 보인다. 그러나 이 파일은 transaction sub-scope 소유이고 coordinator 생성/wiring/history까지 읽지 않았으므로 이번 API scope에서는 **dead-path candidate**로만 넘긴다.
## 8. Negative-space probes — API scope
### 8.1 Public surface reachability
Raw: `032-persistence-jpa-api-public-reachability.txt`
49개 public type을 exact import/FQN + same-package reference 기준으로 production tree와 비교했다.
핵심 결과:
- 대부분 implementation/app-bootstrap consumer가 존재
- `JpaEntityNotFoundException`: current repository production reference 0
- 일부 codec/SPI는 implementation 내부 또는 app-bootstrap fallback으로만 소비
단 이 probe는 external adopter, reflection/generated code를 볼 수 없고 same-package Javadoc reference를 과대계수할 수 있다. 따라서 zero reference만 meaningful negative evidence로 사용했다.
### 8.2 Conditional-wiring sibling comparison
API package 안에는 Spring configuration/conditional/entity/repository stereotype가 없다.
반면 actual runtime composition은 app-bootstrap의:
- `JpaPlatformRuntimeAutoConfiguration`
- `JpaObservabilityAutoConfiguration`
- `JpaTransactionAutoConfiguration`
등이 소유한다.
즉 API가 자체 component scan/autoconfiguration으로 몰래 활성화되는 경로는 확인되지 않았다. 이는 “adapter leaf는 implementation을 제공하고 app-bootstrap이 composition을 소유한다”는 repository 정책과 맞는다.
### 8.3 Duplicate-mechanism sweep
repository에는 다른 bounded context에 같은 simple name이 있다.
- inbound-web `CursorCodec`
- httpclient `RetryDecision`
- httpclient `FailureCategory`
- cache-redis `SortDirection`
그러나 package와 input/output responsibility가 서로 다르다.
예를 들어 inbound web cursor codec은 transport pagination cursor 계약이고, persistence cursor codec은 ordering-key payload에 대한 HMAC integrity seam이다. httpclient retry algebra 역시 HTTP request/retry ambiguity를 다룬다.
따라서 **same-name duplication은 존재하지만 현재 evidence로 competing implementation defect는 아니다.** 오히려 bounded context별 vocabulary가 우연히 같은 이름을 갖는 경우다.
### 8.4 Documentation / count drift
- committed API baseline: 49 `api` top-level types
- current source: 49
- `JpaCapability` enum: 16
- current app-bootstrap capability declarations: 16
현재 수치 drift 없음.
API surface verification도 별도 Gradle task가 소유하므로 수동 문서 count만 믿는 구조가 아니다.
## 9. 테스트와 증명 범위
### 9.1 Dedicated API tests
전용 test class는 6개다.
- `PersistenceOperationNameTest`
- `JpaFailureContextTest`
- `JpaPersistenceExceptionTest`
- `QueryNameTest`
- `SignedJsonCursorCodecTest`
- `TransactionProfileTest`
fresh `--rerun-tasks` 실행에서 모두 통과했다.
이들은 다음을 잘 검증한다.
- low-cardinality operation/query name shape
- completionUnknown/retryable contradiction 차단
- top-level exception message의 provider-message 비노출
- cursor signature/tamper/version/size basics
- transaction/retry profile의 주요 unsafe shape
하지만 확인된 cursor self-round-trip boundary property는 포함하지 않는다.
### 9.2 API surface verification
`verifyJpaApiSurface --rerun-tasks`가 통과했다.
이 task가 증명하는 것은 **public type names가 committed baseline과 동일하다**는 것이다. method semantics나 constructor invariant까지 ABI/API compatibility를 검증하는 것은 아니다.
### 9.3 app-bootstrap capability composition test
current repository composition은 별도 app-bootstrap test로 확인한다. 이 test는 현재 16개 capability와 default usable support 등을 검증하지만 public `CapabilitySupport`에 arbitrary external constraints가 들어오는 경우의 bound를 검증하는 test는 아니다.
## 10. API sub-scope findings backlog
### P2 — `SignedJsonCursorCodec` accepted encode domain과 decode domain 불일치
- **Observed:** 2046~2048-byte payload는 encode 성공 후 자기 token decode 실패.
- **Cause shape:** unpadded Base64URL encoded segment의 decoded byte count를 `encodedLength / 4 * 3 + 3`으로 과대 추정.
- **Why it matters:** public paging cursor codec이 자기가 발급한 token을 다음 page에서 거부할 수 있음.
- **Existing tests:** green이지만 exact-bound round-trip 없음.
- **Verification:** `035` runtime probe + 향후 boundary regression/property test.
- **Candidate fix:** exact unpadded Base64 decoded-size arithmetic 또는 encoded-length exact cap + post-decode byte cap.
- **Tech-Log:** CASE 우선 후보.
### P2 — `CapabilitySupport.constraints`의 bounded/report-safe 계약이 타입에서 강제되지 않음
- **Observed:** 100,000-character constraint accepted.
- **Observed:** capability list는 `JpaPlatformReport`를 통해 actuator endpoint model에 포함됨.
- **Observed:** current shipped composition은 static short literals만 생성.
- **Impact:** 현 composition incident가 아니라 public API invariant gap; fork/dynamic composition에서 bound가 caller discipline에 의존.
- **Candidate fix:** type-level max length/vocabulary 또는 report projection에서 bounding.
- **Tech-Log:** OPEN QUESTION/DECISION 후보; 실제 외부 dynamic source가 확인되면 CASE 승격 가능.
### P3 — `RetryDecision.reason`의 “bounded” 설명과 constructor contract 불일치
- **Observed:** 100,000-character reason accepted.
- **Observed:** current retry metrics는 reason을 tag로 사용하지 않음.
- **Impact:** 현재 cardinality defect로 확인되지 않음.
- **Candidate:** length bound를 추가하거나 javadoc의 low-cardinality claim을 실제 사용 범위에 맞게 좁힘.
### Cross-scope candidate — retry fallback branch reachability
- `TransactionProfile.retryProfile`은 non-null invariant.
- `FullTransactionRetryCoordinator`는 null retryProfile을 fallback policy 선택 조건으로 사용.
- transaction wiring/history까지 확인 후 dead branch인지 판정.
### External-surface candidate — `JpaEntityNotFoundException`
- current repository production consumer 0.
- committed intended external API baseline에는 존재.
- external adoption evidence 없이 dead/remove 판정 금지.
## 11. API sub-scope에서 확인한 것과 남긴 경계
### FULL_READ
- production `api/**`: 49 / 49
- dedicated test `api/**`: 6 / 6
- unclassified: 0
### Cross-scope evidence로 읽은 consumer
- `JpaPlatformAutoConfiguration`
- `JpaPlatformReport`
- `JpaPlatformEndpoint`
- `JpaPlatformRuntimeAutoConfiguration` relevant wiring
- `JpaRetryObservation`
- `DefaultJpaRetryPolicy`
- `FullTransactionRetryCoordinator` relevant control flow
이 consumer 파일들은 API 의미를 확인하기 위한 cross-scope trace이며, 해당 소유 sub-scope 전체가 FULL_READ됐다는 뜻은 아니다.
### 다음 sub-scope로 넘긴 것
- 실제 transaction begin/commit/rollback/evidence semantics
- `RetryBudget`가 maxAttempts/defaultMaxElapsed를 어떻게 결합하는지
- completion unknown record/reconciliation path
- provider failure translator chain과 raw cause logging
- Micrometer query scope가 `Throwable` message를 실제로 무시하는지
- PostgreSQL SQLSTATE/constraint mapping correctness
---
## 12. Sub-scope 03 — transaction + persistence failure
> 내부 상태: COMPLETE — 32 production + 19 test, **51 / 51 FULL_READ**
> 범위: `persistence/transaction/**`, `persistence/failure/**`와 matching dedicated tests
> 핵심 질문: transaction을 여는 코드가 아니라 **commit 결과를 언제 확정하는가, 어떤 failure만 replay하는가, completion-unknown을 어떤 evidence로 남기는가**.
### 12.1 숫자 지도
| package | production | dedicated test | 역할 |
|---|---:|---:|---|
| `transaction` | 29 | 18 | application transaction port, JPA executor, retry, deadline, completion evidence |
| `failure` | 3 | 1 | shared operational-error translation |
| **합계** | **32** | **19** | **51** |
모든 51개 source/test를 FULL_READ했다. 이 scope에서는 implementation class를 샘플링하지 않고 transaction state machine, retry budget, Spring mapping, failure translation, root wiring, consumer reachability까지 연결했다.
## 13. 같은 leaf 안에 두 개의 transaction model이 존재한다
현재 persistence-jpa에는 transaction을 표현하는 두 계열이 동시에 존재한다.
### A. application-core canonical boundary
```text
PolicyTransactionPort / TransactionPort
-> SpringTransactionPort (@Component)
-> SpringPolicyTransactionPort
-> PlatformTransactionManager
```
input/output vocabulary:
- `TransactionRequest`
- `TransactionPolicyId`
- `CallBudget`
- `TransactionResult`
- `TransactionOutcome`
- `OperationId`
- `TransactionPhase`
- `ReconciliationReference`
이 모델은 application-core가 소유한다. use case가 outbound adapter type을 import하지 않아도 transaction policy와 uncertain outcome을 표현할 수 있다.
### B. persistence-jpa public API boundary
```text
JpaTransactionExecutor
-> SpringJpaTransactionExecutor
-> FullTransactionRetryCoordinator
-> TransactionProfile / RetryProfile / JpaRetryPolicy
```
input/output vocabulary:
- `PersistenceOperationName`
- `TransactionProfile`
- `RetryProfile`
- `JpaPersistenceException`
- `TransactionCompletionEvidence`
`JpaPlatformRuntimeAutoConfiguration``PlatformTransactionManager`가 있으면 `SpringJpaTransactionExecutor` bean을 만들고, 그 executor가 있으면 `FullTransactionRetryCoordinator` bean도 만든다.
따라서 source tree 수준에서는 B가 단순 historical class가 아니라 **현재 runtime bean graph에도 포함되는 구현**이다.
그러나 repository production call search에서는 `FullTransactionRetryCoordinator.execute(...)`를 실제 business/application code가 호출하는 경로가 확인되지 않았다. 반대로 application-core transaction port는 sample/use-case/composition에서 canonical contract로 사용된다.
이 공존 자체는 곧바로 defect가 아니다. `api/**`는 intended external surface이므로 fork/application이 B를 programmatically 사용할 수 있다. 문제는 문서가 두 boundary의 관계를 일관되게 설명하지 못하고, 일부 composition helper는 실제 type relationship과 다른 설명을 한다는 점이다.
## 14. `SpringTransactionPort`: application-core의 실제 Spring 구현
`SpringTransactionPort``PolicyTransactionPort`를 구현하며 `JpaAdapterComponentsConfig`의 narrow component scan으로 등록된다.
이 wiring은 중요하다. root `CaSkeletonApplication`은 persistence package를 broad scan에서 의도적으로 제외한다. 그래서 adapter leaf 내부의 `@Component`를 “annotation이 있으니 알아서 등록될 것”이라고 볼 수 없다.
`JpaAdapterComponentsConfig` source에는 과거 실제 회귀가 기록돼 있다.
- persistence package를 broad scan에서 제외
- `SpringTransactionPort` 같은 component를 별도 scan하지 않음
- 처음 transaction port가 필요한 capability가 조립될 때 unsatisfied dependency로 드러남
- 해결: JPA master switch 아래에서만 persistence adapter package를 narrow scan
즉 이 module에서 Spring stereotype의 존재와 runtime reachability는 별개다. current root는 `PersistenceJpaRootAutoConfiguration -> JpaAdapterComponentsConfig -> component scan` 체인을 통해 이를 해결한다.
### 14.1 기본 transaction mode
`TransactionPort` primitive는 다음으로 매핑된다.
| application operation | Spring propagation | isolation | read-only |
|---|---|---|---|
| `inWrite` | REQUIRED | READ_COMMITTED | false |
| `inRootWrite` | REQUIRED | READ_COMMITTED | false |
| `inRead` | REQUIRED | READ_COMMITTED | true |
| `inNew` | REQUIRES_NEW | READ_COMMITTED | false |
특히 vendor default isolation에 맡기지 않고 READ_COMMITTED를 명시한다.
`inRootWrite`는 REQUIRED이지만 일반 `inWrite`와 의미가 다르다. 시작 전에 `TransactionSynchronizationManager.isActualTransactionActive()`를 확인해 ambient physical transaction이 있으면 manager/action 호출 전에 거부한다. “root boundary”를 REQUIRED의 join semantics로 조용히 바꾸지 않는다.
focused test는 실제로 manager call count/action call count까지 0인지 확인한다.
### 14.2 caller-visible 성공은 physical commit 이후
`SpringTransactionPortTest`는 다음을 고정한다.
- work가 value를 만들었다고 바로 caller에게 반환하지 않음
- transaction template/manager commit이 끝난 뒤에만 success가 caller-visible
- commit failure면 work value를 반환하지 않음
- action failure는 rollback
- REQUIRES_NEW는 별도 propagation
이것은 application-core의 `TransactionPort` javadoc이 요구한 “return after physical commit”을 adapter가 실제로 구현하는 evidence다.
## 15. `SpringPolicyTransactionPort`: transaction result를 boolean 성공/실패보다 세밀하게 표현
`PolicyTransactionPort.inTransaction(...)`은 단순 예외 기반 wrapper가 아니다.
결과는 최소 다음 상태를 구분한다.
- `Committed`
- `CommittedWithPostCommitFailure`
- `Participating`
- `DeterminateRollback`
- `Indeterminate`
핵심은 **commit exception = rollback**으로 가정하지 않는 것이다.
### 15.1 commit failure 분기
commit 호출 전에 phase를 `COMMIT_REQUESTED`로 올리고, Spring `TransactionSynchronization` sentinel로 실제 callback을 관찰한다.
commit에서 exception이 발생해도:
1. `afterCommit()`이 이미 확인됐으면 `CommittedWithPostCommitFailure`
2. rollback callback/`UnexpectedRollbackException`/replay-candidate가 확인되면 `DeterminateRollback`
3. 그 외에는 `Indeterminate`
로 나눈다.
즉 연결 끊김 같은 애매한 exception을 “rollback이겠지”라고 간주하지 않는다.
### 15.2 canonical application path는 자동 duplicate replay를 막는다
`Indeterminate`는 retry 대상이 아니다.
replay 조건은 모두 만족해야 한다.
- policy = `COMMAND_SERIALIZABLE_REPLAY_SAFE`
- 현재 attempt가 physical transaction owner
- attempt < configured max
- current thread not interrupted
- result가 `DeterminateRollback`
- failure가 40001 serialization 또는 40P01 deadlock replay candidate
따라서 commit ack를 못 받은 상태는 replay되지 않는다.
이 점은 뒤에서 다룰 JPA public API completion-evidence wiring gap의 중요한 mitigation이다. **현재 canonical application path는 completion evidence infrastructure가 없어도 불확정 commit을 자동 재실행하지 않는다.**
다만 current `SpringPolicyTransactionPort`가 만드는 `TransactionResult.Indeterminate``reconciliationReference`는 두 생성 경로 모두 `Optional.empty()`다. 즉 application-core type은 durable reconciliation reference를 표현할 수 있지만 이 adapter는 현재 그 reference를 채우지 않는다.
## 16. CallBudget를 transaction timeout보다 먼저 적용한다
application policy path는 timeout을 단순히 `TransactionDefinition.setTimeout()` 하나로 끝내지 않는다.
### 16.1 `JpaTransactionSettings`
`ca-skeleton.jpa.transaction` settings는 transaction/resource-budget defaults를 가진다.
주요 invariant:
- duration positive
- duration <= 1 day
- retry max attempts 1..5
- statement timeout <= transaction timeout
- lock timeout < statement timeout
- completion/acquisition/action margin hierarchy
즉 runtime에서 무한 retry나 무한 transaction timeout을 property 하나로 열 수 없게 hard cap을 둔다.
이 점은 API `RetryProfile.maxAttempts`가 upper bound를 갖지 않는 것과 대비된다. canonical application path는 실제 deployment settings에서 최대 5회를 강제한다.
### 16.2 `TransactionDeadlineCalculator`
CallBudget admission은 connection pool을 빌리기 **전**부터 시작한다.
transaction을 열 가치가 있으려면 남은 budget이 최소 다음을 감당해야 한다.
```text
pool acquisition reserve
+ transaction begin reserve
+ minimum action budget
+ completion margin
```
begin 후에는 실제 남은 budget으로:
- Spring whole-transaction timeout
- statement timeout
- lock timeout
- idle-in-transaction timeout
을 다시 계산한다.
따라서 pool에서 오래 기다린 요청이 “원래 5초 timeout이었으니 DB에서 다시 5초”를 받지 않는다. 이미 소비한 wall-clock budget을 transaction layer가 다시 주지 않는 구조다.
### 16.3 `TransactionRetryBackoff`
canonical path의 retry backoff도 CallBudget-aware다.
다음 attempt를 시작하기 전에:
- jitter delay
- 다음 acquisition reserve
- 다음 최소 transaction/action margin
을 모두 감당할 수 있는지 확인한다.
budget이 부족하면 sleep 후 시작했다가 즉시 timeout되는 대신 retry 자체를 포기한다.
## 17. retry classification은 structured state로 제한한다
`TransactionRetryClassifier`는 cause chain에서 SQLSTATE를 찾지만 automatic replay candidate는:
- `40001`
- `40P01`
뿐이다.
`08007` 같은 transaction-resolution-unknown은 candidate가 아니다.
`SpringPolicyTransactionPort`는 ordinary command에서 40001이 나더라도 `COMMAND_SERIALIZABLE_REPLAY_SAFE`가 아니면 retry하지 않는다. failure 종류뿐 아니라 **업무 side-effect가 replay-safe하다고 application policy가 선언했는가**가 함께 필요하다.
이것은 “DB가 retryable이라고 말하니 use case를 다시 실행”하는 구조와 다르다.
## 18. public JPA path: `SpringJpaTransactionExecutor`
이 executor는 한 번의 physical attempt만 담당한다. 자체 retry는 하지 않는다.
실행 순서:
```text
TransactionEvidenceContext.begin(...)
-> TransactionTemplate.execute(work)
-> success return
or
-> attempt boundary에서 failure translation
-> translated runtime exception rethrow
finally
-> TransactionEvidenceScope close
```
attempt boundary에서 operation, attempt number, elapsed time, reconciliation key를 알고 있으므로 raw provider exception을 `JpaPersistenceException`으로 변환하는 위치로 사용된다.
vendor translator가 조립되면 PostgreSQL 40001/40P01 같은 structured SQLSTATE가 stable exception으로 바뀌어 coordinator가 처리할 수 있다.
## 19. `FullTransactionRetryCoordinator`: whole-use-case retry 의도
coordinator는 `JpaPersistenceException`만 catch하고, retry decision에 따라 **새 transaction / 새 persistence context에서 전체 work를 다시 호출**한다.
설계상 중요한 guard:
- completion unknown -> no retry
- irreversible side effect context -> no retry
- retry budget elapsed -> stop
- max attempts -> stop
- backoff interrupt -> stop
- retry listener는 observation only
이 모델 자체의 unit tests는 강하다. serialization/deadlock retry, exhaustion, completion unknown no-retry, interrupted sleep, irreversible side effect 등을 검증한다.
하지만 current implementation에는 public composition contract와 맞지 않는 별도 defect가 있다.
## 20. Confirmed P2 — application-supplied `JpaRetryPolicy`가 valid execution에서 무시된다
`JpaTransactionAutoConfiguration`은 명시적으로 다음 overload를 제공한다.
```java
retryCoordinator(
SpringJpaTransactionExecutor executor,
JpaRetryPolicy policy,
RetryEventListener listener)
```
javadoc도 **“retry coordinator for an application-supplied policy”**라고 설명한다.
constructor는 이 policy를 `fallbackRetryPolicy`로 저장한다.
그러나 `FullTransactionRetryCoordinator.execute(...)`는 실행마다:
```java
profile.retryProfile() == null
? fallbackRetryPolicy
: DefaultJpaRetryPolicy.forProfile(profile.retryProfile())
```
를 선택한다.
API `TransactionProfile` compact constructor는 `retryProfile`을 non-null로 강제한다. 따라서 valid `TransactionProfile`을 사용하면 `fallbackRetryPolicy` branch는 도달할 수 없다.
더구나 바로 다음 `RetryBudget.forProfile(profile.retryProfile(), ...)`도 non-null profile을 요구하므로 null branch가 hypothetically 열려도 정상 execution model과 맞지 않는다.
### 실행 probe
custom policy를 다음처럼 넣었다.
```text
custom policy decision = always FAIL
custom policy invocation counter
valid TransactionProfile with maxAttempts=2
work = SerializationFailureException
```
결과:
```text
customPolicyCalls=0
workCalls=2
```
즉 custom policy가 “retry하지 말라”고 해도 한 번도 호출되지 않고 default profile policy에 따라 work가 두 번 실행됐다.
Raw:
- `evidence/raw/047a-jpa-custom-retry-policy-probe.java`
- `evidence/raw/047-jpa-custom-retry-policy-probe.txt`
이것은 단순 dead field가 아니라 **public composition factory가 제공하는 custom policy 기능이 실제로 작동하지 않는 functional contract bug**다.
우선순위: **P2**
수정 방향 후보:
1. coordinator가 constructor-supplied policy를 authoritative하게 사용하고 budget만 profile에서 계산
2. custom policy overload를 제거하고 RetryProfile이 단일 SSOT임을 API에 명시
3. custom policy가 profile-aware해야 한다면 factory에서 policy/profile을 하나의 object로 합성
현재처럼 두 설정원을 받되 하나를 silent ignore하는 형태가 가장 위험하다.
## 21. completion evidence state machine 자체는 잘 설계돼 있다
`EvidenceAwareJpaTransactionManager``JpaTransactionManager`를 상속하고 transaction phase를 `TransactionEvidenceContext`에 기록한다.
대략:
```text
NOT_STARTED
-> ACTIVE (begin)
-> COMMITTING (provider commit 직전)
-> COMMITTED (provider commit return)
```
rollback은 `ROLLED_BACK`으로 표시한다.
commit 중 RuntimeException이 발생하면 `CommitFailureClassifier`가 **commit phase라는 사실**과 cause chain을 함께 보고 completion unknown 여부를 판단한다.
### 21.1 `CommitFailureClassifier`
completion unknown candidate:
- SQLSTATE 40003
- connection class 08*
- admin shutdown / crash / cannot-connect-now 계열
- transport break cause
이다.
중요한 건 이 classifier를 generic SQLSTATE translator 대신 **commit call 내부에서만** 적용한다는 것이다.
connection reset이 query 실행 중 발생했다면 connection unavailable일 수 있지만, provider에게 COMMIT을 보낸 후 reset됐다면 “commit됐는지 모름”이다. SQLSTATE만으로 이 둘을 구분할 수 없고 transaction phase가 필요하다.
PostgreSQL classifier source도 이 이유를 직접 설명한다.
## 22. historical regression — REQUIRES_NEW evidence stack ownership
`TransactionEvidenceContext`는 single ThreadLocal slot이 아니라 stack을 사용한다.
이유는 `REQUIRES_NEW` 때문이다.
과거에는:
- transaction manager가 commit/rollback 후 frame pop
- executor finally도 자신이 push한 frame을 pop
두 owner가 존재했다.
outer와 inner가 같은 operation/attempt를 가진 경우:
1. inner manager가 inner frame pop
2. inner executor finally가 top을 보고 outer frame까지 자기 것이라고 착각해 pop
3. outer commit failure 시 operation/reconciliation key evidence가 사라짐
현재는:
- manager는 phase만 mark
- `TransactionEvidenceScope`만 pop owner
- scope는 depth identity를 갖고 자기 frame이 top일 때만 pop
으로 고쳐졌다.
`TransactionEvidenceScopeTest``EvidenceAwareJpaTransactionManagerTest`가 nested/thread-local cleanup을 고정한다.
이것은 **현재 defect가 아니라 잘 복구된 historical CASE 후보**다. Clean Architecture보다 transaction infrastructure의 “소유권을 하나로 만들지 않으면 lifecycle evidence가 깨진다”는 주제로 가치가 있다.
## 23. Confirmed P1 — Stable completion-evidence capability가 shipped composition에 설치되지 않는다
여기서는 알고리즘 존재와 runtime wiring을 분리해야 한다.
### 23.1 custom manager production construction = 0
production source 전체에서:
```text
EvidenceAwareJpaTransactionManager.standard(...)
new EvidenceAwareJpaTransactionManager(...)
```
호출이 없다.
`JpaTransactionAutoConfiguration` 문서는:
> manager itself is constructed inside the persistence leaf ... composition root owns the decision whether to install it
라고 설명하지만, 현재 persistence root/import/config 어디에도 실제 installation code가 없다.
`commitFailureClassifier()` factory도 production consumer가 없다.
따라서 `SpringJpaTransactionExecutor``TransactionEvidenceContext.begin()`으로 frame을 만들더라도 일반 `PlatformTransactionManager`는 그 frame을 `ACTIVE/COMMITTING/COMMITTED`로 advance하지 않는다. frame은 기본 `NOT_STARTED` 상태로 남는다.
### 23.2 실제 commit-ack-loss classification probe
현재 compiled executor에 normal fake `PlatformTransactionManager`를 넣고 commit에서:
```text
TransactionSystemException
cause -> SQLException SQLSTATE 08006
```
를 발생시켰다.
현재 vendor translator까지 포함한 결과:
```text
type=ConnectionUnavailableException
category=CONNECTION_UNAVAILABLE
completionUnknown=false
retryable=false
```
Raw:
- `evidence/raw/050a-jpa-commit-ambiguity-probe.java`
- `evidence/raw/050-jpa-commit-ambiguity-probe.txt`
이 결과는 중요한 두 면을 가진다.
#### 안전하게 남은 부분
`CONNECTION_UNAVAILABLE`은 current PostgreSQL translator에서 retryable=false다. 따라서 이 probe의 lost commit ack가 coordinator에서 자동 duplicate retry되는 것은 확인되지 않았다.
#### 깨진 부분
하지만 commit call 중 connection을 잃었다는 **phase-sensitive 의미가 사라졌다.** caller는 “DB에 연결할 수 없었다”와 “COMMIT을 보냈고 결과를 모른다”를 구분할 수 없다.
이 구분을 위해 만들어진 `CommitFailureClassifier`/`TransactionCompletionUnknownException`이 composition에서 동작하지 않는다.
### 23.3 reconciliation record production path = 0
`CompletionUnknownRecord``CompletionUnknownRecorder`는 current production에서 자신들의 정의 외 consumer/implementation이 없다.
그런데 documentation은 훨씬 강한 계약을 선언한다.
support matrix:
```text
Commit completion evidence = Stable
Automatic reconciliation unsupported.
The platform records; the domain resolves.
```
runbook:
```text
Signal:
- jpa.transaction.completion.unknown incremented
- a CompletionUnknownRecord in the reconciliation channel
```
그리고 operator procedure는 그 record의 `transactionKey`를 사용하라고 한다.
현재 이 record를 실제로 쓰는 production channel은 확인되지 않았다.
### 23.4 completion-unknown metric도 현재 transaction path에서 호출되지 않는다
`JpaTransactionObservation.recordCompletionUnknown(...)` 구현은 존재한다.
하지만 production에서:
- `JpaObservabilityAutoConfiguration` construction = 0
- `JpaTransactionObservation.recordCompletionUnknown(...)` call = 0
- `recordCommitted/recordRolledBack/recordTimedOut` call도 0
이다.
`JpaPlatformRuntimeAutoConfiguration`이 만드는 default `RetryEventListener`도 empty implementation이며, `JpaObservabilityAutoConfiguration`을 통해 metric listener로 합성하지 않는다.
따라서 runbook의 `jpa.transaction.completion.unknown` signal은 현재 source wiring으로는 생성 근거를 찾지 못했다.
이 observability factory 전체의 reachability 문제는 later observation/baseline capability sub-scope에서 다시 exhaustive하게 확인한다. 여기서는 completion-unknown path의 cross-scope evidence로만 기록한다.
### 23.5 canonical application boundary의 mitigation
이 defect가 곧 “현재 모든 use case가 unknown commit을 duplicate retry한다”는 뜻은 아니다.
canonical `SpringPolicyTransactionPort`는 독립적인 Spring synchronization sentinel을 사용해 commit exception을 `TransactionResult.Indeterminate`로 반환하며 replay하지 않는다.
즉 current application path의 **automatic retry safety는 별도 mechanism으로 유지**된다.
하지만:
- JPA platform public executor/coordinator가 advertised completion evidence를 제공하지 못함
- durable record 없음
- runbook metric 없음
- application `Indeterminate`도 reconciliationReference는 empty
이므로 advertised operator reconciliation contract는 충족되지 않는다.
우선순위: **P1 — reliability / data-integrity operations contract**
범위 한정:
- current canonical PolicyTransactionPort는 no-auto-retry safety를 유지한다.
- P1은 “commit ambiguity를 stable semantic + durable reconciliation evidence로 표면화한다”는 JPA platform 약속이 실제 composition에서 빠진 점이다.
- real PostgreSQL lost-ack end-to-end behavior는 later PostgreSQL integration lane에서 추가 qualification해야 한다.
Tech-Log: CASE + DECISION 강한 후보.
## 24. dual transaction stack의 architecture drift
commit `2f5d2fc`에서 old `@RetryableJpaTransaction` interceptor가 삭제됐다.
그 diff는 이유를 명시한다.
- application service에 outbound adapter annotation을 붙이면 dependency direction 역전
- canonical boundary는 `PolicyTransactionPort.inTransaction(...)`
여기까지는 Clean Architecture와 일치한다.
문제는 이어지는 문장이:
> the retry coordinator below is what implements it, not a second way to ask for the same thing
이라고 말한다는 점이다.
실제 type graph는 그렇지 않다.
```text
PolicyTransactionPort
<- SpringTransactionPort
FullTransactionRetryCoordinator
X implements PolicyTransactionPort 아님
```
또 runtime auto-configuration은 coordinator를 별도 bean으로 계속 만든다.
따라서 현재 code/doc 관계는:
- annotation-based retry path는 제거됨
- application-core canonical port implementation은 별도로 생김
- old/public JPA executor+coordinator model도 남음
- 문서는 coordinator가 canonical port를 구현한다고 잘못 설명
이다.
이것은 단순 문장 오타보다 architecture transition이 완전히 정리되지 않은 흔적이다.
우선순위: **P2 architecture consistency**
결정이 필요하다.
1. JPA executor/coordinator를 진짜 external platform API로 유지한다면 canonical application port와 역할 차이를 명시하고 runtime bean/export 정책을 분리
2. application-core port로 완전히 수렴한다면 coordinator/profile registry 등 old path를 deprecate/remove하고 completion evidence를 canonical path로 이식
현재처럼 “한 경로라고 문서화했지만 실제 두 경로가 존재”하는 상태는 유지보수자가 어느 retry/evidence system을 고쳐야 하는지 혼란을 만든다.
## 25. P3 — `TransactionProfileRegistry`는 declarative retry 제거 후 legacy residue 후보
`TransactionProfileRegistry`는 current production reference가 0이다.
history를 보면 initial design에서는:
```text
@RetryableJpaTransaction
-> RetryableJpaTransactionInterceptor
-> TransactionProfileRegistry
-> FullTransactionRetryCoordinator
```
형태였다.
commit `2f5d2fc`에서 annotation/interceptor와 그 test를 삭제했지만 registry는 남았다.
현재:
- production consumer 0
- dedicated unit test만 존재
- `api/**` intended external surface가 아니라 implementation `transaction` package
- root bean wiring도 없음
이 evidence 범위에서는 **confirmed unused production implementation candidate**로 볼 수 있다.
단 repository 밖 reflection/external direct construction은 source search로 알 수 없으므로 즉시 삭제 가능성까지 확정하지 않는다. module의 non-api package는 intended external이 아니라는 architecture policy와 함께 보면 cleanup 우선순위는 높아진다.
우선순위: **P3 cleanup**
## 26. zero-reference지만 dead가 아닌 `JpaTransactionConfig`
반대로 `JpaTransactionConfig`도 direct production reference는 거의 없다.
하지만 이 class는:
```java
@Configuration
@EnableConfigurationProperties(JpaTransactionSettings.class)
```
이고 `JpaAdapterComponentsConfig`가 transaction package를 component scan한다.
따라서 direct Java call/import가 0이어도 runtime reachability가 있다.
이 class source 자체도 historical reason을 기록한다.
- root `@ConfigurationPropertiesScan`에서 optional persistence tree 제외
- JPA on 상태에서도 settings가 아무도 bind하지 않던 문제 발생
- transaction port construction 실패
- package-local configuration으로 JPA master switch 안에서만 settings enable
이 사례는 mandatory public-reachability probe가 필요한 이유를 잘 보여준다. static reference count만으로 dead code를 찾으면 Spring discovery path를 오탐한다.
## 27. 두 failure translator 계열은 현재 역할이 다르다
이 scope에는 이름이 비슷한 두 translation mechanism이 있다.
### `PersistenceFailureTranslatorChain`
input:
```text
raw persistence/provider failure
```
output:
```text
JpaPersistenceException hierarchy
```
consumer:
```text
SpringJpaTransactionExecutor / retry semantics
```
목적은 SQLSTATE/optimistic conflict를 retry/completion semantics에 필요한 stable persistence failure로 바꾸는 것이다.
### `failure.PersistenceExceptionTranslator`
input:
```text
RuntimeException / SQLSTATE mapping
```
output:
```text
shared OperationalError / PersistenceFailureException
```
consumer는 adapter/application error boundary 쪽이다.
따라서 동일 이름 영역을 다루지만 current evidence로는 competing duplicate implementation이 아니다. **transaction retry algebra와 platform operational error mapping이라는 서로 다른 output contract**를 가진다.
PostgreSQL vendor translator와 exact SQLSTATE catalog correctness는 vendor sub-scope에서 계속 검증한다.
## 28. conditional-wiring probe
transaction 관련 configuration은 세 종류로 나뉜다.
### 28.1 component-scan-owned
- `SpringTransactionPort`
- `PersistenceExceptionTranslator`
- `JpaTransactionConfig`
`PersistenceJpaRootAutoConfiguration`이 JPA master switch ON일 때 `JpaAdapterComponentsConfig`를 import하고, 그 narrow scan이 이들을 찾는다.
### 28.2 runtime bean-factory-owned
`JpaPlatformRuntimeAutoConfiguration`:
- `SpringJpaTransactionExecutor``PlatformTransactionManager`가 있을 때
- `FullTransactionRetryCoordinator` — executor가 있을 때
- default empty `RetryEventListener`
### 28.3 현재 설치되지 않는 specialized implementation
- `EvidenceAwareJpaTransactionManager`
- `CompletionUnknownRecorder`
- `JpaTransactionObservation` call path
이 셋은 문서상 completion-evidence/operability의 핵심이지만 current root assembly에서 provider가 없다.
이 sibling comparison으로 “class가 있으니 feature가 있다”는 판단을 피했다.
## 29. documentation drift
transaction docs에는 현재 서로 다른 세 시대의 설계가 겹쳐 있다.
### current source truth
application-core canonical:
```text
PolicyTransactionPort -> SpringTransactionPort
```
public JPA runtime:
```text
SpringJpaTransactionExecutor + FullTransactionRetryCoordinator bean도 별도 존재
```
### `JpaTransactionAutoConfiguration` javadoc
canonical port와 coordinator가 같은 구현인 것처럼 설명 — current type graph와 불일치.
### `docs/jpa/transaction-guide.md`
application service가 `TransactionPort or JpaTransactionExecutor`로 boundary를 연다고 설명 — 두 public usage model을 함께 유지하는 설명.
### `support-matrix.md` / runbook
completion evidence Stable, platform records unknown, metric + reconciliation record가 있다고 설명 — current wiring과 불일치.
따라서 transaction documentation은 단순 오래된 class 이름 수준이 아니라 **어떤 transaction model이 canonical인지와 Stable capability가 무엇을 실제 제공하는지**를 재정렬해야 한다.
## 30. fresh verification과 실제 증명 범위
### 30.1 transaction/failure focused tests
fresh:
```text
:adapter:outbound:persistence-jpa:test
--tests transaction.*
--tests failure.*
--rerun-tasks
```
결과: BUILD SUCCESSFUL.
이 19개 dedicated test가 강하게 증명하는 것:
- retry backoff math
- commit failure classifier 자체의 commit-unknown 분류
- completion record value construction
- EvidenceAware manager 자체의 phase marking
- nested evidence scope cleanup
- FullTransactionRetryCoordinator 자체의 retry/no-retry decisions
- CallBudget/timeout calculations
- application policy port의 determinate/indeterminate/commit result
- root-only tx rejection
- SQLSTATE operational error mapping
그러나 **custom manager의 production installation**은 이 test들이 증명하지 않는다.
### 30.2 root wiring tests
fresh app-bootstrap:
- `JpaPlatformAddonAssemblyTest`
- `JpaPlatformRuntimeAutoConfigurationTest`
- `CapabilityEntityScanRegistrationTest`
결과: BUILD SUCCESSFUL.
이들은:
- old class-level `@ConditionalOnBean(DataSource)` ordering regression 방지
- DataSource/PTM이 있을 때 executor/coordinator bean assembly
- persistence root import/entity scan shape
를 증명한다.
하지만 `PlatformTransactionManager``EvidenceAwareJpaTransactionManager`인지, completion record/metric이 실제 transaction path에서 발생하는지는 assert하지 않는다.
### 30.3 real lost-ack qualification은 아직 아님
`050` probe는 fake manager commit failure로 classification path를 isolate한 것이다.
실제 PostgreSQL server가 commit을 적용한 직후 client ack/network를 끊는 시나리오까지 재현한 것은 아니다. 그 수준의 evidence는 `postgresqlIntegrationTest` sub-scope에서 별도 qualification해야 한다.
## 31. transaction/failure findings backlog
### P1 — completion-evidence Stable contract가 actual composition에 연결되지 않음
- `EvidenceAwareJpaTransactionManager` implementation/test는 존재하지만 production construction 0.
- normal manager commit 08006 probe -> `CONNECTION_UNAVAILABLE`, `completionUnknown=false`.
- `CompletionUnknownRecorder` implementation/consumer 0.
- `JpaTransactionObservation` record call 0; observability composition helper construction도 0.
- support matrix/runbook은 Stable evidence + metric + reconciliation record를 약속.
- canonical application path는 `Indeterminate`로 no-auto-retry safety는 유지하지만 durable reconciliation reference를 채우지 않음.
- **우선순위:** P1 reliability/operability, real DB lost-ack integration qualification 필요.
### P2 — custom `JpaRetryPolicy`가 silently ignored
- public composition overload가 application-supplied policy를 받음.
- valid TransactionProfile은 retryProfile non-null 강제.
- coordinator는 profile이 non-null이면 fallback/custom policy를 사용하지 않음.
- runtime probe: customPolicyCalls=0, workCalls=2.
- **우선순위:** P2 functional contract.
### P2 — canonical transaction boundary documentation과 실제 dual stack 불일치
- docs/source comment는 coordinator가 PolicyTransactionPort를 구현한다고 설명.
- 실제 구현체는 SpringTransactionPort.
- coordinator/runtime bean은 별도로 계속 존재.
- **우선순위:** P2 architecture consistency / Decision 필요.
### P3 — TransactionProfileRegistry legacy residue
- declarative retry interceptor 제거 후 production consumer 0.
- non-api implementation package.
- **우선순위:** P3 cleanup candidate.
### Cross-scope candidate — JPA observability composition 전체 reachability
- `JpaObservabilityAutoConfiguration` current production construction 0.
- query/transaction/retry observation 중 일부가 별도 경로에서 살아 있을 수도 있으므로 observation owning sub-scope에서 다시 exhaustive 확인.
- transaction scope에서는 completion-unknown runbook signal 부재 evidence로만 사용.
## 32. Sub-scope 03 완료 조건
확인한 것:
- production 32 / 32 FULL_READ
- dedicated tests 19 / 19 FULL_READ
- unclassified 0
- application canonical transaction implementation trace
- public JPA executor/coordinator trace
- retry/deadline/CallBudget algebra
- completion evidence state machine
- root/component-scan/conditional wiring
- current public reachability
- duplicate retry/failure mechanism comparison
- documentation drift
- declarative-retry migration history
- custom-policy runtime probe
- commit-ambiguity runtime classification probe
- focused tests fresh
- app-bootstrap wiring tests fresh
남긴 경계:
- actual PostgreSQL lost-commit-ack network qualification
- PostgreSQL SQLSTATE/constraint translator 전체 correctness
- observation package 전체 reachability/metrics completeness
- pool runtime behavior
이 후속 항목은 각각 vendor/integration/observation-performance sub-scope에서 다시 확인한다.
---
## 33. Sub-scope 04 — Spring Data + Hibernate + Querydsl
> 내부 상태: COMPLETE — 42 production + 11 dedicated test, **53 / 53 FULL_READ**
> 범위: `persistence/hibernate/**`, `persistence/springdata/**`, `persistence/querydsl/**`와 matching dedicated tests
> 핵심 질문: JPA query/batch 최적화 helper가 실제로 어떤 비용 경계를 강제하는지, Stable/Advanced 기능이 runtime과 release evidence에서 어디까지 살아 있는지.
### 33.1 숫자 지도
| package | production | dedicated test | 역할 |
|---|---:|---:|---|
| `hibernate` | 22 | 2 | provider policy, statistics, SQL naming, JDBC batch, bulk DML, StatelessSession |
| `springdata` | 17 | 8 | repository fragment, entity graph, sort, keyset, stream, Specification policy |
| `querydsl` | 3 | 1 | optional dynamic-query integration |
| **합계** | **42** | **11** | **53** |
53개 source/test를 모두 FULL_READ했다. 이 scope의 integration semantics를 확인하기 위해 PostgreSQL-backed batch/ID strategy/collection-fetch contract, release-registry/task mapping, app-bootstrap architecture rule도 cross-scope evidence로 읽고 fresh 실행했다.
## 34. 이 sub-scope는 하나의 query framework가 아니라 세 단계의 정책층이다
현재 code shape는 대략 다음처럼 읽는 것이 맞다.
```text
application-owned query/repository contract
|
v
springdata/**
- allowlisted sort
- keyset assembly/predicate
- fetch-plan catalog
- bounded stream lifetime
- Specification safety
|
v
hibernate/**
- provider/version facts
- real Statistics/JDBC batch evidence
- statement naming
- batch/bulk/stateless provider optimization
|
+----------------------+
|
v v
JPA/Hibernate runtime querydsl/**
optional Advanced helper
```
중요한 점은 `springdata``querydsl`이 application-core의 repository contract를 대체하는 generic CRUD layer가 아니라는 것이다. `JpaRepositoryFragmentSupport`에는 범용 `save/findAll/delete`가 없고, domain-owned adapter가 필요한 query mechanism만 조합하게 설계돼 있다.
이 방향은 support matrix의 “platform-owned generic CRUD repository는 unsupported”와 일치한다.
## 35. Hibernate provider policy는 declared baseline과 실제 runtime을 분리한다
`HibernateProviderPolicy`는 상수로 선언된 Stable provider baseline과 실제 classpath에서 읽은 runtime version을 구분한다.
이 설계가 필요한 이유는 repository가 과거 “7.4를 Stable baseline이라고 문서화하면서 실제 Spring Boot BOM은 7.1.x를 resolve”한 상태를 경험했기 때문이다.
현재는:
- declared baseline: policy constant
- runtime provider: `org.hibernate.Version`에서 읽음
- drift 여부: `driftsFromDeclaredBaseline()`
- app-bootstrap capability/report가 runtime value를 사용
으로 나뉜다.
즉 “문서 상수와 같은 상수를 assert해서 green”인 self-fulfilling test는 피한다.
이 sub-scope에서 outside-leaf production consumer가 명확히 존재하는 핵심 Hibernate type도 `HibernateProviderPolicy`다. app-bootstrap이 이를 composition/report에 사용한다.
## 36. 통계 수집은 configuration이 아니라 실제 실행 evidence를 보려 한다
`HibernateStatisticsCollector``HibernateStatisticsSnapshot`은 다음을 분리해서 측정한다.
- prepared statements
- entity loads/fetches
- collection fetches
- flushes
- JDBC batches
특히 JDBC batch count를 Hibernate `Statistics` 값으로 추정하지 않고 `JdbcBatchCounter`를 별도 주입한다.
이것은 중요한 설계 선택이다.
```text
hibernate.jdbc.batch_size = 50
```
은 batching을 “요청한 설정”이지 실제 driver/JDBC batch가 실행됐다는 증거가 아니다.
실제 PostgreSQL integration test도 `delta.jdbcBatches()`를 보고 sequence entity와 IDENTITY entity의 차이를 측정한다.
또 statistics가 disabled면 0을 반환하지 않고 실패한다. 0을 “실제 쿼리가 없었다”와 “측정 자체가 꺼져 있었다” 사이에서 공유하지 않는다.
## 37. batch executor — 과거 data-loss 회귀는 현재 수정돼 있다
`HibernateJpaBatchExecutor`의 기본 전략은:
```text
persist
-> 주기적 flush
-> 주기적 clear
-> final flush
-> final clear
```
이다.
Persistence Context를 clear해야 heap growth를 제한할 수 있지만, flush하지 않은 managed entity를 clear하면 INSERT 자체가 사라질 수 있다.
과거 구현은 `flushSize=100`, `clearSize=150`처럼 경계가 어긋날 때 150번째 clear에서 101~150 rows를 detach해 버릴 수 있었다. executor는 processed=300을 반환하는데 DB에는 250 rows만 남는 형태였다.
현재 코드는:
```java
if (flushDue || clearDue) {
entityManager.flush();
}
if (clearDue) {
entityManager.clear();
}
```
로 바뀌었다.
즉 clear는 항상 flush barrier를 동반한다.
PostgreSQL-backed `HibernateJpaBatchExecutorIntegrationTest`에도 다음 regression이 존재한다.
- mismatched flush/clear boundary에서도 모든 rows 보존
- clear가 flush보다 멀리 있어도 row loss 없음
- flush size보다 적은 rows도 final flush로 보존
- 중간 failure 시 전체 transaction rollback
- active transaction 밖 batch 거부
- real JDBC batch count > 1
- Persistence Context max entity count bounded
이번 fresh 선택 실행에서도 이 integration class 7 tests가 skip/failure 없이 통과했다.
## 38. Confirmed P2 — property-access `IDENTITY` entity가 batch guard를 우회한다
`HibernateBatchConfigurationGuard`는 batching-required profile에서 `GenerationType.IDENTITY`를 거부한다.
그 이유 자체는 실제 PostgreSQL evidence가 있다.
- sequence fixture: JDBC batches > 1
- IDENTITY fixture: JDBC batches = 0
문제는 guard의 annotation 탐색 방식이다.
현재 `usesIdentityGeneration(Class<?>)`은 class hierarchy의 **declared fields**만 읽는다.
```text
field @Id
-> field @GeneratedValue
-> strategy == IDENTITY ?
```
하지만 JPA는 field access뿐 아니라 property access도 허용한다. 즉 다음과 같은 mapping도 정상적인 JPA mapping이다.
```java
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long getId() { ... }
```
### 실행 probe
getter에 `@Id + @GeneratedValue(IDENTITY)`를 선언한 entity class를 현재 compiled guard에 전달했다.
결과:
```text
propertyIdentityDetected=false
propertyIdentityValidation=PASSED
```
`batchingRequired=true` profile이어도 legal property-access IDENTITY entity를 통과시킨다.
현재 repository production entity search에서는 property-access ID mapping이 확인되지 않았으므로 **현재 shipped entity가 이 결함을 밟는 evidence는 없다.**
그러나 이 class는 generic JPA platform guard이고, IDENTITY를 fail-closed로 거부한다고 문서화한다. 따라서 adopter가 property access를 사용하면 guard의 핵심 안전 계약이 깨진다.
우선순위: **P2 — provider guard correctness / adoption safety**
수정 방향:
- JPA metamodel로 actual id attribute/access strategy를 해석하거나
- field/getter 모두 검사하되 duplicate/override access 규칙까지 JPA access semantics와 맞추거나
- 지원 mapping을 field access로 제한한다면 그 제한을 architecture rule로 강제
단순히 getter reflection을 추가하는 것만으로 mixed access/`@Access`까지 완전히 해결됐다고 보기는 어렵다.
Raw: `evidence/raw/053-jpa-query-hibernate-boundary-probe.txt`
## 39. `BatchExecutionResult.batched()`는 작은 실행에 false-negative가 있다
`BatchExecutionResult` javadoc은 `jdbcBatches`를 “batching happened at all”을 판단하는 값으로 설명한다.
그런데 convenience method는:
```java
return jdbcBatches > 1L;
```
이다.
probe에서:
```text
jdbcBatches=1
batched()=false
```
가 확인됐다.
다만 이것을 즉시 높은 우선순위 defect로 올리지는 않는다.
한 번의 `executeBatch`가 여러 statement를 묶었다면 “batching은 발생했다”고 말할 수 있지만, counter가 단지 executeBatch call count만 제공한다면 `1`만으로 그 batch에 몇 row가 묶였는지는 알 수 없다. 현재 real integration contract도 1,000-row run에서 `jdbcBatches > 1`을 강한 evidence로 사용한다.
따라서 현재 판단은:
- **P3 semantic/naming edge**
- `batched()`가 “at least one JDBC batch call”인지 “multiple measured batch executions”인지 API 의미를 명확히 할 필요
이다.
## 40. bulk DML과 StatelessSession은 일반 repository path와 다른 비용 모델을 명시한다
### 40.1 Hibernate bulk DML
`HibernateBulkDmlExecutor`는 arbitrary JPQL string을 아무 데서나 실행하는 helper가 아니다.
- operation name 등록
- affected-row expectation
- persistence-context cleanup
- transaction requirement
를 contract로 둔다.
bulk DML은 managed entity lifecycle을 우회하므로 ordinary entity save와 같은 audit/lifecycle guarantee를 기대하면 안 된다. support matrix도 이를 Advanced capability로 분리한다.
현재 production business consumer는 확인되지 않았고 PostgreSQL integration fixture에서 실제 behavior를 qualification한다. 따라서 “runtime에서 사용 중”이라고 주장하지 않는다.
### 40.2 StatelessSession
`HibernateStatelessSessionRunner`는 오히려 이 platform에서 transaction ownership 예외를 명시적으로 드러낸다.
일반 repository adapter:
```text
application transaction boundary에 참여
```
StatelessSession runner:
```text
새 StatelessSession
-> 자체 physical transaction
-> registered work만 허용
-> affected row cap 확인
-> 초과 시 rollback
```
과거 review에서는 caller가 선언한 maxRows가 실제 affected rows와 연결되지 않는 문제가 있었다. 현재는 `StatelessWorkResult(value, affectedRows)`를 요구하고 cap 초과 시 commit 전에 rollback한다.
즉 과거의 “이름만 row cap” 문제는 현재 코드에서 수정돼 있다.
## 41. Spring Data repository support는 generic CRUD보다 query execution policy에 가깝다
`JpaRepositoryFragmentSupport`는 domain-specific repository adapter가 사용할 공통 실행 support다.
제공하는 것은 대략:
- `EntityManager` access
- query name context
- fetch plan application
- bounded query observation scope
이고 범용 business repository contract는 제공하지 않는다.
이 구조는 Clean Architecture 관점에서 의미가 있다.
application-core가 `JpaRepository`, `EntityManager`, `Specification`을 알 필요가 없고, 실제 domain repository port를 구현하는 outbound adapter 내부에서만 Spring Data/JPA mechanics를 사용한다.
## 42. entity graph catalog는 EntityManager-affinity를 피한다
`EntityGraphCatalog`는 이미 만들어진 `EntityGraph` instance를 전역 보관하지 않는다.
대신 factory를 등록하고 현재 `EntityManager`에서 graph를 생성한다.
그 이유는 JPA graph/object가 provider/session/entity-manager lifetime에 묶일 수 있기 때문이다.
`FetchPlanApplier`는 registered fetch plan을 조회해서:
- fetch graph
- load graph
hint를 구분해 query에 적용한다.
이 역시 raw client path를 받지 않고 registered name을 통해 query behavior를 선택한다.
## 43. sort는 allowlist + total order를 강제한다
`SafeSortMapper`의 핵심 invariant는 두 개다.
1. client field를 entity path로 그대로 넘기지 않는다.
2. ordering 끝에 unique tie-breaker를 붙인다.
### 43.1 allowlist
`SafeSortRegistry`가 public sort name -> `SafeSortField` mapping을 가진다.
unknown name은 fail-closed다.
따라서 `JpaSort.unsafe(clientString)` 같은 raw ORDER BY path가 없다.
### 43.2 tie-breaker direction historical fix
과거 mapper는 request가 tie-breaker를 생략하면 무조건 DESC를 붙였다.
예:
```text
createdAt ASC
```
요청이 실제로는:
```text
createdAt ASC, id DESC
```
가 되었다.
이는 caller가 고르지 않은 mixed ordering이고 single-direction keyset logic과 충돌했다.
현재는 registry 자체가 `tieBreakerDirection`을 선언하고 mapper가 그 값을 사용한다.
이 regression은 현재 수정된 상태다.
## 44. keyset predicate는 mixed type / mixed direction을 표현하도록 진화했다
`KeysetPredicateBuilder`는 conjunction이 아니라 lexicographic predicate를 만든다.
예를 들어 `(createdAt ASC, id DESC)`라면 cursor 뒤는 개념적으로:
```text
createdAt > cursorTime
OR
(createdAt = cursorTime AND id < cursorId)
```
이다.
현재 `KeysetTerm<T>`는 각 term마다:
- expression
- cursor value
- direction
을 가진다.
그래서 `(Instant, UUID)`처럼 term type이 다르고 direction도 다른 ordering을 표현할 수 있다.
source history에는 과거 one-type/one-direction API가 mixed order에서 rows를 skip/repeat했던 이유가 주석으로 남아 있고, 현재 code/test는 이를 보완했다.
### 44.1 남는 contract boundary
builder는 “마지막 term이 unique tie-breaker여야 한다”고 문서화하지만 runtime에서 uniqueness를 증명할 metadata는 받지 않는다.
검사할 수 있는 것은:
```text
terms.size() >= 2
```
정도다.
따라서 uniqueness는 caller/registry contract다. 현재 evidence만으로 이를 defect라 단정하지 않는다. platform이 이를 fail-closed invariant로 승격하려면 unique-key metadata까지 contract에 포함해야 한다.
## 45. keyset execution은 `size + 1`로 hasNext를 판정하고 count query를 제거한다
`JpaKeysetQuerySupport`는:
```text
query.setMaxResults(page.fetchSize()) // size + 1
-> result
-> KeysetSliceAssembler
```
형태다.
반환은 최대 `size`개이고 추가 1개로 `hasNext`를 판단한다.
이 path에는 `COUNT(*)`가 없다.
즉 keyset을 도입해 OFFSET full-walk 비용을 줄여 놓고 total count로 다시 full-work를 추가하는 구조를 피한다.
실제 PostgreSQL readiness query도 `(occurred_at,id) > (?,?) ORDER BY ... LIMIT ?` 형태와 representative index 사용을 별도 integration lane에서 검증한다. 해당 entire integration lane 자체는 later sub-scope 11의 denominator이므로 여기서는 cross-scope evidence로만 사용한다.
## 46. stream helper는 resource lifetime을 return type shape로 제한한다
`JpaStreamExecutor`의 핵심은 `Stream<T>`를 외부로 반환하지 않는 것이다.
```text
active read-only transaction 확인
-> supplier가 stream open
-> consumer에 bounded stream 전달
-> consumer result 생성
-> stream close
-> query scope close
-> result만 반환
```
`ScrollPolicy.maxRows()``stream.limit()`에 적용하고 실제 소비 row count를 observation에 기록한다.
또 supplier type이 현재는:
```java
Function<ScrollPolicy, Stream<T>>
```
이라 fetch-size policy가 query 생성 지점까지 전달될 수 있다.
과거에는 plain `Supplier<Stream<T>>`여서 executor가 가진 fetch-size가 stream-opening query에 도달하지 않는 문제가 있었고, 현재는 수정됐다.
reactive Publisher를 결과로 반환하는 것도 hierarchy name 기반으로 거부한다. Reactor/Reactive Streams dependency를 blocking JPA module compile classpath에 직접 추가하지 않고도 application-declared Publisher implementation까지 탐지하려는 방식이다.
## 47. Confirmed P2 — `SpecificationPolicy`는 `Specification.unrestricted()`를 bounded로 오인한다
`SpecificationPolicy`의 문서 계약은 명확하다.
> a specification with no predicate is a full table scan wearing a builder's clothing
그리고 predicate가 없으면 explicit `allow-unbounded-scan` token이 필요하다고 설명한다.
하지만 구현은 다음만 확인한다.
```java
if (specification == null && !allowToken) {
reject
}
```
즉 **Specification object의 존재와 predicate의 존재를 동일시**한다.
### 47.1 Spring Data 4.0.7 자체가 non-null unrestricted Specification을 제공한다
현재 resolve된 `spring-data-jpa:4.0.7` bytecode를 확인했다.
```text
Specification.unrestricted()
-> non-null Specification lambda
-> lambda toPredicate(...) returns null
```
따라서 이건 인위적인 edge case가 아니다. 현재 dependency가 공식적으로 제공하는 representation이다.
### 47.2 실행 probe
```java
Specification<Object> noPredicate = (root, query, cb) -> null;
SpecificationPolicy.requireBounded(noPredicate, page, null);
```
결과:
```text
nonNullNullPredicateSpecification=PASSED
```
이다.
즉 policy가 막겠다고 문서화한 **predicate-free bounded-page full scan**이 explicit opt-in 없이 통과한다.
현재 repository production consumer search에서는 `SpecificationPolicy` 사용자가 0이므로 shipped business path 영향은 관찰되지 않았다.
그러나 intended Spring Data safety helper로서 자기 계약을 만족하지 않는다.
우선순위: **P2 — safety-contract correctness before adoption**
주의할 점은 generic `Specification`을 실행 전에 평가해 predicate null 여부를 확인하려면 Criteria context가 필요하다는 것이다. 단순 reflection으로 해결하기 어렵다.
가능한 방향:
- raw `Specification`을 safety boundary로 받지 않고 platform-owned bounded predicate descriptor를 사용
- explicit “unrestricted” 여부를 caller가 별도 contract로 선언
- repository execution helper 안에서 실제 Criteria predicate 생성과 policy validation을 결합
Raw:
- `evidence/raw/053-jpa-query-hibernate-boundary-probe.txt`
- `evidence/raw/061-spring-data-specification-unrestricted-contract.txt`
## 48. Querydsl integration은 production runtime classpath를 강제로 오염시키지 않는다
`QuerydslJpaSupport`는 Advanced opt-in으로 설계돼 있다.
build:
```gradle
compileOnly 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
testImplementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
```
lockfile에서 Querydsl은:
- compileClasspath
- test/integration/performance classpaths
에는 나타나지만 production `runtimeClasspath` configuration에는 포함되지 않는다.
따라서 JPA leaf를 사용하는 것만으로 Querydsl runtime dependency가 Stable deployment에 따라오는 구조는 아니다.
`QuerydslJpaSupport`도:
- bounded page size <= 500
- null predicate는 explicit unbounded opt-in 없으면 거부
- registered `QueryName`을 Hibernate comment hint로 적용
한다.
현재 production consumer는 확인되지 않았다. 이는 Advanced opt-in helper의 미채택 상태로 기록하며 dead-code defect로 단정하지 않는다.
## 49. SQL query naming mechanism은 구현은 있으나 shipped composition wiring을 찾지 못했다
`QueryNameContext``NamedStatementInspector`의 설계는 다음과 같다.
```text
registered QueryName을 thread-local scope에 bind
-> Hibernate StatementInspector
-> SQL prefix/comment에 bounded query identity 추가
```
이렇게 하면 raw SQL text가 아니라 registered query identity로 DB statement와 application observation을 연결할 수 있다.
문제는 current production source/config 전체에서:
- `NamedStatementInspector` construction = class definition 외 0
- Hibernate `statement_inspector` property registration = 0
이라는 점이다.
`JpaRepositoryFragmentSupport``QueryNameContext`를 사용하지만 실제 Hibernate statement inspector가 설치되지 않으면 그 name은 SQL layer까지 내려가지 않는다.
따라서 query/observability documentation에서 “registered query name이 generated SQL에 연결된다”는 설명은 current shipped composition evidence가 없다.
이것은 transaction scope에서 확인한:
- `JpaObservabilityAutoConfiguration` production construction 0
- transaction observation call path 0
와 같은 방향의 증거다.
**Cross-scope finding:** JPA observability capability의 구현 클래스들은 존재하지만 composition completeness가 부족하다.
최종 severity는 later observation/config owning sub-scope에서 전체 mechanism을 다시 읽고 확정한다. 여기서는 SQL query naming path가 현재 unwired라는 observed evidence만 추가한다.
## 50. 대부분의 optimization helper가 production에서 직접 소비되지 않는다는 사실은 이미 repository가 알고 있다
negative-space search에서 다음 implementation roots는 repository production consumer가 확인되지 않았다.
- `HibernateJpaBatchExecutor`
- `JpaBatchProfileRegistry`
- `HibernateBulkDmlExecutor`
- `HibernateStatelessSessionRunner`
- `FetchPlanApplier`
- `JpaKeysetQuerySupport`
- `JpaRepositoryFragmentSupport`
- `JpaStreamExecutor`
- `SpecificationPolicy`
- `QuerydslJpaSupport`
하지만 이것을 곧바로 “dead code가 대량 존재한다”라고 해석하면 안 된다.
이 repository의 기존 study/review 문서도 이미 JPA platform helper가 **구현/qualification되어 있지만 sample production path가 대부분 채택하지 않은 상태**라고 기록한다. 또한 batch/bulk/stateless helper는 real PostgreSQL integration tests에서 직접 실행된다.
따라서 현재 판단은 capability별로 나눈다.
### implemented + qualified + not adopted
예:
- batch
- bulk DML
- stateless
이들은 library capability로 유지할 수 있다.
### implemented but production composition itself가 필요한데 wiring 없음
예:
- `NamedStatementInspector`처럼 global Hibernate hook이 필요한 기능
이 경우는 “아무 use case가 안 쓴다”와 다르다. feature를 사용하려면 composition이 먼저 존재해야 한다.
### old mechanism이 consumer 제거 후 남은 경우
transaction scope의 `TransactionProfileRegistry`처럼 history를 통해 실제 residue로 판정해야 한다.
`grep refs=0`은 finding의 시작점이지 결론이 아니다.
## 51. export boundary는 현재 split SSOT다
이 leaf는 하나의 jar 안에 많은 public implementation type이 존재한다. 그래서 “public Java modifier”와 “architecturally exported package”를 별도로 관리하려 한다.
### 51.1 leaf-local `EXPORTED_PACKAGES`
`JpaModuleBoundaryTest`에는 다음 export set이 있다.
- api
- notification.configuration
- transaction
- security
- observation
- migration
- hibernate
- fileserver
- failure
- config
`springdata`, `querydsl`은 여기 없다.
### 51.2 실제 app-bootstrap consumer rule은 별도 allowlist를 다시 가진다
`CleanArchitectureTest.BOOTSTRAP_USES_ONLY_THE_PERSISTENCE_EXPORT_SURFACE`는 또 다른 `EXPORTED` set을 정의한다.
여기에는 root composition이 vendor entry point를 import해야 하므로:
- postgresql
- h2
까지 추가돼 있다.
즉 두 목록은 이미 동일하지 않다.
### 51.3 leaf list 자체는 outside consumer를 검사하지 않는다
`JpaModuleBoundaryTest`의 local export test는:
- export package가 실제 존재하는지
- 새 top-level package가 governance 대상인지
를 보지만 repository의 outside consumer import를 직접 스캔하지 않는다.
실제 consumer restriction은 app-bootstrap의 별도 ArchUnit rule이 담당한다.
따라서 current architecture fitness function은:
```text
leaf export declaration A
X shared SSOT 아님
bootstrap allowed imports B
```
형태다.
fresh architecture tests는 모두 통과했다. 이것은 현재 import graph가 각자의 rule을 만족한다는 뜻이지 **A와 B가 서로 drift하지 않는다는 증명은 아니다.**
우선순위: **P2/P3 architecture-governance hardening**
권장 방향은 exported package registry를 한 곳으로 옮기고 leaf package DAG와 consumer ArchUnit rule이 같은 데이터를 읽게 하는 것이다.
## 52. Confirmed P1 — `collection-fetch-pagination` blocking release gate가 실제 위험을 증명하지 않는다
이번 sub-scope에서 가장 중요한 finding이다.
support matrix는 다음 gate를 blocking release gate로 선언한다.
```text
collection-fetch-pagination
```
목적:
> paged collection fetch가 전체 table을 읽고 memory에서 pagination하는 provider regression을 차단
이다.
이 위험의 특성상 **returned page size는 증거가 아니다.** provider가 모든 rows를 읽고 Java에서 20개만 반환해도 결과는 정확하기 때문이다.
실제 `HibernateCollectionFetchPaginationContractTest` javadoc도 정확히 이 점을 알고 있다.
> assertion is therefore on the generated SQL, not on the returned page size
그러나 실제 test body는 그 설명을 구현하지 않는다.
### 52.1 실제 collection-fetch test가 SQL limit을 보지 않는다
`oneCollectionPageIsBoundedInSql()`의 핵심 assertion은:
```text
returned page size <= expected max
expected.requiresDatabaseLimit() == true
```
뿐이다.
다음을 검사하지 않는다.
- generated SQL의 LIMIT/FETCH FIRST/subquery shape
- StatementInspector capture
- query AST
- provider warning/failure
- `hibernate.query.fail_on_pagination_over_collection_fetch`
repository search에서도 해당 fail-on-pagination setting의 runtime configuration은 확인되지 않았다.
다른 같은 class tests도:
- prepared statement count <= 2
- N+1 comparison
- fixture 전체 row amplification bound
을 검증할 뿐 parent selection이 SQL에서 제한됐는지 증명하지 않는다.
이번 real PostgreSQL fresh run에서 해당 class 4 tests는 전부 통과했다. 하지만 **green은 현재 assertion이 green이라는 뜻**이지 documented risk가 차단됐다는 뜻이 아니다.
### 52.2 release registry가 가리키는 producer task는 그 test를 실행하지도 않는다
더 큰 문제는 provenance mapping이다.
`config/jpa/release-registry.json`:
```text
collection-fetch-pagination
-> :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
```
그런데 Gradle task registration은:
```text
jpaPlatformQueryPlanTest
-> includeTags "jpa-queryplan"
```
이다.
실제 collection-fetch class는:
```text
@Tag("jpa-contract")
```
이고 `PostgreSqlQueryPlanContractTest``@Tag("jpa-queryplan")`이다.
### 52.3 exact registry task fresh 실행 결과
이번에 registry가 지정한 task 자체를 fresh 실행했다.
결과:
```text
BUILD SUCCESSFUL
executed XML suites:
PostgreSqlQueryPlanContractTest
```
`HibernateCollectionFetchPaginationContractTest`는 이 task의 executed suite에 존재하지 않았다.
즉 release registry가 **이 gate의 evidence producer라고 지목한 task가 gate scenario를 실행하지 않는다.**
### 52.4 현재 gate-validator도 이 mismatch를 잡지 못한다
`verifyJpaReleaseGateTasks`는 registry의 각 gate에 대해:
- absolute Gradle path인가
- project가 존재하는가
- task가 존재하는가
- `Test` task인가
까지만 검사한다.
fresh 실행 결과:
```text
verifyJpaReleaseGateTasks: OK — 6 gate task(s) resolve to real Test tasks.
BUILD SUCCESSFUL
```
이다.
즉 지금처럼 **의미상 완전히 다른 tests를 실행하는 Test task**도 valid producer로 인정한다.
### 52.5 aggregate release task가 collection test도 실행한다는 점은 mitigation이지 provenance fix가 아니다
`jpaPlatformReleaseGate` aggregate는 `jpaPlatformContractTest``jpaPlatformQueryPlanTest` 둘 다 dependsOn 한다.
따라서 full aggregate를 실행하면 `@Tag("jpa-contract")`인 collection test 자체는 다른 lane에서 실행될 수 있다.
하지만 이것은 registry mapping을 올바르게 만들지 않는다.
- gate별 evidence provenance가 틀림
- gate task를 단독 재검증하면 target scenario 미실행
- collection test가 실행돼도 target behavior assertion 부족
이므로 false evidence 문제가 두 겹이다.
### 52.6 역사
- collection-fetch contract test: initial JPA platform commit `0e61f86`에서 이미 현재 SQL-inspection 없는 shape로 추가
- release registry gate mapping: 이후 integration commit `2f5d2fc`에서 추가
즉 최근 refactor regression이라기보다 **초기 evidence design부터 존재한 gap**이다.
우선순위: **P1 — blocking release evidence integrity**
이유:
support matrix 자체가 release gate를 “tests가 pass해도 production에서 틀릴 수 있는 경우를 막기 위한 것”이라고 정의한다. 그런데 이 gate는 바로 그 종류의 false green을 허용한다.
수정 조건은 둘 다 필요하다.
1. gate producer가 실제 collection-fetch scenario를 실행하도록 registry/task/tag 연결 수정
2. test가 generated SQL 또는 fail-closed provider signal로 **DB-side pagination**을 직접 검증
둘 중 하나만 고치면 gate는 여전히 불완전하다.
Raw:
- `evidence/raw/056-persistence-jpa-collection-fetch-gate-provenance.txt`
- `evidence/raw/057-persistence-jpa-query-hibernate-postgresql-contracts.txt`
- `evidence/raw/058-persistence-jpa-queryplan-gate-task.txt`
- `evidence/raw/059-verify-jpa-release-gate-tasks.txt`
- `evidence/raw/060-persistence-jpa-query-hibernate-history.txt`
## 53. 기존 review finding 중 현재 해결된 것과 남은 것을 분리한다
기존 `docs/reviews/2026-08-14-jpa-module-code-review.md`에는 이 영역의 여러 문제를 이미 지적했다.
현재 source와 대조하면 다음은 **해결됨**으로 관찰된다.
- batch clear가 unflushed entity를 버리던 문제 -> clear 전 flush
- stream fetch-size가 query supplier에 전달되지 않던 문제 -> `Function<ScrollPolicy,...>`
- stateless row cap이 실제 affected rows와 연결되지 않던 문제 -> `StatelessWorkResult.affectedRows`
- sort tie-breaker direction 고정 문제 -> registry-declared direction
- keyset mixed type/direction 표현 문제 -> per-term type/direction
반면 이번에 확인한:
- property-access IDENTITY guard bypass
- `Specification.unrestricted()` bypass
- collection-fetch release false evidence
- StatementInspector composition 부재
- split export SSOT
는 current snapshot에 남아 있다.
이 분리를 하지 않으면 과거 review의 defect를 현재 defect처럼 중복 보고하거나, 반대로 “이미 review했으니 해결됐다”고 잘못 가정하게 된다.
## 54. fresh verification과 증명 범위
### 54.1 dedicated unit tests
fresh command:
```text
:adapter:outbound:persistence-jpa:test
--tests hibernate.*
--tests springdata.*
--tests querydsl.*
--rerun-tasks
```
결과:
```text
BUILD SUCCESSFUL in 23s
18 actionable tasks: 18 executed
```
이 11 dedicated tests는 현재 helper behavior를 확인하지만 다음 새 경계는 포함하지 않는다.
- property-access IDENTITY
- non-null null-predicate Specification
- one JDBC batch convenience semantics
- release registry provenance
### 54.2 architecture tests
fresh:
- `JpaModuleBoundaryTest`
- app-bootstrap `CleanArchitectureTest`
결과:
```text
BUILD SUCCESSFUL in 1m 48s
100 actionable tasks: 100 executed
```
현재 package DAG와 bootstrap import graph는 rules를 만족한다.
하지만 두 export allowlist가 같은 SSOT인지까지 검증하지 않는다.
### 54.3 selected real PostgreSQL contracts
fresh `jpaPlatformContractTest`에서 다음 classes를 직접 선택했다.
- `HibernateCollectionFetchPaginationContractTest`: 4 tests
- `HibernateJpaBatchExecutorIntegrationTest`: 7 tests
- `IdStrategyContractTest`: 4 tests
총 15 tests:
```text
skipped=0
failures=0
errors=0
BUILD SUCCESSFUL
```
이는 실제 PostgreSQL 위에서 current assertions가 통과함을 증명한다.
특히 batch/ID strategy evidence에는 의미가 크다. 반면 collection-fetch의 SQL-limit 부재는 assertion design 문제라 이 green 결과로 해소되지 않는다.
### 54.4 exact query-plan gate task
fresh `jpaPlatformQueryPlanTest`:
```text
3 tests
PostgreSqlQueryPlanContractTest only
BUILD SUCCESSFUL
```
registry mapping mismatch를 runtime result XML까지 확인했다.
### 54.5 release-task existence validator
fresh `verifyJpaReleaseGateTasks`도 성공했다.
이 success는 오히려 validator limitation의 evidence다. task semantic coverage/tag를 검사하지 않기 때문이다.
## 55. Sub-scope 04 findings backlog
### P1 — blocking `collection-fetch-pagination` release gate false evidence
- registry producer = `jpaPlatformQueryPlanTest`
- producer actual suite = `PostgreSqlQueryPlanContractTest` only
- target collection-fetch class는 `jpa-contract` tag
- target test 자체도 generated SQL limit을 검사하지 않음
- current gate validator는 task existence/Test type만 검증해 mismatch를 허용
- **P1 release-evidence integrity**
### P2 — property-access IDENTITY가 batching-required guard를 우회
- guard field annotation만 탐색
- legal getter/property access entity probe가 `usesIdentityGeneration=false`
- validation passes
- current production entity exposure는 field access라 shipped-hit evidence 없음
- **P2 platform guard correctness**
### P2 — `SpecificationPolicy`가 unrestricted non-null Specification을 허용
- current Spring Data 4.0.7 `Specification.unrestricted()`는 non-null + null predicate
- policy는 object null만 검사
- explicit allow token 없이 predicate-free scan 통과
- current production consumer 0
- **P2 safety contract before adoption**
### Cross-scope P1/P2 — query SQL naming/observability composition 부재
- `NamedStatementInspector` runtime registration 0
- QueryNameContext는 존재하지만 SQL layer bridge가 확인되지 않음
- transaction observation wiring gap과 함께 later observation/config scope에서 최종 판정
### P2/P3 — export surface split SSOT
- leaf export list와 app-bootstrap consumer list가 중복 정의되고 이미 다름
- current tests pass하지만 두 목록 간 drift를 막는 single-source rule 없음
- **architecture governance hardening**
### P3/open — `BatchExecutionResult.batched()` one-batch semantics
- jdbcBatches=1 -> false
- method naming/javadoc 의미를 더 명확히 해야 함
- current large-run integration evidence에는 영향 없음
### acknowledged, not newly promoted defect — unadopted platform helpers
- many springdata/hibernate/querydsl executors have no production business consumer
- repository docs/review already record platform implementation vs sample adoption gap
- integration qualification이 존재하는 helper도 있으므로 refs=0만으로 dead code라 하지 않음
## 56. Sub-scope 04 완료 조건
확인한 것:
- production 42 / 42 FULL_READ
- dedicated tests 11 / 11 FULL_READ
- unclassified 0
- package DAG / export policy
- external production reachability
- Querydsl production-runtime optionality
- provider statistics / batch measurement
- batch flush/clear lifecycle
- ID generation guard
- bulk/stateless execution model
- repository fragment / fetch-plan mechanism
- safe sort / keyset predicate / keyset slice
- stream lifecycle / fetch-size path / reactive rejection
- Specification safety contract
- NamedStatementInspector/QueryName wiring
- history against prior review
- blocking release gate provenance
- focused unit tests fresh
- architecture tests fresh
- selected PostgreSQL contracts fresh
- exact registry query-plan task fresh
- release-task validator fresh
남긴 경계:
- PostgreSQL vendor-specific translator/native-query/type implementation 전체
- complete PostgreSQL integration/readiness source set
- full observation/config package composition
- real provider behavior under an intentionally regressed collection-fetch pagination implementation
이 항목들은 각각 sub-scope 05, 06/11에서 다시 owning-scope 기준으로 확인한다.
## 57. Sub-scope 05 범위와 denominator
이번 sub-scope의 소유 범위는 PostgreSQL vendor 구현과 root vendor migration이다.
| 구분 | 범위 | 파일 수 | 판정 |
|---|---|---:|---|
| production Java | `src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/**` | 55 | FULL_READ |
| dedicated unit test | `src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/**` | 9 | FULL_READ |
| vendor migration | `src/main/resources/db/migration/postgresql/*.sql` | 9 | FULL_READ |
| 합계 | | **73** | **73 / 73 FULL_READ** |
unclassified file은 0이다. `evidence/raw/064-postgresql-vendor-manifest-reachability.txt`에 현재 revision의 tracked blob과 production construction/reachability snapshot을 남겼다.
이 sub-scope는 PostgreSQL 전용 SQLSTATE/constraint translation, native write/COPY, work claiming, JSON/array/range support, owner-safe idempotency, same-store inbox, polling outbox와 vendor migration을 소유한다. 반면 전체 `postgresqlIntegrationTest` source set의 완전독해는 sub-scope 11이 소유한다. 여기서는 finding 검증에 필요한 정확한 integration lane만 실행했다.
## 58. PostgreSQL failure translation: SQLSTATE 분류는 맞지만 `40003` 의미가 translator에서 소실된다
`PostgreSqlFailureClassifier`는 PostgreSQL SQLSTATE를 bounded `FailureCategory`로 분류한다. serialization failure, deadlock, lock-not-available, constraint family, timeout, connection failure, schema/data 문제를 문자열 메시지가 아니라 SQLSTATE/structured server field 기준으로 다루는 방향은 적절하다. constraint 이름도 server error field에서 꺼내 catalog로 번역하므로 localized message parsing에 의존하지 않는다.
문제는 `COMPLETION_UNKNOWN`이다.
현재 `PostgreSqlExceptionTranslator.translate()`는 classifier 결과가 `COMPLETION_UNKNOWN`이어도 `JpaFailureContext``completionUnknown`을 항상 `false`로 만들고, switch에서 `COMPLETION_UNKNOWN``UNKNOWN`과 함께 일반 `JpaPersistenceException(FailureCategory.UNKNOWN, ...)`으로 강등한다.
직접 probe에서 SQLSTATE `40003`은 다음처럼 변환됐다.
```text
type=JpaPersistenceException
category=UNKNOWN
sqlState=40003
completionUnknown=false
retryable=false
```
여기서 단순 진단 정보만 사라지는 것이 아니다. 현재 `DefaultJpaRetryPolicy``TransactionCompletionUnknownException` 또는 `FailureCategory.COMPLETION_UNKNOWN`을 가장 먼저 검사해 `RECONCILE`로 보낸다. 그런데 실제 translator를 통과시키면 focused policy probe 결과가 다음과 같다.
```text
translated.category=UNKNOWN
translated.completionUnknown=false
decision.disposition=FAIL
decision.reason=failure was classified as non-retryable
```
**재실행은 막지만, commit 결과를 확인해야 하는 reconciliation 경로도 잃는다.** fail-closed라는 이유로 안전하다고 볼 수 없는 이유다. commit이 실제로 적용됐는지 알 수 없는 상태를 terminal failure로 바꾸면 caller는 설계된 recovery protocol을 실행할 근거를 잃는다.
현재 revision에서는 이 translator가 실제 composition에 들어온다. `PostgreSqlPersistenceConfig``VendorFailureTranslator`를 제공하고, `JpaPlatformRuntimeAutoConfiguration`이 이를 `SpringJpaTransactionExecutor``PersistenceFailureTranslatorChain`에 넣는다. 따라서 예전 리뷰의 “vendor translator caller 없음” 문제는 현재 wiring에서 해소됐지만, 그 결과 `40003` 의미 손실은 이제 실제 transaction path에 도달 가능한 문제다.
2026-08-14 JPA review도 completion-unknown에 대해 **body replay 0회 + reconciliation key 보존**을 기대 계약으로 이미 기록했다. 따라서 이 finding은 새로운 정책 제안이 아니라 기존 recovery contract와 현재 구현 간 불일치다.
**판정: P1 — production completion-unknown recovery contract violation.**
필요한 수정 방향은 `40003`/`COMPLETION_UNKNOWN``TransactionCompletionUnknownException` 또는 최소한 `FailureCategory.COMPLETION_UNKNOWN + completionUnknown=true`로 보존하고, translator → retry policy까지 한 테스트에서 `RECONCILE`을 고정하는 것이다.
## 59. PostgreSQL Idempotency V2: owner/CAS 구조는 강하지만 replay 경계가 두 군데 어긋난다
`PostgreSqlOwnerSafeIdempotencyStore`는 row lock, owner token, attempt, state revision, operation id와 transition digest를 결합해 claim/renew/fail/complete를 보호한다. `renew``markFailed`는 동일 operation id replay에서도 semantic argument를 digest에 넣어 `SAME_ARGUMENTS``DIFFERENT_ARGUMENTS`를 분리한다. 이 구조 자체는 강하다.
현재 revision에서는 `PostgreSqlIdempotencyProviderConfig`가 이 store를 production provider로 실제 생성하므로 아래 두 finding은 dormant helper 문제가 아니다.
### 59.1 P1 — `inspect()`와 `claim()`이 만료된 COMPLETED row를 동시에 다른 상태로 해석한다
`inspect()`는 row가 `COMPLETED`이고 response payload가 있으면 `replayUntil`이 이미 지난 값인지 확인하지 않고 무조건 `COMPLETED_REPLAY`를 반환한다. 반면 claim path는 DB time과 expiry를 보고 만료된 row를 takeover 가능 상태로 처리한다.
실제 PostgreSQL 16에서 replay TTL 25ms로 완료한 뒤 50ms를 기다린 probe 결과:
```text
expiredInspect.outcome=COMPLETED_REPLAY
expiredInspect.replayUntil=<already expired>
expiredInspect.claimAfterExpiry=TakenOverClaimed
```
즉 같은 시점의 같은 row가:
```text
inspect -> "이전 응답을 replay하라"
claim -> "이전 replay window는 끝났으니 새 실행을 소유할 수 있다"
```
로 갈린다.
Application의 `IdempotencyExecutorV2`는 reconciliation에서 `COMPLETED_REPLAY`를 실제 저장 응답 반환 신호로 사용한다. 따라서 이 불일치는 단순 introspection 문제가 아니라 **만료 후 새 실행이 허용된 시점에도 이전 응답을 reconciliation 결과로 반환할 수 있는 lifecycle correctness 문제**다.
JPA 설계 문서가 동일 Idempotency V2 contract를 구현한다고 참조하는 Redis state machine도 `COMPLETED -> [*] : replay TTL expires`로 수명을 끝낸다. JPA `inspect()`만 이 만료를 무시한다.
**판정: P1 — production idempotency lifecycle/reconciliation inconsistency.**
수정 시 `inspect()`도 claim과 같은 DB-time 기준 expiry semantics를 사용해야 하며, `replayUntil <= dbNow` 이후에는 더 이상 `COMPLETED_REPLAY`를 반환하지 않는 real-PostgreSQL boundary test가 필요하다.
### 59.2 P2 — `complete()`의 replay 판정이 `replayTtl` 변경을 무시한다
첫 completion에서는 transition digest에 다음이 들어간다.
- transition kind
- operation id
- owner tuple
- response digest
- `replayTtl.toMillis()`
코드 주석도 “completion이 replay window도 결정하므로 transition digest에 포함해야 한다”고 설명한다.
하지만 이미 `COMPLETED`인 동일 operation replay branch는 full transition digest를 비교하지 않고 **operation id + response digest만** 비교한다. 따라서 response는 같고 replay TTL만 바뀌면 `ALREADY_COMPLETED_SAME_RESULT`가 나온다.
실제 PostgreSQL probe:
```text
completeReplayTtl.first=COMPLETED
completeReplayTtl.secondDifferentTtl=ALREADY_COMPLETED_SAME_RESULT
completeReplayTtl.storedSeconds=3600
```
첫 호출은 1시간, 두 번째 호출은 동일 operation/response에 9시간을 전달했다. 두 번째 호출은 semantic argument가 다른데도 same-result로 판정됐고 DB에는 최초 1시간 window가 그대로 남았다.
`IdempotencyDigestPolicyTest`는 이미 “replay window가 다르면 completion digest가 다르다”는 정책을 테스트한다. 또한 같은 integration test suite의 renew/markFailed는 동일 operation id + 다른 TTL/retention을 conflict로 검증한다. complete만 대응하는 replay-argument test가 빠져 있다.
**판정: P2 — production idempotency replay semantic mismatch.**
동일 operation replay에서도 첫 적용과 같은 complete transition digest를 계산해 `replayTtl`까지 비교해야 한다.
## 60. Same-store inbox / polling outbox: 구현 계약은 강하지만 현재 미조립 candidate에 replay holes가 있다
`PostgreSqlSameStoreInboxAdapter``PostgreSqlPollingDeliveryAdapter``application-core`의 owner-safe transition contract를 구현하지만, 현재 production composition에서 bean construction이나 stereotype은 확인되지 않았다. 따라서 아래 finding은 **현재 배포 기본 경로의 즉시 장애가 아니라, 이 candidate adapter를 채택할 때 활성화되는 latent defect**로 분리한다.
### 60.1 P2 latent — inbox `markProcessing()` duplicate replay가 owner 검증보다 먼저 persisted owner를 반환한다
`markProcessing()`은 같은 `START + operationId`를 발견하면 `classifyMismatch()`보다 먼저 `owner(row)`를 반환한다. 이 때문에 scope/operation id만 맞춘 forged owner로 replay하면 DB에 저장된 실제 owner token을 돌려받을 수 있다.
실제 PostgreSQL probe:
```text
inboxForgedReplay.outcome=PROCESSING_STARTED
inboxForgedReplay.returnedActualToken=true
inboxForgedReplay.returnedForgedToken=false
inboxForgedReplay.completeWithReturnedOwner=COMPLETED
```
즉 duplicate handling이 owner capability recovery oracle처럼 동작한다. 채택 전에는 duplicate replay에서도 persisted owner tuple/revision과 supplied owner를 먼저 검증하도록 고쳐야 한다.
### 60.2 P2 latent — inbox retry/dead replay digest가 retention을 포함하지 않는다
`markRetryable`/`markDead``retention`은 실제 SQL update에는 들어가지만 transition digest에는 들어가지 않는다.
```text
inboxRetention.first=RETRYABLE
inboxRetention.secondDifferentRetention=ALREADY_APPLIED_SAME_OPERATION
inboxRetention.remainingHours=1.000
```
동일 operation id로 retention만 바꾼 replay가 same-operation으로 흡수된다. retention은 terminal row 보존 기간을 결정하는 semantic argument이므로 digest에 canonical millis를 포함해야 한다.
### 60.3 P2 latent — outbox retry replay digest가 `nextAttemptAt`을 포함하지 않는다
`markRetryable()``nextAttemptAt`을 DB에 기록하지만 transition digest는 kind + operation + owner + errorCode만 포함한다.
```text
outboxRetry.first=RETRY_SCHEDULED
outboxRetry.secondDifferentSchedule=ALREADY_APPLIED_SAME_OPERATION
outboxRetry.storedEqualsFirst=true
outboxRetry.storedEqualsSecond=false
```
재시도 시각은 delivery scheduling 자체를 바꾸는 semantic argument다. 동일 operation replay consistency를 주장하려면 canonical instant를 digest에 넣어야 한다.
## 61. Native write, COPY, work claiming, JSON/array/range support
### 61.1 확인된 안전 경계
native write와 COPY는 caller가 임의 SQL identifier를 조립하도록 두지 않고 registered statement/name boundary를 사용한다. 값은 JDBC parameter 또는 COPY stream으로 전달된다. COPY에는 format/size bound와 transaction requirement가 있고, work claiming은 등록된 queue definition과 PostgreSQL `FOR UPDATE ... SKIP LOCKED` 경계를 사용한다.
JSON path/query support와 range query support도 registry/typed value boundary를 두고 실제 값은 bind한다. constraint translation 역시 structured SQLSTATE/server fields를 사용한다.
이번 sub-scope에서 이 영역의 새로운 SQL-injection/runtime-wiring defect는 확인되지 않았다.
### 61.2 P2 latent — `PgRangeCodec`이 자신이 escape한 quote를 다시 parse하지 못한다
`format()`은 endpoint 내부 `"``\`를 escape한다. 그런데 `separatorIndex()`는 backslash escape를 고려하지 않고 모든 `"` 문자를 quote-state toggle로 취급한다.
self round-trip probe 결과:
```text
comma.roundTrip=true
quote.error=IllegalArgumentException:postgresql range literal has no endpoint separator
quote-comma.error=IllegalArgumentException:range lower bound exceeds upper bound
backslash-quote-comma.error=IllegalArgumentException:range lower bound exceeds upper bound
```
즉 comma만 포함한 endpoint는 통과하지만 escaped quote가 포함되면 formatter가 만든 literal조차 parser가 읽지 못한다. 현재 `PgRangeTest`는 timestamp 중심이라 이 grammar boundary를 덮지 않는다.
현재 production consumer는 정적 reachability에서 확인되지 않았으므로 **P2 latent helper algebra defect**로 둔다. 채택 전에는 PostgreSQL quoted-range grammar에 맞게 escaped quote/backslash를 인식하는 tokenizer/state machine과 round-trip property test가 필요하다.
## 62. Vendor migrations
다음 9개 migration을 모두 읽었다.
```text
V1__idempotency_record.sql
V3__outbox_event.sql
V4__int_lock.sql
V5__int_lock_expired_after.sql
V6__capability_schema_registry_adoption.sql
V9__widen_capability_schema_stream.sql
V10__idempotency_request_hash_varchar.sql
V11__durable_operation.sql
V12__live_event_log.sql
```
확인한 경계는 다음과 같다.
- idempotency owner/state/replay/transition metadata의 persisted shape
- outbox claim/delivery/index shape
- integer advisory/row-lock support table와 expiry extension
- capability schema registry adoption/widening
- request hash `char`/`varchar` drift 보정
- durable operation / live-event log schema
real PostgreSQL probe에서 Flyway는 vendor 9 migrations를 모두 validate/apply했다. 이번 sub-scope에서 migration 순서, 현재 schema 제약, index 선언 자체로 승격할 신규 defect는 확인하지 못했다. capability-specific migration의 완전한 cross-stream adoption은 각 owning capability scope에서 다시 본다.
## 63. Production reachability와 이전 리뷰 대비 변화
현재 revision에서 reachability는 동일하게 취급하면 안 된다.
| 구현 | 현재 production composition | 판정 의미 |
|---|---|---|
| `PostgreSqlExceptionTranslator` | 있음 — vendor failure bean → JPA transaction executor chain | `40003` finding은 production reachable |
| `PostgreSqlOwnerSafeIdempotencyStore` | 있음 — PostgreSQL idempotency provider config | TTL/replay findings는 production reachable |
| `PostgreSqlSameStoreInboxAdapter` | 확인 안 됨 | findings는 latent candidate |
| `PostgreSqlPollingDeliveryAdapter` | 확인 안 됨 | findings는 latent candidate |
| `PostgreSqlImmutableOutboxAppendAdapter` | 확인 안 됨 | candidate implementation |
| `PgRangeCodec` | current production consumer 확인 안 됨 | finding은 latent helper |
이 구분은 중요하다. 2026-08-14 review에서는 vendor translator와 PostgreSQL idempotency store의 composition 부재가 별도 finding이었다. 이후 commit에서 runtime auto-configuration/provider wiring이 추가되어 그 “미조립” 문제 일부는 해결됐다. 따라서 현재 분석은 과거 finding을 그대로 복사하지 않고 **현재 revision의 wiring 이후 실제 semantics**를 다시 판정했다.
history와 prior-review exact-term snapshot은 `evidence/raw/068-postgresql-vendor-history-review-provenance.txt`에 남겼다.
## 64. Fresh verification evidence
### 64.1 PostgreSQL replay semantic probe
`evidence/raw/062-postgresql-replay-semantic-probe.txt`
- PostgreSQL 16.15 Testcontainers
- vendor migration 9개 validate/apply
- inbox forged-owner replay
- inbox changed-retention replay
- outbox changed-nextAttemptAt replay
### 64.2 SQLSTATE `40003`
`evidence/raw/063-postgresql-40003-probe.txt`
- direct translator classification
- `40003 -> UNKNOWN`
- `completionUnknown=false`
`evidence/raw/069-postgresql-40003-policy-probe.txt`
- production translator → `DefaultJpaRetryPolicy`
- 최종 `decision.disposition=FAIL`
- expected recovery branch인 `RECONCILE`에 도달하지 못함
### 64.3 Range escaped-quote round trip
`evidence/raw/065-pg-range-escaped-quote-probe.txt`
- current source의 `PgRange`/`PgRangeCodec`만 격리 compile
- comma는 통과
- escaped quote / quote+comma / backslash+quote+comma 실패
### 64.4 Idempotency real-PostgreSQL TTL boundaries
`evidence/raw/066-postgresql-idempotency-replay-boundary-probe.txt`
- exact `postgresqlIdempotencyIntegrationTest` lane
- `complete()` changed replay TTL false-same replay 재현
- expired COMPLETED row의 `inspect()`/`claim()` lifecycle 불일치 재현
- temporary test는 실행 후 source에서 복원
- BUILD SUCCESSFUL
### 64.5 Dedicated PostgreSQL unit test full fresh rerun
`evidence/raw/067-persistence-jpa-postgresql-unit-tests.txt`
9 dedicated test classes를 `--rerun-tasks`로 실행했고 BUILD SUCCESSFUL이다.
이 green 결과는 current assertions의 통과를 증명하지만, 위 finding들의 boundary assertions가 기존 suite에 없다는 사실을 해소하지 않는다.
## 65. Sub-scope 05 findings backlog
| 우선순위 | finding | 현재 reachability |
|---|---|---|
| **P1** | SQLSTATE `40003` completion-unknown이 translator에서 `UNKNOWN`으로 강등되어 retry policy가 `RECONCILE` 대신 `FAIL` | **production** |
| **P1** | 만료된 COMPLETED idempotency row를 `inspect()``COMPLETED_REPLAY`, `claim()`은 takeover 가능으로 동시에 해석 | **production** |
| **P2** | idempotency `complete()` replay가 changed `replayTtl`을 same-result로 흡수 | **production** |
| **P2 latent** | inbox `markProcessing()` duplicate replay가 owner mismatch 검증 전에 persisted owner를 반환 | candidate/uncomposed |
| **P2 latent** | inbox retry/dead replay digest가 retention을 누락 | candidate/uncomposed |
| **P2 latent** | outbox retry replay digest가 `nextAttemptAt`을 누락 | candidate/uncomposed |
| **P2 latent** | `PgRangeCodec` escaped quote round-trip 실패 | current production consumer 미확인 |
### 이번 scope에서 finding으로 승격하지 않은 항목
- registered native write/COPY의 SQL/value boundary
- work-claim `SKIP LOCKED` 기본 구조
- structured SQLSTATE/constraint-name 추출
- array/json helper의 bounded value handling
- polling outbox cutover sentinel의 transition별 반복 검사 차이: claim 자체가 immutable sentinel을 요구하고 current evidence만으로 stale claim이 cutover를 우회한다고 입증되지 않아 보류
- vendor migration 9개의 현재 적용 순서/문법
## 66. Sub-scope 05 완료 조건
확인한 것:
- production Java 55 / 55 FULL_READ
- dedicated unit Java 9 / 9 FULL_READ
- vendor migration SQL 9 / 9 FULL_READ
- total 73 / 73 FULL_READ
- unclassified 0
- PostgreSQL SQLSTATE/constraint translation
- current vendor translator production composition
- owner-safe idempotency implementation + production provider composition
- same-store inbox / polling-outbox replay semantics + current non-composition 확인
- registered native write / COPY / work claim
- JSON / array / range support
- vendor migration chain
- prior review 및 key-file history
- real PostgreSQL replay probes
- `40003` translator → retry-policy end-to-end policy probe
- idempotency replay-TTL/expiry integration probes
- dedicated PostgreSQL unit tests fresh rerun
- temporary source probes 모두 복원
남긴 경계:
- baseline capability stores/config/audit/cache/H2 등 나머지 production surface: sub-scope 06
- fileserver persistence: sub-scope 07
- notification persistence: sub-scope 08
- experimental platform: sub-scope 09
- testkit/fixture: sub-scope 10
- PostgreSQL integration/readiness source set 75 files의 완전독해: sub-scope 11
- pool/performance: sub-scope 12
따라서 **Sub-scope 05는 COMPLETE**로 닫는다. JPA module 전체는 아직 IN_PROGRESS다.
## 67. Sub-scope 06 범위와 denominator
이번 sub-scope는 baseline persistence capability와 그 주변의 configuration/audit/cache/H2/operation/live-event/outbox/security 구현을 소유한다. `evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt`에 고정한 denominator는 다음과 같다.
| 구분 | 파일 수 | 판정 |
|---|---:|---|
| production Java | 61 | FULL_READ |
| dedicated unit Java | 19 | FULL_READ |
| capability migration SQL | 7 | FULL_READ |
| 합계 | **87** | **87 / 87 FULL_READ** |
이번 범위는 `audit`, `auditing`, `cache`, `envers`, `h2`, `idempotency`, `liveevent`, `lock`, `migration`, `observation`, `operation`, `outbox`, `security`와 baseline configuration을 포함한다. PostgreSQL vendor-specific 구현은 sub-scope 05에서 이미 닫았고, complete PostgreSQL integration source set은 sub-scope 11이 소유한다. 여기서는 finding 검증에 필요한 real-PostgreSQL lane만 선택 실행했다.
## 68. Baseline composition을 먼저 분리해야 하는 이유
`JpaAdapterComponentsConfig`는 adapter 전체를 넓게 scan하지 않고 다음 package만 명시적으로 component scan한다.
- `audit`
- `failure`
- `idempotency`
- `lock`
- `outbox`
- `transaction`
따라서 같은 leaf 안에 있어도 reachability가 다르다.
- `OutboxStoreAdapter`는 baseline scan에 들어가고 `app-bootstrap``OutboxConfig``OutboxStorePort`로 사용한다.
- `DurableOperationStoreAdapter`, `JpaLiveEventReplayAdapter`는 현재 baseline component scan에 들어가지 않고 별도 production constructor/reference도 확인되지 않았다.
- `HibernateCacheGuard`, `HibernateEnversHistoryReader`와 Spring Data auditing candidate도 default composition에 들어가지 않는다.
- runtime-role verifier 자체는 app-bootstrap bean으로 구성되지만, policy를 적용하는 `requireSafe()` caller가 없다.
이 차이 때문에 아래 finding은 `production`, `conditional-production`, `latent`를 분리해 판정한다. 정적 composition snapshot은 `evidence/raw/072-baseline-capability-reachability.txt`에 남겼다.
## 69. P1 — Stable runtime-role verification이 startup에서 실제 policy를 적용하지 않는다
문서 계약은 명확하다. `docs/jpa/security.md`는 runtime role이 allowlist 밖이거나 schema/database `CREATE`를 가지면 **startup이 실패한다**고 적고, platform design도 `startup verifier``current_user`, `search_path`, schema privilege를 검사한다고 정의한다. `JpaPlatformAutoConfiguration.capabilities()``RUNTIME_ROLE_VERIFICATION`을 Stable로 광고한다.
구현에는 policy가 존재한다.
- `PostgreSqlRuntimeRoleVerifier.verify()``current_user`, `search_path`, schema/database CREATE privilege를 읽는다.
- `DatabaseRolePolicy.requireSafe()`는 approved role, CREATE privilege, `SearchPathPolicy`를 검사한다.
- `PostgreSqlRuntimeRoleVerifier.requireSafe(dataSource, policy)`는 둘을 연결한다.
하지만 production composition에서 이 마지막 경로가 호출되지 않는다. `JpaPlatformRuntimeAutoConfiguration.jpaPlatformStartupCheck()`가 refresh 시 실행하는 것은 `JpaDangerousConfigurationGuard.validate(environment)`뿐이며, runtime database role policy는 받지도 않는다. repository 전체 production source에서 `DatabaseRolePolicy`를 생성하거나 `requireSafe()`를 호출하는 caller 역시 없다. 해당 호출은 unit/integration test에만 존재한다.
더 나쁜 점은 actuator semantics다. `JpaPlatformReport.sanitized()``runtimeRoleVerified`를 다음 한 조건으로 계산한다.
```text
privileges != null && !privileges.holdsCreatePrivilege()
```
**wrong role name이나 unsafe `search_path`는 검사하지 않은 채 `runtimeRoleVerified=true`가 될 수 있다.** 문서가 말하는 “role passed verification”과 실제 boolean 의미도 다르다.
결과적으로 현재 Stable capability는:
```text
startup fail-fast policy -> 미조립
actuator verification -> CREATE privilege 일부만 확인
```
상태다.
**판정: P1 — production security/runtime-composition contract violation.**
필요한 수정 방향은 app-bootstrap이 실제 `DatabaseRolePolicy`/`SearchPathPolicy`를 구성해 startup `InitializingBean`에서 `roleVerifier.requireSafe(dataSource, policy)`를 실행하고, actuator의 `runtimeRoleVerified`도 동일 policy 결과를 기반으로 계산하도록 SSOT를 하나로 만드는 것이다. startup negative composition test는 wrong role, CREATE privilege, unapproved search_path를 각각 포함해야 한다.
## 70. P1 conditional-production — baseline outbox는 stale relay worker를 fence하지 못해 terminal state를 되돌릴 수 있다
baseline outbox는 현재 composition에 실제 들어간다. `OutboxStoreAdapter``JpaAdapterComponentsConfig``outbox` scan 대상이고, `OutboxConfig``ca-skeleton.outbox.enabled=true`일 때 `OutboxStorePort``PublishPendingOutboxEventsUseCase`에 전달한다. relay까지 켜면 다음 흐름이 된다.
```text
Tx1: claimBatch()
-> row IN_FLIGHT
-> next_attempt_at = now + inFlightTimeout
commit
outside transaction: broker publish
Tx2: markPublished / markFailed / markDead
```
PostgreSQL claim query의 `FOR UPDATE SKIP LOCKED`는 **동시에 claim하는 순간**만 직렬화한다. timeout이 지나면 `IN_FLIGHT` row도 다시 claim 가능하다. 그런데 baseline entity에는 owner token/claim revision이 없고, `markPublished(eventId)`, `markFailed(eventId, retryAt)`, `markDead(eventId)`는 event id로 row를 다시 읽어 현재 owner/attempt/state를 조건 없이 변경한다.
따라서 다음 race가 가능하다.
1. worker A가 attempt 1을 claim하고 broker I/O에서 오래 멈춘다.
2. visibility timeout이 지난 뒤 worker B가 같은 row를 attempt 2로 claim한다.
3. B가 publish 성공 후 `PUBLISHED`로 mark한다.
4. 늦게 돌아온 A의 failure path가 `markFailed()`를 호출한다.
5. 이미 `PUBLISHED`인 row가 `FAILED`로 되돌아가 다시 delivery 대상이 된다.
focused probe에서 실제 adapter transition은 다음과 같이 재현됐다.
```text
outboxStaleWorker.before=PUBLISHED
outboxStaleWorker.after=FAILED
outboxStaleWorker.retryAt=2026-06-11T10:00:30Z
```
즉 이 문제는 일반적인 at-least-once의 “publish 성공 후 DB mark 실패” window와 별개다. **새 worker가 소유권을 이어받은 뒤에도 stale worker가 새 상태/terminal 상태를 덮어쓸 수 있는 fencing 부재**다.
**판정: P1 conditional-production — `app.outbox.enabled` + relay 사용 시 delivery state corruption / duplicate publication risk.**
V2 `PostgreSqlPollingDeliveryAdapter`에는 owner-safe transition 개념이 있지만 현재 default composition에 들어오지 않는다. baseline V1을 유지한다면 claim owner/attempt revision을 persisted state에 포함하고 모든 terminal/retry update를 CAS 조건으로 막아야 한다. 최소 regression은 stale attempt가 newer attempt 또는 `PUBLISHED` state를 변경하지 못함을 real PostgreSQL에서 고정해야 한다.
Evidence: `evidence/raw/075-outbox-stale-worker-state-regression-output.txt`, `075a-outbox-stale-worker-state-regression-probe.java`.
## 71. P1 latent — durable operation은 lease가 만료돼도 takeover 전 stale owner가 완료할 수 있다
`DurableOperationJpaRepository`의 주석은 state-changing statement가 owner를 확인하고, lease를 잃은 worker가 결과를 기록하지 못해야 한다는 fencing contract를 설명한다. `heartbeat()`은 실제로 `lease_expires_at > :now`를 조건에 포함한다.
반면 `reportProgress()`, `succeed()`, `fail()`은 다음만 확인한다.
- operation id
- state = RUNNING
- lease owner
**lease expiry 자체는 확인하지 않는다.**
따라서 takeover가 아직 일어나 owner 문자열이 바뀌지 않은 짧은 window에서는 lease를 이미 잃은 worker가 상태를 확정할 수 있다. real PostgreSQL 16 probe 결과:
```text
durableExpiredLease.completionAt=2026-08-25T09:02:01Z
durableExpiredLease.leaseExpiredAtCompletion=true
durableExpiredLease.succeedUpdatedRows=1
durableExpiredLease.finalState=SUCCEEDED
```
기존 stale-worker test는 새 worker가 takeover해 owner가 이미 달라진 뒤를 검증하므로 이 **expiry-after / takeover-before** 경계를 덮지 않는다.
현재 `DurableOperationStoreAdapter`는 baseline component scan 및 다른 production constructor에서 확인되지 않았으므로 즉시 production reachable로 분류하지 않는다.
**판정: P1 latent — durable-operation adapter 채택 시 lease fencing contract violation.**
수정 시 progress/succeed/fail에도 DB time 기준 `lease_expires_at > now`를 적용하거나 claim revision/fencing token을 도입해야 한다. Evidence: `evidence/raw/073-durable-operation-expired-lease-output.txt`, `073a-durable-operation-expired-lease-probe.java`.
## 72. P2 latent — live-event stream이 전부 sweep되면 position high-water mark가 사라져 position 1을 재사용한다
`JpaLiveEventReplayAdapter`는 sweep 뒤에도 position을 재사용하지 않아 cursor가 과거 event와 새 event를 혼동하지 않는다고 설명한다. 그러나 append allocator는 `LiveEventJpaRepository.highestEverAssigned(streamId)`를 사용하고, 이 query는 별도 high-water metadata가 아니라 **현재 `live_event_log` row의 `max(position)`**을 계산한다.
부분 sweep에서는 마지막 row가 남아 있으므로 문제가 숨는다. stream의 모든 row가 retention sweep으로 삭제되면 `max(position)``null`이 되고 allocator는 다시 1부터 시작한다.
real PostgreSQL 16 probe:
```text
liveEventFullSweep.swept=1
liveEventFullSweep.highestAfterSweep=null
liveEventFullSweep.nextPosition=1
liveEventFullSweep.payloadAtReusedPosition=new-event
```
기존 `sweptPositionsAreNotReused()` contract test는 allocator를 호출하지 않고 test fixture가 직접 position을 지정하므로 이 경계를 검증하지 않는다.
현재 `JpaLiveEventReplayAdapter` 역시 baseline production composition에서 확인되지 않았다.
**판정: P2 latent — live-event adapter 채택 시 monotonic cursor/position invariant violation.**
수정은 stream별 durable high-water row/sequence를 sweep 대상과 분리하거나, 삭제되어도 allocation state가 보존되는 구조가 필요하다. Evidence: `evidence/raw/071-liveevent-full-sweep-probe-output.txt`, `071a-liveevent-full-sweep-probe.java`.
## 73. 이번 sub-scope에서 finding으로 올리지 않은 항목
### 73.1 H2 idempotency와 V2 owner 필드
처음에는 `H2IdempotencyClaimRepository`의 MERGE/takeover가 V2 owner/transition field를 초기화하지 않는 점을 의심했다. 그러나 baseline `IdempotencyRecordEntity` 자체가 V1 field만 mapping하고, owner-safe V2는 PostgreSQL capability stream으로 분리돼 현재 별도 activation contract를 가진다. 서로 다른 schema generation의 field를 H2 V1이 reset하지 않는 것은 현 계약 위반이 아니다.
### 73.2 `audit`와 `auditing` 두 경로
manual `AuditableEntity`/`AuditContextPort` 경로와 Spring Data `AuditMetadata`/`JpaAuditingConfiguration`이 함께 존재하지만 tests/docs가 후자를 candidate/dormant로 명시하고 default composition도 canonical manual audit 경로만 사용한다. 현재 중복 활성화 defect로 판정하지 않는다.
### 73.3 cache / Envers
`HibernateCacheGuard`, `HibernateEnversHistoryReader`는 Advanced/opt-in surface이며 default bean construction이 없다. 이 sub-scope에서 production mis-wiring으로 올릴 근거는 없었다.
## 74. Fresh verification evidence
- `evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt` — 87-file exact denominator
- `evidence/raw/072-baseline-capability-reachability.txt` — baseline package scan, runtime-role policy caller absence, outbox composition, latent adapter construction snapshot
- `evidence/raw/071-liveevent-full-sweep-probe-output.txt` — real PostgreSQL full-sweep position reuse
- `evidence/raw/073-durable-operation-expired-lease-output.txt` — real PostgreSQL expired lease completion
- `evidence/raw/075-outbox-stale-worker-state-regression-output.txt` — baseline outbox terminal-state regression
- `evidence/raw/076-persistence-jpa-baseline-unit-tests.txt` — fresh full `:adapter:outbound:persistence-jpa:test --rerun-tasks` BUILD SUCCESSFUL
세 probe 모두 temporary source replacement를 shell `trap`으로 복원했고 실행 뒤 code repository `git status --short`는 clean이었다.
## 75. Sub-scope 06 findings backlog
| 우선순위 | finding | 현재 reachability |
|---|---|---|
| **P1** | Stable runtime-role verification이 startup에서 `DatabaseRolePolicy`/`SearchPathPolicy`를 적용하지 않고 actuator도 CREATE privilege 일부만으로 verified 판단 | **production** |
| **P1** | baseline outbox stale worker가 newer/terminal state를 owner fencing 없이 덮어쓸 수 있음 | **conditional-production (`outbox` relay enabled)** |
| **P1 latent** | durable operation lease 만료 후 takeover 전 stale owner가 progress/succeed/fail 가능 | adapter currently uncomposed |
| **P2 latent** | live-event full sweep 후 high-water mark 소실로 position 재사용 | adapter currently uncomposed |
Sub-scope 06은 **87 / 87 FULL_READ + targeted runtime verification 완료**로 닫는다. 다음 owning unit은 sub-scope 07 Fileserver persistence + migrations 29개다.
## 76. Sub-scope 07 범위와 denominator
Fileserver persistence의 owning denominator는 `evidence/raw/077-persistence-jpa-fileserver-manifest.txt`로 고정했다.
| 구분 | 파일 수 | 판정 |
|---|---:|---|
| production Java | 25 | FULL_READ |
| Fileserver migration SQL | 4 | FULL_READ |
| 합계 | **29** | **29 / 29 FULL_READ** |
구현 범위는 file/upload/verification/quota/cleanup/recovery entity와 repository, JPA adapters, schema activation, V1~V4 migration을 포함한다. `postgresqlIntegrationTest` source set 자체의 denominator는 sub-scope 11이 소유하지만, 이 sub-scope에서 발견한 Fileserver semantic boundary를 검증하기 위해 해당 real-PostgreSQL lane을 선택적으로 실행했다.
## 77. Fileserver composition과 schema lifecycle
Fileserver persistence는 latent helper가 아니라 실제 opt-in production capability다.
- `PersistenceJpaRootAutoConfiguration``FileserverJpaPersistenceConfig`를 import한다.
- `app.fileserver-platform.enabled=true`이면 Fileserver entity/repository/component scan이 열린다.
- `FileserverStorageConfiguration.fileserverSchemaActivation()``JdbcOperations`가 있으면 startup에서 `requireActive()`를 호출한다.
- 따라서 schema activation, quota, cleanup, recovery adapter는 Fileserver capability가 켜진 배포에서 production-reachable하다.
V1은 registry에 `jpa-fileserver-metadata-v1`, `feature_revision=1`, `INSTALLED_INACTIVE`를 기록하고, V2는 recovery schema를 추가한 뒤 revision을 2로 올린다. 이후 V3는 fenced cleanup lease column을, V4는 upload terminal lifecycle column을 추가하지만 registry revision은 더 이상 갱신하지 않는다. 이 차이는 아래 startup fail-open finding의 직접 원인이다.
## 78. P1 — persistent byte quota가 실제 admission에서 집행되지 않는다
Fileserver 설계와 deviation 문서는 quota를 단순 accounting이 아니라 **scope별 byte enforcement**로 설명한다.
- `docs/fileserver/design-deviations.md`는 quota decision이 scope별 `reserved + committed` 합을 사용한다고 명시한다.
- implementation plan은 DB conditional update로 quota byte를 보호하고, scope limit 초과를 `QuotaExceededException`으로 매핑한다고 정의한다.
- `DefaultTransferAdmissionController`의 class-level 설명도 “scope over its ceiling”을 `QUOTA_EXCEEDED`라고 표현한다.
하지만 production call graph에는 그 ceiling이 없다.
1. `DefaultUploadApplicationService.create()`는 namespace를 `QuotaScope`로 만든 뒤 `admissionController.acquireUpload(scope, bytes)`를 호출한다.
2. admission controller가 검사하는 것은 단일 파일 최대 크기, global storage high-water, JVM-local scope/instance semaphore뿐이다.
3. 그 다음 `JpaFileQuotaService.reserve()`는 byte aggregate나 limit을 조회하지 않고 `QuotaReservationEntity`를 unconditional `save()`한다.
4. production source에서 `reservedBytes(scope)` / `committedBytes(scope)` 또는 repository의 aggregate query를 quota decision에 사용하는 caller는 0개다.
5. `FileserverPlatformSettings.Quota`에도 byte ceiling/tenant capacity가 없고 concurrency permits와 storage high-water만 있다.
즉 현재 DB quota ledger는 **사용량 기록**은 하지만 그 사용량을 기반으로 admission을 거절하지 않는다. namespace/tenant가 얼마나 많은 byte를 이미 예약·commit했든, 단일 파일 크기와 global storage high-water/동시성만 통과하면 새 reservation이 생성된다.
이는 단순 naming 문제가 아니다. per-scope quota는 multi-tenant resource isolation 경계인데, 현재 구현은 이를 JVM-local concurrent-upload 제한으로 대체하고 있다. 여러 인스턴스 배포에서는 scope semaphore 자체도 instance-local이다.
**판정: P1 production cross-scope contract violation — persistent scope/tenant byte quota enforcement missing.**
수정 방향은 persistent quota와 transfer concurrency를 분리해야 한다.
- explicit scope/tenant byte ceiling policy를 둔다.
- `reserve/extend``committed + live reserved + delta <= ceiling`을 DB에서 원자적으로 보장해야 한다.
- 단순 `SUM()` 후 INSERT는 concurrent reservation race가 있으므로 scope별 aggregate row lock/CAS, advisory lock, 또는 동일 수준의 serialized invariant가 필요하다.
- JVM semaphore는 local concurrency guard로 유지하되 durable byte quota의 대체물이 되어서는 안 된다.
- regression은 두 인스턴스가 limit 직전에서 동시에 reserve하는 case, expired reservation 제외, committed usage 포함, unknown-length extend, cleanup reclaim을 포함해야 한다.
Static evidence: `evidence/raw/079-fileserver-reachability-quota-schema-contract.txt`.
## 79. P1 conditional-production — schema activation이 V2를 current schema로 오인한다
`FileserverSchemaActivation`의 목적은 주석 그대로 **첫 user request에서 missing relation/column 500이 나기 전에 startup에서 fail closed**하는 것이다. 그러나 현재 gate는 다음만 요구한다.
```text
capability_id = jpa-fileserver-metadata-v1
core_epoch = 1
feature_revision >= 2
lifecycle_state = ACTIVE
```
문제는 current code가 V2보다 뒤의 schema를 필요로 한다는 점이다.
- V3: `fs_cleanup_item.claim_owner`, `claim_token`, `lease_until`, `claim_fence`
- V4: `fs_upload_session.lifecycle_state`
그런데 V3/V4는 registry revision을 올리지 않는다. 따라서 V2까지만 적용된 DB를 `ACTIVE`로 promote하면 현재 gate를 통과한다.
이를 PostgreSQL 16에서 별도 database로 재현했다. base/core migration 후 Fileserver Flyway를 **target 2**까지만 적용하고 registry를 ACTIVE로 만든 결과:
```text
fileserverSchemaV2.featureRevision=2
fileserverSchemaV2.activationAccepted=true
fileserverSchemaV2.cleanupClaimToken=false
fileserverSchemaV2.uploadLifecycleState=false
```
즉 startup activation은 성공했지만 현재 cleanup/upload repository가 요구하는 V3/V4 column은 존재하지 않았다.
이 문제는 Hibernate `validate`가 항상 구해주는 것도 아니다. `JpaDangerousConfigurationGuardTest`가 production에서도 `spring.jpa.hibernate.ddl-auto=none`을 허용하도록 고정하고 있기 때문이다. 이 profile에서는 activation이 사실상 deployment fail-fast gate인데 현재 V2를 허용한다.
**판정: P1 conditional-production — Fileserver enabled + schema V2 ACTIVE + `ddl-auto=none`에서 startup fail-open / first-use SQL failure risk.**
이미 V3/V4가 배포된 migration history가 있을 수 있으므로 기존 migration 파일의 checksum을 바꾸는 방식은 피해야 한다. 안전한 수선은 새 forward migration에서 current schema revision marker를 올리고 activation이 그 revision 이상을 요구하게 하는 것이다. 그 뒤 V2 ACTIVE database가 startup에서 거부되는 regression을 고정해야 한다.
Evidence: `evidence/raw/081-fileserver-schema-activation-v2-output.txt`, `081a-fileserver-schema-activation-v2-probe.java`.
## 80. P2 — quota reclaim은 최대 64개 committed row만 처리하고 남은 byte를 조용히 버린다
`JpaQuotaReclaimGateway.reclaim(scope, bytes)``findCommittedWithBytes(scope, Limit.of(64))`**한 번만** 조회한다. 그 64개 row를 모두 소진한 뒤에도 `outstanding > 0`이면 추가 page/query를 하지 않고 method가 끝난다.
real PostgreSQL에서 동일 scope에 1-byte committed row 65개를 만든 뒤 65 bytes reclaim을 요청한 결과:
```text
fileserverQuotaReclaim.before=65
fileserverQuotaReclaim.requested=65
fileserverQuotaReclaim.after=1
```
physical delete가 성공한 뒤 cleanup service가 이 gateway를 호출하므로, 64개보다 많은 ledger row에 걸친 reclaim은 실제 사용량보다 committed accounting을 높게 남긴다. 현재 byte quota enforcement가 빠져 있어 즉시 admission rejection으로 이어지지는 않지만, ledger 자체가 quota/reclamation SSOT라는 계약을 위반하고 향후 enforcement가 복구되면 capacity leak로 직결된다.
**판정: P2 production accounting correctness defect.**
수정은 outstanding이 0이 될 때까지 bounded page를 반복하되 forward progress를 보장하거나, scope aggregate usage를 별도 row로 유지해 reclaim을 O(1) CAS로 만드는 편이 낫다. “최대 64개만 처리”를 의도한 batch boundary라면 caller가 remainder를 재-enqueue해야 하지만 현재 그런 contract는 없다.
Evidence: `evidence/raw/078-fileserver-quota-boundary-probe-output.txt`, `078a-fileserver-quota-boundary-probe.java`.
## 81. P2 — direct `FileQuotaService.commit()`은 만료 reservation을 commit한다
`JpaFileQuotaService`의 own Javadoc은 “already expired or released reservation can never be extended or committed”라고 명시한다. `extend()` query는 실제로 `expiresAt > now`를 조건으로 둔다.
반면 `FileserverQuotaRepository.commit()``status='RESERVED'`만 확인하고 expiry predicate가 없다. real PostgreSQL에서 reservation의 `expires_at`을 과거로 이동한 뒤 public `FileQuotaService.commit()`을 호출하면:
```text
fileserverExpiredQuota.status=COMMITTED
fileserverExpiredQuota.committedBytes=600
```
으로 전환됐다.
여기서는 수정 경계를 주의해야 한다. 별도 `JpaQuotaCommitGateway`는 upload가 TTL보다 오래 걸렸더라도 실제 durable byte를 under-count하지 않기 위해 expired upload usage를 기록하는 의도적 path를 가진다. 따라서 shared repository `commit()`에 무조건 expiry predicate를 추가하면 그 settlement contract까지 깨질 수 있다.
**판정: P2 production API-contract defect.**
수정은 “live reservation direct commit”과 “expired upload durable usage settlement”를 별도 SQL/API로 분리해 전자는 expiry를 엄격히 거부하고 후자는 명시적 recovery/settlement 의미로 유지해야 한다.
Evidence: `evidence/raw/078-fileserver-quota-boundary-probe-output.txt`.
## 82. P2 — recovery queue의 `enqueue()`는 concurrent upsert가 아니다
`JpaRecoveryQueue`는 Javadoc에서 enqueue를 upsert라고 정의하고 “same file reported twice updates the open item rather than adding a second one”이라고 설명한다. 구현은:
```text
UPDATE existing PENDING
if updated == 0:
INSERT new PENDING
```
이고 DB에는 `WHERE status='PENDING'` partial unique index가 있다. 최초 item이 없는 상태에서 두 transaction이 동시에 들어오면 둘 다 UPDATE 0을 보고 INSERT로 진행할 수 있다. unique index는 duplicate row는 막지만 loser transaction을 정상 upsert로 흡수하지는 않는다.
real PostgreSQL concurrent probe 결과:
```text
fileserverRecovery.concurrentFailures=1
fileserverRecovery.first=SUCCESS
fileserverRecovery.second=org.springframework.dao.DataIntegrityViolationException
fileserverRecovery.rowCount=1
```
recovery enqueue는 finalize의 ambiguous commit path와 reconciliation worker 양쪽에서 production 호출되므로 동일 file에 대한 동시 report가 가능한 seam이다. 한 row만 남는 DB invariant는 지켜지지만 “enqueue request가 durable work item으로 합쳐진다”는 adapter contract 대신 caller 하나가 persistence exception을 받는다.
**판정: P2 production concurrency/idempotency defect.**
PostgreSQL native upsert가 partial unique predicate와 동일 semantics를 갖도록 구성하거나, insert unique conflict를 잡아 bounded update retry로 수렴시켜야 한다. regression은 barrier를 둔 two-transaction 최초 enqueue에서 둘 다 성공하고 open row는 1개임을 검증해야 한다.
Evidence: `evidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt`, `080a-fileserver-recovery-concurrent-enqueue-probe.java`.
## 82.1. P2 — cleanup crash-reclaim은 `MAXIMUM_ATTEMPTS`를 우회해 poison item을 무한 재시도할 수 있다
`JpaCleanupQueue``MAXIMUM_ATTEMPTS = 8`을 두고, 정상적인 `markFailed()` 경로에서는 `item.attempt() + 1 >= 8`이면 `ABANDONED`로 전환한다. class Javadoc도 반복 실패한 poison item을 계속 재시도하지 않는 것이 이 queue의 명시적 계약이라고 설명한다.
그러나 worker crash는 다른 경로를 탄다. `DefaultCleanupService.runBatch()`는 매 batch 시작 시 `reclaimExpiredClaims()`를 먼저 호출하고, `FileserverCleanupRepository.reclaimExpiredClaim()`은 expired `IN_PROGRESS` row를 항상 다음 상태로 되돌린다.
```text
status = FAILED
attempt = attempt + 1
last_error_code = CLAIM_LEASE_EXPIRED
claim fields = null
```
여기에는 `MAXIMUM_ATTEMPTS` 또는 현재 `attempt`에 대한 terminal 조건이 없다. 따라서 worker가 physical cleanup 중 계속 crash하면 정상 실패 budget을 거치지 않고 lease expiry → reclaim → claim → crash를 반복할 수 있다.
real PostgreSQL에서 claim 후 settlement 없이 lease expiry만 9회 반복한 결과:
```text
fileserverCleanupCrash.maxAttempts=8
fileserverCleanupCrash.actualAttempt=9
fileserverCleanupCrash.status=FAILED
fileserverCleanupCrash.lastError=CLAIM_LEASE_EXPIRED
```
즉 명시된 최대 8회를 넘겼는데도 row는 `ABANDONED`가 아니라 다시 claim 가능한 `FAILED`로 남았다. 이는 cleanup queue의 poison-item bounded retry 계약을 깨고, 반복적으로 crash를 유발하는 cleanup item이 scheduler capacity를 계속 소비하게 만든다.
**판정: P2 production liveness / bounded-retry defect.**
수정은 crash-reclaim과 normal failure가 동일한 attempt budget을 공유하게 해야 한다. `reclaimExpiredClaim()`에서 증가 후 attempt가 limit에 도달하면 `ABANDONED`로 전환하거나, repository가 next-state를 caller로부터 받되 DB CAS가 token과 attempt를 함께 검증하도록 구성할 수 있다. regression은 normal failure와 crash-reclaim을 섞어도 총 attempt budget을 넘으면 반드시 terminal `ABANDONED`가 되는지 고정해야 한다.
Evidence: `evidence/raw/079-fileserver-cleanup-crash-budget-output.txt`, `079a-fileserver-cleanup-crash-budget-probe.java`.
## 83. 이번 sub-scope에서 finding으로 올리지 않은 항목
### 83.1 quota FIFO settlement 자체
reservation row가 upload id와 연결되지 않아 `JpaQuotaCommitGateway`가 scope의 가장 오래된 live reservation부터 정산하는 것은 `docs/fileserver/design-deviations.md`에 명시적으로 기록된 adaptation이다. row identity와 실제 upload identity가 1:1이 아닌 것 자체는 현재 설계 계약이다. 다만 그 문서가 전제로 둔 aggregate byte enforcement가 실제로 없다는 점은 §78의 별도 P1 finding으로 올렸다.
### 83.2 cleanup fenced lease의 expiry-after / takeover-before window
cleanup settlement query는 claim token을 fence하고 reaper takeover가 token을 교체한다. lease expiry 직후 아직 takeover 전인 worker가 settle할 수 있는 window는 보이지만, 새 owner가 생긴 뒤 stale worker가 상태를 덮어쓰는 race는 token CAS가 막는다. durable-operation과 달리 현재 계약만으로 “expiry 순간부터 절대 settle 금지”라고 확정할 충분한 근거가 없어 finding으로 올리지 않았다.
### 83.3 과거 JPA-028 cleanup fencing finding
이전 review의 Fileserver cleanup owner/token/terminal-state 부재는 V3/V4와 현재 repository code에서 실제로 보완돼 있다. 이번 분석은 그 과거 finding을 중복 집계하지 않는다.
## 84. Fresh Fileserver verification evidence
- `evidence/raw/077-persistence-jpa-fileserver-manifest.txt` — 25 production Java + 4 migration SQL, **29 / 29 FULL_READ**
- `evidence/raw/079-fileserver-reachability-quota-schema-contract.txt` — production quota readers/ceiling absence, admission logic, schema revision/activation snapshot
- `evidence/raw/078-fileserver-quota-boundary-probe-output.txt` — real PostgreSQL expired commit + 65-row reclaim truncation
- `evidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt` — real PostgreSQL concurrent recovery enqueue race
- `evidence/raw/081-fileserver-schema-activation-v2-output.txt` — real PostgreSQL V2-only ACTIVE activation acceptance
- `evidence/raw/082-fileserver-official-readiness-lanes.txt` — original-source Fileserver migration/metadata/reclamation no-skip lanes, fresh `--rerun-tasks`, BUILD SUCCESSFUL, 21/21 tasks executed, git clean before/after
- `evidence/raw/079-fileserver-cleanup-crash-budget-output.txt`, `079a-fileserver-cleanup-crash-budget-probe.java` — real PostgreSQL에서 crash-reclaim만으로 attempt 9 / `FAILED`를 재현해 8회 poison budget 우회를 확인
기존 temporary probe source는 실행 후 원본으로 복구했다. cleanup crash-budget probe는 Gradle test 자체는 `BUILD SUCCESSFUL` / exit 0이었지만 wrapper의 restore trap이 `cd src` 뒤 상대경로를 사용해 복원 단계만 실패했다. 직전 clean snapshot의 exact `HEAD` blob을 해당 analysis-owned test file 하나에 다시 기록한 뒤 worktree hash와 `HEAD` hash가 동일함을 확인했고, 최종 `git status --short`는 clean이었다.
## 85. Sub-scope 07 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P1** | persistent `reserved + committed` byte quota를 실제 admission에서 읽거나 ceiling과 비교하는 경로가 없음 | **production when Fileserver enabled** |
| **P1** | schema activation이 revision 2 ACTIVE를 허용하지만 current code는 V3/V4 columns를 요구 | **conditional-production; especially `ddl-auto=none`** |
| **P2** | reclaim이 64 committed rows 이후 remainder를 처리하지 않아 usage accounting이 남음 | **production** |
| **P2** | public direct quota commit이 expired RESERVED row를 COMMITTED로 전환 | **production** |
| **P2** | concurrent first recovery enqueue 중 한 transaction이 unique violation으로 실패 | **production** |
| **P2** | cleanup crash-reclaim이 `MAXIMUM_ATTEMPTS=8`을 적용하지 않아 attempt 9+도 `FAILED`로 재활성화됨 | **production** |
Sub-scope 07은 **29 / 29 FULL_READ + targeted real-PostgreSQL boundary verification 완료**로 닫는다. 다음 owning unit은 sub-scope 08 Notification persistence + migrations 68개다.
## 86. Sub-scope 08 범위와 denominator
Notification persistence의 owning denominator는 `evidence/raw/083-persistence-jpa-notification-manifest.txt`로 고정했다.
| 구분 | 파일 수 | 판정 |
|---|---:|---|
| production Java | 53 | FULL_READ |
| Notification migration SQL | 10 | FULL_READ |
| dedicated unit Java | 5 | FULL_READ |
| 합계 | **68** | **68 / 68 FULL_READ** |
범위는 notification schema activation/facade, payload/contact-point crypto, request/recipient/attempt/policy/admin/reconciliation/provider-event/inbox entities·repositories·stores, V1~V10 opt-in migration을 포함한다. `postgresqlIntegrationTest` notification classes는 sub-scope 11 denominator에 남겨 두되, 이번 finding의 DB semantics를 검증하기 위해 기존 contract/readiness lane과 별도 PostgreSQL probe를 선택적으로 실행했다.
## 87. Notification composition과 schema lifecycle
Notification JPA capability는 production opt-in path로 실제 composition된다.
- `PersistenceJpaRootAutoConfiguration``NotificationJpaPersistenceFacade`를 import한다.
- facade가 `NotificationJpaPersistenceConfig`를 import하고 entity/repository/store bean을 조립한다.
- application-side worker/config가 recipient lease, reconciliation, provider-event ledger, admin operation store를 실제 소비한다.
- `NotificationSchemaActivation`은 capability registry를 읽어 startup activation을 검사한다.
schema stream은 V1~V10까지 진화했지만 registry는 V4에서 `jpa-notification-platform-v4`, `feature_revision=4`, `INSTALLED_INACTIVE`를 기록한 뒤 더 이상 revision을 올리지 않는다. 반면 current Java mapping과 SQL은 V5~V10에서 추가된 column/constraint에 실제 의존한다. 이 drift가 §88의 startup false-positive를 만든다.
## 88. P1 conditional-production — V4 ACTIVE schema가 current V10-compatible schema로 오인된다
`NotificationSchemaActivation`은 다음 조건이면 capability를 active로 인정한다.
```text
capability_id = jpa-notification-platform-v4
core_epoch = 1
feature_revision >= 4
lifecycle_state = ACTIVE
```
하지만 current code는 revision 4 이후 migration을 요구한다. 대표적으로:
- V5: recipient `expires_at`
- V6: delivery-attempt projection facts/version + suppression side-effect claim state
- V7: request collapse fields
- V8: admin `command_fingerprint`, `phase`, `claimed_at`
- V9: provider execution-evidence certainty fields
- V10: protected payload envelope constraint
V5~V10 어느 migration도 capability registry revision을 5 이상으로 올리지 않는다.
PostgreSQL 16에서 core V1/V2 + Notification V1~V4만 적용하고 registry row를 ACTIVE로 promote한 뒤 activation SQL을 그대로 실행했다.
```text
activation_count = 1
feature_revision = 4
lifecycle_state = ACTIVE
```
동시에 current code가 요구하는 column 존재 여부는 다음과 같았다.
```text
notification_recipient_delivery.expires_at = false
notification_delivery_attempt.projection_version = false
notification_request.collapse_key = false
notification_admin_audit.phase = false
notification_delivery_attempt.request_started_certainty = false
```
즉 startup gate는 성공하지만 first-use 시 current repository/entity SQL과 DB schema가 맞지 않을 수 있다. 기존 `postgresqlNotificationSchemaActivationIntegrationTest`는 fresh rerun으로 green이지만, 그 green은 현재 gate가 정의한 V4 lifecycle을 검증할 뿐 V10 mapping compatibility를 증명하지 않는다.
**판정: P1 conditional-production schema fail-open.** Notification capability가 켜진 상태에서 V4까지만 적용된 DB가 ACTIVE라면 startup이 current schema 부재를 잡지 못한다.
기존 V1~V10 migration checksum을 수정하기보다 새 forward migration에서 current schema revision을 명시적으로 올리고 activation이 그 revision 이상을 요구하게 하는 편이 안전하다. regression은 V4 ACTIVE를 반드시 거부하고 current revision만 허용해야 한다.
Evidence: `evidence/raw/084-notification-schema-v4-activation-probe.txt`, `089-notification-schema-official-readiness.txt`.
## 89. P1 — provider 호출 뒤 recipient projection write가 lease fencing을 우회한다
recipient lease 설계 자체는 owner + monotonic fence를 갖는다. claim은 `lease_fence`를 증가시키고 `stillHeld()` / `renewLease()``lease_until > now`까지 검사한다. 문제는 **provider side effect 이후 결과를 저장하는 실제 production path**다.
`NotificationDispatchService.dispatch()` 흐름은 다음이다.
1. provider call 직전에 `leases.stillHeld(lease)`를 확인한다.
2. provider call은 transaction 밖에서 실행한다.
3. call이 돌아오면 `DispatchOutcomeRecorder.record(...)`를 write transaction에서 실행한다.
4. recorder는 attempt result를 저장한 뒤 recipient projection에 **`recipients.save(updated)`**를 호출한다.
5.`save()`는 row를 현재 시점에 ID로 다시 읽고 projection field를 변경할 뿐 owner/fence/expiry를 검증하지 않는다.
따라서 lease가 provider call 도중 만료되거나 다른 worker가 takeover해도 stale caller의 결과 write가 자동으로 거부되지 않는다.
실제 fenced helper도 완전하지 않다. `saveProjectionHeldBy()` / `transitionHeldBy()``id + lease_owner + lease_fence`만 조건으로 두고 `lease_until > now`는 확인하지 않는다. PostgreSQL에서 이미 만료되어 `stillHeld` 조건이 0건인 row에 동일 owner/fence write를 실행하면 `UPDATE 1`이었다.
더 강한 takeover case도 재현했다.
```text
# worker B가 takeover한 직후
state=DISPATCHING, owner=worker-b, fence=8, version=1
# stale worker A의 recorder/save와 동등한 ID-only projection write 뒤
state=RECONCILIATION_REQUIRED, owner=worker-b, fence=8, version=2
```
즉 새 owner의 lease identity는 그대로인데 **이전 provider call의 stale outcome이 recipient state를 덮을 수 있다.** 이는 fencing token을 둔 목적과 직접 충돌한다.
**판정: P1 production concurrency/correctness defect.** provider side effect와 authoritative outcome write 사이의 lease handoff에서 stale writer가 살아남는다. 결과에 따라 중복 전송 위험 판단, retry/reconciliation state, attempt count가 새 holder의 흐름과 충돌할 수 있다.
수정은 provider completion 이후의 authoritative recipient mutation을 반드시 `RecipientLease`에 결박해야 한다. 최소한 `owner + fence + lease_until > completedAt/now`를 하나의 conditional write에서 검증하고, 0-row update는 superseded result로 처리해야 한다. recorder가 일반 `save()`를 호출하는 구조도 제거하거나 lease-aware recorder API로 바꿔야 한다.
Evidence: `evidence/raw/085-notification-expired-lease-write-probe.txt`, `091-notification-stale-provider-overwrite-probe.txt`, `092-notification-reachability-test-gap.txt`.
## 90. P2 — reconciliation `FOR UPDATE SKIP LOCKED`는 worker 처리 구간을 claim하지 않는다
`JdbcReconciliationJobStore.claimDue()`는 due row를 다음 SQL로 읽는다.
```text
SELECT ...
FROM notification_reconciliation_job
WHERE next_check_at <= ?
ORDER BY next_check_at, id
LIMIT ?
FOR UPDATE SKIP LOCKED
```
그러나 이 method는 별도 transaction boundary를 열지 않고 durable `owner/status/lease`도 기록하지 않는다. `ReconciliationJobWorker.reconcileOnce()``claimDue()` 뒤 provider reconciliation을 수행한 다음에야 `complete()` 또는 `reschedule()`을 호출하며 전체 구간을 감싸는 `TransactionPort`/`@Transactional`이 없다.
따라서 normal JdbcTemplate autocommit에서는 SELECT가 반환되는 순간 row lock이 풀린다. PostgreSQL에서 worker A의 claim SELECT가 끝난 뒤 A가 아직 complete/reschedule하지 않은 상태를 유지하고 worker B가 같은 SQL을 실행하자 두 호출 모두 같은 job을 반환했다.
```text
worker A -> job 5555... attempts=0
worker B -> job 5555... attempts=0
row state -> next_check_at unchanged, attempts=0, last_result=null
```
`SKIP LOCKED` 자체가 잘못된 것이 아니라 **lock lifetime과 work lifetime이 다르다.** 현재 형태는 동시에 SELECT statement를 실행하는 아주 짧은 순간만 중복 read를 피하고 provider 조회/정산 중복을 막지 못한다.
**판정: P2 production multi-instance coordination defect.** reconciliation은 send 자체가 아니라 provider 상태 조회/상태 projection이어서 recipient dispatch P1보다 영향도를 낮게 잡지만, 두 worker가 같은 job을 처리할 수 있다는 class contract는 깨진다.
수정은 delivery claim처럼 durable owner/fence/lease를 기록하는 short claim transaction을 두거나, 전체 processing을 DB lock transaction 안에 두어야 한다. 외부 provider call을 긴 DB transaction에 넣는 것은 피하는 편이 좋으므로 전자가 더 적합하다.
Evidence: `evidence/raw/086-notification-reconciliation-claim-probe.txt`, `092-notification-reachability-test-gap.txt`.
## 91. P2 — V8 atomic admin claim은 production service에 연결되지 않았고 completion 모델도 미완성이다
V8 migration과 `AdminOperationStorePort.claim()`의 설명은 문제를 정확히 알고 있다. 기존 `find -> act -> save` 구조에서는 두 caller가 모두 빈 상태를 읽고 같은 operation id의 action을 실행할 수 있으므로 `INSERT ... ON CONFLICT DO NOTHING`으로 먼저 claim해야 한다는 설계다.
하지만 current production call graph에서 `operations.claim(...)` 호출은 **0개**다. `NotificationAdminApplicationService`의 redrive/reconcile/suppress/provider-state 경로는 여전히 모두:
```text
findByOperationId(operationId)
... action ...
operations.save(...)
```
를 사용한다.
게다가 현재 persistence completion model은 claim API를 단순히 연결하는 것만으로 끝나지 않는다. `claim()``notification_admin_audit``phase='CLAIMED'` row를 먼저 INSERT하지만, `JpaAdminOperationStore.save()`는 그 row를 update-to-COMPLETED하지 않고 동일 `operation_id`의 새 entity를 INSERT한다. PostgreSQL probe에서 claim 성공 후 현재 save 방식과 동등한 두 번째 INSERT는 unique violation이 났고 기존 row는 계속 `CLAIMED`였다.
```text
claim -> INSERT 1, phase=CLAIMED
save-style completion -> unique_violation
final -> phase=CLAIMED
```
기존 `AdminOperationClaimContractTest`는 atomic claim primitive 자체는 검증하고 fresh rerun도 green이지만, production service가 이를 쓰는지와 claim→completion lifecycle은 검증하지 않는다.
**판정: P2 production idempotency/wiring defect.** V8에서 만든 fix가 dead path이며 completion state machine도 이어지지 않는다. DB transaction 안에서 수행되는 redrive/suppress 일부 경로는 마지막 unique conflict가 loser transaction을 rollback시켜 결과를 완화하지만, reconcile/provider runtime control처럼 action과 final audit insert가 하나의 동일 DB transaction으로 묶이지 않는 경로까지 전체적으로 exactly-once operation claim을 보장하지 못한다.
수정은 service entry에서 command fingerprint와 함께 atomic claim을 먼저 수행하고, owner가 아니면 CLAIMED/COMPLETED 상태를 명시적으로 해석해야 한다. winner는 동일 row를 `COMPLETED`로 update하면서 result snapshot을 저장해야 하며, 별도 duplicate INSERT로 완료해서는 안 된다.
Evidence: `evidence/raw/087-notification-admin-claim-completion-probe.txt`, `088-notification-admin-claim-reachability.txt`, `092-notification-reachability-test-gap.txt`.
## 92. 이번 sub-scope에서 finding으로 올리지 않은 항목
### 92.1 provider-event replay의 중복 scan 자체
`ProviderEventReplayWorker`도 unmatched/pending event를 durable lease 없이 scan할 수 있지만, projection write는 transaction 안에서 수행되고 ledger의 applied transition과 suppression side-effect에는 별도 conditional claim이 존재한다. 동일 event가 두 worker에 보일 가능성만으로 중복 external side effect까지 현재 evidence에서 확정할 수 없어 이번 backlog에는 올리지 않았다.
### 92.2 crypto envelope와 contact-point secret protection
request variable payload는 `NotificationPayloadProtection`을 필수 collaborator로 받아 보호된 envelope를 저장하고, contact point는 ciphertext/nonce/lookup HMAC/key id로 분리된다. V10은 plaintext-looking request envelope를 DB constraint로도 거부한다. 이번 완독에서 이 경계 자체를 우회하는 production write path는 확인하지 못했다.
### 92.3 tenant-bound repository guard
tenant-sensitive lookup이 전부 완전하다고 corpus 전체 결론을 내리지는 않았지만, `TenantBoundRepositoryGuard`와 tenant-qualified repository method가 존재하고 이번 68-file owning scope에서 즉시 재현 가능한 cross-tenant bypass는 확정하지 못했다. 별도 inbound/application authorization 조합은 cross-scope 단계가 소유한다.
## 93. Fresh Notification verification evidence
- `evidence/raw/083-persistence-jpa-notification-manifest.txt` — 53 production Java + 10 migration SQL + 5 dedicated unit Java, **68 / 68 FULL_READ**
- `evidence/raw/084-notification-schema-v4-activation-probe.txt` — PostgreSQL 16에서 V4 ACTIVE activation은 통과하지만 current-required V5~V9 columns 5개가 모두 absent
- `evidence/raw/085-notification-expired-lease-write-probe.txt``stillHeld=0`인 expired lease의 owner+fence write가 `UPDATE 1`
- `evidence/raw/091-notification-stale-provider-overwrite-probe.txt` — replacement holder B/fence 8 이후 stale result의 ID-only projection write가 새 lease를 보존한 채 lifecycle을 overwrite
- `evidence/raw/086-notification-reconciliation-claim-probe.txt` — autocommit SKIP LOCKED를 worker A/B가 순차 실행해 동일 reconciliation job을 둘 다 획득
- `evidence/raw/087-notification-admin-claim-completion-probe.txt` — atomic claim 뒤 current save-style second insert가 unique violation, row는 CLAIMED 유지
- `evidence/raw/088-notification-admin-claim-reachability.txt`, `092-notification-reachability-test-gap.txt` — production admin claim caller 0, dispatch/reconciliation actual call graph와 predicate snapshot
- `evidence/raw/089-notification-schema-official-readiness.txt` — original-source `postgresqlNotificationSchemaActivationIntegrationTest --rerun-tasks`, **BUILD SUCCESSFUL in 40s, 19/19 tasks executed**
- `evidence/raw/090-notification-existing-contracts-fresh.txt` — original `RecipientClaimContractTest` + `AdminOperationClaimContractTest`, fresh `jpaPlatformContractTest --rerun-tasks`, **BUILD SUCCESSFUL in 31s, 19/19 tasks executed**
모든 신규 semantic probe는 임시 PostgreSQL container와 repository의 existing migration/source를 읽어 실행했으며 source file을 수정하지 않았다. 최종 code repository `git status --short`는 clean이다.
## 94. Sub-scope 08 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P1** | schema activation이 revision 4 ACTIVE를 current-compatible로 인정하지만 code는 V5~V10 schema를 요구 | **conditional-production when Notification JPA enabled** |
| **P1** | provider call 결과 recorder가 lease-unaware `recipients.save()`를 사용해 expired/replaced holder의 stale projection이 새 holder state를 덮을 수 있음 | **production dispatch** |
| **P2** | reconciliation `FOR UPDATE SKIP LOCKED` lock이 SELECT 종료와 함께 풀려 처리 중 동일 job을 다른 worker가 재claim 가능 | **production multi-instance worker** |
| **P2** | V8 atomic admin claim API가 production caller 0이고 claim row를 current `save()`로 완료할 수도 없음 | **production admin operations** |
Sub-scope 08은 **68 / 68 FULL_READ + targeted real-PostgreSQL verification + original contract/readiness fresh rerun 완료**로 닫는다. 다음 owning unit은 sub-scope 09 Experimental platform 38개다.
## 95. Sub-scope 09 범위와 denominator
Sub-scope 09는 `experimental/**`가 소유하는 multi-tenancy, RLS, schema/database-per-tenant, read-replica routing, next-version compatibility/promotion surface를 분석한다.
Owning denominator는 **38개**다.
- production Java: **29**
- dedicated unit Java: **8**
- experimental RLS migration SQL: **1**
여기에 실제 PostgreSQL 의미를 확인하기 위해 `postgresqlIntegrationTest/.../platform/experimental`의 contract 5개를 verification dependency로 추가 완독했다. 따라서 이번 실행에서 읽은 파일은 **38 / 38 owning FULL_READ + 5 / 5 verification dependency FULL_READ = 43개**다. 이전 checkpoint의 “38개”와 현재 tree inventory가 처음에는 어긋나 보였지만, 차이는 이 5개 PostgreSQL contract를 owning denominator가 아니라 검증 의존성으로 분리한 데서 나온다.
Evidence: `evidence/raw/093-persistence-jpa-experimental-manifest.txt`.
## 96. 현재 production composition은 Experimental을 실행하지 않지만 opt-in 경계는 완전히 구조적이지 않다
현재 repository 내부 production call graph에서는 `TenantDataSourceRegistry`, `TenantEntityManagerFactoryRegistry`, `SchemaMultiTenantConnectionProvider`, `ConsistencyAwareDataSourceRouter`, `RlsTenantSessionBinder`, `SchemaTenantMigrationOrchestrator` 등을 app-bootstrap이나 다른 production leaf가 조립하는 경로를 찾지 못했다. `backend.jpa.experimental.*` property도 production configuration에서 읽어 bean을 만드는 경로가 없고, 실제 문자열은 `ExperimentalFeature` enum의 property vocabulary에만 존재한다.
따라서 아래 semantic finding은 **현재 app-bootstrap runtime에서 즉시 활성화된 production defect가 아니라 latent experimental defect**로 분류한다. 이 구분은 중요하다. public API surface에 올라 있고 같은 artifact에 포함된 library code가 잘못된 것과, 현재 기본 애플리케이션이 그 code를 실제 실행하는 것은 다른 주장이다.
반면 structural opt-in은 완전히 닫혀 있지 않다. `PersistenceJpaConfig`의 Stable `@EntityScan``@EnableJpaRepositories` 문자열 목록에는 이미 `dev.caskeleton.adapter.outbound.persistence.experimental`이 들어 있다. 현재 experimental package에는 `@Entity`, `@Repository`, `JpaRepository`, `@MappedSuperclass`가 없어서 당장 persistence unit에 들어오는 concrete JPA type은 없지만, 이후 experimental entity/repository 하나가 추가되면 별도 feature condition 없이 Stable persistence unit이 스캔한다.
Evidence: `evidence/raw/096-experimental-gate-reachability.txt`, `099-experimental-structural-optin-gap.txt`.
## 97. P1 latent — RLS verifier가 “반드시 보호돼야 하는 table”의 부재를 성공으로 인정한다
`RlsPolicyVerifier.requireEnforced(runtimeDataSource, tenantScopedTables)`의 이름과 Javadoc은 caller가 지정한 tenant-scoped table들이 실제로 RLS에 의해 보호되는지 증명하는 contract다. 구현은 runtime role의 `BYPASSRLS`를 확인하고, `current_schema()`의 실제 table들을 순회하면서 이름이 `tenantScopedTables`에 포함된 row만 검사한다.
문제는 반대 방향 검증이 없다는 것이다. 즉 caller가 요구한 table 이름이 실제 catalog 결과에 **한 번도 등장하지 않아도** 성공한다.
```text
requested = [missing_tenant_scoped_table]
actual catalog row = rls_item
loop:
rls_item ∉ requested -> continue
loop end -> success
```
PostgreSQL 16에서 존재하지 않는 required table 하나를 넘긴 probe도 exception 없이 종료됐다.
```text
experimentalRls.requiredTable=missing_tenant_scoped_table
experimentalRls.verifierAcceptedMissingTable=true
BUILD SUCCESSFUL
```
이 경계가 위험한 이유는 단순히 “없는 table을 못 찾는다”가 아니다. tenant table rename/config drift/오타로 expected list가 stale해지면 verifier는 실제 tenant table을 검사하지 않은 채 startup evidence를 성공으로 만들 수 있다. security verifier가 coverage 대상 자체를 증명하지 못하는 fail-open이다.
**판정: P1 latent security verification defect.** 현재 기본 composition에는 RLS capability가 연결되지 않아 latent지만, 기능을 활성화해 이 verifier를 startup guard로 사용하는 순간 잘못된 table inventory가 green으로 통과한다.
수정은 catalog에서 발견한 tenant-scoped 대상의 상태만 검사할 것이 아니라 `requested - discovered`가 비어 있음을 먼저 강제해야 한다. 가능하면 expected table inventory도 임의 문자열 list가 아니라 migration/schema registry의 SSOT에서 파생하고, missing/renamed table을 real-PostgreSQL regression으로 고정해야 한다.
Evidence: `evidence/raw/098-experimental-rls-missing-table-probe.txt`.
## 98. P1 latent — database-per-tenant global connection budget이 새 pool 크기를 계산하지 않아 ceiling을 넘긴다
`TenantPoolBudget` 문서는 pool 개수와 전체 connection 합계를 모두 제한해야 한다고 명시한다. 특히 pool마다 크기가 다르기 때문에 connection total ceiling이 별도로 필요하다고 설명한다.
하지만 `TenantDataSourceRegistry.require()`의 순서는 다음이다.
```text
1. 현재 openPools / allocatedConnections 계산
2. budget.requireCapacity(currentOpenPools, currentAllocatedConnections)
3. 새 DataSource 생성
4. map에 추가
```
`requireCapacity()` 역시 현재 값이 이미 ceiling 이상인지 확인할 뿐, **이번에 추가할 pool의 크기**를 인자로 받지 않는다.
따라서 `maxConnectionsAcrossPools=10`이고 현재 8 connections을 가진 pool 하나가 열려 있으면 `8 < 10`이므로 admission이 통과한다. 그 다음 5-connection pool을 열면 결과는 13이다.
실측 probe:
```text
experimentalPool.maxConnections=10
experimentalPool.openPools=2
experimentalPool.allocatedConnections=13
BUILD SUCCESSFUL
```
기존 `TenantPoolCapacityContractTest`는 모든 tenant pool 크기를 2로 고정하고 `4/8`, `2/4`처럼 정확히 boundary에 도달한 뒤 다음 tenant를 거부하는 case만 검증한다. 그래서 **remaining capacity보다 다음 pool이 더 큰 case**를 보지 못한다.
**판정: P1 latent fleet-capacity defect.** 이 기능의 자체 문서가 connection ceiling 초과 시 한 tenant만이 아니라 전체 DB fleet이 connection refusal을 맞을 수 있다고 정의한다. 현재 app runtime에는 database-per-tenant registry가 조립되지 않아 latent지만, library contract 자체는 global ceiling을 보장하지 못한다.
수정은 admission이 `current + candidate`를 검사하게 해야 한다. 후보 pool size를 creation 전에 알 수 있는 profile metadata를 budget input으로 넣거나, 불가피하게 pool을 먼저 만들면 map에 publish하기 전에 size를 검증하고 초과 시 즉시 close해야 한다. regression은 heterogeneous pool sizes로 `8 + 5 > 10` 같은 부분 여유 case를 포함해야 한다.
Evidence: `evidence/raw/095-experimental-pool-overshoot-probe.txt`.
## 99. P2 latent — replica evidence가 완전히 unavailable이어도 EVENTUAL read는 replica로 간다
`ReplicaLagMonitor`의 contract는 명확하다.
- monitor down
- replica unreachable
- lag metric stale
같이 freshness evidence를 얻을 수 없으면 “I do not know”이고 router는 primary를 사용해야 한다. `satisfies()` Javadoc도 evidence가 없으면 false가 default라고 적는다.
그러나 구현은 consistency level별로 다음처럼 분기한다.
```text
EVENTUAL -> true
BOUNDED_STALENESS -> lag().map(...).orElse(false)
PRIMARY_REQUIRED -> replayedThrough().map(...).orElse(false)
```
즉 EVENTUAL만 evidence availability를 전혀 보지 않는다. `lag()``replayedThrough()`가 모두 `Optional.empty()`인 monitor를 넣은 probe는 replica를 선택했다.
```text
experimentalReplica.unavailableEvidence=true
experimentalReplica.consistency=EVENTUAL
experimentalReplica.target=REPLICA
```
EVENTUAL이 stale data를 허용하는 것과 **replica가 usable하다는 evidence 자체가 없는 것**은 다른 조건이다. 현재 contract는 후자를 primary fallback 조건으로 선언해 놓고 EVENTUAL path에서만 우회한다.
**판정: P2 latent routing fail-open.** 현재 default runtime에는 router가 조립되지 않는다. 활성화될 경우 monitor outage/unknown state에서 eventual read가 replica target을 선택할 수 있다.
수정은 consistency satisfaction과 replica health/evidence availability를 분리하는 편이 명확하다. EVENTUAL은 staleness bound를 요구하지 않을 수 있지만, 최소한 replica가 현재 route 가능한 대상이라는 health/evidence gate는 공통으로 통과해야 한다.
Evidence: `evidence/raw/097-experimental-replica-provider-probe.txt`.
## 100. P2 latent — Hibernate compatibility policy가 8만 blacklist하고 unknown major 9를 Stable 교체 가능으로 인정한다
`HibernateCompatibilityPolicy.mayReplaceStableProvider(version)`은 이름 그대로 특정 provider version이 promotion 없이 Stable provider를 대체해도 되는지를 답한다.
현재 구현은 `!providerPolicy.isExperimental(version)`이다. 그런데 `HibernateProviderPolicy`의 experimental provider list는 `List.of("8")` 하나뿐이다. 결과적으로 known Stable 7.x는 true, known Experimental 8.x는 false지만 **아직 어떤 compatibility evidence도 없는 9.x 같은 unknown major는 true**가 된다.
probe:
```text
experimentalHibernate.candidate=9.0.0.Final
experimentalHibernate.mayReplaceStable=true
```
이는 compatibility policy를 denylist로 모델링한 결과다. provider generation이 추가될수록 미측정 버전이 자동 허용되는 방향이라 promotion gate의 목적과 반대다.
기존 test도 7.x true와 8.x false만 검증해 unknown-major 경계를 놓친다.
**판정: P2 latent compatibility fail-open.** 실제 classpath는 현재 Hibernate 7이고 Hibernate 8 workflow도 `NOT_EXECUTABLE`을 명시하므로 지금 Stable runtime이 9.x라는 주장은 아니다. 문제는 policy가 미래 unknown major를 자동 승인한다는 점이다.
수정은 “experimental이 아니면 Stable”이 아니라 **명시적으로 허용된 Stable generation만 true**가 되게 해야 한다. 현재 policy 의도대로라면 최소 `7.x` allowlist 외 major는 false로 닫고, 새 major는 compatibility lane + promotion evidence를 거쳐 allowlist를 바꾸는 방향이 맞다.
Evidence: `evidence/raw/097-experimental-replica-provider-probe.txt`.
## 101. P2 latent — experimental opt-in이 세 entry point에만 강제되고 Stable scan은 experimental package를 이미 포함한다
experimental plan의 global constraint는 “모든 기능은 `backend.jpa.experimental.*` feature flag를 요구한다”이다. `ExperimentalEntryConsentTest`도 더 강하게 “behaviour-bearing entry point는 외부 package에서 public constructor로 만들 수 없어야 하고 gate-taking `enabledBy` factory만 제공해야 한다”고 선언한다.
그 test가 실제로 열거하는 class는 세 개뿐이다.
```text
ConsistencyAwareDataSourceRouter
RlsTenantSessionBinder
SchemaTenantMigrationOrchestrator
```
하지만 같은 experimental public API에는 flag 없이 바로 생성해서 behavior를 실행할 수 있는 type이 더 있다.
- `TenantDataSourceRegistry` — public constructor + tenant pool open
- `TenantEntityManagerFactoryRegistry` — public constructor + tenant EMF build
- `SchemaMultiTenantConnectionProvider` — public constructor + connection `search_path` 변경
- `TenantEntityListenerGuard` — public constructor + `@PrePersist/@PreUpdate` tenant write guard
`javap -public`로 이 constructor surface를 확인했고, 현재 repository production caller는 0이었다. 즉 지금 app-bootstrap이 우회하고 있다는 finding이 아니라 **consent test가 “all entry points”라고 부르는 집합 자체가 수동 3-class allowlist라 새/기존 activator를 놓친다**는 finding이다.
여기에 Stable `PersistenceJpaConfig`가 experimental package를 `@EntityScan`/`@EnableJpaRepositories`에 unconditional string으로 포함하는 구조가 겹친다. 현재 JPA stereotype이 0개라 즉시 bean activation은 없지만, 향후 experimental entity/repository가 추가되면 이 경로는 feature gate를 거치지 않는다. ArchUnit의 Stable→Experimental dependency rule은 bytecode type edge를 검사하므로 문자열 package scan을 잡지 못한다.
**판정: P2 latent architecture/consent gap.** 현재 production wiring이 없어 latent지만 “presence on classpath is not consent”라는 핵심 방어가 type마다 일관되게 강제되지 않는다.
수정은 experimental capability를 Stable persistence scan에서 제외하고, 각 feature가 자기 gated configuration에서 필요한 entity/repository/bean을 조립하게 해야 한다. entry-point 검증도 수동 3-class list가 아니라 annotation/package convention 또는 explicit registry SSOT에서 exhaustive하게 파생해야 새 behavior-bearing type이 추가될 때 test가 fail-closed 해야 한다.
Evidence: `evidence/raw/096-experimental-gate-reachability.txt`, `099-experimental-structural-optin-gap.txt`.
## 102. 이번 sub-scope에서 finding으로 올리지 않은 항목
### 102.1 JPA 4 / Hibernate 8 / PostgreSQL 19 workflow의 `NOT_EXECUTABLE`
세 workflow는 현재 target dependency/server를 실제로 resolve/run하지 않는다. 그러나 artifact에 `status=NOT_EXECUTABLE`과 이유를 명시하고 promotion checklist도 missing evidence를 통과로 취급하지 않는다. 따라서 “실행하지 않았는데 compatibility green으로 속인다”는 false-evidence finding으로 올리지 않는다. 실제 lane이 실행 가능해지기 전까지는 미검증 상태다.
### 102.2 RLS tenant binding 자체
`RlsTenantSessionBinder``set_config(..., true)`로 transaction-local tenant setting을 사용하고, existing real-PostgreSQL contract는 session-scoped setting이 pool reuse에서 leak하는 case와 transaction-scoped setting이 leak하지 않는 case를 구분한다. 이번 분석에서 binder 자체의 cross-tenant leak을 재현하지 못했다.
### 102.3 schema identifier selection/reset
`SchemaTenantRegistry`는 unquoted PostgreSQL identifier shape를 제한하고, connection provider는 schema 값을 statement text에 직접 붙이지 않고 bound `set_config`로 적용하며 release 시 neutral `pg_catalog`로 reset한다. 별도 failure-in-reset / pool-implementation semantics까지 corpus 전체 보장은 하지 않지만, 현재 happy-path isolation contract를 뒤집을 evidence는 없었다.
### 102.4 tenant repository/listener guard가 곧 production isolation이라는 주장
`TenantAwareRepositoryGuard``TenantEntityListenerGuard`의 local behavior는 fail-closed지만 현재 production repository/entity에 연결된 caller/listener registration은 없다. 따라서 이 type들이 존재한다는 이유만으로 현재 application의 tenant isolation이 보장된다고 쓰지 않는다.
## 103. Fresh Experimental verification evidence
- `evidence/raw/093-persistence-jpa-experimental-manifest.txt`**38 / 38 owning FULL_READ**(29 production + 8 unit + 1 SQL), 추가 PostgreSQL verification dependency **5 / 5 FULL_READ**
- `evidence/raw/095-experimental-pool-overshoot-probe.txt` — connection ceiling 10에서 heterogeneous pools 8 + 5가 admission되어 total **13**으로 overshoot, BUILD SUCCESSFUL
- `evidence/raw/097-experimental-replica-provider-probe.txt` — evidence가 모두 empty인 EVENTUAL read가 `REPLICA`, unknown Hibernate `9.0.0.Final``mayReplaceStable=true`, BUILD SUCCESSFUL
- `evidence/raw/098-experimental-rls-missing-table-probe.txt` — PostgreSQL 16에서 required missing table을 `RlsPolicyVerifier`가 성공으로 인정, `jpaPlatformSecurityTest` BUILD SUCCESSFUL
- `evidence/raw/096-experimental-gate-reachability.txt`, `099-experimental-structural-optin-gap.txt` — omitted public activation constructors, production caller 0, Stable scan의 experimental package unconditional inclusion
- `evidence/raw/100-experimental-original-unit-tests.txt` — original-source experimental 8 unit classes + `JpaModuleBoundaryTest` + `PersistenceEntityScanCoverageTest`, fresh `--rerun-tasks`, **BUILD SUCCESSFUL in 28s / 18 actionable executed**, git clean before/after
- `evidence/raw/101-experimental-original-postgresql-contracts.txt` — original-source contract 3 classes **BUILD SUCCESSFUL in 30s / 19 executed**, schema migration **BUILD SUCCESSFUL in 5s**, RLS security **BUILD SUCCESSFUL in 4s**, git clean before/after
- `evidence/raw/094-preflight-fixture-compile-failure.txt` — 최초 analysis-only DataSource fixture의 `@Override` 누락이 repository `-Werror`에 걸린 preflight 실패. 제품 code failure가 아니며 fixture 수정 후 095/097 clean probe로 대체했다.
모든 semantic probe는 temporary test source를 trap으로 복원했다. 최종 code repository `git status --short`는 clean이다.
## 104. Sub-scope 09 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P1 latent** | RLS verifier가 requested tenant table의 존재/coverage를 확인하지 않아 missing/stale table inventory를 green으로 인정 | **experimental public API, current app runtime unwired** |
| **P1 latent** | database-per-tenant admission이 candidate pool size를 반영하지 않아 global connection ceiling을 초과 가능 | **experimental public API, current app runtime unwired** |
| **P2 latent** | replica evidence가 unavailable이어도 EVENTUAL read는 replica target을 선택 | **experimental public API, current app runtime unwired** |
| **P2 latent** | Hibernate compatibility policy가 8.x만 deny해 unknown 9.x를 promotion 없이 Stable 교체 가능으로 판정 | **experimental compatibility policy, current Stable classpath 7.x** |
| **P2 latent** | feature consent가 3개 entry point에만 구조적으로 강제되고 Stable JPA scan은 experimental package를 unconditional 포함 | **latent structural activation path; current experimental JPA stereotypes 0** |
Sub-scope 09는 **38 / 38 owning FULL_READ + 5 / 5 PostgreSQL verification dependency FULL_READ + targeted semantic probes + original unit/contract/migration/security fresh rerun**으로 닫는다.
## 105. Sub-scope 10 범위와 denominator
Sub-scope 10은 JPA platform의 testkit 자체와 그 testkit을 검증하는 fixture/unit test를 소유한다. 이 범위는 production persistence 동작이 아니라 **그 동작을 증명한다고 주장하는 architecture rule, query/plan assertion, failure injector, migration runner, release registry adapter, PostgreSQL matrix helper가 false-green evidence를 만들 수 있는지**를 본다.
Owning denominator는 정확히 **62개**다.
- `src/testkit/java/**`: **41 Java**
- `src/test/java/**/testkit/**`: **21 Java**
이번 sub-scope에서 **62 / 62 FULL_READ**했다.
Evidence: `evidence/raw/102-persistence-jpa-testkit-manifest.txt`.
## 106. Testkit reachability를 production guard와 self-test helper로 나눈다
같은 testkit package에 있어도 영향도는 동일하지 않다.
production/release evidence에 실제 연결된 핵심 helper는 다음이다.
- `JpaArchitectureRules` / `EntityExposureCondition``app-bootstrap``JpaProductionArchitectureTest`가 실제 production graph에 적용한다.
- `JpaAuditMechanismRule` — 동일 production architecture suite가 audit mechanism과 audited bulk update를 검사한다.
- `PostgreSqlExplainRunner` / `QueryPlanAssertions` / `QueryPlanExpectation` — real PostgreSQL `PostgreSqlQueryPlanContractTest`가 사용하고 `jpaPlatformQueryPlanTest`라는 blocking release Test task 안에서 실행된다.
- `MigrationContractRunner`, `CountingDataSource`, lifecycle/mapping fixture들은 실제 PostgreSQL integration lane에서 사용된다.
반대로 public surface지만 현재 repository source에서 defining file 외 reference가 0인 helper도 있다.
- `CommitAmbiguityProxy`
- `PostgreSqlContractExtension`
`testkit.id.UuidV7Generator`도 simple name은 notification 모듈의 별도 production `UuidV7Generator`와 충돌하지만 **정확한 testkit FQN consumer는 0**이다. 현재는 자신의 unit test만 존재한다.
이 구분 때문에 아래 backlog는 “testkit code에 버그가 있다”만으로 승격하지 않고, **현재 production architecture/release evidence producer와 연결된 false-negative**를 우선한다.
Evidence: `evidence/raw/105-testkit-public-reachability-sweep.txt`.
## 107. P1 latent — SELECT-only query-plan runner가 data-modifying CTE를 허용해 `EXPLAIN ANALYZE`가 실제 DML을 실행한다
`PostgreSqlExplainRunner`는 이 위험을 정확히 문서화한다.
```text
EXPLAIN ANALYZE executes the statement;
therefore this runner refuses anything but SELECT.
```
하지만 실제 `requireReadOnly()`는 다음 두 prefix를 허용한다.
```text
select...
with...
```
PostgreSQL의 `WITH`는 read-only CTE만 의미하지 않는다. data-modifying CTE가 가능하다.
```sql
WITH changed AS (
UPDATE plan_row
SET bucket = 99
WHERE id = ?
RETURNING id
)
SELECT id FROM changed
```
이 statement는 `WITH`로 시작하므로 guard를 통과하고, runner가 붙이는 `EXPLAIN (ANALYZE, ...)`는 실제 UPDATE를 실행한다.
PostgreSQL 16 real-container probe에서 id 42의 `bucket`을 조회한 뒤 위 statement를 runner에 넣었다.
```text
testkitExplain.guardAcceptedWithUpdateCte=true
testkitExplain.bucketBefore=9
testkitExplain.bucketAfter=99
BUILD SUCCESSFUL
```
즉 guard가 보호한다고 명시한 side-effect가 실제로 발생했다.
기존 `refusesNonSelect()` contract는 direct `UPDATE ...`만 넣기 때문에 green이다. 원본 `PostgreSqlQueryPlanContractTest` 전체도 fresh rerun에서 green이지만, 그것은 현재 input이 SELECT라는 증거이지 `WITH`가 read-only라는 증거가 아니다.
**판정: P1 latent release-evidence safety defect.** 현재 committed query-plan tests는 SELECT만 사용하므로 지금 release run이 DB를 변경했다는 주장은 아니다. 그러나 이 helper는 blocking `jpaPlatformQueryPlanTest`가 사용하는 evidence producer이고, documented safety invariant를 우회하는 legal PostgreSQL syntax가 실제 mutation까지 재현됐다.
수정은 string prefix whitelist로 SQL read-only 여부를 판정하지 않는 방향이 필요하다. 최소한 data-modifying CTE를 fail-closed로 거부하는 parser/statement classification을 사용하고, query-plan lane 자체의 connection/transaction도 read-only defense-in-depth로 묶어야 한다. regression은 direct UPDATE뿐 아니라 `WITH ... UPDATE/DELETE/INSERT ... SELECT`를 포함해야 한다.
Evidence: `evidence/raw/104-testkit-explain-dml-cte-probe.txt`, `106-testkit-original-verification.txt`.
## 108. P1 latent — production entity-exposure rule이 async/reactive wrapper 안의 JPA entity를 보지 못한다
`EntityExposureCondition`의 목적은 controller/web method가 persistence entity를 return graph 어디에서도 노출하지 못하게 하는 것이다. 직접 entity뿐 아니라 `List<Entity>`, `Optional<Entity>`, `Map<..., Entity>`까지 generic argument를 검사한다고 명시한다.
하지만 generic traversal은 raw return type이 다음 container일 때만 실행된다.
```text
Collection
Map
Optional
array
```
따라서 다음처럼 실제 transport 계층에서 흔한 wrapper는 raw type 단계에서 즉시 `Optional.empty()`가 된다.
```text
CompletableFuture<OrderEntity>
CompletionStage<OrderEntity>
ResponseEntity<OrderEntity>
Mono<OrderEntity>
Flux<OrderEntity>
Page<OrderEntity>
```
analysis fixture로 `CompletableFuture<OrderEntity>`를 반환하는 `..web..` class를 넣고 **실제 production에서 사용하는 동일 `JpaArchitectureRules.noEntityFromWeb()`**를 실행했다. rule은 exception 없이 통과했다.
```text
testkitArchitecture.wrapper=CompletableFuture<OrderEntity>
testkitArchitecture.entityLeakAccepted=true
```
이 문제는 가상의 wrapper family만의 이야기가 아니다. 현재 inbound web 코드도 `Mono<ResponseEntity<...>>` 같은 nested transport wrapper를 실제로 사용한다. 지금 그 내부 payload는 persistence entity가 아니라 DTO/String이므로 current production violation은 확인되지 않았고, 원본 `JpaProductionArchitectureTest`도 fresh green이다.
**판정: P1 latent architecture-enforcement false-negative.** controller가 persistence entity를 직접 노출하는 것은 repository의 HARD-STOP 계열 경계이고, 이 rule은 release-wide `app-bootstrap:test`에서 그 경계를 증명하는 production guard다. 현재 code가 위반 중이라는 finding이 아니라, **위반을 추가해도 대표적인 async wrapper 형태면 guard가 green일 수 있다는 것**이 finding이다.
수정은 container allowlist로 들어갈지 말지를 결정하지 말고 return `JavaType`의 generic graph를 재귀적으로 traverse하되 cycle을 방지하는 방식이 더 안전하다. 최소 regression에는 `CompletableFuture<Entity>`와 실제 runtime stack의 `Mono<ResponseEntity<Entity>>` 또는 동등한 nested wrapper를 포함해야 한다.
Evidence: `evidence/raw/103-testkit-unit-boundary-probes.txt`, `106-testkit-original-verification.txt`.
## 109. P2 latent — plan normalizer가 root node 하나의 estimate ratio만 읽어 child node의 큰 cardinality miss를 숨긴다
`PostgreSqlExplainRunner.normalize()``Node Type`은 전체 JSON에서 반복 탐색한다. 반면 `Actual Rows`, `Plan Rows`, `Shared Read Blocks``indexOf(key)`**첫 occurrence 하나만** 읽는다.
PostgreSQL JSON plan은 root node 뒤에 child `Plans[]`가 중첩되는 구조이므로 현재 `estimateRatio`는 사실상 root node ratio다.
analysis probe에 다음 plan을 넣었다.
```text
root Nested Loop: Actual 10 / Plan 10 -> ratio 1
child Seq Scan: Actual 1000 / Plan 1 -> ratio 1000
```
normalizer는 child node type은 발견하면서 estimate ratio는 1.0으로 보고했다.
```text
testkitPlan.nodes=[Nested Loop, Seq Scan]
testkitPlan.reportedEstimateRatio=1.0
testkitPlan.childActualToPlanned=1000.0
```
`QueryPlanExpectation.estimateOnly(10)` 같은 assertion은 이런 plan을 estimate-quality 관점에서 green으로 통과시킬 수 있다.
**판정: P2 latent query-plan false-evidence.** 현재 committed estimate assertion의 대표 query는 단순 index lookup이라 이 probe만으로 현재 release 결과가 거짓이라고 확대하지 않는다. 그러나 blocking query-plan lane의 normalized model이 “planner estimate error”를 plan 전체가 아니라 root 한 node로 축소하는 것은 명시적인 측정 공백이다.
수정은 node별 actual/planned pair를 구조적으로 parse하고, maximum symmetric error ratio 또는 명시한 aggregation policy를 `NormalizedPlan`에 보존해야 한다. raw JSON을 substring scanning하기보다 JSON tree parser로 node recursion을 수행하는 것이 node pairing과 buffer aggregation 모두 안전하다.
Evidence: `evidence/raw/103-testkit-unit-boundary-probes.txt`.
## 110. P2 latent — audited bulk-update guard가 audit column 이름을 “대입 대상”이 아니라 substring으로 찾아 false-green을 만든다
`JpaAuditMechanismRule.bulkUpdateViolation()`은 audited entity의 bulk update가 `updated_at`/`updatedAt`/`modified_at`/`modifiedAt`를 직접 stamp하는지 검사한다.
하지만 현재 판정은 `SET ... WHERE` 문자열 전체에 audit-column token이 **어디든 포함되는지**만 본다.
```text
AUDIT_COLUMNS.stream().anyMatch(assignments::contains)
```
따라서 실제 audit field를 변경하지 않고 parameter 이름에 token만 들어 있어도 통과한다.
```sql
update WorkLogEntity w
set w.status = :updatedAtValue
where w.id = :id
```
probe 결과:
```text
testkitAudit.decoyParameter=:updatedAtValue
testkitAudit.violationPresent=false
```
`w.updatedAt = ...` assignment가 하나도 없는데 “stamp 있음”으로 해석했다.
현재 production graph에는 이 rule의 audited-entity bulk-update branch를 실제로 밟는 committed query가 없어 original architecture suite는 green이다. class 자체도 이 점을 Javadoc에서 인정하고 direct branch unit test를 둔다. 문제는 그 branch test가 real assignment와 완전 unstamped case만 보고 decoy token을 보지 않는다는 것이다.
**판정: P2 latent audit architecture false-negative.** 현재 unstamped audited bulk update가 production에 있다는 주장은 아니다. 다만 future query가 parameter/함수/문자열 literal에 audit token을 포함하면 release architecture guard가 잘못 통과할 수 있다.
수정은 SQL/JPQL의 `SET` assignment left-hand side를 최소한 token boundary 기준으로 추출하여 audit property/column과 정확히 비교해야 한다. regression에는 parameter name, string literal, unrelated property suffix에 `updatedAt` token이 포함된 case를 넣어야 한다.
Evidence: `evidence/raw/103-testkit-unit-boundary-probes.txt`, `106-testkit-original-verification.txt`.
## 111. 이번 sub-scope에서 finding으로 올리지 않은 항목
### 111.1 `UuidV7Generator` same-millisecond wrap
이 testkit generator는 12-bit counter를 같은 millisecond마다 `& 0x0FFF`로 증가시킨다. lower-half random seed는 wrap을 제거하지 않고 늦출 뿐이다. deterministic probe에서는 같은 millisecond **3,784번째**에 이전 UUID보다 작아지는 정렬 역전이 재현됐다.
```text
testkitUuid.sameMillisecondGeneratedBeforeInversion=3784
testkitUuid.monotonicityBroken=true
```
하지만 exact `dev.caskeleton.adapter.outbound.persistence.testkit.id.UuidV7Generator` FQN의 repository consumer는 현재 0이고, notification runtime이 사용하는 production `UuidV7Generator`는 다른 모듈의 별도 class다. 따라서 production UUID defect로 승격하지 않고 **self-tested unadopted test fixture defect**로 남긴다. 만약 이 fixture를 ID-strategy PostgreSQL contract에 실제 채택한다면 그 시점에는 same-ms exhaustion policy가 필요하다.
### 111.2 `EntityState.REMOVED`
`EntityState` enum은 `REMOVED`를 제공하지만 `EntityStateProbe.stateOf()` 구현은 `MANAGED`, `TRANSIENT`, `DETACHED` 세 값만 반환한다. `REMOVED` consumer/assertion도 현재 없다. API vocabulary와 probe capability가 어긋나지만 current evidence lane을 잘못 통과시키는 소비 경로가 없어 backlog 우선순위에는 올리지 않는다.
### 111.3 `CommitAmbiguityProxy` / `PostgreSqlContractExtension`
두 public helper는 defining file 밖 exact FQN reference가 0이다. 특히 `PostgreSqlContractExtension.serverVersion()`은 이름과 달리 database의 `SHOW server_version`이 아니라 Docker image + container id를 반환하지만 현재 integration support는 별도 `JpaPlatformContractSupport.serverVersion()`로 실제 server version을 읽는다. 따라서 잘못된 current evidence로 분류하지 않고 dead/unadopted helper로 기록한다.
### 111.4 `JpaReleaseManifest`의 regex parser
Java testkit parser 자체는 정규식 기반이라 일반-purpose JSON parser가 아니다. 그러나 실제 registry 파일은 root Gradle `verifyJpaReleaseGateTasks`에서 `JsonSlurper`로 다시 parse되고 real task graph까지 resolve한다. 현재 malformed JSON을 Java regex parser 하나가 받아들일 가능성만으로 release fail-open을 별도 finding으로 중복 승격하지 않는다.
## 112. Fresh Testkit verification evidence
- `evidence/raw/102-persistence-jpa-testkit-manifest.txt`**62 / 62 FULL_READ** (41 testkit Java + 21 fixture/unit Java)
- `evidence/raw/103-testkit-unit-boundary-probes.txt` — async wrapper entity leak accepted, audit decoy token accepted, child estimate miss hidden by root ratio, testkit UUID same-ms inversion 재현; analysis fixture는 trap으로 복원
- `evidence/raw/104-testkit-explain-dml-cte-probe.txt` — PostgreSQL 16에서 `WITH UPDATE ... SELECT`가 read-only guard를 통과하고 `EXPLAIN ANALYZE`로 row를 실제 `9 -> 99` 변경; BUILD SUCCESSFUL
- `evidence/raw/105-testkit-public-reachability-sweep.txt` — public testkit symbol reachability와 zero-reference helper sweep
- `evidence/raw/106-testkit-original-verification.txt` — 원본 source 상태에서 `:adapter:outbound:persistence-jpa:test --rerun-tasks` **BUILD SUCCESSFUL in 29s / 18 executed**, `JpaProductionArchitectureTest` **BUILD SUCCESSFUL in 1m47s / 98 executed**, original `PostgreSqlQueryPlanContractTest` **BUILD SUCCESSFUL in 25s / 19 executed**, git clean before/after
모든 analysis-only source/fixture 변경은 trap으로 복원했고 최종 code repository `git status --short`는 clean이다.
## 113. Sub-scope 10 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P1 latent** | `PostgreSqlExplainRunner`가 모든 `WITH`를 read-only로 허용해 data-modifying CTE를 `EXPLAIN ANALYZE`로 실제 실행 가능 | **blocking query-plan evidence producer; current committed inputs are SELECT** |
| **P1 latent** | production `noEntityFromWeb` architecture rule이 async/reactive/custom wrapper 안 entity를 탐색하지 않아 `CompletableFuture<Entity>`가 green | **release-wide app-bootstrap architecture guard; current payloads are DTOs** |
| **P2 latent** | query-plan estimate ratio가 root node first pair만 사용해 child cardinality miss를 숨김 | **blocking query-plan evidence producer** |
| **P2 latent** | audited bulk-update guard가 audit column substring만 찾아 parameter/string decoy로 false-green | **production architecture guard; current audited bulk-update branch absent** |
Sub-scope 10은 **62 / 62 FULL_READ + four focused unit false-negative probes + real-PostgreSQL DML-CTE probe + full original unit/production-architecture/query-plan fresh rerun**으로 닫는다.
---
## 114. Sub-scope 01 범위와 denominator
> 내부 상태: COMPLETE — **11 / 11 FULL_READ**
> 범위: leaf 최상위 4개 파일 + `package-info.java` + `config/**` production 3개 + root-level test 3개
> 역할: 이 leaf가 "무엇이고, 무엇을 always-install하며, 어떤 경계를 스스로 강제하는가"를 선언하는 층
| 구분 | 파일 | 라인 |
|---|---|---:|
| governance | `CLAUDE.md` | 295 |
| rationale | `README.md` | 379 |
| build | `build.gradle` | 356 |
| build | `gradle.lockfile` | 224 |
| production | `package-info.java` | 2 |
| production | `config/JpaAdapterComponentsConfig.java` | 68 |
| production | `config/PersistenceJpaConfig.java` | 95 |
| production | `config/PersistenceVendorSettings.java` | 35 |
| test | `CandidateAdapterCompositionTest.java` | 63 |
| test | `JpaModuleBoundaryTest.java` | 411 |
| test | `integration/.gitkeep` | 1 |
denominator 근거는 `evidence/raw/107-persistence-jpa-governance-manifest.txt`다. 이 11개는 "다른 어떤 sub-scope manifest도 claim하지 않은 leaf tracked file"로 정의했다. 특히 `config/PersistenceEntityScanCoverageTest`, `config/PersistenceVendorSelectionTest`, `platform/PoolLaneClaimTest` 3개 test는 sub-scope 06 manifest(`070-...`)가 이미 자기 denominator에 넣었기 때문에 여기서 다시 세지 않는다. 대신 이 sub-scope는 그 test들이 **검사하는 대상**인 production type을 소유한다. 즉 소유 경계는 "production type은 01, 그 test의 계수는 06"으로 갈라져 있고, 이 문서는 그 사실을 명시한 뒤 내용 분석은 여기서 한다.
`.gitkeep`은 빈 디렉터리 marker이므로 `STRUCTURAL_ONLY`가 아니라 `FULL_READ`(1바이트, 내용 없음)로 처리했다. `gradle.lockfile`은 224줄 전부를 읽었으나 해석은 configuration별 classpath 소속 확인에 한정했다.
## 115. governance는 세 겹이고, 세 겹의 강제력이 서로 다르다
이 leaf의 규칙은 세 곳에 나뉘어 있고 각각 강제 수단이 다르다.
| 층 | 문서/코드 | 강제 수단 | 위반 시 실패 지점 |
|---|---|---|---|
| 정책 서술 | `CLAUDE.md` | 없음(산문) | 없음 — 읽는 사람만 안다 |
| 근거 서술 | `README.md` | 없음(산문) | 없음 |
| build 계약 | `build.gradle` | Gradle task | task 실행 시 |
| package 경계 | `JpaModuleBoundaryTest` | ArchUnit + 파일시스템 대조 | `:test` 실행 시 |
`build.gradle`은 산문이 아니라 실제 계약이다. `strictTestLanes``postgresqlIntegrationTest` / `testkit` / `jpaPlatformPerformanceTest` 세 source set을 만들고, `testkitPublisher`가 testkit을 `test``postgresqlIntegrationTest`에만 소비시킨다. 즉 "production module은 testkit에 의존하지 않는다"는 설계 주장은 여기서 **구조적으로** 참이 되고, `JpaModuleBoundaryTest.noProductionClassDependsOnTheTestkit()`가 bytecode 수준에서 다시 확인한다. 같은 주장을 두 층이 서로 다른 방식으로 잡는다.
`JpaModuleBoundaryTest`는 이 leaf에서 가장 강한 governance 장치다. 24개 top-level package 각각의 허용 edge를 `PACKAGE_CATALOG`에 닫힌 집합으로 적고, 디스크의 실제 package 목록과 **정확히 같은 집합인지** 양방향으로 대조하며(`theCatalogNamesExactlyThePackagesThatExist`), 관측된 모든 package 간 edge가 선언된 edge인지 확인하고(`everyObservedEdgeIsDeclared`), 선언된 edge가 DAG인지 검사한다(`theDeclaredEdgesFormADag`). `importActuallyLoadedTheProductionClasses``noClasses()` rule이 "아무것도 매칭되지 않아 vacuously 통과"하는 실패 모드를 명시적으로 막는다 — ArchUnit rule 모음에서 가장 자주 조용히 무너지는 지점을 이 파일은 알고 있다.
다만 catalog의 **값** 쪽은 key 쪽만큼 검증되지 않는다. `postgresql`의 허용 대상에 `"inbox"`가 들어 있는데 `..persistence.inbox`라는 top-level package는 존재하지 않는다(실제 inbox는 `postgresql.inbox` 하위 package라 `topLevelPackageOf()`가 항상 `postgresql`을 돌려준다). 즉 이 항목은 어떤 edge도 허용하지 않는 사문(死文)이다. `theCatalogNamesExactlyThePackagesThatExist`는 key만 대조하므로 이런 값은 잡히지 않는다. 방향은 안전한 쪽이다 — 존재하지 않는 이름은 rule을 **더 엄격하게** 만들 뿐 느슨하게 만들지 않는다 — 그래서 결함이 아니라 잔여 설정으로 기록한다. 근거: `evidence/raw/110-governance-doc-count-drift.txt` §I.
## 116. Confirmed P2 — vendor selector의 fail-fast 계약이 shipped composition에 설치돼 있지 않다
`PersistenceVendorSettings`는 자기 존재 이유를 javadoc에 명확히 적는다.
> Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the two `@ConditionalOnProperty` vendor configurations would both stay off, and the first missing SPI bean would surface as a `NoSuchBeanDefinitionException` naming `OutboxClaimRepository` — a symptom several layers away from the misspelled value that caused it.
`CLAUDE.md` §Vendor selection도 같은 주장을 한다. `application.yml:463` 주석은 한 발 더 나가 "PersistenceVendorSettings (adapter-persistence), which rejects any other value at startup"라고 쓴다.
문제는 이 타입이 **production에서 한 번도 `@ConfigurationProperties` bean으로 등록되지 않는다**는 것이다.
- leaf 안의 `@ConfigurationProperties` 타입은 정확히 3개다: `LockSettings`, `JpaTransactionSettings`, `PersistenceVendorSettings`.
- 그중 앞의 둘만 같은 package 안의 configuration이 `@EnableConfigurationProperties`로 켠다 — `DistributedLockPersistenceConfig`, `JpaTransactionConfig`.
- `PersistenceVendorSettings`를 켜는 곳은 repository 전체에서 `PersistenceVendorSelectionTest`의 중첩 `@Configuration` 하나뿐이다.
- composition root `CaSkeletonApplication``@ConfigurationPropertiesScan` basePackages 21개 중 `dev.caskeleton.adapter.outbound.persistence`를 덮는 항목은 0개다.
- `PersistenceJpaRootAutoConfiguration`은 이 사실을 알고 있고 javadoc에 적어 두었다 — vendor를 `Environment.getProperty(...)`로 직접 읽는다.
역설적인 것은 `JpaAdapterComponentsConfig`의 javadoc이 규칙 자체를 정확히 서술한다는 점이다. "Each package's `@ConfigurationProperties` type is enabled by a configuration inside that same package — `JpaTransactionConfig` for `JpaTransactionSettings`, `DistributedLockPersistenceConfig` for `LockSettings`". 셋 중 둘을 열거하고 셋째를 빠뜨렸는데, 그 셋째를 소유한 package가 바로 이 javadoc이 들어 있는 `config`다.
### 실행 probe
`evidence/raw/109-vendor-selector-shipped-shape-probe.txt` / `109a-...java`. shipped composition과 같은 모양(두 vendor configuration만 import, properties 타입은 켜지 않음)에서 `ca-skeleton.persistence.vendor=mysql`을 준다.
```text
shipped.unknownVendor.contextFailed=false
shipped.unknownVendor.vendorSettingsBeans=0
shipped.unknownVendor.sqlStateErrorMappingBeans=0
shipped.unknownVendor.postgreSqlConfigBeans=0
shipped.unknownVendor.h2ConfigBeans=0
```
같은 모양에 `@EnableConfigurationProperties(PersistenceVendorSettings.class)`만 추가하면 문서가 약속한 실패가 실제로 난다.
```text
enabled.unknownVendor.contextFailed=true
enabled.unknownVendor.mentionsProperty=true
```
(context가 남긴 예외: `ConfigurationPropertiesBindException: ... Could not bind properties to 'PersistenceVendorSettings' : prefix=ca-skeleton.persistence`)
selector를 아예 주지 않은 경우에는 PostgreSQL 쪽이 활성화된다는 것도 같은 probe에서 확인된다 — Spring Data가 4개 repository interface를 스캔하고 `entityManagerFactory` 부재로 실패하므로, `@Import(PersistenceJpaConfig.class)` 사슬이 실제로 돌았다는 뜻이다. `matchIfMissing = true`는 살아 있다.
`JpaAdapterComponentsConfig`까지 넣은 네 번째 case에서는 context가 실패하지만 **실패 메시지 어디에도 vendor property가 등장하지 않는다**.
```text
components.unknownVendor.contextFailed=true
components.unknownVendor.mentionsVendorProperty=false
```
**판정: P2 confirmed.** 오타 난 vendor 값은 startup을 실패시키기는 하지만, 그 실패는 property를 지목하지 않는다 — `PersistenceVendorSettings`가 막겠다고 선언한 바로 그 증상이다. app-bootstrap의 `PersistenceVendorProdSafetyValidator`도 도움이 되지 않는다. 그 validator는 prod profile에서 값이 `h2`인지만 보고 알 수 없는 값은 통과시킨다.
수정은 작다 — `config` package 안에 `@EnableConfigurationProperties(PersistenceVendorSettings.class)`를 가진 configuration을 두고 `PersistenceJpaRootAutoConfiguration`이 그것을 import하면, 이미 존재하는 두 sibling과 같은 모양이 된다. regression은 unknown vendor로 context를 띄워 실패 메시지가 property 이름을 포함하는지 보면 된다(위 probe가 그대로 red/green 쌍이다).
한계: probe context는 full application context가 아니다. 실제 배포에서 **첫 번째로** 실패하는 bean은 다를 수 있다. 증명된 것은 (a) unknown 값이 아무것도 bind하지 않고 두 vendor configuration을 모두 비활성으로 남긴다, (b) 그 경로의 실패가 property를 지목하지 않는다, (c) properties 타입을 켜면 지목하는 실패가 난다 — 세 가지다.
## 117. always-install scan과 opt-in scan의 경계는 실제로 지켜지고 있다
`PersistenceJpaConfig`는 persistence root를 통째로 스캔하지 않고 20개 package를 열거한다. 빠진 것은 `config`, `h2`(JPA stereotype 없음)와 opt-in 두 개(`notification`, `fileserver`)다. 두 opt-in은 각자의 `@ConditionalOnProperty` configuration이 자기 package만 스캔한다. 이 배치의 이유는 javadoc과 `PersistenceEntityScanCoverageTest`에 기록돼 있다 — 과거에 root를 스캔해서 capability를 끈 배포가 `ddl-auto=validate`에서 `notification_request` / `fs_cleanup_item`을 요구하며 부팅에 실패했다.
측정 결과 always-install scan의 건전성은 유지되고 있다. `@Entity` 25개 중 opt-in package(`notification` 13, `fileserver` 6) 밖의 4개는 `idempotency_record`, `outbox_event`, `live_event_log`, `durable_operation`이고, 이 네 테이블은 모두 default location `db/migration/postgresql`(V1/V3/V11/V12)이 만든다. `postgresql` package는 scan 대상이지만 그 안의 candidate adapter들(`inbox`, `outbox` v2, `idempotency` v2)은 `@Entity`가 아니라 native SQL 기반이라 persistence unit에 들어오지 않는다. 즉 sub-scope 08이 발견한 "opt-in stream을 always-install scan이 끌고 들어온다" 유형의 결함은 현재 남아 있지 않다.
다만 `PersistenceEntityScanCoverageTest`가 지키는 범위에는 비대칭이 하나 있다. opt-in configuration 두 개에 대해서는 `@EntityScan` 목록과 `@EnableJpaRepositories` 목록이 **정확히 같은지** `containsExactly`로 검사한다("entities without repositories is half a scan, and fails at the first query"). 그런데 always-install `PersistenceJpaConfig`에 대해서는 `@EntityScan` 목록만 읽어 디스크와 대조하고, 두 목록의 일치는 검사하지 않는다. 현재 두 목록은 20개로 동일하다(`110-...` §H, diff 결과 identical). 그래서 지금은 무해하지만, 새 package를 `@EntityScan`에만 추가하는 실수는 이 test가 잡지 못한다 — 그 test가 opt-in 쪽에 대해서만 명시적으로 막고 있는 바로 그 실수다. P3.
`CandidateAdapterCompositionTest`는 반대 방향을 지킨다. PostgreSQL 전용 candidate adapter 3종에 `@Repository`/`@Component`/`@Service`가 없는지, 그리고 이미 stereotype을 제거한 `PostgreSqlOwnerSafeIdempotencyStore`가 계속 그 상태인지 검사한다. sub-scope 05가 이 adapter들을 "미조립 candidate"로 분류한 근거가 이 test로 고정돼 있다.
## 118. Negative-space probes — governance scope
근거: `evidence/raw/108-governance-config-reachability.txt`.
### 118.1 Public surface reachability
`config` package의 public type 3개는 모두 leaf 밖 소비자가 있다.
| type | leaf 밖 소비자 |
|---|---|
| `PersistenceJpaConfig` | `sample-portfolio``SamplePostgreSqlPersistenceConfig``@Import`. app-bootstrap은 직접 import하지 않고 두 vendor configuration을 통해 간접 도달 |
| `JpaAdapterComponentsConfig` | `PersistenceJpaRootAutoConfiguration``@Import` 목록 |
| `PersistenceVendorSettings` | `PersistenceJpaRootAutoConfiguration``VENDOR_PROPERTY` 상수만 사용(타입 자체는 bean 아님), `application.yml` 주석 |
`config``JpaModuleBoundaryTest.EXPORTED_PACKAGES`에 들어 있으므로 이 도달은 선언된 export를 통한 것이다. zero-reference public type은 없다.
### 118.2 Conditional sibling comparison
두 축에서 비교했다.
- **`@ConfigurationProperties` 3형제**: §116. 셋 중 하나만 enablement가 없다 — 비대칭이 확인된 결함이다.
- **configuration 활성화 조건**: `PersistenceJpaConfig``JpaAdapterComponentsConfig`는 조건이 없고, `PostgreSqlPersistenceConfig`/`H2PersistenceConfig`는 vendor 조건, `NotificationJpaPersistenceConfig`/`FileserverJpaPersistenceConfig`는 capability 조건을 갖는다. 무조건인 둘은 composition root의 JPA master switch(`ca-skeleton.persistence-jpa.enabled`) 뒤 `@Import`로만 도달하므로 "off는 구조적 사실"이라는 설계가 유지된다.
### 118.3 Duplicate-mechanism sweep
entity/repository/component scan 선언을 repository 전체에서 훑었다. persistence 관련 선언은 5곳이다 — leaf의 `PersistenceJpaConfig`(always), `NotificationJpaPersistenceConfig`, `FileserverJpaPersistenceConfig`, `JpaAdapterComponentsConfig`(component scan만), 그리고 `sample-portfolio`의 자기 package 전용 `JpaConfig`. app-bootstrap production에는 persistence entity/repository scan이 없다. test 쪽 2곳(`OutboxContainerTestSupport`, `FileserverRoundTripContractTest` 주석)은 harness 소유다. 경쟁 구현은 없다.
### 118.4 Documentation / measured-count drift
§119에서 따로 다룬다.
## 119. Confirmed documentation / measured-count drift
근거: `evidence/raw/110-governance-doc-count-drift.txt`.
이 repository는 leaf count drift를 잡는 전용 gate를 갖고 있다. root `build.gradle``verifyDocumentedLeafCount``(\d+)\s*(?:개\s*)?-?\s*(?:leaf|leaves)` 패턴을 찾아 registry의 실제 leaf 수와 비교하고, `check`에 연결돼 있다. 그 gate의 주석 자체가 "named list missed five module CLAUDE.md files and four leaf build.gradle headers, each restating 19-leaf from before the messaging platform's leaves were registered"라고 과거 사고를 기록한다.
그런데 gate의 탐색 domain은 `CLAUDE.md`와 (root를 뺀) `build.gradle` **두 파일명뿐**이다. `README.md`, `docs/**`, `*.java`는 들어가지 않는다. 그 사각지대에 stale claim이 그대로 남아 있다.
| 항목 | 문서가 말하는 값 | 측정값 | 위치 |
|---|---|---|---|
| registered leaf | 19 | **44** | `docs/jpa/repository-adaptation.md:21`, `:101`, `JpaModuleBoundaryTest.java:20` |
| 같은 claim(형제 leaf) | 19 | 44 | httpclient / mongo / cache-redis boundary test 3개 |
| public top-level type | "318 of 324 production files" | **338 / 350** | `JpaModuleBoundaryTest.java:124`, `CleanArchitectureTest.java:1110` |
| package root | `dev.caskeleton.adapter.persistence` | `dev.caskeleton.adapter.outbound.persistence` | `README.md:3`, `:77` |
| module 이름 | `adapter-persistence-rdbms` | registry id `adapter-outbound-persistence-jpa` | `README.md:1` |
`verifyDocumentedLeafCount`는 실제로 실행했고 **통과한다**(`114-governance-pool-original-verification.txt`, `BUILD SUCCESSFUL`, 9 actionable executed). 즉 gate가 green인 상태에서 leaf 자신의 module-boundary test와 leaf 자신의 adaptation 문서가 44개 registry를 19개라고 말하고 있다. `docs/jpa/repository-adaptation.md`는 leaf `build.gradle`이 `tasks.named('test') { inputs.file(...) }`로 up-to-date 입력에 명시한 **살아 있는** 문서라 더 눈에 띈다.
public type 수는 확인 가능한 측정값이다. `docs/architecture/jpa-api-surface.txt`의 committed baseline이 스스로 `# types: 338`을 적고 있고 비주석 항목도 338개다. production Java 파일은 350개다. 문서의 318/324는 두 값 모두 과거치다.
반대로 **drift가 아닌 것**도 기록해 둔다. `CLAUDE.md` §Platform lanes가 나열한 7개 lane task 이름은 전부 실재하고, root `jpaReleaseGate`도 `build.gradle:1097`에 등록돼 있다. lane 이름 쪽 문서는 현재 정확하다.
**판정: P3 documentation/count drift(확정).** 코드 동작에는 영향이 없다. 영향은 신뢰도다 — module boundary test의 도입 주석이 module registry 크기를 절반 이하로 말하고 있고, 그 숫자를 잡으려고 만든 gate는 java 파일을 보지 않는다. 수정은 두 갈래다. 숫자를 고치거나(값이 또 늙는다), gate가 권하는 대로 "registry가 목록의 소유자"라고 명사를 바꾸거나. gate의 탐색 domain을 `*.java`/`docs/**`로 넓히는 쪽이 근본적이지만, 그러면 위 6개 파일이 즉시 red가 되므로 함께 고쳐야 한다.
## 120. Sub-scope 01 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P2** | `PersistenceVendorSettings`가 production에서 `@ConfigurationProperties` bean으로 등록되지 않아, 문서가 약속한 "unknown vendor는 startup에서 property를 지목하며 실패" 계약이 shipped composition에 없다 | **shipped composition — 모든 JPA-on 배포** |
| **P3** | `verifyDocumentedLeafCount`의 탐색 domain(`CLAUDE.md`/`build.gradle`) 밖에서 19-leaf claim 6곳이 생존, registry는 44 | 문서/주석; build는 green |
| **P3** | `JpaModuleBoundaryTest`/`CleanArchitectureTest`의 "318 of 324 production files" 측정 주석이 현재 338/350과 불일치 | 주석 |
| **P3** | `README.md`가 package root를 `dev.caskeleton.adapter.persistence`로, module을 `adapter-persistence-rdbms`로 적음 | 문서 |
| **P3** | `PersistenceEntityScanCoverageTest`가 always-install configuration의 `@EntityScan` / `@EnableJpaRepositories` 목록 일치를 검사하지 않음(opt-in 쪽은 검사) | latent — 현재 두 목록 동일 |
| **P3/기록** | `JpaModuleBoundaryTest.PACKAGE_CATALOG`의 `postgresql -> "inbox"` 항목이 존재하지 않는 top-level package를 가리켜 사문 | 무해(엄격 방향) |
## 121. Sub-scope 01 완료 조건
- denominator 11 / 11 FULL_READ (`107-...`)
- public surface reachability / conditional sibling / duplicate mechanism 3종 probe 수행(`108-...`)
- documentation/count drift 재측정 및 확정(`110-...`), gate 실행 결과 포함(`114-...`)
- 실행 probe 1건(`109-...`, `109a-...`)으로 P2 확정, 원본 source 복구 후 `git status --short` clean
- original source에서 leaf unit lane 재실행 green(`114-...`)
---
## 122. Sub-scope 12 범위와 denominator
> 내부 상태: COMPLETE — **3 / 3 FULL_READ**
> 범위: `src/jpaPlatformPerformanceTest` (268 Java LOC)
> Gradle lane: `jpaPlatformPoolContractTest`
| 파일 | 라인 | 컨테이너 |
|---|---:|---|
| `platform/pool/HikariPoolSaturationContractTest.java` | 119 | 자체 PG 16 |
| `platform/pool/RequiresNewPoolPressureContractTest.java` | 100 | 자체 PG 16 |
| `platform/pool/PoolPressureContractTest.java` | 49 | 없음 |
manifest: `evidence/raw/111-persistence-jpa-pool-lane-manifest.txt`. `PoolLaneClaimTest`(lane 이름/문구 drift guard)와 `PoolMeasurement`, `PostgreSqlContainerFactory`는 각각 sub-scope 06·10 소유라 여기서는 cross-scope 참조로만 쓴다.
## 123. 이 lane의 역사는 이미 한 번 교정됐다
lane 이름은 `jpaPlatformPerformanceTest`였고, "pool pressure certification"을 한다고 서술됐으며, `performance.assertions.enabled` flag 뒤에 있었다 — 그런데 그 flag는 build에서도, 그것을 명시적으로 끄던 nightly job에서도 기본값이 false였다. `build.gradle`의 주석이 그 결과를 직설적으로 적는다. "the release gate depended on a lane whose only threshold assertion was that thresholds were not being asserted".
교정은 세 갈래로 이루어졌다. task 이름을 `jpaPlatformPoolContractTest`로 바꾸고, flag를 제거하고, `PoolLaneClaimTest`가 lane을 설명하는 6개 파일에 그 flag 이름과 "certification"/"machine bounds" 문구가 남아 있지 않은지 텍스트로 검사한다. flag 이름은 test 자신이 검색 대상이 되지 않도록 세 조각으로 나눠 상수를 만든다 — 자기 자신을 매칭하는 guard는 통과할 수 없다는 것까지 고려돼 있다.
## 124. 남아 있는 문제 — lane이 "행동 계약"이라고 부르는 것 중 둘은 산술 항등식이다
교정 뒤에도 lane의 8개 test 중 2개는 데이터베이스도, pool도 보지 않는다.
`PoolPressureContractTest.requiresNewNeedsTwoConnectionsPerThread()`:
```java
int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1; // 8 * 2 + 1
assertThat(required).isEqualTo(17);
```
`RequiresNewPoolPressureContractTest.sizingRuleMatchesTheObservedRequirement()`:
```java
int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1; // 1 * 2 + 1
assertThat(required).isEqualTo(3);
```
두 test 모두 공식을 test 안에서 다시 쓴 다음 그 결과를 상수와 비교한다. `CLAUDE.md`의 pool-sizing 공식이 바뀌어도 이 assertion은 실패하지 않는다. 특히 두 번째는 `@DisplayName`이 "the sizing rule matches the observed requirement"인데, 같은 class가 **관측한** 요구치는 2다(pool 1은 실패, pool 2는 성공). 공식의 답 3과 관측치 2를 비교하는 assertion은 없고, 주석이 "the rule adds headroom"이라고 차이를 설명할 뿐이다.
`PoolPressureContractTest.reportsPendingAndAcquireLatencyTogether()`도 손으로 만든 `PoolMeasurement(4, 2, 3, 80ms)`의 accessor를 확인한다. 이는 record 계약 검증이지 pool 관측이 아니다.
**판정: P3.** lane은 `jpaPlatformReleaseGate`의 구성원이므로 여기서 green이 나는 것이 release 판단에 들어간다. 다만 lane의 실질 가치는 나머지 6개(컨테이너 기반) test가 만들고, 이 2개는 그 위에 얹힌 항등식이다. flag를 없앤 교정이 "측정하지 않는 것을 측정한다고 말하지 않기"였다면, 이 두 개는 그 교정이 닿지 않은 잔여물이다.
## 125. Confirmed P2 — nightly workflow가 광고하는 세 가지 중 하나를 lane이 실제로 관측하지 않는다
`.github/workflows/jpa-nightly.yml:122-129`는 이 lane이 검사하는 것을 세 가지로 적는다.
> that REQUIRES_NEW needs two connections per concurrent thread, that **a saturated pool reports its pending count**, that a caller waits rather than proceeding without a connection
세 번째와 첫 번째는 실제 assertion이 있다. 두 번째는 없다. lane 전체에서 `pending()`/`saturated()`를 assert하는 곳은 `PoolPressureContractTest`의 손으로 만든 record 하나뿐이고, 실제 pool에서 `getThreadsAwaitingConnection()`을 읽는 유일한 지점(`HikariPoolSaturationContractTest.measurementReportsPoolState`)은 **포화되지 않은** pool(size 2, 1개 점유)에서 읽은 뒤 `active()==1`과 `total()>=1`만 확인하고 pending에 대해서는 아무 assertion도 하지 않는다.
### 실행 probe
`evidence/raw/113-pool-lane-saturation-probe.txt` / `113a-...java`. 실제 PostgreSQL 16 + Hikari(size 2)에서 두 연결을 점유하고 세 번째 요청 스레드를 대기시킨 뒤 측정했다.
```text
realPool.active=2
realPool.idle=0
realPool.pending=1
realPool.saturated=true
realPool.waiterOutcome=acquired
```
즉 이 주장은 **검증 가능하며 현재 검증되지 않고 있다**. 참고로 손으로 만든 fixture 상태(active=4, idle=2, pending=3)는 실제 포화 pool이 보여준 조합(idle=0)과 다르다.
같은 probe에서 acquisition 경계도 측정했다.
```text
acquire.configuredTimeoutMillis=500
acquire.observedWaitMillis=504
acquire.outcome=SQLTransientConnectionException
acquire.assertedUpperBoundMillis=2500
```
`HikariPoolSaturationContractTest`의 javadoc은 "a further acquisition must fail **within the configured timeout**"이라고 쓰지만 assertion 상한은 `ACQUIRE_TIMEOUT.plusSeconds(2)` = 2,500ms — 설정값의 5배다. 실제 동작은 504ms로 설정값에 4ms 붙어 있다. CI 여유를 감안해도 상한이 400% 넓어서, 500ms 설정에도 2초를 기다리게 되는 회귀는 이 assertion을 통과한다. P3.
## 126. release gate 소속은 양방향으로 검증되지 않는다
근거: `evidence/raw/112-pool-lane-claim-registry-reachability.txt`.
`config/jpa/release-registry.json`의 gate는 6개이고 모두 blocking이다.
```text
postgresql-contract :adapter:outbound:persistence-jpa:jpaPlatformContractTest
completion-unknown-no-retry :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
osiv-disabled :app-bootstrap:test
flyway-validate :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
runtime-role-no-ddl :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
collection-fetch-pagination :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
```
pool lane은 registry에도, `docs/jpa/support-matrix.md` §Release gates 6행에도, testkit `JpaReleaseGate.required()`에도 없다(세 곳 모두 grep exit=1). 그런데 `jpaPlatformReleaseGate`는 `dependsOn jpaPlatformPoolContractTest`를 갖고, root `jpaReleaseGate`가 그것을 다시 의존한다.
`verifyJpaReleaseGateTasks`는 registry → task graph 한 방향만 검사한다(registry의 각 gate가 실제 `Test` task로 resolve되는가). 반대 방향 — release gate에 들어 있는 lane이 registry에 있는가 — 은 어디서도 검사되지 않는다. 따라서 `jpaPlatformReleaseGate`에서 pool lane 의존을 지워도 어떤 verifier도 반응하지 않고, 남는 실행 경로는 nightly workflow 한 줄뿐이다.
이것을 결함으로 올리지는 않는다. `jpaPlatformReleaseGate`의 주석이 밝힌 집계 기준은 "documented gate가 검증되지 않은 채 통과하게 만드는 lane"이고, pool lane은 문서화된 gate를 뒷받침하지 않으므로 기준상 registry에 없는 것이 일관적이다. 다만 그 결과로 이 lane의 release gate 소속만은 아무 계약도 보호하지 않는다는 사실을 기록한다. P3.
## 127. Fresh verification evidence — sub-scope 12
- `evidence/raw/114-governance-pool-original-verification.txt` — original source에서 `:adapter:outbound:persistence-jpa:test --rerun-tasks` **BUILD SUCCESSFUL in 28s / 18 executed**, `jpaPlatformPoolContractTest --rerun-tasks` **BUILD SUCCESSFUL in 24s / 18 executed**, root `verifyDocumentedLeafCount --rerun-tasks` **BUILD SUCCESSFUL in 3s / 9 executed**, git clean before/after
- `evidence/raw/113-...` / `113a-...` — 실제 포화 pool의 pending/saturated 관측, acquisition 대기 504ms 대 설정 500ms 대 assertion 상한 2,500ms
## 128. Sub-scope 12 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P2** | nightly workflow가 lane의 검증 항목으로 명시한 "a saturated pool reports its pending count"를 어떤 assertion도 실제 pool에서 확인하지 않음(손으로 만든 record만 확인) | release gate 구성 lane; 관측 가능함을 probe로 확인 |
| **P3** | `PoolPressureContractTest`/`RequiresNewPoolPressureContractTest`의 sizing-rule assertion 2개가 공식을 test 안에서 재작성한 뒤 자기 자신과 비교하는 항등식 | 실패할 수 없는 assertion |
| **P3** | saturation timeout assertion 상한이 설정값의 5배(2,500ms vs 500ms)여서 javadoc이 말하는 "within the configured timeout"을 강제하지 않음 | 회귀 탐지 폭 |
| **P3/기록** | pool lane이 release gate 구성원이면서 release registry·support matrix·`JpaReleaseGate` 어디에도 없어, 소속이 양방향으로 검증되지 않음 | governance |
## 129. Sub-scope 12 완료 조건
- denominator 3 / 3 FULL_READ (`111-...`)
- lane claim / release-gate 소속 / assertion 실체 3종 대조(`112-...`)
- 실제 PostgreSQL probe 1건(`113-...`, `113a-...`), 원본 복구 후 git clean
- original source lane 재실행 green(`114-...`)
---
## 130. Sub-scope 11 범위와 denominator
> 내부 상태: COMPLETE — **75 / 75 FULL_READ**
> 범위: `src/postgresqlIntegrationTest` (71 Java + 4 SQL, 13,977 lines)
> 역할: 이 leaf에서 "실제 PostgreSQL이 답해야만 하는 주장"의 증거 생산자 전부
manifest: `evidence/raw/120-persistence-jpa-integration-lane-manifest.txt`.
| package | 파일 | 성격 |
|---|---:|---|
| `platform/**` (experimental 5 포함) | 33 | design §11~§40 계약, tag 기반 lane |
| `readiness/**` | 29 | readiness card producer + tag lane 혼재 |
| `notification/**` | 8 | notification 저장소 계약 + fixture |
| `operation/`, `liveevent/` | 2 | durable operation / live event 저장소 계약 |
| `resources/db/readiness/**` | 4 SQL | Flyway 시나리오 fixture(중단·롤링) |
이 sub-scope는 sub-scope 10(testkit)과 같은 성격이다 — **production 동작이 아니라 evidence 생산자의 정확성**을 분석한다. 다만 규모가 다르다. testkit이 62파일 3,070 LOC였다면 여기는 75파일 13,977 LOC이고, 이 leaf가 "H2로는 만족시킬 수 없다"고 선언한 모든 계약이 여기에 있다.
## 131. 이 source set 안에 서로 다른 두 개의 evidence 세계가 있다
파일은 한 source set에 있지만 실행 경로는 둘로 갈라진다.
**(1) tag lane.** `@Tag("jpa-contract" | "jpa-migration" | "jpa-failure" | "jpa-queryplan" | "jpa-security")`를 단 클래스는 `registerJpaPlatformLane`이 만든 5개 Test task가 `includeTags`로 고른다. 이 5개가 `jpaPlatformReleaseGate`와 release registry의 blocking gate에 연결된다.
**(2) readiness card.** `@Tag`가 없는 클래스는 `registerPostgreSqlReadinessTest`가 클래스 이름으로 하나씩 등록한 14개 task가 `filter.includeTestsMatching`으로 고른다. 이 task들은 `config/jpa/readiness-cards.yaml`의 card가 `readiness-task` / `support-tasks`로 지목하고, `generateJpaEvidenceManifests`가 active card의 producer를 모두 `dependsOn`한다.
두 세계의 대응은 정확히 맞아떨어진다. `@Tag`가 없는 test 클래스는 13개, readiness task가 이름으로 지목하는 클래스는 14개이고, 그 차이 1개는 `PostgreSqlNotificationSchemaActivationIntegrationTest` — 유일하게 tag와 readiness task를 **둘 다** 가진 클래스다. 즉 orphan test class는 0개다. `--dry-run`으로 실제 task graph를 resolve해 확인했다.
```text
$ ./gradlew :adapter:outbound:persistence-jpa:generateJpaEvidenceManifests --dry-run
… postgresqlAggregateIntegrationTest … postgresqlFileserverMetadataIntegrationTest
… postgresqlFileserverMigrationIntegrationTest … postgresqlFileserverReclamationIntegrationTest
… (14개 전부 SKIPPED 로 등장)
```
Fileserver migration/reclamation 두 task는 card의 `readiness-task`가 아니라 `jpa-fileserver-metadata-v1`의 `support-tasks`로 들어와 있어서 실행은 되지만 `evidence.task-claims`가 비어 있어 어떤 required-evidence tag도 덮지 않는다. 실행은 fail-closed, 증거 연결은 없음 — 결함은 아니고 배선의 성격이다.
반대로 `jpaPlatformReleaseGate --dry-run`에는 readiness task가 **하나도** 없다. 그리고 `.github/workflows` 전체에서 `postgresql*IntegrationTest` task를 직접 부르는 곳도 없다(grep exit=1). readiness 세계의 유일한 자동 실행 경로는 `ci-quality-gates.yml`의 `jpa-candidate-evidence` job이 부르는 `verifyJpaCandidateEvidence`와 `jpa-r2-evidence.yml`의 `verifyJpaPrimaryFoundationEvidence` 둘뿐이다. 이 구조가 §132의 결함이 오래 보이지 않은 이유다.
card 자체의 수치도 재측정했다. 총 17개 card 중 `not-implemented` 4개를 뺀 **13개가 active**(selected 7 + implemented-candidate 6)이고, `generateJpaEvidenceManifests`는 그중 `jpa-primary-foundation`을 제외한 **12개의 readiness-task**를 의존한다. `README.md`가 "active card 11개의 producer를 실행"이라고 적은 것과는 어긋난다(작은 count drift, P3).
## 132. Confirmed P1 — selected base card `jpa-flyway-migration`의 producer가 현재 revision에서 실패한다
`postgresqlMigrationIntegrationTest`는 `jpa-flyway-migration` card의 `readiness-task`다. 이 card는 `state: selected`이고 `jpa-primary-foundation`(R2 집계 gate)의 prerequisite 6개 중 하나다. 원본 소스, `--rerun-tasks`, git clean 상태에서 실행하면 **BUILD FAILED**다.
근거: `evidence/raw/117-flyway-migration-readiness-lane-failure.txt`, `118-readiness-task-result-matrix.txt`.
```text
PostgreSqlMigrationIntegrationTest > adoptsImmutableLegacyHistoryThenRunsTheIndependentCoreStream() FAILED
PostgreSqlMigrationIntegrationTest > freshCoreStreamInitializesWithoutLegacyHistory() FAILED
taskExit=1
```
두 실패의 정체는 같다 — stream에 migration이 추가됐는데 그 stream의 applied set을 고정한 assertion이 갱신되지 않았다.
```text
Expecting actual:
["1", "3", "4", "5", "6", "9", "10", "11", "12"]
to contain exactly (and in same order):
["1", "3", "4", "5", "6"]
but some elements were not expected:
["9", "10", "11", "12"]
```
```text
Expecting actual:
["1", "2"]
to contain exactly (and in same order):
["1"]
but some elements were not expected:
["2"]
```
앞은 `db/migration/postgresql`(legacy adoption stream, 현재 9개 파일), 뒤는 `db/migration/jpa/core`(현재 2개 파일)다. history로 시점을 맞춰 보면 원인이 분명하다.
```text
2026-07-31 PostgreSqlMigrationIntegrationTest.java ← assertion 최종 수정
2026-08-15 db/migration/jpa/core/V2__widen_capability_schema_stream.sql
2026-08-15 db/migration/postgresql/V9__widen_capability_schema_stream.sql
2026-08-18 db/migration/postgresql/V10__idempotency_request_hash_varchar.sql
2026-08-28 db/migration/postgresql/V11__durable_operation.sql
2026-08-28 db/migration/postgresql/V12__live_event_log.sql
```
4주에 걸쳐 5개 migration이 두 stream에 들어오는 동안 이 lane의 assertion은 한 번도 갱신되지 않았다. 같은 source set의 `PostgreSqlOptionalStreamLifecycle`은 이 실패 유형을 정확히 알고 있다 — notification stream의 버전 목록 주석에 "the version that landed without being added is why the lane failed the first time anybody ran it"라고 적혀 있다. 그 교훈이 base stream 쪽 lane에는 적용되지 않았다.
**드러나지 않은 이유**는 §131의 구조다. 이 task는 5개 tag lane 어디에도 속하지 않고, `jpaPlatformReleaseGate`에도 없고, 어떤 workflow도 이름으로 부르지 않는다. 같은 실행에서 5개 tag lane은 **244 tests / 0 failures**로 전부 green이었다. 이 lane을 실제로 도는 자동 경로는 `verifyJpaCandidateEvidence` 하나뿐이고, 그것이 실패하면 `jpa-candidate-evidence` CI job이 red가 된다.
같이 실행한 나머지 13개 readiness task 결과는 다음과 같다(`118-...`).
```text
task tests skip failures errors verdict
postgresqlAggregateIntegrationTest 1 0 0 0 PASS
postgresqlFileserverMetadataIntegrationTest 12 0 0 0 PASS
postgresqlFileserverMigrationIntegrationTest 6 0 0 0 PASS
postgresqlFileserverReclamationIntegrationTest 15 0 0 0 PASS
postgresqlIdempotencyIntegrationTest 8 0 0 0 PASS
postgresqlInboxIntegrationTest 5 0 0 0 PASS
postgresqlLifecycleIntegrationTest 2 0 0 0 PASS
postgresqlMigrationIntegrationTest 4 0 2 0 FAIL
postgresqlNotificationSchemaActivationIntegrationTest 8 0 0 0 PASS
postgresqlOutboxPollingIntegrationTest 5 0 0 0 PASS
postgresqlOutboxStorageIntegrationTest 5 0 0 0 PASS
postgresqlQueryIntegrationTest 1 0 0 0 PASS
postgresqlSecurityBaselineIntegrationTest 3 0 1 0 FAIL
postgresqlTransactionIntegrationTest 7 0 0 0 PASS
```
`postgresqlSecurityBaselineIntegrationTest`의 실패는 **분석 환경 제약**이지 결함이 아니다. `verifyFullAcceptsTrustedHostAndRejectsHostnameMismatchAndUntrustedCertificate`는 `PostgreSqlTlsMaterial`이 `CN=localhost` / `SAN=DNS:localhost`로 발급한 인증서를 `verify-full`로 검증하므로 컨테이너의 매핑 포트가 **테스트 JVM의 loopback**에서 열려 있어야 한다. 이번 분석은 Docker 소켓을 공유하는 형제 컨테이너 안에서 실행돼 매핑 포트가 Docker 브리지(172.17.0.1)에만 열렸고, 실패는 `java.net.ConnectException`이다. 이 lane은 skip이 아니라 실패하도록 설계돼 있으므로(no-skip) 동작 자체는 의도대로다. 다만 "no-skip"의 대가로 **Docker 호스트와 테스트 JVM이 loopback을 공유하는 환경**이 이 lane의 암묵적 전제가 된다는 사실은 기록해 둔다.
**판정: P1 confirmed.** 수정은 assertion을 stream의 현재 applied set으로 갱신하는 것이고, 재발 방지는 `PostgreSqlOptionalStreamLifecycle`이 이미 쓰는 방식(stream별 버전 목록을 한 곳에 고정)을 base stream에도 적용하는 것이다. 더 근본적으로는 이 lane이 tag lane과 완전히 분리돼 있다는 구조 자체가 재검토 대상이다 — 5개 tag lane이 green이라는 사실이 readiness lane의 상태에 대해 아무것도 말해주지 않는다.
## 133. Confirmed P2 — selected base card 3개의 evidence tag가 production code 없는 fixture로 충족된다
card의 `required-evidence` tag는 `evidence.scenarios[].covers`로 실제 JUnit selector에 연결된다. 그 연결을 실제 test 본문과 대조하면 세 card에서 tag와 증거의 격차가 나온다.
**`jpa-observability-lifecycle` — `observability`**
`PostgreSqlLifecycleIntegrationTest.poolCapacityExhaustionAndShutdownAreBoundedAndObservable`이 이 tag를 덮는다. 관측 assertion은 다음 한 줄이다.
```java
assertThat(saturated.boundedTags())
.containsExactlyInAnyOrderEntriesOf(
Map.of("component", "postgresql-primary", "state", "saturated"));
```
`boundedTags()`는 같은 파일 안의 private record `PoolSnapshot`의 메서드이고, 비교 대상 literal도 같은 파일에 있다. `postgresql-primary`라는 문자열은 repository 전체에서 이 파일 두 줄에만 존재한다(`108-...` 계열 검색과 별개로 `git grep` 확인). production의 persistence metric tag는 `JpaMetricTags`가 만드는 `persistence.unit` / `persistence.operation` / `persistence.query` / `outcome` / `failure.category` 5종이고 `component` tag도, pool 상태 metric도 없다. 즉 이 card의 `observability` 증거는 production 계측이 하나도 없어도 그대로 green이다. pool 자체의 포화/종료 동작(active 2, 종료 후 0, closed)은 실제로 관측하므로 lifecycle 쪽 증거는 유효하다 — 문제는 `observability` tag가 그 위에 얹혀 있다는 점이다.
**`jpa-query-model` — `query-contract`, `query-plan`**
유일한 scenario `PostgreSqlQueryIntegrationTest.boundedKeysetQueryUsesTheRepresentativeIndex`는 `readiness_query` 테이블·인덱스·쿼리를 test가 직접 만든다. production의 `KeysetPageRequest`/`KeysetSlice`/`springdata` keyset 실행 경로는 한 줄도 지나지 않는다. 게다가 plan 확인 직전에 `set enable_seqscan=off`를 실행한다 — 대안을 제거한 상태에서 "인덱스를 쓴다"를 확인하는 것이라, 인덱스나 쿼리 모양이 나빠도 index scan이 가능하기만 하면 통과한다. 같은 source set에는 production `PostgreSqlExplainRunner`/`QueryPlanAssertions`로 실제 plan 구조와 추정 오차를 보는 `PostgreSqlQueryPlanContractTest`가 있는데, card는 그쪽을 가리키지 않는다.
**`jpa-aggregate-store` — `mapping`, `optimistic-conflict`**
유일한 scenario `PostgreSqlAggregateIntegrationTest.roundTripsUuidAndInstantAndDetectsExpectedVersionConflict`는 `readiness_aggregate` 테이블에 대해 raw JDBC로 UUID/timestamptz 왕복과 `update ... where version = ?`가 0행을 반환하는 것을 확인한다. JPA entity도, `@Version`도, Hibernate optimistic locking도, production repository도 없다. **JPA** aggregate store card의 `mapping` 증거가 JPA를 거치지 않는다. 이쪽 역시 같은 source set에 production 경로를 쓰는 `JpaValueMappingContractTest`(Hibernate + `MappingEntity`)와 `OptimisticRetryIntegrationTest`(production `OptimisticConflictTranslator` + 실제 버전 충돌)가 있다.
이 셋을 하나로 묶는 사실은 다음과 같다. **card scenario가 가리키는 클래스 12개는 전부 `readiness/**` 안에 있고, `platform/**`의 33개 계약 test 중 card가 가리키는 것은 0개다.** 강한 증거 생산자와 card evidence가 서로 다른 세계에 있고, tag는 약한 쪽에 붙어 있다.
**판정: P2 confirmed.** 대비되는 반례가 같은 card 집합 안에 있다는 점이 판단을 쉽게 해 준다 — `jpa-transaction-runtime`의 7개 scenario는 production `SpringTransactionPort` + `PostgreSqlLocalTimeoutConfigurer` + `PersistenceExceptionTranslator`를 실제 서버에서 돌리고, deadlock 40P01, serializable 재시도, lock/statement timeout 경계, pool admission 거부, `pg_terminate_backend`로 만든 commit 유실의 `INDETERMINATE` 판정까지 확인한다. 즉 이 결함은 체계적인 것이 아니라 세 card에 국한된다. 수정은 tag를 옮기는 문제다 — 이미 존재하는 강한 test를 scenario로 등재하거나, 약한 scenario의 `covers`에서 과대 tag를 떼는 것.
## 134. notification contract fixture는 하나의 stream을 세 갈래로 다시 만든다
같은 source set 안에서 notification schema를 만드는 방법이 두 가지다.
- `readiness/PostgreSqlNotification*`은 Flyway를 `classpath:db/migration/jpa/notification-platform`에 겨눈다 — stream 전체가 자동으로 따라온다. `PostgreSqlNotificationSchemaActivationIntegrationTest`는 한 걸음 더 나아가 production `NotificationSchemaStream`을 호출하며 그 이유를 적는다("a test that restates them proves that two authors agreed rather than that the stream is right").
- `notification/*ContractTest`는 migration 파일 이름을 **손으로 나열**해 `Statement.execute`로 돌린다. 그리고 그 목록이 세 벌 있고 셋 다 길이가 다르다.
| 목록 소유자 | notification 버전 | 주석 |
|---|---|---|
| `NotificationFixtures.migrations()` | V1V9 | "The whole notification stream, in order… Applying a subset certifies a schema nobody deploys" |
| `RecipientClaimContractTest.migrations()` | V1V8 | "The whole stream, in order. Applying a subset certifies a schema nobody deploys — … which is how the first version of this test discovered that it was testing a database that could not exist." |
| `ProjectionFactDurabilityContractTest.migrations()` | V1V6 | (주석 없음) |
| (배포 stream) | **V1V10** | `PostgreSqlOptionalStreamLifecycle.notificationPlatform()`이 applied set `0..10`으로 고정 |
세 목록 모두 "the whole stream"이라고 말하고, 셋 다 아니다.
### 실행 probe
`evidence/raw/119-notification-migration-ladder-probe.txt` / `119a-...java`. 각 사다리를 실제 PostgreSQL 16에 적용하고 `information_schema.columns`로 `notification%` 테이블의 컬럼 집합을 비교했다.
```text
ladder.notificationVersions=6 columns=208
ladder.notificationVersions=8 columns=213
ladder.notificationVersions=9 columns=218
ladder.notificationVersions=10 columns=218
ladder.9.missing=[]
ladder.8.missing=[notification_delivery_attempt.provider_acceptance,
notification_delivery_attempt.provider_acceptance_certainty,
notification_delivery_attempt.provider_response_received_certainty,
notification_delivery_attempt.request_body_committed_certainty,
notification_delivery_attempt.request_started_certainty]
ladder.6.missing=[notification_admin_audit.claimed_at,
notification_admin_audit.command_fingerprint,
notification_admin_audit.phase,
notification_delivery_attempt.provider_acceptance,
notification_delivery_attempt.provider_acceptance_certainty,
notification_delivery_attempt.provider_response_received_certainty,
notification_delivery_attempt.request_body_committed_certainty,
notification_delivery_attempt.request_started_certainty,
notification_request.collapse_key,
notification_request.collapse_scope]
```
읽는 방법은 이렇다.
- **V1–V9는 현재 배포 형상과 컬럼이 동일하다(218 = 218).** V10이 DDL 없는 guard(`variables_payload`가 base64 envelope가 아닌 행이 있으면 `RAISE EXCEPTION`)이기 때문이다. 그래서 `NotificationFixtures`를 쓰는 5개 test는 지금은 배포 형상 위에서 돈다. 다만 stream보다 한 칸 뒤에 있으므로 **다음에 DDL을 가진 migration이 들어오는 순간** 조용히 어긋난다.
- **V1V8은 evidence-certainty 5개 컬럼이 없다.** `RecipientClaimContractTest`는 `notification_recipient_delivery`만 다루므로 현재 false-green은 없다.
- **V1V6은 admin-claim 3 + evidence-certainty 5 + collapse 2, 총 10개 컬럼이 없다.** `ProjectionFactDurabilityContractTest`는 V6가 도입한 projection fact 컬럼만 다루므로 역시 현재 false-green은 없다.
**판정: P3.** 현재 잘못된 통과를 만드는 경로는 확인되지 않는다. 문제는 유지보수 계약이다 — stream에 migration을 하나 추가하려면 네 곳(Flyway location은 자동, 나머지 세 목록은 수동)을 맞춰야 하고, 세 목록은 이미 각각 1·2·4 버전씩 뒤처져 있다. 그리고 세 목록의 주석이 모두 "subset은 아무도 배포하지 않는 schema를 인증한다"고 경고하고 있다. 수정 방향은 같은 source set이 이미 보여 준다 — Flyway location을 겨누거나 production `NotificationSchemaStream`을 호출하면 목록 자체가 사라진다.
부수적으로, `notification/*ContractTest`의 fixture는 `variables_payload`에 평문 `'{}'`를 넣는다. 이는 V10 guard가 거부하는 모양이다(guard는 migration 시점의 기존 행만 보므로 지금은 충돌하지 않는다). at-rest 계약이 "불가능하다"고 선언한 형상 위에서 contract test가 도는 셈이라, V10을 목록에 넣는 순간 fixture도 함께 바뀌어야 한다.
## 135. `JpaPlatformContractSupport`의 컨테이너 수명 서술은 실제와 다르다
클래스 javadoc은 이렇게 말한다.
> The containers are shared for the JVM: the contracts verify server behaviour, which does not change between test classes, and **starting a server per class turns a three-version matrix into minutes of container startup.**
실제 사용은 정확히 그 "per class"다. `JpaPlatformContractSupport.start()` 호출 지점은 31곳이고 대부분 `@BeforeAll`에서 시작해 `@AfterAll`에서 `close()`한다. JVM 수준 공유 인스턴스나 static holder는 없다. `StablePostgreSqlMatrixContractTest`는 test마다, `JpaPlatformContractSupportOwnershipTest`는 test마다(5개) 컨테이너를 새로 띄운다.
실측치는 다음과 같다(`115-integration-lane-original-verification.txt`, XML의 Testcontainers 로그 집계).
| lane | 클래스 | tests | skipped | failures | PostgreSQL 컨테이너 기동 |
|---|---:|---:|---:|---:|---:|
| jpaPlatformContractTest | 36 | 182 | 0 | 0 | 49 |
| jpaPlatformMigrationTest | 10 | 43 | 0 | 0 | 33 |
| jpaPlatformFailureTest | 2 | 6 | 0 | 0 | 2 |
| jpaPlatformQueryPlanTest | 1 | 3 | 0 | 0 | 1 |
| jpaPlatformSecurityTest | 2 | 10 | 0 | 0 | 2 |
| **합계** | **51** | **244** | **0** | **0** | **87** |
한 번의 전체 tag lane 통과에 PostgreSQL 컨테이너가 87번 기동한다. 그럼에도 5개 lane 전체가 3분 10초에 끝났으므로 비용 주장이 무너지는 수준은 아니다. 기록하는 이유는 서술과 구현의 불일치다 — 클래스가 자기 설계 근거로 내세운 "JVM 공유"가 소비자 31곳 어디에서도 성립하지 않는다. P3.
같은 클래스의 다른 서술은 사실이다. multi-version 선택을 fail-closed로 거부하는 것(`start()`가 `selected.size() != 1`이면 예외), 그리고 "the CI matrix fans out"은 `jpa-release.yml`(16/17/18), `jpa-pr.yml`(16/18), `jpa-nightly.yml`이 `-Pjpa.matrix.versions`로 실제 fan-out하는 것으로 확인된다. `JpaPlatformContractSupportOwnershipTest`가 지키는 pool 소유권(호출당 새 pool을 만들어 참조를 잃던 과거 결함)도 실제 assertion으로 고정돼 있다.
## 136. 이 lane이 실제로 강한 지점
결함만 나열하면 이 corpus를 오해하게 된다. 다음은 "실서버가 아니면 성립하지 않는" 주장을 실제로 실서버에서 확인하는 사례이고, 대부분 production 클래스를 그대로 쓴다.
- **commit ambiguity** — `CommitAmbiguityContractTest`가 `pg_terminate_backend`로 백엔드를 죽인 뒤 commit해서 실제 SQLSTATE가 `57P01`(class 08이 **아님**)임을 확인하고, production `CommitFailureClassifier`가 그것을 `TransactionCompletionUnknownException` / `retryable=false`로 번역하는지 본다. "연결이 끊기면 08일 것"이라는 합리적 추측이 왜 틀리는지가 주석에 적혀 있다.
- **transaction runtime** — `PostgreSqlTransactionIntegrationTest`가 production `SpringTransactionPort`로 transaction-local timeout 적용/복원, 확정 롤백, serializable 충돌의 replay-safe 정책 한정 재시도(action 호출 3회), 결정적 deadlock 40P01 단일 희생자, lock/statement timeout 경계(55P03/57014), pool 고갈 시 **애플리케이션 작업 시작 전** 거부, commit 중 연결 유실의 `INDETERMINATE`+재시도 금지를 모두 실측한다.
- **owner-safe idempotency / same-store inbox / immutable outbox / polling delivery** — 네 readiness test 모두 production adapter를 실제 transaction 안에서 돌리고, 가상 스레드로 두 caller를 경쟁시켜 "첫 business commit 전에는 경쟁자가 owner row를 통과하지 못한다"를 실제로 블로킹시켜 확인한다.
- **RLS 실패 모드** — `RlsIsolationFailureTest`가 enable-but-not-forced일 때 소유자가 정책을 우회하는 고전적 false-green, session-scope 바인딩이 pool 반납 후 다음 차용자에게 새는 것, transaction-scope 바인딩은 새지 않는 것을 실제 pool로 구분한다.
- **schema/mapping 정합** — `PostgreSqlDefaultPersistenceUnitIntegrationTest`가 `PersistenceJpaConfig`의 `@EntityScan` 목록을 **읽어서** `ddl-auto=validate`를 돌린다. 스캔 목록이 늘면 검사도 함께 늘어난다. notification 쪽도 같은 방식이 있고, 추가로 `jsonb_typeof`로 "JSON처럼 보이는 text"가 아닌지까지 본다.
- **evidence certainty** — `EvidenceCertaintyContractTest`가 5×5×5×5 = 625조합을 실제 행에 왕복시키고, DB CHECK가 `UNKNOWN`인데 값이 true인 행과 모델 밖 certainty를 거부하는지 확인한다.
`RecipientClaimContractTest`는 방법론 면에서 이 corpus의 모범이다 — production `RecipientClaimSql.CLAIM_BATCH` 텍스트를 그대로 가져와 placeholder만 바꿔 쓰고, 그 이유를 "Retyping the SQL here would prove that two authors agreed about a query rather than that the query is right"라고 적는다.
## 137. 이전 sub-scope 발견과의 교차 정합
이 sub-scope의 파일들은 앞선 sub-scope가 올린 결함이 **왜 lane에서 잡히지 않았는지**를 직접 설명한다.
| 앞선 발견 | 이 lane 쪽 대응 사실 |
|---|---|
| §52 (sub-scope 04, P1) collection-fetch gate가 SQL limit을 보지 않음 | `HibernateCollectionFetchPaginationContractTest.oneCollectionPageIsBoundedInSql`은 반환 페이지 크기와 `expected.requiresDatabaseLimit()`(기대 객체 자신의 상수)만 확인한다. javadoc은 "The assertion is therefore on the generated SQL"이라고 쓴다. 파일 소유는 sub-scope 11, 결함 판정은 §52 — 중복 계상하지 않는다 |
| sub-scope 10 (P1 latent) `PostgreSqlExplainRunner`가 data-modifying CTE를 허용 | `PostgreSqlQueryPlanContractTest.refusesNonSelect`가 확인하는 것은 평범한 `update` 한 건뿐이다. CTE 형태는 이 assertion의 사각지대 |
| sub-scope 06 (P1) Stable runtime-role 검증이 startup에서 실제 policy를 적용하지 않음 | production `requireSafe` 호출자는 0. 유일한 호출자는 `PostgreSqlSecurityContractTest.policyAcceptsVerifiedRole`이고, 그 role과 policy는 test가 만든 것이다. blocking gate `runtime-role-no-ddl`이 green이라는 사실은 verifier가 동작한다는 뜻이지 배포가 그것을 부른다는 뜻이 아니다 |
| sub-scope 09 (P1 latent) database-per-tenant budget이 이질적 pool 크기에서 ceiling 초과 | `TenantPoolCapacityContractTest`는 `POOL_SIZE_PER_TENANT = 2`로 **균일한** pool만 연다. 이질적 조합이 lane에 없다는 것이 그 결함이 green으로 남은 이유다 |
| sub-scope 08 (P2) V8 atomic admin claim에 production caller 0 | `AdminOperationClaimContractTest`는 `INSERT ... ON CONFLICT DO NOTHING`을 **test가 직접 작성해** 검증한다(`RecipientClaimSql` 방식과 대조적). claim SQL이 옳다는 것과 production이 그것을 부른다는 것은 별개다 |
## 138. finding으로 올리지 않은 관찰
- **order-dependent test.** `ConstraintRaceContractTest.exactlyOneRowSurvives`는 앞선 test가 넣은 행에 의존한다. XML의 실행 순서상 현재는 race test가 먼저 돈다. JUnit 기본 순서는 결정적이지만 명세된 계약이 아니고, 같은 source set의 `PostgreSqlOutboxStorageIntegrationTest`는 `@TestMethodOrder(OrderAnnotation)`로 명시한다. 한 corpus 안에 명시적 순서와 암묵적 순서 의존이 공존한다.
- **삼킨 예외.** `PostgreSqlUpsertContractTest.upsertConcurrently`는 `SQLException`/`InterruptedException`을 기록 없이 삼킨다. 두 스레드 중 하나가 죽어도 `count(*) == 1`은 성립하므로 "동시 upsert가 수렴했다"와 "하나만 돌았다"를 구분하지 못한다.
- **display name과 assertion 불일치.** `PostgreSqlWorkClaimContractTest.claimsAreDeterministicallyOrdered`의 이름은 결정적 순서를 약속하지만 assertion은 `isNotEmpty()` 하나다.
- **Docker 없이 도는 tag lane 클래스 2개.** 51개 중 `JpaAuditingContractTest`와 `JpaPlatformContractSupportTest`만 컨테이너를 하나도 띄우지 않는다. 후자는 그 사실을 javadoc에 적어 두었고, 전자는 순수 단위 assertion 4개다. blocking gate `postgresql-contract`("The real database ran the contract suite, not H2")의 test 수 182에는 데이터베이스를 만난 적 없는 assertion이 섞여 있다.
- **credential fixture 3종.** `JpaPlatformContractSupport.generatedPassword()`는 "리터럴은 committed credential"이라는 이유로 존재하는데, 소비자는 `RlsIsolationFailureTest` 하나다. `PostgreSqlSecurityContractTest`(blocking gate 생산자)는 `password 'contract_runtime'` 리터럴을 쓰고, `PostgreSqlSecurityBaselineIntegrationTest`는 `UUID.randomUUID()`를 `pg_temp` 함수에 파라미터로 넘긴다. 세 형제가 세 방식을 쓴다.
- **중복 assertion.** `commitAmbiguityHasThreeDistinctInjectionPoints`가 `CommitAmbiguityContractTest`와 `PostgreSqlConcurrencyFailureContractTest`에 거의 같은 형태로 두 번 있다(같은 `jpa-failure` lane).
- **`PostgreSqlInboxCutoffIntegrationTest`의 SQL 재작성.** production `InboxItemJpaRepository.markAllRead`의 native `@Query`를 test가 다시 타이핑했다. 현재 두 문장은 술어 구조가 일치하지만, `RecipientClaimSql` 방식이 아니라 "두 저자가 합의했음"을 증명하는 형태다.
- **죽은 helper.** `PostgreSqlNotificationInvariantIntegrationTest.unused(...)`는 `@SuppressWarnings("unused")`와 함께 의도적으로 남아 있다.
## 139. Fresh verification evidence — sub-scope 11
- `evidence/raw/115-integration-lane-original-verification.txt` — 원본 소스에서 5개 tag lane `--rerun-tasks` **BUILD SUCCESSFUL in 3m 10s**, 51 클래스 / 244 tests / 0 skipped / 0 failures / 87 컨테이너 기동, git clean before/after. 이어서 실행한 `verifyJpaCandidateEvidence`는 `:app-bootstrap:test`의 `ComposeMergeCharacterizationTest`에서 멈춘다 — 그 test는 `docker compose` 유무만 assume으로 확인하고 스크립트가 요구하는 `jq`는 확인하지 않으며, 분석 컨테이너에 `jq`가 없다(스크립트 직접 실행 시 `jq is required`). app-bootstrap 소유 사안이자 환경 제약이므로 이 sub-scope의 결함으로 계상하지 않는다
- `evidence/raw/116-readiness-lane-original-verification.txt` — 14개 readiness task `--rerun-tasks --continue`, git clean before/after
- `evidence/raw/117-flyway-migration-readiness-lane-failure.txt` — `postgresqlMigrationIntegrationTest` 단독 `--rerun-tasks` **BUILD FAILED / taskExit=1**, 두 assertion 실패 원문, stream 파일 목록, migration/assertion 최종 수정일 대조
- `evidence/raw/118-readiness-task-result-matrix.txt` — 14 task × tests/skipped/failures/errors 표, 12 PASS / 2 FAIL
- `evidence/raw/119-...` / `119a-...` — notification 사다리 4종의 컬럼 집합 실측과 차집합
- `evidence/raw/120-persistence-jpa-integration-lane-manifest.txt` — 75/75 파일 해시·라인수, tag 분포, 무-tag 클래스 목록, readiness task 등록 목록
모든 임시 분석 test는 실행 후 삭제했고 최종 `git status --short`는 clean이다.
## 140. Sub-scope 11 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P1** | selected base card `jpa-flyway-migration`의 producer `postgresqlMigrationIntegrationTest`가 HEAD에서 실패(base/legacy stream 5개 migration 추가 후 applied-set assertion 미갱신). tag lane·release gate 어디에도 속하지 않아 5개 lane green과 무관 | **CI `jpa-candidate-evidence` job의 유일 실행 경로; `jpa-primary-foundation`(R2)의 prerequisite** |
| **P2** | base card 3종(`observability` / `query-contract`+`query-plan` / `mapping`+`optimistic-conflict`)의 evidence tag가 production code를 지나지 않는 fixture로 충족. `query-plan`은 `enable_seqscan=off` 상태에서 판정 | selected card 3개; 같은 source set에 더 강한 생산자가 존재 |
| **P3** | notification contract fixture의 migration 사다리가 3벌(V1V9 / V1V8 / V1V6)로 갈라져 있고 배포 stream은 V1–V10. 실측 컬럼 차이 0 / 5 / 10 | 현재 false-green 경로 없음; 다음 DDL migration에서 어긋남 |
| **P3** | `JpaPlatformContractSupport` javadoc의 "containers are shared for the JVM"이 소비자 31곳 어디에서도 성립하지 않음(실측 87 컨테이너 기동/전체 tag lane 1회) | 서술/구현 불일치 |
| **P3** | `README.md`의 "active card 11개" 대 실측 active 13 / producer 12 | 문서 count drift |
| **P3** | `postgresql-contract` blocking gate의 182 tests에 컨테이너를 띄우지 않는 클래스 2개가 포함 | gate 문구 대 구성 |
| **P3** | order-dependent test 1건, 삼킨 예외 1건, display-name과 assertion 불일치 1건, credential fixture 3방식, 중복 assertion 1쌍 | §138 |
| **환경 제약(결함 아님)** | `postgresqlSecurityBaselineIntegrationTest`의 TLS scenario는 컨테이너 매핑 포트가 테스트 JVM의 loopback에 열려 있어야 성립(인증서 SAN이 `localhost` 단일). 형제 컨테이너 실행 환경에서는 `ConnectException`으로 실패 | no-skip 설계의 암묵적 환경 전제 |
## 141. Sub-scope 11 완료 조건
- denominator 75 / 75 FULL_READ (`120-...`), 71 Java + 4 SQL
- lane 소속을 tag/Gradle task/card 세 축으로 대조하고 orphan test 0을 `--dry-run` task graph로 확인
- negative-space probe: test 도달성(무-tag 클래스 ↔ readiness task 1:1), card evidence tag ↔ 실제 assertion 대조, 중복 mechanism(notification 사다리 4벌, SQL 재작성 대 production 상수), 문서/수치 drift(active card 수, 컨테이너 공유 서술, gate 문구)
- 실행 evidence: 5개 tag lane 전량 재실행, 14개 readiness task 재실행, 실패 lane 단독 재현, notification 사다리 컬럼 실측
- 앞선 sub-scope 04·06·08·09·10 발견과의 교차 정합을 lane 쪽 사실로 설명
- 모든 임시 source 복구, `git status --short` clean
---
## 142. Module ledger 재조정과 module 완료 조건
### 142.1 최종 ledger
| # | sub-scope | denominator | status | 주요 evidence |
|---:|---|---:|---|---|
| 1 | governance / build / docs / root boundary | 11 | **COMPLETE** | 107110, 114 |
| 2 | API contracts (`api/**`) | 55 | COMPLETE | 031040 |
| 3 | transaction + persistence failure | 51 | COMPLETE | 041051 |
| 4 | Spring Data + Hibernate + Querydsl | 53 | COMPLETE | 052061 |
| 5 | PostgreSQL vendor + vendor migrations | 73 | COMPLETE | 062069 |
| 6 | baseline capability stores/config/audit/cache/H2 등 | 87 | COMPLETE | 070076 |
| 7 | Fileserver persistence + migrations | 29 | COMPLETE | 077082 |
| 8 | Notification persistence + migrations | 68 | COMPLETE | 083092 |
| 9 | Experimental platform | 38 | COMPLETE | 093101 |
| 10 | testkit + fixture tests | 62 | COMPLETE | 102106 |
| 11 | PostgreSQL integration/readiness lane | 75 | **COMPLETE** | 115120 |
| 12 | pool/performance contract lane | 3 | **COMPLETE** | 111114 |
| | **TOTAL** | **605** | **12 / 12** | |
605 = leaf top-level 4 + `src/main` 381 + `src/test` 101 + `src/testkit` 41 + `src/postgresqlIntegrationTest` 75 + `src/jpaPlatformPerformanceTest` 3. 12개 sub-scope의 denominator 합도 605이며, 모든 tracked file이 정확히 하나의 sub-scope에 귀속된다. 중복 계상은 한 곳에서만 발생할 수 있었고(§114) sub-scope 06이 이미 계상한 3개 test를 sub-scope 01이 다시 세지 않는 것으로 정리했다.
module 전체 disposition은 **605 FULL_READ / 0 STRUCTURAL_ONLY / 0 EXCLUDED / 0 UNCLASSIFIED**다.
### 142.2 module-level 완료 조건 대조
- **정량 denominator**: 605 tracked file, `src/main` 350 Java ≈ 27,744 LOC, public top-level type 338, package 24, 독립 Flyway stream 7, 실서버 lane 5(tag) + 14(readiness) + 1(pool), release gate 6
- **coverage ledger**: 미분류 0
- **top-level package map**: 24개 전부 `JpaModuleBoundaryTest.PACKAGE_CATALOG`와 대조 완료
- **build/runtime wiring**: composition root → `PersistenceJpaRootAutoConfiguration` → vendor config → `PersistenceJpaConfig` / `JpaAdapterComponentsConfig` 사슬을 §116–§117에서 실행 probe로 확인
- **invariant / failure path**: sub-scope 02~10에서 문서화, sub-scope 11에서 실서버 증거와 대조
- **test ↔ claim 매핑**: sub-scope 11이 lane·card·gate 세 축으로 완료
- **rationale 분리**: 코드 주석·문서가 밝힌 역사(observed)와 분석 추론(inferred)을 각 절에서 구분
- **§8 4종 probe**: 12개 sub-scope 전부에서 수행 또는 비적용 사유 기록
- **dead/unwired/duplicate**: 정적 검색과 task graph resolve로 확인
- **documentation/count drift**: §119(leaf), §132·§134·§135(evidence 계층)에서 재측정
- **한계와 제외 영역**: 각 sub-scope 완료 조건에 기재. module 수준 한계는 §142.3
- **improvement backlog**: 12개 sub-scope backlog 유지
### 142.3 module 수준 한계
- 정적 도달성 분석은 reflection, service loader, 생성 코드 등록, **이 repository 밖의 adopter**를 증명하지 않는다. `api` package는 의도된 외부 surface이므로 내부 참조 0이 곧 dead를 뜻하지 않는다.
- 실서버 증거는 PostgreSQL 16 단일 major에서 수집했다. registry가 Stable로 선언한 17·18은 CI matrix가 fan-out하며, 이 분석에서는 실행하지 않았다.
- `postgresqlSecurityBaselineIntegrationTest`의 TLS scenario와 `:app-bootstrap:test`의 compose scenario는 분석 환경 제약으로 실행하지 못했다(§132, §139). 두 건 모두 원인을 특정했고 repository 결함으로 계상하지 않았다.
- 성능·부하 특성은 이 분석의 대상이 아니다. pool lane은 행동 계약이지 측정 lane이 아니며(§123–§125), repository에도 threshold를 가진 성능 gate는 없다.
### 142.4 module findings 요약
| 우선순위 | 건수 | 출처 sub-scope |
|---|---:|---|
| P1 (confirmed) | 8 | 03(completion-evidence 미설치), 04(collection-fetch gate false evidence), 07(quota 미집행, schema activation 오인), 08(V4 schema 오인, lease fencing 우회), 11(flyway-migration lane 실패) 외 |
| P1 latent / conditional | 6 | 06, 07, 09, 10 |
| P2 | 12+ | 02, 03, 04, 05, 08, 09, 11, 12, 01 |
| P3 / 기록 | 다수 | 전 sub-scope |
module 전체에서 반복되는 단일 주제가 하나 있다. **구현은 계약을 정확히 서술하는데, 그 계약을 실제 배포나 실제 gate에 연결하는 마지막 한 칸이 비어 있는 경우**다 — completion-evidence capability가 composition에 없고(§23), runtime-role verifier를 production이 부르지 않고(sub-scope 06), admin claim을 production이 쓰지 않고(sub-scope 08), vendor selector의 properties 타입이 켜지지 않고(§116), 그리고 그 미연결을 잡아야 할 evidence lane 자체가 4주간 실패한 채 아무도 실행하지 않았다(§132). 이 leaf의 다음 작업 우선순위는 새 기능이 아니라 이 마지막 한 칸들을 잇는 것이다.
## Source anchors
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **230개** (main 143 · test 36 · 기타 51).
```
src/adapter/outbound/persistence-jpa/build.gradle
src/config/architecture/modules.json (adapter-outbound-persistence-jpa 항목)
main:
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationName.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/CapabilitySupport.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/JpaCapability.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConnectionUnavailableException.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintCode.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintViolationDetails.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/FailureCategory.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaEntityNotFoundException.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContext.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceException.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionCompletionUnknownException.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/VendorFailureTranslator.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorCodec.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetPageRequest.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetSlice.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/NoopQueryObservation.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryName.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryObservation.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryScope.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SortDirection.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaRetryPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDecision.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryEventListener.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryProfile.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionCompletionEvidence.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfile.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditContextPort.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntity.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/AuditMetadata.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditingConfiguration.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuard.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/HibernateEnversHistoryReader.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeature.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantEntityManagerFactoryRegistry.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantPoolBudget.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyAwareDataSourceRouter.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaLagMonitor.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsPolicyVerifier.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsTenantSessionBinder.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantMigrationOrchestrator.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantRegistry.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantAwareRepositoryGuard.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantEntityListenerGuard.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/FileserverJpaPersistenceConfig.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/FileserverSchemaActivation.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaCleanupQueue.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaFileQuotaService.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaQuotaCommitGateway.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaQuotaReclaimGateway.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaRecoveryQueue.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/entity/QuotaReservationEntity.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/repository/FileserverCleanupRepository.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/repository/FileserverQuotaRepository.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateProviderPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollector.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsSnapshot.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/JdbcBatchCounter.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/NamedStatementInspector.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/QueryNameContext.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/BatchExecutionResult.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuard.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateJpaBatchExecutor.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfileRegistry.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/HibernateBulkDmlExecutor.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/HibernateStatelessSessionRunner.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessWorkResult.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/entity/IdempotencyRecordEntity.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/JpaLiveEventReplayAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventJpaRepository.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/DistributedLockPersistenceConfig.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockSettings.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationJpaPersistenceConfig.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaActivation.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaStream.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/configuration/NotificationJpaPersistenceFacade.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JdbcReconciliationJobStore.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/RecipientClaimSql.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/TenantBoundRepositoryGuard.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/inbox/InboxItemJpaRepository.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTags.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaRetryObservation.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaTransactionObservation.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationJpaRepository.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxClaimRepository.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/package-info.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurer.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlExceptionTranslator.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifier.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/inbox/PostgreSqlSameStoreInboxAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlImmutableOutboxAppendAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRange.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeCodec.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupport.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/security/SearchPathPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityGraphCatalog.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplier.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupport.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupport.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutor.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortField.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapper.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortRegistry.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SpecificationPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifier.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecord.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorder.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicy.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManager.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionConfig.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettings.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/OptimisticConflictTranslator.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/PersistenceFailureTranslatorChain.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudget.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculator.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceContext.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceScope.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistry.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java
src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifier.java
test:
src/test/java/dev/caskeleton/adapter/outbound/persistence/CandidateAdapterCompositionTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationNameTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContextTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceExceptionTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryNameTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodecTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfileTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceEntityScanCoverageTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSelectionTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalEntryConsentTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/platform/PoolLaneClaimTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/IdempotencyDigestPolicyTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManagerTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java
src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceScopeTest.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityExposureCondition.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRules.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaAuditMechanismRule.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/CommitAmbiguityProxy.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7Generator.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingDataSource.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityState.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbe.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/MappingEntity.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationContractRunner.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurement.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContractExtension.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/NormalizedPlan.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/PostgreSqlExplainRunner.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertions.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanExpectation.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java
src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java
기타:
CLAUDE.md
README.md
docs/architecture/jpa-api-surface.txt
docs/fileserver/design-deviations.md
docs/jpa/repository-adaptation.md
docs/jpa/security.md
docs/jpa/support-matrix.md
docs/jpa/transaction-guide.md
docs/reviews/2026-08-14-jpa-module-code-review.md
src/build.gradle
src/config/jpa/readiness-cards.yaml
src/config/jpa/release-registry.json
src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java
src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java
src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/AdminOperationClaimContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/EvidenceCertaintyContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationFixtures.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/ProjectionFactDurabilityContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/RecipientClaimContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/CommitAmbiguityContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/ConstraintRaceContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateCollectionFetchPaginationContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateJpaBatchExecutorIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/IdStrategyContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaAuditingContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupport.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupportOwnershipTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupportTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaValueMappingContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/OptimisticRetryIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlConcurrencyFailureContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlQueryPlanContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlUpsertContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlWorkClaimContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/StablePostgreSqlMatrixContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/RlsIsolationFailureTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantPoolCapacityContractTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlAggregateIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlDefaultPersistenceUnitIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlInboxCutoffIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlLifecycleIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlNotificationInvariantIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlNotificationSchemaActivationIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOptionalStreamLifecycle.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxStorageIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlQueryIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java
src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTransactionIntegrationTest.java
해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등:
092-notification-reachability-test-gap.txt
evidence/raw/103-testkit-unit-boundary-probes.txt
evidence/raw/078-fileserver-quota-boundary-probe-output.txt
evidence/raw/096-experimental-gate-reachability.txt
099-experimental-structural-optin-gap.txt
evidence/raw/097-experimental-replica-provider-probe.txt
106-testkit-original-verification.txt
evidence/raw/053-jpa-query-hibernate-boundary-probe.txt
evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt
evidence/raw/072-baseline-capability-reachability.txt
evidence/raw/075-outbox-stale-worker-state-regression-output.txt
evidence/raw/073-durable-operation-expired-lease-output.txt
```