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>
455 lines
42 KiB
Markdown
455 lines
42 KiB
Markdown
# 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_READ` **1021** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
|
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **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이며 1~128 printable non-whitespace ASCII로 제한된다. `ReconciliationReference`도 1~256으로 bounded/sanitized된다.
|
|
|
|
**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 실행했다.
|
|
|
|
1. `./gradlew :application-core:test --rerun-tasks`
|
|
결과: BUILD SUCCESSFUL, 14 tasks executed. application-core의 136 test source가 포함된 normal lane을 fresh 실행했다.
|
|
|
|
2. `:application-core:messagingApplicationContractQualificationTest --rerun-tasks`
|
|
첫 실행: digest properties 미제공으로 `prepareMessagingContractEvidence`가 fail-closed.
|
|
재실행: source=`git archive HEAD` SHA-256, current application-core JAR SHA-256, exact profile bytes SHA-256을 명시.
|
|
결과: **15 tests, 0 skipped, BUILD SUCCESSFUL**.
|
|
|
|
3. `./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 실행했다.
|
|
|
|
4. `./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가 있지만 legacy `TransactionPort` template은 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
|
|
|
|
```
|