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>
288 KiB
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_READ605 /STRUCTURAL_ONLY0 /EXCLUDED0 /UNCLASSIFIED0 - 최초 분석 revision
a24ece9c→ 재검증 revision21234e38· 이 리프의 변경 파일 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 LOCsrc/test: 101 files / 100 Java / 1.gitkeep/ 약 9,511 Java LOCsrc/testkit: 41 Java / 약 3,070 LOCsrc/postgresqlIntegrationTest: 75 files / 71 Java / 4 SQL / 약 13,958 Java LOCsrc/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로 명시한다. 따라서 다음 두 사실을 구분해야 한다.
- repository 내부 production consumer가 있는가
- 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는:
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 흐름은:
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되는 것을 확인했다.
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는 보존한다. 이는 중요한 구분이다.
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에도 명시적으로 포함된다. 따라서 현 단계 판정은:
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는:
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는:
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을 만든다.
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.isEqualconstant-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다.
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:
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이 다르다.
형식적으로:
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.javaevidence/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 바로 아래와 정확히 같은 크기에 대해:
decode(encode(payload)) == payload
을 검증하지 않는다.
따라서 security-bound 테스트는 많지만 양쪽 bound가 같은 집합을 표현하는지에 대한 property가 빠져 있다.
수정 후보는 두 방향이다.
- unpadded Base64URL의 decoded length를 remainder까지 반영해 정확히 계산
- MAC 검증 전에 encoded segment의 최대 허용 길이를 exact bound로 계산하고, 실제 decode 후 byte length도 재확인
어느 구현을 택하든 regression criterion은 최소:
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됐다.
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는:
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의:
JpaPlatformRuntimeAutoConfigurationJpaObservabilityAutoConfigurationJpaTransactionAutoConfiguration
등이 소유한다.
즉 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
apitop-level types - current source: 49
JpaCapabilityenum: 16- current app-bootstrap capability declarations: 16
현재 수치 drift 없음.
API surface verification도 별도 Gradle task가 소유하므로 수동 문서 count만 믿는 구조가 아니다.
9. 테스트와 증명 범위
9.1 Dedicated API tests
전용 test class는 6개다.
PersistenceOperationNameTestJpaFailureContextTestJpaPersistenceExceptionTestQueryNameTestSignedJsonCursorCodecTestTransactionProfileTest
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:
035runtime 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
JpaPlatformAutoConfigurationJpaPlatformReportJpaPlatformEndpointJpaPlatformRuntimeAutoConfigurationrelevant wiringJpaRetryObservationDefaultJpaRetryPolicyFullTransactionRetryCoordinatorrelevant 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가
Throwablemessage를 실제로 무시하는지 - 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
PolicyTransactionPort / TransactionPort
-> SpringTransactionPort (@Component)
-> SpringPolicyTransactionPort
-> PlatformTransactionManager
input/output vocabulary:
TransactionRequestTransactionPolicyIdCallBudgetTransactionResultTransactionOutcomeOperationIdTransactionPhaseReconciliationReference
이 모델은 application-core가 소유한다. use case가 outbound adapter type을 import하지 않아도 transaction policy와 uncertain outcome을 표현할 수 있다.
B. persistence-jpa public API boundary
JpaTransactionExecutor
-> SpringJpaTransactionExecutor
-> FullTransactionRetryCoordinator
-> TransactionProfile / RetryProfile / JpaRetryPolicy
input/output vocabulary:
PersistenceOperationNameTransactionProfileRetryProfileJpaPersistenceExceptionTransactionCompletionEvidence
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가 아니다.
결과는 최소 다음 상태를 구분한다.
CommittedCommittedWithPostCommitFailureParticipatingDeterminateRollbackIndeterminate
핵심은 commit exception = rollback으로 가정하지 않는 것이다.
15.1 commit failure 분기
commit 호출 전에 phase를 COMMIT_REQUESTED로 올리고, Spring TransactionSynchronization sentinel로 실제 callback을 관찰한다.
commit에서 exception이 발생해도:
afterCommit()이 이미 확인됐으면CommittedWithPostCommitFailure- rollback callback/
UnexpectedRollbackException/replay-candidate가 확인되면DeterminateRollback - 그 외에는
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이 최소 다음을 감당해야 한다.
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는:
4000140P01
뿐이다.
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는 하지 않는다.
실행 순서:
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를 제공한다.
retryCoordinator(
SpringJpaTransactionExecutor executor,
JpaRetryPolicy policy,
RetryEventListener listener)
javadoc도 **“retry coordinator for an application-supplied policy”**라고 설명한다.
constructor는 이 policy를 fallbackRetryPolicy로 저장한다.
그러나 FullTransactionRetryCoordinator.execute(...)는 실행마다:
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를 다음처럼 넣었다.
custom policy decision = always FAIL
custom policy invocation counter
valid TransactionProfile with maxAttempts=2
work = SerializationFailureException
결과:
customPolicyCalls=0
workCalls=2
즉 custom policy가 “retry하지 말라”고 해도 한 번도 호출되지 않고 default profile policy에 따라 work가 두 번 실행됐다.
Raw:
evidence/raw/047a-jpa-custom-retry-policy-probe.javaevidence/raw/047-jpa-custom-retry-policy-probe.txt
이것은 단순 dead field가 아니라 public composition factory가 제공하는 custom policy 기능이 실제로 작동하지 않는 functional contract bug다.
우선순위: P2
수정 방향 후보:
- coordinator가 constructor-supplied policy를 authoritative하게 사용하고 budget만 profile에서 계산
- custom policy overload를 제거하고 RetryProfile이 단일 SSOT임을 API에 명시
- custom policy가 profile-aware해야 한다면 factory에서 policy/profile을 하나의 object로 합성
현재처럼 두 설정원을 받되 하나를 silent ignore하는 형태가 가장 위험하다.
21. completion evidence state machine 자체는 잘 설계돼 있다
EvidenceAwareJpaTransactionManager는 JpaTransactionManager를 상속하고 transaction phase를 TransactionEvidenceContext에 기록한다.
대략:
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를 가진 경우:
- inner manager가 inner frame pop
- inner executor finally가 top을 보고 outer frame까지 자기 것이라고 착각해 pop
- 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 전체에서:
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에서:
TransactionSystemException
cause -> SQLException SQLSTATE 08006
를 발생시켰다.
현재 vendor translator까지 포함한 결과:
type=ConnectionUnavailableException
category=CONNECTION_UNAVAILABLE
completionUnknown=false
retryable=false
Raw:
evidence/raw/050a-jpa-commit-ambiguity-probe.javaevidence/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:
Commit completion evidence = Stable
Automatic reconciliation unsupported.
The platform records; the domain resolves.
runbook:
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에서:
JpaObservabilityAutoConfigurationconstruction = 0JpaTransactionObservation.recordCompletionUnknown(...)call = 0recordCommitted/recordRolledBack/recordTimedOutcall도 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는 그렇지 않다.
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
결정이 필요하다.
- JPA executor/coordinator를 진짜 external platform API로 유지한다면 canonical application port와 역할 차이를 명시하고 runtime bean/export 정책을 분리
- 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에서는:
@RetryableJpaTransaction
-> RetryableJpaTransactionInterceptor
-> TransactionProfileRegistry
-> FullTransactionRetryCoordinator
형태였다.
commit 2f5d2fc에서 annotation/interceptor와 그 test를 삭제했지만 registry는 남았다.
현재:
- production consumer 0
- dedicated unit test만 존재
api/**intended external surface가 아니라 implementationtransactionpackage- 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는:
@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:
raw persistence/provider failure
output:
JpaPersistenceException hierarchy
consumer:
SpringJpaTransactionExecutor / retry semantics
목적은 SQLSTATE/optimistic conflict를 retry/completion semantics에 필요한 stable persistence failure로 바꾸는 것이다.
failure.PersistenceExceptionTranslator
input:
RuntimeException / SQLSTATE mapping
output:
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
SpringTransactionPortPersistenceExceptionTranslatorJpaTransactionConfig
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
EvidenceAwareJpaTransactionManagerCompletionUnknownRecorderJpaTransactionObservationcall path
이 셋은 문서상 completion-evidence/operability의 핵심이지만 current root assembly에서 provider가 없다.
이 sibling comparison으로 “class가 있으니 feature가 있다”는 판단을 피했다.
29. documentation drift
transaction docs에는 현재 서로 다른 세 시대의 설계가 겹쳐 있다.
current source truth
application-core canonical:
PolicyTransactionPort -> SpringTransactionPort
public JPA runtime:
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:
: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:
JpaPlatformAddonAssemblyTestJpaPlatformRuntimeAutoConfigurationTestCapabilityEntityScanRegistrationTest
결과: 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에 연결되지 않음
EvidenceAwareJpaTransactionManagerimplementation/test는 존재하지만 production construction 0.- normal manager commit 08006 probe ->
CONNECTION_UNAVAILABLE,completionUnknown=false. CompletionUnknownRecorderimplementation/consumer 0.JpaTransactionObservationrecord 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
JpaObservabilityAutoConfigurationcurrent 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는 대략 다음처럼 읽는 것이 맞다.
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를 별도 주입한다.
이것은 중요한 설계 선택이다.
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의 기본 전략은:
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만 남는 형태였다.
현재 코드는:
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만 읽는다.
field @Id
-> field @GeneratedValue
-> strategy == IDENTITY ?
하지만 JPA는 field access뿐 아니라 property access도 허용한다. 즉 다음과 같은 mapping도 정상적인 JPA mapping이다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long getId() { ... }
실행 probe
getter에 @Id + @GeneratedValue(IDENTITY)를 선언한 entity class를 현재 compiled guard에 전달했다.
결과:
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는:
return jdbcBatches > 1L;
이다.
probe에서:
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:
application transaction boundary에 참여
StatelessSession runner:
새 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다.
제공하는 것은 대략:
EntityManageraccess- 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는 두 개다.
- client field를 entity path로 그대로 넘기지 않는다.
- 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를 붙였다.
예:
createdAt ASC
요청이 실제로는:
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 뒤는 개념적으로:
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는 받지 않는다.
검사할 수 있는 것은:
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는:
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>를 외부로 반환하지 않는 것이다.
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이 현재는:
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이 필요하다고 설명한다.
하지만 구현은 다음만 확인한다.
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를 확인했다.
Specification.unrestricted()
-> non-null Specification lambda
-> lambda toPredicate(...) returns null
따라서 이건 인위적인 edge case가 아니다. 현재 dependency가 공식적으로 제공하는 representation이다.
47.2 실행 probe
Specification<Object> noPredicate = (root, query, cb) -> null;
SpecificationPolicy.requireBounded(noPredicate, page, null);
결과:
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.txtevidence/raw/061-spring-data-specification-unrestricted-contract.txt
48. Querydsl integration은 production runtime classpath를 강제로 오염시키지 않는다
QuerydslJpaSupport는 Advanced opt-in으로 설계돼 있다.
build:
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의 설계는 다음과 같다.
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 전체에서:
NamedStatementInspectorconstruction = class definition 외 0- Hibernate
statement_inspectorproperty registration = 0
이라는 점이다.
JpaRepositoryFragmentSupport는 QueryNameContext를 사용하지만 실제 Hibernate statement inspector가 설치되지 않으면 그 name은 SQL layer까지 내려가지 않는다.
따라서 query/observability documentation에서 “registered query name이 generated SQL에 연결된다”는 설명은 current shipped composition evidence가 없다.
이것은 transaction scope에서 확인한:
JpaObservabilityAutoConfigurationproduction 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가 확인되지 않았다.
HibernateJpaBatchExecutorJpaBatchProfileRegistryHibernateBulkDmlExecutorHibernateStatelessSessionRunnerFetchPlanApplierJpaKeysetQuerySupportJpaRepositoryFragmentSupportJpaStreamExecutorSpecificationPolicyQuerydslJpaSupport
하지만 이것을 곧바로 “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은:
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로 선언한다.
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은:
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:
collection-fetch-pagination
-> :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
그런데 Gradle task registration은:
jpaPlatformQueryPlanTest
-> includeTags "jpa-queryplan"
이다.
실제 collection-fetch class는:
@Tag("jpa-contract")
이고 PostgreSqlQueryPlanContractTest만 @Tag("jpa-queryplan")이다.
52.3 exact registry task fresh 실행 결과
이번에 registry가 지정한 task 자체를 fresh 실행했다.
결과:
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가 존재하는가
Testtask인가
까지만 검사한다.
fresh 실행 결과:
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을 허용한다.
수정 조건은 둘 다 필요하다.
- gate producer가 실제 collection-fetch scenario를 실행하도록 registry/task/tag 연결 수정
- test가 generated SQL 또는 fail-closed provider signal로 DB-side pagination을 직접 검증
둘 중 하나만 고치면 gate는 여전히 불완전하다.
Raw:
evidence/raw/056-persistence-jpa-collection-fetch-gate-provenance.txtevidence/raw/057-persistence-jpa-query-hibernate-postgresql-contracts.txtevidence/raw/058-persistence-jpa-queryplan-gate-task.txtevidence/raw/059-verify-jpa-release-gate-tasks.txtevidence/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:
:adapter:outbound:persistence-jpa:test
--tests hibernate.*
--tests springdata.*
--tests querydsl.*
--rerun-tasks
결과:
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
결과:
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 testsHibernateJpaBatchExecutorIntegrationTest: 7 testsIdStrategyContractTest: 4 tests
총 15 tests:
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:
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 =
PostgreSqlQueryPlanContractTestonly - target collection-fetch class는
jpa-contracttag - 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 부재
NamedStatementInspectorruntime 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은 다음처럼 변환됐다.
type=JpaPersistenceException
category=UNKNOWN
sqlState=40003
completionUnknown=false
retryable=false
여기서 단순 진단 정보만 사라지는 것이 아니다. 현재 DefaultJpaRetryPolicy는 TransactionCompletionUnknownException 또는 FailureCategory.COMPLETION_UNKNOWN을 가장 먼저 검사해 RECONCILE로 보낸다. 그런데 실제 translator를 통과시키면 focused policy probe 결과가 다음과 같다.
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 결과:
expiredInspect.outcome=COMPLETED_REPLAY
expiredInspect.replayUntil=<already expired>
expiredInspect.claimAfterExpiry=TakenOverClaimed
즉 같은 시점의 같은 row가:
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:
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:
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에는 들어가지 않는다.
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만 포함한다.
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 결과:
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을 모두 읽었다.
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/varchardrift 보정 - 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 -> UNKNOWNcompletionUnknown=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
postgresqlIdempotencyIntegrationTestlane 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
40003translator → 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한다.
auditfailureidempotencylockoutboxtransaction
따라서 같은 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를 다음 한 조건으로 계산한다.
privileges != null && !privileges.holdsCreatePrivilege()
즉 wrong role name이나 unsafe search_path는 검사하지 않은 채 runtimeRoleVerified=true가 될 수 있다. 문서가 말하는 “role passed verification”과 실제 boolean 의미도 다르다.
결과적으로 현재 Stable capability는:
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까지 켜면 다음 흐름이 된다.
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가 가능하다.
- worker A가 attempt 1을 claim하고 broker I/O에서 오래 멈춘다.
- visibility timeout이 지난 뒤 worker B가 같은 row를 attempt 2로 claim한다.
- B가 publish 성공 후
PUBLISHED로 mark한다. - 늦게 돌아온 A의 failure path가
markFailed()를 호출한다. - 이미
PUBLISHED인 row가FAILED로 되돌아가 다시 delivery 대상이 된다.
focused probe에서 실제 adapter transition은 다음과 같이 재현됐다.
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 결과:
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:
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 denominatorevidence/raw/072-baseline-capability-reachability.txt— baseline package scan, runtime-role policy caller absence, outbox composition, latent adapter construction snapshotevidence/raw/071-liveevent-full-sweep-probe-output.txt— real PostgreSQL full-sweep position reuseevidence/raw/073-durable-operation-expired-lease-output.txt— real PostgreSQL expired lease completionevidence/raw/075-outbox-stale-worker-state-regression-output.txt— baseline outbox terminal-state regressionevidence/raw/076-persistence-jpa-baseline-unit-tests.txt— fresh full:adapter:outbound:persistence-jpa:test --rerun-tasksBUILD 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이 없다.
DefaultUploadApplicationService.create()는 namespace를QuotaScope로 만든 뒤admissionController.acquireUpload(scope, bytes)를 호출한다.- admission controller가 검사하는 것은 단일 파일 최대 크기, global storage high-water, JVM-local scope/instance semaphore뿐이다.
- 그 다음
JpaFileQuotaService.reserve()는 byte aggregate나 limit을 조회하지 않고QuotaReservationEntity를 unconditionalsave()한다. - production source에서
reservedBytes(scope)/committedBytes(scope)또는 repository의 aggregate query를 quota decision에 사용하는 caller는 0개다. 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는 다음만 요구한다.
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로 만든 결과:
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을 요청한 결과:
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()을 호출하면:
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”이라고 설명한다. 구현은:
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 결과:
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를 항상 다음 상태로 되돌린다.
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회 반복한 결과:
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_READevidence/raw/079-fileserver-reachability-quota-schema-contract.txt— production quota readers/ceiling absence, admission logic, schema revision/activation snapshotevidence/raw/078-fileserver-quota-boundary-probe-output.txt— real PostgreSQL expired commit + 65-row reclaim truncationevidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt— real PostgreSQL concurrent recovery enqueue raceevidence/raw/081-fileserver-schema-activation-v2-output.txt— real PostgreSQL V2-only ACTIVE activation acceptanceevidence/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/afterevidence/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은 V1V10까지 진화했지만 registry는 V4에서 V10에서 추가된 column/constraint에 실제 의존한다. 이 drift가 §88의 startup false-positive를 만든다.jpa-notification-platform-v4, feature_revision=4, INSTALLED_INACTIVE를 기록한 뒤 더 이상 revision을 올리지 않는다. 반면 current Java mapping과 SQL은 V5
88. P1 conditional-production — V4 ACTIVE schema가 current V10-compatible schema로 오인된다
NotificationSchemaActivation은 다음 조건이면 capability를 active로 인정한다.
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을 그대로 실행했다.
activation_count = 1
feature_revision = 4
lifecycle_state = ACTIVE
동시에 current code가 요구하는 column 존재 여부는 다음과 같았다.
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() 흐름은 다음이다.
- provider call 직전에
leases.stillHeld(lease)를 확인한다. - provider call은 transaction 밖에서 실행한다.
- call이 돌아오면
DispatchOutcomeRecorder.record(...)를 write transaction에서 실행한다. - recorder는 attempt result를 저장한 뒤 recipient projection에 **
recipients.save(updated)**를 호출한다. - 이
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도 재현했다.
# 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로 읽는다.
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을 반환했다.
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 경로는 여전히 모두:
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였다.
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_READevidence/raw/084-notification-schema-v4-activation-probe.txt— PostgreSQL 16에서 V4 ACTIVE activation은 통과하지만 current-required V5~V9 columns 5개가 모두 absentevidence/raw/085-notification-expired-lease-write-probe.txt—stillHeld=0인 expired lease의 owner+fence write가UPDATE 1evidence/raw/091-notification-stale-provider-overwrite-probe.txt— replacement holder B/fence 8 이후 stale result의 ID-only projection write가 새 lease를 보존한 채 lifecycle을 overwriteevidence/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 snapshotevidence/raw/089-notification-schema-official-readiness.txt— original-sourcepostgresqlNotificationSchemaActivationIntegrationTest --rerun-tasks, BUILD SUCCESSFUL in 40s, 19/19 tasks executedevidence/raw/090-notification-existing-contracts-fresh.txt— originalRecipientClaimContractTest+AdminOperationClaimContractTest, freshjpaPlatformContractTest --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 결과에 한 번도 등장하지 않아도 성공한다.
requested = [missing_tenant_scoped_table]
actual catalog row = rls_item
loop:
rls_item ∉ requested -> continue
loop end -> success
PostgreSQL 16에서 존재하지 않는 required table 하나를 넘긴 probe도 exception 없이 종료됐다.
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()의 순서는 다음이다.
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:
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별로 다음처럼 분기한다.
EVENTUAL -> true
BOUNDED_STALENESS -> lag().map(...).orElse(false)
PRIMARY_REQUIRED -> replayedThrough().map(...).orElse(false)
즉 EVENTUAL만 evidence availability를 전혀 보지 않는다. lag()와 replayedThrough()가 모두 Optional.empty()인 monitor를 넣은 probe는 replica를 선택했다.
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:
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는 세 개뿐이다.
ConsistencyAwareDataSourceRouter
RlsTenantSessionBinder
SchemaTenantMigrationOrchestrator
하지만 같은 experimental public API에는 flag 없이 바로 생성해서 behavior를 실행할 수 있는 type이 더 있다.
TenantDataSourceRegistry— public constructor + tenant pool openTenantEntityManagerFactoryRegistry— public constructor + tenant EMF buildSchemaMultiTenantConnectionProvider— public constructor + connectionsearch_path변경TenantEntityListenerGuard— public constructor +@PrePersist/@PreUpdatetenant 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_READevidence/raw/095-experimental-pool-overshoot-probe.txt— connection ceiling 10에서 heterogeneous pools 8 + 5가 admission되어 total 13으로 overshoot, BUILD SUCCESSFULevidence/raw/097-experimental-replica-provider-probe.txt— evidence가 모두 empty인 EVENTUAL read가REPLICA, unknown Hibernate9.0.0.Final이mayReplaceStable=true, BUILD SUCCESSFULevidence/raw/098-experimental-rls-missing-table-probe.txt— PostgreSQL 16에서 required missing table을RlsPolicyVerifier가 성공으로 인정,jpaPlatformSecurityTestBUILD SUCCESSFULevidence/raw/096-experimental-gate-reachability.txt,099-experimental-structural-optin-gap.txt— omitted public activation constructors, production caller 0, Stable scan의 experimental package unconditional inclusionevidence/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/afterevidence/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/afterevidence/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 Javasrc/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 PostgreSQLPostgreSqlQueryPlanContractTest가 사용하고jpaPlatformQueryPlanTest라는 blocking release Test task 안에서 실행된다.MigrationContractRunner,CountingDataSource, lifecycle/mapping fixture들은 실제 PostgreSQL integration lane에서 사용된다.
반대로 public surface지만 현재 repository source에서 defining file 외 reference가 0인 helper도 있다.
CommitAmbiguityProxyPostgreSqlContractExtension
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는 이 위험을 정확히 문서화한다.
EXPLAIN ANALYZE executes the statement;
therefore this runner refuses anything but SELECT.
하지만 실제 requireReadOnly()는 다음 두 prefix를 허용한다.
select...
with...
PostgreSQL의 WITH는 read-only CTE만 의미하지 않는다. data-modifying CTE가 가능하다.
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에 넣었다.
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일 때만 실행된다.
Collection
Map
Optional
array
따라서 다음처럼 실제 transport 계층에서 흔한 wrapper는 raw type 단계에서 즉시 Optional.empty()가 된다.
CompletableFuture<OrderEntity>
CompletionStage<OrderEntity>
ResponseEntity<OrderEntity>
Mono<OrderEntity>
Flux<OrderEntity>
Page<OrderEntity>
analysis fixture로 CompletableFuture<OrderEntity>를 반환하는 ..web.. class를 넣고 **실제 production에서 사용하는 동일 JpaArchitectureRules.noEntityFromWeb()**를 실행했다. rule은 exception 없이 통과했다.
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을 넣었다.
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으로 보고했다.
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이 어디든 포함되는지만 본다.
AUDIT_COLUMNS.stream().anyMatch(assignments::contains)
따라서 실제 audit field를 변경하지 않고 parameter 이름에 token만 들어 있어도 통과한다.
update WorkLogEntity w
set w.status = :updatedAtValue
where w.id = :id
probe 결과:
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보다 작아지는 정렬 역전이 재현됐다.
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 SUCCESSFULevidence/raw/105-testkit-public-reachability-sweep.txt— public testkit symbol reachability와 zero-reference helper sweepevidence/raw/106-testkit-original-verification.txt— 원본 source 상태에서:adapter:outbound:persistence-jpa:test --rerun-tasksBUILD SUCCESSFUL in 29s / 18 executed,JpaProductionArchitectureTestBUILD SUCCESSFUL in 1m47s / 98 executed, originalPostgreSqlQueryPlanContractTestBUILD 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
@ConditionalOnPropertyvendor configurations would both stay off, and the first missing SPI bean would surface as aNoSuchBeanDefinitionExceptionnamingOutboxClaimRepository— 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의@ConfigurationPropertiesScanbasePackages 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을 준다.
shipped.unknownVendor.contextFailed=false
shipped.unknownVendor.vendorSettingsBeans=0
shipped.unknownVendor.sqlStateErrorMappingBeans=0
shipped.unknownVendor.postgreSqlConfigBeans=0
shipped.unknownVendor.h2ConfigBeans=0
같은 모양에 @EnableConfigurationProperties(PersistenceVendorSettings.class)만 추가하면 문서가 약속한 실패가 실제로 난다.
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가 등장하지 않는다.
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
두 축에서 비교했다.
@ConfigurationProperties3형제: §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 --shortclean - 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():
int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1; // 8 * 2 + 1
assertThat(required).isEqualTo(17);
RequiresNewPoolPressureContractTest.sizingRuleMatchesTheObservedRequirement():
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)에서 두 연결을 점유하고 세 번째 요청 스레드를 대기시킨 뒤 측정했다.
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 경계도 측정했다.
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이다.
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-tasksBUILD SUCCESSFUL in 28s / 18 executed,jpaPlatformPoolContractTest --rerun-tasksBUILD SUCCESSFUL in 24s / 18 executed, rootverifyDocumentedLeafCount --rerun-tasksBUILD SUCCESSFUL in 3s / 9 executed, git clean before/afterevidence/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해 확인했다.
$ ./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.
PostgreSqlMigrationIntegrationTest > adoptsImmutableLegacyHistoryThenRunsTheIndependentCoreStream() FAILED
PostgreSqlMigrationIntegrationTest > freshCoreStreamInitializesWithoutLegacyHistory() FAILED
taskExit=1
두 실패의 정체는 같다 — stream에 migration이 추가됐는데 그 stream의 applied set을 고정한 assertion이 갱신되지 않았다.
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"]
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로 시점을 맞춰 보면 원인이 분명하다.
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-...).
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은 다음 한 줄이다.
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는 한 걸음 더 나아가 productionNotificationSchemaStream을 호출하며 그 이유를 적는다("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() |
V1–V9 | "The whole notification stream, in order… Applying a subset certifies a schema nobody deploys" |
RecipientClaimContractTest.migrations() |
V1–V8 | "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() |
V1–V6 | (주석 없음) |
| (배포 stream) | V1–V10 | 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% 테이블의 컬럼 집합을 비교했다.
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이 들어오는 순간 조용히 어긋난다. - V1–V8은 evidence-certainty 5개 컬럼이 없다.
RecipientClaimContractTest는notification_recipient_delivery만 다루므로 현재 false-green은 없다. - V1–V6은 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이 아님)임을 확인하고, productionCommitFailureClassifier가 그것을TransactionCompletionUnknownException/retryable=false로 번역하는지 본다. "연결이 끊기면 08일 것"이라는 합리적 추측이 왜 틀리는지가 주석에 적혀 있다. - transaction runtime —
PostgreSqlTransactionIntegrationTest가 productionSpringTransactionPort로 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 gatepostgresql-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-failurelane). PostgreSqlInboxCutoffIntegrationTest의 SQL 재작성. productionInboxItemJpaRepository.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-tasksBUILD 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/afterevidence/raw/117-flyway-migration-readiness-lane-failure.txt—postgresqlMigrationIntegrationTest단독--rerun-tasksBUILD FAILED / taskExit=1, 두 assertion 실패 원문, stream 파일 목록, migration/assertion 최종 수정일 대조evidence/raw/118-readiness-task-result-matrix.txt— 14 task × tests/skipped/failures/errors 표, 12 PASS / 2 FAILevidence/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벌(V1–V9 / V1–V8 / V1–V6)로 갈라져 있고 배포 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-runtask 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 --shortclean
142. Module ledger 재조정과 module 완료 조건
142.1 최종 ledger
| # | sub-scope | denominator | status | 주요 evidence |
|---|---|---|---|---|
| 1 | governance / build / docs / root boundary | 11 | COMPLETE | 107–110, 114 |
| 2 | API contracts (api/**) |
55 | COMPLETE | 031–040 |
| 3 | transaction + persistence failure | 51 | COMPLETE | 041–051 |
| 4 | Spring Data + Hibernate + Querydsl | 53 | COMPLETE | 052–061 |
| 5 | PostgreSQL vendor + vendor migrations | 73 | COMPLETE | 062–069 |
| 6 | baseline capability stores/config/audit/cache/H2 등 | 87 | COMPLETE | 070–076 |
| 7 | Fileserver persistence + migrations | 29 | COMPLETE | 077–082 |
| 8 | Notification persistence + migrations | 68 | COMPLETE | 083–092 |
| 9 | Experimental platform | 38 | COMPLETE | 093–101 |
| 10 | testkit + fixture tests | 62 | COMPLETE | 102–106 |
| 11 | PostgreSQL integration/readiness lane | 75 | COMPLETE | 115–120 |
| 12 | pool/performance contract lane | 3 | COMPLETE | 111–114 |
| 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/main350 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를 증명하지 않는다.
apipackage는 의도된 외부 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