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>
42 KiB
application-core 상세 분석
SSOT identity — 2026-08-31 재검증
- registered leaf id:
application-core - canonical state
analysisFile:analysis/03-application-core.md(이 문서) — 이 leaf의 단일 SSOT - source path:
src/application-core· Gradle:application-core - registry
allowed_dependencies:["domain-core", "shared-contract"] - registry
runtime_memberships:["app-bootstrap", "sample-portfolio"] - coverage ledger:
FULL_READ1021 /STRUCTURAL_ONLY0 /EXCLUDED0 /UNCLASSIFIED0 - 최초 분석 revision
a24ece9c→ 재검증 revision21234e38· 이 리프의 변경 파일 0 - 재검증 증거:
EVD-333(소스 드리프트 0),EVD-334(lane 재실행)
재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
Status: COMPLETE
Source revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
Analysis cycle: 1 (normal)
1. 분석 범위와 완료 기준
application-core는 src/application-core 하나의 Gradle leaf이지만 작은 use-case 모듈이 아니다. framework-free inbound use-case 계약, transaction/idempotency/outbox/inbox, cache/lease/lock, durable operation, realtime, object/file publication, fileserver, notification까지 애플리케이션 정책과 outbound port를 폭넓게 소유한다.
이번 분석의 source denominator는 src/application-core/src 아래 Java source 전부다. src/main 885개, src/test 136개로 총 1,021개이며 resource/non-Java source는 없다. build/의 generated output은 source coverage에서 제외했다. CLAUDE.md, README.md, build.gradle, downstream adapter/bootstrap/architecture-test source는 별도 source anchor로 읽었으며 1,021개 denominator에는 포함하지 않는다.
| top-level package | production | test | disposition |
|---|---|---|---|
| cache | 29 | 5 | FULL_READ |
| capability | 3 | 1 | FULL_READ |
| command | 1 | 0 | FULL_READ |
| fileexport | 2 | 0 | FULL_READ |
| filepublication | 16 | 1 | FULL_READ |
| fileserver | 166 | 38 | FULL_READ |
| idempotency | 34 | 6 | FULL_READ |
| inbox | 9 | 1 | FULL_READ |
| lease | 14 | 2 | FULL_READ |
| lock | 3 | 2 | FULL_READ |
| messaging | 13 | 3 | FULL_READ |
| notification | 415 | 51 | FULL_READ |
| objectstorage | 88 | 5 | FULL_READ |
| observability | 2 | 1 | FULL_READ |
| operation | 11 | 2 | FULL_READ |
| outbound | 1 | 1 | FULL_READ |
| outbox | 28 | 9 | FULL_READ |
| query | 1 | 0 | FULL_READ |
| realtime | 14 | 0 | FULL_READ |
| security | 7 | 2 | FULL_READ |
| storage | 8 | 0 | FULL_READ |
| transaction | 17 | 5 | FULL_READ |
| usecase | 3 | 1 | FULL_READ |
| 합계 | 885 | 136 | FULL_READ 1,021 / UNCLASSIFIED 0 |
따라서 이 문서에서 COMPLETE는 “대표 파일을 샘플링했다”는 뜻이 아니라 source denominator 전체를 읽고 package별 contract/invariant/test/wiring을 분류했다는 뜻이다.
2. 모듈 경계와 빌드 의존성
Observed. build.gradle의 production project dependency는 :shared-contract 하나뿐이다. application-core가 Spring, JPA, Redis, Kafka, filesystem provider 같은 구현 모듈을 직접 참조하지 않고, 외부 구현은 composition root와 adapter가 역으로 이 모듈의 port를 구현한다.
Observed. CommandUseCase<C extends Command,R>와 QueryUseCase<Q extends Query,R>는 UseCase<I,O>.handle(I)를 write/read intent에 맞게 타입으로 좁힌다. 자체적으로 transaction을 열거나 security interceptor를 실행하지 않는다. 실행 정책은 @UseCaseCapability에 별도로 선언된다.
Observed. @UseCaseCapability는 runtime TYPE annotation이며 transactionMode, idempotency, repositoryAccess를 필수로 받고 externalOutboundAllowed, sensitiveRead, bulkWrite, crossTenantAdmin을 추가 선언한다. annotation 자체는 metadata에 불과하지만 CleanArchitectureTest가 concrete Command/Query use case에 annotation 존재를 강제한다.
Observed. architecture fitness function은 다음 coherence를 직접 검사한다.
READ_ONLY + READ_REPOSITORY는TransactionPort.inRead를 직접 호출해야 한다.WRITE + WRITE_REPOSITORY는inWrite또는inRootWrite를 직접 호출해야 한다.REQUIRES_NEW는inNew를 직접 호출해야 한다.repositoryAccess != WRITE_REPOSITORY인 use case가 repository write verb를 직접 호출하면 실패한다.bulkWrite=true는WRITE_REPOSITORY를 요구한다.- mutating use case는 type-level
@RequiresPermission을 선언해야 한다. - application/domain은 Spring Security에 의존할 수 없다.
이 enforcement에는 의도적으로 한계가 있다. ArchUnit의 direct-call 분석이므로 helper 뒤에 숨은 repository mutation/transaction call은 잡지 못하고, AOP self-invocation/non-bean path도 static rule만으로 보장하지 않는다. 이 제한은 테스트 설명 자체에 명시돼 있어 최종 계약의 일부로 봐야 한다.
3. authorization: permission과 object access를 분리한다
AuthorizationPort는 principal의 raw role/permission을 기준으로 “이 종류의 작업을 수행할 수 있는가”를 판정하는 framework-free PEP다. AuthorizationPrincipal은 role set을 defensive copy + unmodifiable로 만들고 null roles는 empty set으로 정규화한다. AuthorizationDeniedException은 Spring AccessDeniedException 대신 application-owned failure를 사용한다.
object-level access는 별도 ObjectAccessPolicy가 담당한다. 같은 permission을 가진 사용자라도 ownership, membership, workflow state에 따라 특정 object 접근 결과가 달라질 수 있기 때문이다. ObjectAccessDecision은 denial에 stable code를 요구하고 hideExistence를 별도 boolean으로 보존해 transport가 403/404 disclosure 정책을 추측하지 않게 한다.
Historical evidence. ObjectAccessPolicyTest에는 이 계약이 과거 inbound GraphQL adapter에 있었고 GraphQL request context를 signature에 포함해 application-core가 구현하려면 transport에 역의존해야 했던 문제가 기록돼 있다. 현재 regression test는 policy/request/decision signature에 dev.caskeleton.adapter.* 타입이 다시 등장하면 실패한다. 이 프로젝트에서 “여러 호출자가 공유해야 하는 계약을 inbound adapter가 소유하면 Core가 Adapter에 의존하게 된다”는 문제가 실제로 있었던 근거다.
decideAll()의 default는 요청 순서를 보존하지만 object마다 decide()를 호출한다. set-based authorization을 제공하는 구현체가 override하지 않으면 batched loading 안에서 authorization N+1을 다시 만들 수 있다는 제한도 계약에 명시돼 있다.
4. transaction: framework vocabulary 대신 application semantic policy
TransactionPort는 inWrite, inRootWrite, inRead, inNew 네 개의 framework-neutral boundary를 노출한다. PolicyTransactionPort는 기존 surface를 깨지 않고 TransactionRequest -> TransactionResult 정책 기반 API를 추가한다.
TransactionPolicyId는 Spring propagation 숫자가 아니라 COMMAND_DEFAULT, COMMAND_SERIALIZABLE_REPLAY_SAFE, QUERY_PRIMARY, QUERY_REPLICA_ELIGIBLE, OUTBOX_APPEND, INBOX_AND_HANDLER, MAINTENANCE_NEW처럼 application semantic ID를 노출한다. TransactionRequest constructor는 read policy의 consistency allowlist, non-read의 readConsistency 금지, operationId-required policy의 stable id 존재를 fail-fast한다.
TransactionResult는 commit 결과를 다섯 상태로 분리한다.
Committed: physical commit을 확인한 결과.Participating: outer transaction에 참여했지만 아직 commit을 주장할 수 없는 결과.DeterminateRollback: rollback이 확정된 실패.Indeterminate: commit 여부를 확정할 수 없는 결과.CommittedWithPostCommitFailure: commit은 됐지만 이후 operational cleanup이 실패한 결과.
이 algebra의 핵심은 “exception이 발생했다 = rollback”으로 단순화하지 않는 것이다. 특히 Indeterminate는 last observed transaction phase와 optional reconciliation reference를 보존하며, CompletionResolution은 STILL_UNKNOWN을 정식 상태로 둔다. 불확실한 commit을 임의로 NOT_COMMITTED로 가정해 use case를 재실행하는 것을 피한다.
OperationId는 caller-owned opaque identity이며 1128 printable non-whitespace ASCII로 제한된다. 256으로 bounded/sanitized된다.ReconciliationReference도 1
Historical evidence. TransactionCompletionResolver는 과거 JPA transaction engine 옆에 있었지만 실제 commit 증거(고유 제약, business row, idempotency row, outbox row)를 해석하는 주체는 application/domain이어서 dependency direction이 뒤집히는 문제가 있었다. 현재 SPI는 application-core에 있고 adapter가 이 계약에 의존한다. resolver는 evidence read만 해야 하며 original use case 재실행을 금지한다.
IrreversibleSideEffectContext 역시 persistence 쪽에서 application-core로 이동했다. use case가 email/payment/broker/object-storage 같은 rollback 불가능한 effect 직전에 mark()해야 retry coordinator가 해당 attempt를 재실행하지 않을 수 있기 때문이다. ThreadLocal marker는 in-transaction external I/O를 권장하는 장치가 아니라 아직 제거되지 못한 side effect에서 unsafe retry를 차단하는 fence다.
4.1 Spring/JPA 구현까지 추적한 결과
SpringTransactionPort는 PolicyTransactionPort를 구현한다. write/read/root-write는 REQUIRED, independent write는 REQUIRES_NEW이며 모든 legacy template은 provider default가 아니라 READ_COMMITTED를 명시적으로 pin한다. read template만 read-only다.
inRootWrite는 TransactionSynchronizationManager.isActualTransactionActive()를 transaction manager나 action 호출 전에 검사한다. focused adapter test는 ambient transaction이 있으면 action도 transaction manager도 호출되지 않음을 증명한다. 또 action 반환값은 physical commit 이후에만 caller에게 반환되고 commit failure 시 caller-visible result가 publish되지 않음을 검증한다.
Isolation enum에는 READ_COMMITTED/REPEATABLE_READ/SERIALIZABLE이 있지만 IsolationTest는 stricter level routing이 아직 planned라고 명시한다. 따라서 enum vocabulary가 존재한다는 사실을 “현재 legacy TransactionPort에서 세 isolation을 선택할 수 있다”로 확대 해석하면 안 된다.
5. idempotency, inbox, outbox: uncertainty를 상태로 보존한다
5.1 idempotency
초기 contract는 scope + request fingerprint로 claim/replay를 제공하고, same key/different fingerprint를 conflict로 분리한다. completed result는 replay하고 in-flight는 bounded poll한다. 이 버전은 “DB operation의 효과가 이미 발생했지만 응답만 잃은 상태”를 충분히 표현하지 못한다.
V2는 owner-safe CAS handle에 scope/token/attempt/revision/claimOperationId를 넣고 stale owner mutation을 거부한다. processing-start를 durable하게 확인하기 전에는 body를 실행하지 않으며 claim/start/completion의 unknown result는 inspect/reconcile 대상으로 남긴다. processing start 이후 ordinary RuntimeException은 효과가 없다고 증명할 수 없으므로 EFFECT_UNKNOWN_ABANDONED 쪽으로 분류되고 자동 replay 권한을 주지 않는다. 명시적인 RetryableNoEffect만 안전 재시도 근거로 취급한다.
scope digest는 versioned keyed digest + operation code로 정규화되고 raw identity는 외부 surface에서 제거된다. lease/replay TTL과 owner token grammar도 bounded다.
Historical evidence. V2 contract가 인접한 package에 중복 복제돼 구현체들이 서로 다른 nominal type을 참조한 문제가 있었고, singular contract를 유지하는 regression test가 존재한다.
5.2 inbox
Inbox contract는 same-store 처리와 owner-safe receive/process state를 모델링한다. RECEIVED -> PROCESSING -> COMPLETED/RETRYABLE/DEAD 상태를 가지고 ACK는 handler transaction commit 이후에만 가능하다. expired owner가 늦게 결과를 기록하는 것을 owner token/attempt/revision/operation identity로 막는다. acquire/processing uncertainty 역시 provider-neutral typed outcome으로 보존한다.
5.3 outbox
Outbox append는 caller write transaction에 참여하고 broker publish는 transaction 밖에서 수행한다. claim/status transition만 짧은 transaction으로 분리한다. publish outcome은 accepted/ambiguous/rejected/exception을 구분한다.
payload validator는 append boundary에서 poison event를 미리 차단한다. legacy JSON payload는 256 KiB, depth 64 등 bounded parser contract를 갖고 control/trailing content를 거부한다. 과거 relay에서야 invalid payload를 발견해 batch를 독성화하던 문제가 이 boundary 이동의 근거다.
accepted 후 markPublished가 실패하면 row는 IN_FLIGHT에 남고 lease expiry 뒤 reclaim되어 중복 publish가 실제로 가능하다. 테스트는 이 duplicate window를 숨기지 않고 증명한다. 따라서 이 outbox는 exactly-once가 아니라 at-least-once + downstream dedupe 모델이다.
V2는 immutable event version/ordinal, DB-authoritative receipt retention/publication epoch/dispatch authority를 추가한다. claim batch와 lease가 bounded되고 owner-safe CAS가 사용된다.
6. durable operation: process-local future 대신 durable state machine
DurableOperation은 PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/EXPIRED를 저장 가능한 state로 모델링한다. RUNNING은 lease가 필수이고 terminal state는 completion timestamp가 필수이며 terminal state가 lease를 유지할 수 없다. SUCCEEDED는 result reference, FAILED는 failure가 필수다.
DurableOperationStorePort의 running-state mutation은 worker identity를 받아 stale worker가 takeover 이후 result/progress를 기록하지 못하게 한다. expired lease는 reclaim되어 PENDING으로 돌아가고 TTL이 지난 nonterminal record는 EXPIRED 처리된다.
SubmitDurableOperationUseCase는 operation row와 outbox publish intent를 같은 inWrite transaction 안에 기록한다. broker를 직접 호출하지 않는다. 테스트는 outbox write failure 시 operation row도 rollback되어 둘 중 하나만 남는 상태를 막는 것을 검증하고, identical resubmission은 기존 operation을 반환하며 두 번째 outbox row를 만들지 않는 것을 검증한다.
7. cache, lease, lock: 동시성 완화와 correctness authority를 구분한다
7.1 cache
CacheAsideExecutor는 fresh/negative hit, hard miss, stale, incompatible schema, provider unavailable을 명시적으로 구분한다. source load에는 key-local single-flight와 global source bulkhead를 함께 적용한다. in-flight key 수, waiter 수, source concurrency, admission wait, load deadline이 모두 bounded다.
stale value는 hard expiry 이전이며 classified transient failure일 때만 fallback될 수 있다. permanent failure에는 stale을 반환하지 않는다. source load 중 invalidation이 발생하면 lookup 때 캡처한 CacheWriteCondition이 더 이상 일치하지 않아 이전 source result의 refill을 거부한다. 이는 invalidate 직후 늦게 끝난 source load가 stale value를 resurrect하는 race를 막는다.
optional distributed refresh coordination은 soft lease로 한 pod만 refresh하도록 하지만 correctness lock은 아니다. owner는 lease 획득 후 cache를 재확인해 다른 pod가 이미 fill했다면 source를 호출하지 않는다. claim 결과가 indeterminate이면 동일 attempt token으로 한 번만 재시도한다. contender는 stale이 아직 valid하면 즉시 stale을 반환할 수 있다.
CacheSingleFlight는 waiter timeout/interruption을 보존하고 완료된 flight를 제거한다. leader가 영원히 남아 key bound를 점유하지 않도록 monotonic deadline 이후 abandoned flight를 opportunistic reap한다.
7.2 distributed lease
V2 DistributedLeasePort는 caller가 provider send 전에 owner/operation token을 생성하고 acquire retry/inspection에서 동일 attempt를 유지하게 한다. response-loss uncertainty를 Indeterminate로 별도 표현한다. resource는 raw key가 아니라 versioned lowercase SHA-256 digest를 사용하고 toString은 token/digest를 redaction한다.
LeaseGuarantee는 명시적으로 EFFICIENCY_ONLY 하나다. 즉 generic lease는 duplicate work를 줄일 뿐 correctness-sensitive write를 authorize할 수 없다. LeaseWatchdog도 renewal failure/unknown이면 work cancellation과 loss signal을 한 번만 발생시키는 bounded scheduler일 뿐 process pause/Redis failover를 correctness guarantee로 감추지 않는다.
7.3 distributed lock
DistributedLockPort도 문서상 efficiency lock이다. finite wait와 crash-safety TTL을 갖지만 DB constraint 같은 correctness authority를 대체하지 않는다. lock release는 protected transaction commit 이후에 수행해야 한다. timeout은 shared OperationalError.LOCK_ACQUISITION_TIMEOUT으로 매핑 가능한 application exception으로 표현된다.
8. messaging과 realtime은 provider/transport vocabulary를 밖으로 밀어낸다
messaging application contract catalog는 contract id, logical destination, schema resource, ordering, payload/envelope bounds, sensitivity, retry/requeue horizon 등 semantic 정보만 가진다. Kafka topic/provider runtime type은 public contract에 없다. validated integration event는 partition key, schema/content hash, catalog/binding revision 같은 immutable evidence를 보존한다.
strict messagingApplicationContractQualificationTest는 normal test source set의 세 required class를 no-skip 조건으로 실행한다. 처음 digest property 없이 실행했을 때 prepareMessagingContractEvidence가 fail-closed로 거부했다. current source/archive, current application-core JAR, exact profile file의 SHA-256을 공급한 재실행에서는 15 tests, 0 skipped, BUILD SUCCESSFUL이었다. 즉 qualification은 단순 테스트 이름이 아니라 evidence provenance property까지 요구한다.
realtime contract는 durable fanout과 ephemeral fanout을 분리한다. durable은 accepted와 delivered를 동일시하지 않고 stream+position dedupe/replay를 모델링한다. stale cursor는 resnapshot 요구로 분리된다. presence는 non-authoritative이며 TTL/heartbeat failure 시 empty로 degrade할 뿐 security 판단에 사용하지 않는다. logical channel은 WebSocket/STOMP 같은 transport 명칭을 소유하지 않는다.
9. storage/file publication: legacy 경로와 semantic 경로가 공존한다
application.storage.ObjectStoragePort는 raw object key/whole-byte 방식의 legacy contract이며 forRemoval 표시가 있지만 실제 production consumer가 남아 있다. sample poster upload, adapter/config, characterization test에서 사용되므로 dead code로 분류할 수 없다. 제거 시점은 날짜가 아니라 실제 migration/zero usage로 판단하도록 문서화돼 있다.
fileexport 역시 raw filesystem path를 반환하는 opt-in legacy capability이며 FilesystemCsvExportAdapter/configuration을 통해 조건부 활성화된다.
반대로 filepublication은 logical destination, operation/reference/version, schema, row streaming/checkpoint, durability semantic을 provider-neutral 계약으로 만든다. CSV formula injection(=, +, -, @, tab, CR)을 reject하는 정책이 테스트로 고정돼 있고, raw Path/SFTP/fileserver 타입이 receipt surface에 나오지 않는다.
10. objectstorage: staged lifecycle, opaque identity, privilege separation
semantic objectstorage API는 provider/filesystem type을 노출하지 않는다. object identity/reference는 prefix + check digit를 포함한 opaque routed representation이고 redacted rendering을 제공한다. tampered/cross-prefix reference를 거부한다.
content I/O는 bounded pull/push callback context와 budget/cancellation/chunk contract를 사용하며 callback lifetime 밖에서 context를 재사용할 수 없다. zero-progress가 무한 loop로 이어지지 않도록 bounded 후 실패한다.
lifecycle은 staged -> verified -> published를 분리한다. scanner verdict는 exact stage/version/operation/policy revision에 결합되고 publish/cleanup mutation은 exact-version/fencing을 요구한다. scanner 권한과 purge 권한은 분리돼 검증 주체가 임의 삭제까지 할 수 없게 한다.
transient bearer grant는 URI/header를 redaction하고 TTL은 최대 24시간으로 제한한다. multipart part count는 1..10000이고 completion은 expected content identity를 요구한다. FullContentIdentity는 SHA-256 기반으로 ETag를 content identity로 오인하지 않는다.
11. fileserver: DB metadata와 physical content 사이의 실패 seam을 명시한다
fileserver는 application-core 안의 가장 큰 독립 orchestration 중 하나다. 핵심 contract는 “metadata transaction과 filesystem/object I/O가 원자적이지 않다”는 사실을 숨기지 않고 recovery model을 두는 것이다.
11.1 upload/write fencing
upload admission은 authorization을 quota/storage admission보다 먼저 수행해 denial이 side-effect-free이도록 한다. reservation metadata/session은 DB transaction에서 만들지만 staging physical object는 외부 작업이므로 실패 시 compensation/reconciliation 대상이 된다.
writer는 one-writer lease + fencing token을 사용한다. stale token은 append/finalize를 진행할 수 없고 takeover는 새 token을 만든다. append 시 metadata offset과 physical length가 다르면 자동 repair하지 않고 conflict로 중단한다.
finalize는 declared length, server-computed digest, optional client digest를 순서대로 검사한다. client digest는 server digest를 대체하지 않는다. 이후 VERIFYING으로 이동하고 verifier가 publish 승인해야 READY가 된다. READY가 유일한 public/downloadable state다.
publish physical success 뒤 READY metadata transaction이 실패하면 결과는 단순 retryable failure가 아니라 AmbiguousCompletionException과 recovery queue로 간다. physical publish가 이미 발생했을 수 있기 때문이다.
11.2 cleanup/recovery
cancel/cleanup race를 막기 위해 cleanup claim은 writer/cleaner barrier를 형성하며 stale cleanup claim을 reclaim하는 경로가 실제로 호출된다. physical delete 전에는 terminal state, lease, exact size/digest/metadata key를 재확인한다.
orphan reconciliation은 reference race를 다시 검사하고 retire/quarantine 후 physical purge를 분리한다. recovery 결과는 CONFIRMED_SUCCESS, NOT_APPLIED, RECOVERABLE_PARTIAL, QUARANTINE_REQUIRED, UNRESOLVED 등으로 unknown을 추측하지 않는다. READY인데 physical content가 없거나 digest가 불일치하면 정상으로 가장하지 않고 quarantine한다.
11.3 download/security/HTTP semantics
download authorization은 physical open보다 먼저 수행된다. HTTP precondition ordering을 명시하고 range 수를 최대 8개로 제한하며 overlap을 merge한다. malformed/unsatisfiable range는 typed 416 path로 분리되고 HEAD는 body를 열지 않는다.
stored-XSS 위험이 있는 HTML/SVG/XHTML/JavaScript 계열은 attachment로 강제하며 filename sanitizer는 path/control/bidi/Windows reserved-name을 처리한다. observability는 raw file/path/user id 대신 HMAC fingerprint와 low-cardinality dimensions를 사용한다.
12. notification: logical acceptance, provider uncertainty, callback reconciliation
notification은 application-core production 415개로 가장 큰 package다. public API, contact protection, routing/template, dispatch, callback, admin/operator plane까지 application-level semantics를 소유한다.
12.1 public API와 secret boundary
public contract는 arbitrary Object/Map<String,Object>를 허용하지 않고 sealed NotificationVariable algebra를 사용한다. 과거 mutable/arbitrary variable 때문에 serialization/fingerprint drift와 toString collision이 가능했던 것이 변경 근거다. structural test는 public API에 arbitrary Object가 다시 들어오지 않는지 검사하며, 과거 잘못된 test root로 vacuous pass했던 문제도 regression guard로 남아 있다.
NotificationPlan은 exact template version을 pin한다. recipient/metadata/variable count/depth가 bounded되어 있고 receipt는 “durable logical acceptance”이지 provider delivery를 의미하지 않는다.
contact point는 encrypted value + keyed fingerprint로 분리되고 protected contact rendering은 원문을 노출하지 않는다. template variable 자체에 reset token 같은 secret이 들어갈 수 있어 payload protection이 존재하며 decrypt 실패를 빈 값으로 degrade하지 않는다. contact lookup/provider request/callback fingerprint는 HMAC purpose를 분리해 동일 secret-purpose reuse를 피한다.
12.2 routing과 dispatch
routing은 explicit 또는 ordered fallback이며 parallel-first-success가 없다. 두 provider를 동시에 호출하면 irreversible duplicate를 만들 수 있기 때문이다. fallback도 앞 attempt가 ambiguous이면 차단된다.
NotificationDispatchService는 pre-call attempt state를 짧은 transaction에서 commit한 뒤 provider를 transaction 밖에서 호출하고, 결과를 두 번째 transaction에서 finalize한다. irreversible call 직전 lease ownership을 다시 검사하고 post-call transition은 lease generation으로 fencing한다.
provider response는 accepted/rejected/ambiguous를 구분한다. classified “provider call not started” failure는 NOT_SUBMITTED로 판단할 수 있지만 unclassified runtime exception은 effect가 발생했을 가능성을 버리지 않고 ambiguous로 간다. cancellation도 future attempt를 막을 뿐 provider 쪽 이미 발생한 effect를 undo했다고 주장하지 않는다.
canonical plan encoding은 versioned/length-framed 형식이고 map을 sort하며 fallback order까지 fingerprint semantics에 포함한다. 과거 canonical string을 JSON codec으로 다시 decode해 모든 dispatch가 실패하던 문제 때문에 writer와 dispatcher가 동일 canonical codec을 공유하도록 고정됐다.
12.3 callback/receipt
provider callback은 signature 검증을 위해 raw bytes를 보존하고 normalized headers를 제공하며 rendering은 body를 redaction한다. callback append와 projection 사이의 atomicity bug 때문에 durable callback write 후 projector 예외가 broker redelivery를 만들던 과거 경로가 제거되고 batch append contract가 명확해졌다.
provider request id가 callback보다 늦게 알려지는 경우를 위해 unmatched callback을 이후 attempt에 연결하는 late-match 경로가 있다. synthetic provider event fingerprint도 과거 사실상 attempt UUID만 반영해 event type이 달라도 dedupe될 수 있던 문제를 length-framed SHA-256 semantics로 교정했다.
12.4 확인된 P1 contract/implementation drift: admin atomic claim 미사용
Observed defect. AdminOperationStorePort.claim()의 javadoc은 과거 admin 경로가 find -> destructive side effect -> save여서 같은 operation id를 동시에 제시한 두 요청이 모두 “not found”를 보고 redrive를 두 번 실행할 수 있었음을 명시한다. 이를 막기 위해 command fingerprint를 포함한 atomic claim-before-effect 계약이 추가됐고 JpaAdminOperationStore도 DB-level claimOperation(...)을 구현한다.
그러나 현재 NotificationAdminApplicationService는 redrive/reconcile/suppress/provider-state 작업에서 여전히 operations.findByOperationId(...)를 먼저 읽고 side effect 이후 operations.save(...)한다. application-core notification production/test에서 operations.claim(...) 호출은 발견되지 않았다. 즉 저장소와 port에는 race fix가 구현돼 있지만 application service가 그 경로를 사용하지 않는다.
이것은 단순 미사용 API가 아니라 계약이 설명하는 동일 race가 service path에서 다시 열려 있는 drift다. 특히 concurrent same-operation-id redrive나 provider state/suppression에서 destructive/operator action이 중복 실행될 수 있다. 현재 application-core에는 이 race를 재현하는 admin concurrency test도 없다.
검증/수정 후보는 명확하다. service가 side effect 전에 command semantic fingerprint로 claim()하고, CLAIMED만 실행하며 replay/conflict/in-progress를 typed result로 반환하도록 바꾼 뒤 두 concurrent caller가 같은 operation id로 들어와도 side effect count가 정확히 1인지 regression test로 고정해야 한다. 이 분석에서는 source를 수정하지 않았다.
12.5 P2 hardening: derived idempotency key의 32-bit hash
AcceptNotificationApplicationUseCase.derivedKey()는 caller key가 없을 때 Integer.toHexString(Objects.hash(...))로 recipient/channel/template/version/variables를 축약한다. 이는 32-bit Java hash이므로 javadoc의 “서로 다른 요청은 collapse하지 않는다”는 표현을 수학적으로 보장하지 못한다.
다만 downstream submission logic은 동일 idempotency key의 canonical request fingerprint가 다르면 conflict로 분리하므로, 관찰된 구조상 collision의 주된 영향은 다른 요청이 조용히 같은 delivery로 합쳐지는 것보다 false idempotency conflict/availability failure에 가깝다. 따라서 P1 data corruption으로 확대하지 않고 P2 hardening으로 기록한다. canonical plan에 대한 cryptographic/keyed digest 또는 caller-supplied key를 우선하는 방향이 더 강한 계약이다.
13. 실제 production reachability와 legacy/dead-path 판정
static production reference scan에서 주요 application package는 모두 외부 production consumer를 확인했다.
| package | application-core 밖 production reference file 수 |
|---|---|
| notification | 156 |
| objectstorage | 96 |
| fileserver | 91 |
| transaction | 35 |
| idempotency | 30 |
| usecase | 23 |
| security | 22 |
| filepublication | 16 |
| storage | 16 |
| outbox | 15 |
| messaging | 14 |
| realtime | 12 |
| outbound | 11 |
| observability | 10 |
| lock | 4 |
| operation | 4 |
| cache | 2 |
| fileexport | 2 |
| lease | 2 |
| inbox | 1 |
이 count는 “모든 type이 각각 호출된다”는 의미가 아니라 package-level runtime/repository reachability의 evidence다. 세부 파일은 evidence/raw/013-application-core-reachability.txt에 보존했다.
legacy surface도 무조건 dead로 분류하지 않았다. application.storage.ObjectStoragePort, root notification NotificationPort, NotificationVariablesCodecPort, old idempotency-related exception 등은 adapter/config/characterization path에서 실제 reference가 남아 있다. 현재 상태는 dead code가 아니라 migration/compatibility surface다.
반대로 notification admin atomic claim()은 adapter 구현까지 존재하지만 application service consumer가 없는 unwired corrective path로 판정했다. 이것이 이번 scope의 가장 중요한 reachability finding이다.
14. 테스트 및 build-time verification
현재 snapshot에서 다음을 fresh 실행했다.
-
./gradlew :application-core:test --rerun-tasks
결과: BUILD SUCCESSFUL, 14 tasks executed. application-core의 136 test source가 포함된 normal lane을 fresh 실행했다. -
:application-core:messagingApplicationContractQualificationTest --rerun-tasks
첫 실행: digest properties 미제공으로prepareMessagingContractEvidence가 fail-closed.
재실행: source=git archive HEADSHA-256, current application-core JAR SHA-256, exact profile bytes SHA-256을 명시.
결과: 15 tests, 0 skipped, BUILD SUCCESSFUL. -
./gradlew :app-bootstrap:test --tests dev.caskeleton.bootstrap.architecture.CleanArchitectureTest --rerun-tasks
결과: BUILD SUCCESSFUL, 98 actionable tasks executed. capability/transaction/repository/security dependency fitness rules를 fresh 실행했다. -
./gradlew verifyCleanArchitectureDependencies
결과: BUILD SUCCESSFUL. module-registry allowlist와 실제 project dependency edge 검증이 통과했다.
qualification에 사용한 source digest는 e52b60c97a9496a0b18dc03f9232c295da37de7c0f56e692d000660eef0e370a, artifact digest는 6fd061854ad9a8631b65f5940f2a0792241d688aa4c11efcf392662109965942, profile hash는 7ac987233951a4d0427ac9e731a20df3a1c4aa1c15f30a5ed2b1e6c280b72afd였다. 이 값은 release-wide provenance를 주장하기 위한 것이 아니라 이 분석에서 실행한 application-core qualification 입력을 재현하기 위한 evidence다.
15. 주요 역사적 회귀 근거
현재 코드 형태의 이유를 source가 직접 설명하는 사례가 여러 개 확인됐다.
| 현재 형태 | source가 기록한 과거 문제 |
|---|---|
ObjectAccessPolicy가 application-core 소유 |
GraphQL adapter-owned contract가 transport 역의존을 만들었음 |
TransactionCompletionResolver가 application-core 소유 |
JPA 옆 SPI를 domain이 구현하려면 adapter에 역의존해야 했음 |
IrreversibleSideEffectContext가 application-core 소유 |
use case가 persistence adapter를 import해야 marker를 호출할 수 있었음 |
| outbox payload append-boundary validation | relay에서 poison payload를 늦게 발견해 batch를 망가뜨릴 수 있었음 |
| V2 idempotency singular contract | 인접 duplicate nominal contract가 서로 다른 구현 타입을 만들었음 |
| fileserver fenced writer/cleanup/recovery | cancel/cleanup/writer race와 ambiguous physical/metadata seam |
| notification canonical codec | canonical payload를 JSON codec으로 읽어 dispatch가 실패하던 path |
| notification owner+generation fencing | expired worker가 renewal/write를 계속할 수 있던 race |
| callback append/project separation | durable append 뒤 projector failure가 redelivery를 유발하던 atomicity 문제 |
admin claim() 계약 |
find-before-side-effect race로 동일 destructive operation이 동시에 두 번 실행될 수 있었음 |
따라서 application-core의 복잡성 상당 부분은 단순 추상화 선호가 아니라 failure/uncertainty/concurrency를 provider 구현보다 안쪽의 semantic contract로 끌어올린 결과로 관찰된다.
16. Findings / improvement backlog
P1 — notification admin atomic claim contract가 service에서 사용되지 않음
- Fact:
AdminOperationStorePort.claim()과JpaAdminOperationStore.claim()은 존재하지만NotificationAdminApplicationService는 redrive/reconcile/suppress/provider-state에서findByOperationId -> side effect -> save를 사용한다. - Why it matters: 동일 operation id의 concurrent 요청이 둘 다 side effect를 실행할 수 있으며, 이는 claim javadoc이 명시한 과거 race와 동일하다.
- Verification: 동일 operation id/command를 barrier로 동시에 호출하고 destructive action invocation count가 1인지 검증하는 concurrency regression test.
- Candidate direction: service가 command fingerprint를 계산해 atomic claim을 먼저 수행하고 claimed/replay/conflict/in-progress를 분기.
- Tech-Log: CASE + OPEN QUESTION/DECISION 후보.
P2 — notification derived idempotency key가 32-bit hash
- Fact: fallback key는
Integer.toHexString(Objects.hash(...))다. - Why it matters: 서로 다른 request의 collision을 배제할 수 없어 javadoc의 강한 uniqueness 표현과 실제 guarantee가 맞지 않는다. canonical fingerprint 비교가 있으므로 silent convergence보다는 false conflict 위험이 중심이다.
- Verification: known Java hash collision fixture 또는 property search로 distinct canonical request가 같은 derived key를 만들 수 있음을 확인하고 downstream conflict behavior를 고정.
- Candidate direction: canonical plan에 대한 SHA-256/HMAC 계열 digest.
- Tech-Log: OPEN QUESTION/REFERENCE 후보.
P2 — legacy storage/notification compatibility surface의 제거 조건 추적
- Fact: deprecated/legacy 계약이 production wiring에 여전히 사용된다.
- Why it matters: 이름만 보고 dead로 삭제할 수 없고 adapter/runtime migration이 먼저 끝나야 한다.
- Verification: external production reference 0 + characterization replacement + config path removal을 migration gate로 사용.
- Tech-Log: DECISION/REFERENCE 후보.
P3 — isolation vocabulary와 legacy routing capability의 시차
- Fact:
Isolation에는 stricter levels가 있지만 legacyTransactionPorttemplate은 READ_COMMITTED로 고정되고 test도 stricter routing을 planned라고 명시한다. - Why it matters: public vocabulary만 보고 이미 지원되는 capability로 오해할 수 있다.
- Verification: future routing이 추가될 때 use-case policy -> adapter transaction definition test를 함께 추가.
- Tech-Log: OPEN QUESTION 후보.
17. 분석 한계
이 scope의 1,021 source/test 파일은 전부 읽었지만 모든 downstream adapter 내부 구현을 exhaustive 분석한 것은 아니다. application-core 계약의 실제 reachability/중요 semantic을 검증하는 데 필요한 adapter/bootstrap/architecture source만 cross-scope evidence로 추적했다. JPA, Redis, messaging provider, objectstorage/fileserver adapters 자체의 전체 implementation detail은 각 후속 bounded scope에서 다시 exhaustive 분석해야 한다.
CleanArchitectureTest가 통과해도 helper indirect call, reflection/string lookup, AOP self-invocation 같은 static-analysis blind spot은 남는다. 또한 in-memory/fake contract test가 실제 provider failure model 전체를 증명하지는 않는다. 이 한계는 관련 adapter scope에서 real engine/runtime evidence로 보강해야 한다.
18. 완료 판정
application-core는 다음 gate를 충족했다.
- quantified denominator: production 885 + test 136 = 1,021.
- coverage: FULL_READ 1,021 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0.
- 23 top-level package를 모두 account했다.
- build dependency와 shared-contract-only production edge를 확인했다.
- use-case/capability/transaction/security architecture fitness function을 추적했다.
- 주요 transaction/idempotency/cache/lease/outbox/fileserver/notification uncertainty와 failure mechanics를 문서화했다.
- external production reachability와 legacy/unwired path를 검사했다.
- fresh normal tests, strict messaging qualification, architecture tests, dependency verification이 모두 통과했다.
- historical regression 근거와 P1/P2/P3 backlog를 분리했다.
- source revision은 분석 종료 시점까지
a24ece9cf797f7ea647e33bf846b115208ed1ba5이며 source working tree는 변경하지 않았다.
따라서 application-core bounded scope를 COMPLETE로 판정한다. 프로젝트 전체는 아직 후속 adapter/messaging/bootstrap scope가 남아 있으므로 clean-architecture-backend-template 자체는 계속 IN_PROGRESS다.
Source anchors
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 40개 (main 35 · test 2 · 기타 3).
src/application-core/build.gradle
src/config/architecture/modules.json (application-core 항목)
main:
src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java
src/main/java/dev/caskeleton/application/cache/CacheSingleFlight.java
src/main/java/dev/caskeleton/application/cache/CacheWriteCondition.java
src/main/java/dev/caskeleton/application/fileserver/api/error/AmbiguousCompletionException.java
src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java
src/main/java/dev/caskeleton/application/lease/LeaseGuarantee.java
src/main/java/dev/caskeleton/application/lease/LeaseWatchdog.java
src/main/java/dev/caskeleton/application/lock/DistributedLockPort.java
src/main/java/dev/caskeleton/application/notification/NotificationPort.java
src/main/java/dev/caskeleton/application/notification/platform/admin/AdminOperationStorePort.java
src/main/java/dev/caskeleton/application/notification/platform/admin/NotificationAdminApplicationService.java
src/main/java/dev/caskeleton/application/notification/platform/api/NotificationPlan.java
src/main/java/dev/caskeleton/application/notification/platform/api/NotificationVariable.java
src/main/java/dev/caskeleton/application/notification/platform/dispatch/AcceptNotificationApplicationUseCase.java
src/main/java/dev/caskeleton/application/notification/platform/dispatch/NotificationDispatchService.java
src/main/java/dev/caskeleton/application/notification/platform/dispatch/NotificationVariablesCodecPort.java
src/main/java/dev/caskeleton/application/operation/DurableOperation.java
src/main/java/dev/caskeleton/application/operation/DurableOperationStorePort.java
src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java
src/main/java/dev/caskeleton/application/security/AuthorizationDeniedException.java
src/main/java/dev/caskeleton/application/security/AuthorizationPort.java
src/main/java/dev/caskeleton/application/security/AuthorizationPrincipal.java
src/main/java/dev/caskeleton/application/security/ObjectAccessDecision.java
src/main/java/dev/caskeleton/application/security/ObjectAccessPolicy.java
src/main/java/dev/caskeleton/application/transaction/CompletionResolution.java
src/main/java/dev/caskeleton/application/transaction/IrreversibleSideEffectContext.java
src/main/java/dev/caskeleton/application/transaction/Isolation.java
src/main/java/dev/caskeleton/application/transaction/OperationId.java
src/main/java/dev/caskeleton/application/transaction/PolicyTransactionPort.java
src/main/java/dev/caskeleton/application/transaction/ReconciliationReference.java
src/main/java/dev/caskeleton/application/transaction/TransactionCompletionResolver.java
src/main/java/dev/caskeleton/application/transaction/TransactionPolicyId.java
src/main/java/dev/caskeleton/application/transaction/TransactionPort.java
src/main/java/dev/caskeleton/application/transaction/TransactionRequest.java
src/main/java/dev/caskeleton/application/transaction/TransactionResult.java
test:
src/test/java/dev/caskeleton/application/security/ObjectAccessPolicyTest.java
src/test/java/dev/caskeleton/application/transaction/IsolationTest.java
기타:
CLAUDE.md
README.md
src/build.gradle
해석되지 않은 인용 (1종) — 외부 타입·문서상 약칭 등:
evidence/raw/013-application-core-reachability.txt