# 포트가 선언한 세 연산 (33 줄 전문)
  AdminOperationStorePort.java:1 package dev.caskeleton.application.notification.platform.admin;
  AdminOperationStorePort.java:2 
  AdminOperationStorePort.java:3 import java.util.Optional;
  AdminOperationStorePort.java:4 
  AdminOperationStorePort.java:5 /** Idempotent record of operator actions, so a retried request cannot redrive twice. */
  AdminOperationStorePort.java:6 public interface AdminOperationStorePort {
  AdminOperationStorePort.java:7 
  AdminOperationStorePort.java:8   /** Claim an operation id, or return the previous result for it. */
  AdminOperationStorePort.java:9   Optional<AdminOperationResult> findByOperationId(String operationId);
  AdminOperationStorePort.java:10 
  AdminOperationStorePort.java:11   /** Store the result of an operation. */
  AdminOperationStorePort.java:12   AdminOperationResult save(AdminOperationResult result, AdminActor actor, String action);
  AdminOperationStorePort.java:13 
  AdminOperationStorePort.java:14   /**
  AdminOperationStorePort.java:15    * Claims an operation id before anything happens, atomically.
  AdminOperationStorePort.java:16    *
  AdminOperationStorePort.java:17    * <p>The admin path was find, then side effect, then save. Two callers presenting the same
  AdminOperationStorePort.java:18    * operation id both read "not found", both ran the redrive, and both saved — the idempotency key
  AdminOperationStorePort.java:19    * was checked but never held, so it prevented a repeat and not a race. A destructive operation
  AdminOperationStorePort.java:20    * run twice concurrently is the exact failure the id exists to stop.
  AdminOperationStorePort.java:21    *
  AdminOperationStorePort.java:22    * <p>The fingerprint is part of the claim: the same id with a different command is not a replay,
  AdminOperationStorePort.java:23    * it is a conflict, and answering it with the earlier result would silently execute neither.
  AdminOperationStorePort.java:24    *
  AdminOperationStorePort.java:25    * @param operationId the caller's idempotency key
  AdminOperationStorePort.java:26    * @param commandFingerprint a digest of the command this id was presented with
  AdminOperationStorePort.java:27    * @param actor who is asking
  AdminOperationStorePort.java:28    * @param action what they are asking for
  AdminOperationStorePort.java:29    * @return whether this caller may proceed, and what the earlier one did if not
  AdminOperationStorePort.java:30    */
  AdminOperationStorePort.java:31   AdminOperationClaim claim(
  AdminOperationStorePort.java:32       String operationId, String commandFingerprint, AdminActor actor, String action);
  AdminOperationStorePort.java:33 }

# claim 이 돌려주는 네 답
  AdminOperationClaim.java:5 
  AdminOperationClaim.java:6 /**
  AdminOperationClaim.java:7  * What an admin operation id claim decided.
  AdminOperationClaim.java:8  *
  AdminOperationClaim.java:9  * <p>Four outcomes, because collapsing them loses the distinction that matters. A replay must
  AdminOperationClaim.java:10  * return the earlier result without acting; an in-progress claim must not act and must not pretend
  AdminOperationClaim.java:11  * the work is done; and the same id with a different command is a mistake worth reporting rather
  AdminOperationClaim.java:12  * than an idempotent repeat.
  AdminOperationClaim.java:13  *
  AdminOperationClaim.java:14  * @param outcome what the caller may do
  AdminOperationClaim.java:15  * @param existing the earlier result, when there is one
  AdminOperationClaim.java:16  */
  AdminOperationClaim.java:17 public record AdminOperationClaim(Outcome outcome, Optional<AdminOperationResult> existing) {
  AdminOperationClaim.java:18 
  AdminOperationClaim.java:19   /** The four answers a claim can give. */
  AdminOperationClaim.java:20   public enum Outcome {
  AdminOperationClaim.java:21 
  AdminOperationClaim.java:22     /** This caller holds the operation and may proceed. */
  AdminOperationClaim.java:23     CLAIMED,
  AdminOperationClaim.java:24 
  AdminOperationClaim.java:25     /** The operation already completed; {@code existing} is what it produced. */
  AdminOperationClaim.java:26     REPLAY,
  AdminOperationClaim.java:27 
  AdminOperationClaim.java:28     /** Another caller holds it right now. Nothing has been decided yet. */
  AdminOperationClaim.java:29     IN_PROGRESS,
  AdminOperationClaim.java:30 
  AdminOperationClaim.java:31     /** The same id was presented with a different command. */
  AdminOperationClaim.java:32     CONFLICT
  AdminOperationClaim.java:33   }
  AdminOperationClaim.java:34 
  AdminOperationClaim.java:35   /** Validates the claim. */

# 그 연산이 생긴 이유 — 마이그레이션 헤더
  V8__notification_admin_claim.sql:1 -- NTF-020 — the admin path was find, then side effect, then save.
  V8__notification_admin_claim.sql:2 --
  V8__notification_admin_claim.sql:3 -- Two callers presenting the same operation id both read "not found", both ran the redrive, and
  V8__notification_admin_claim.sql:4 -- both saved. The unique constraint on operation_id was already there and made the *second save*
  V8__notification_admin_claim.sql:5 -- fail — after the second side effect had already happened. An idempotency key that is checked but
  V8__notification_admin_claim.sql:6 -- never held prevents a repeat and does nothing about a race, which is the case that matters for a
  V8__notification_admin_claim.sql:7 -- destructive operation.
  V8__notification_admin_claim.sql:8 --
  V8__notification_admin_claim.sql:9 -- The claim is now the insert itself: ON CONFLICT DO NOTHING means exactly one caller creates the
  V8__notification_admin_claim.sql:10 -- row, and everyone else reads what that caller is doing.
  V8__notification_admin_claim.sql:11 

# JPA 구현이 무엇을 보내고 무엇으로 판단하는가
  JpaAdminOperationStore.java:30 
  JpaAdminOperationStore.java:31   @Override
  JpaAdminOperationStore.java:32   public dev.caskeleton.application.notification.platform.admin.AdminOperationClaim claim(
  JpaAdminOperationStore.java:33       String operationId, String commandFingerprint, AdminActor actor, String action) {
  JpaAdminOperationStore.java:34     Objects.requireNonNull(operationId, "operationId");
  JpaAdminOperationStore.java:35     Objects.requireNonNull(commandFingerprint, "commandFingerprint");
  JpaAdminOperationStore.java:36     Objects.requireNonNull(actor, "actor");
  JpaAdminOperationStore.java:37     Objects.requireNonNull(action, "action");
  JpaAdminOperationStore.java:38 
  JpaAdminOperationStore.java:39     int claimed =
  JpaAdminOperationStore.java:40         audits.claimOperation(
  JpaAdminOperationStore.java:41             ids.nextId(),
  JpaAdminOperationStore.java:42             operationId,
  JpaAdminOperationStore.java:43             action,
  JpaAdminOperationStore.java:44             actor.actorRef(),
  JpaAdminOperationStore.java:45             commandFingerprint,
  JpaAdminOperationStore.java:46             clock.instant());
  JpaAdminOperationStore.java:47     if (claimed == 1) {
  JpaAdminOperationStore.java:48       return dev.caskeleton.application.notification.platform.admin.AdminOperationClaim.claimed();
  JpaAdminOperationStore.java:49     }
  JpaAdminOperationStore.java:50     // Somebody else has the row. What they are doing decides this caller's answer.
  JpaAdminOperationStore.java:51     List<Object[]> existing = audits.phaseOf(operationId);
  JpaAdminOperationStore.java:52     if (existing.isEmpty()) {
  JpaAdminOperationStore.java:53       // The row vanished between the insert and the read, which only a concurrent delete explains.
  JpaAdminOperationStore.java:54       // Refusing is safer than assuming the operation is free.
  JpaAdminOperationStore.java:55       return dev.caskeleton.application.notification.platform.admin.AdminOperationClaim
  JpaAdminOperationStore.java:56           .inProgress();
  JpaAdminOperationStore.java:57     }
  JpaAdminOperationStore.java:58     String phase = String.valueOf(existing.get(0)[0]);
  JpaAdminOperationStore.java:59     String fingerprint = existing.get(0)[1] == null ? null : String.valueOf(existing.get(0)[1]);
  JpaAdminOperationStore.java:60     if (fingerprint != null && !fingerprint.equals(commandFingerprint)) {
  JpaAdminOperationStore.java:61       // The same id, a different command. Returning the earlier result would execute neither.
  JpaAdminOperationStore.java:62       return dev.caskeleton.application.notification.platform.admin.AdminOperationClaim.conflict();
  JpaAdminOperationStore.java:63     }
  JpaAdminOperationStore.java:64     if ("CLAIMED".equals(phase)) {
  JpaAdminOperationStore.java:65       return dev.caskeleton.application.notification.platform.admin.AdminOperationClaim
  JpaAdminOperationStore.java:66           .inProgress();
  JpaAdminOperationStore.java:67     }
  JpaAdminOperationStore.java:68     return findByOperationId(operationId)
  JpaAdminOperationStore.java:69         .map(dev.caskeleton.application.notification.platform.admin.AdminOperationClaim::replay)
  JpaAdminOperationStore.java:70         .orElseGet(
  JpaAdminOperationStore.java:71             dev.caskeleton.application.notification.platform.admin.AdminOperationClaim::inProgress);

  AdminAuditJpaRepository.java:28    *
  AdminAuditJpaRepository.java:29    * @return 1 when this caller claimed it, 0 when somebody else already had
  AdminAuditJpaRepository.java:30    */
  AdminAuditJpaRepository.java:31   @org.springframework.data.jpa.repository.Modifying
  AdminAuditJpaRepository.java:32   @org.springframework.data.jpa.repository.Query(
  AdminAuditJpaRepository.java:33       value =
  AdminAuditJpaRepository.java:34           "INSERT INTO notification_admin_audit ("
  AdminAuditJpaRepository.java:35               + " id, operation_id, action, actor_ref, attributes, dry_run, occurred_at,"
  AdminAuditJpaRepository.java:36               + " command_fingerprint, phase, claimed_at)"
  AdminAuditJpaRepository.java:37               + " VALUES (:id, :operationId, :action, :actorRef, '{}'::jsonb, false, :now,"
  AdminAuditJpaRepository.java:38               + " :fingerprint, 'CLAIMED', :now)"
  AdminAuditJpaRepository.java:39               + " ON CONFLICT (operation_id) DO NOTHING",
  AdminAuditJpaRepository.java:40       nativeQuery = true)
  AdminAuditJpaRepository.java:41   int claimOperation(
  AdminAuditJpaRepository.java:42       @org.springframework.data.repository.query.Param("id") java.util.UUID id,
  AdminAuditJpaRepository.java:43       @org.springframework.data.repository.query.Param("operationId") String operationId,
  AdminAuditJpaRepository.java:44       @org.springframework.data.repository.query.Param("action") String action,
  AdminAuditJpaRepository.java:45       @org.springframework.data.repository.query.Param("actorRef") String actorRef,

# 서비스가 operations 포트에 거는 호출 전부
  NotificationAdminApplicationService.java:94  Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
  NotificationAdminApplicationService.java:145  return operations.save(result, actor, "ADMIN_REDRIVE");
  NotificationAdminApplicationService.java:154  Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
  NotificationAdminApplicationService.java:194  return operations.save(result, actor, "ADMIN_RECONCILE");
  NotificationAdminApplicationService.java:203  Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
  NotificationAdminApplicationService.java:272  return operations.save(
  NotificationAdminApplicationService.java:282  Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
  NotificationAdminApplicationService.java:328  return operations.save(result, actor, "ADMIN_PROVIDER_STATE");
  operations.claim : 0 줄
  [자기시험] operations.claimZZZ : 0 줄 (0 이어야 정상)
  [대조] operations.save : 4 줄 (0 이면 검색이 깨진 것)
  commandFingerprint 를 계산하는 자리 :
  adapter/outbound/persistence-jpa · main · AdminAuditJpaRepository.java:36  + " command_fingerprint, phase, claimed_at)"
  adapter/outbound/persistence-jpa · main · AdminAuditJpaRepository.java:52  "SELECT phase, command_fingerprint FROM notification_admin_audit"
  adapter/outbound/persistence-jpa · main · JpaAdminOperationStore.java:33  String operationId, String commandFingerprint, AdminActor actor, String action) {
  adapter/outbound/persistence-jpa · main · JpaAdminOperationStore.java:35  Objects.requireNonNull(commandFingerprint, "commandFingerprint");
  adapter/outbound/persistence-jpa · main · JpaAdminOperationStore.java:45  commandFingerprint,
  adapter/outbound/persistence-jpa · main · JpaAdminOperationStore.java:60  if (fingerprint != null && !fingerprint.equals(commandFingerprint)) {
  adapter/outbound/persistence-jpa · postgresqlIntegrationTest · AdminOperationClaimContractTest.java:123  + " command_fingerprint, phase, claimed_at)"
  adapter/outbound/persistence-jpa · postgresqlIntegrationTest · AdminOperationClaimContractTest.java:141  return column(operationId, "command_fingerprint");
  application-core · main · AdminOperationStorePort.java:26  * @param commandFingerprint a digest of the command this id was presented with
  application-core · main · AdminOperationStorePort.java:32  String operationId, String commandFingerprint, AdminActor actor, String action);

# 네 경로가 조회와 저장 사이에 부르는 협력자
  (서비스가 선언한 협력자 필드에서 유도한 패턴 : authorization|duplicateRiskGuard|attempts|recipients|reconciliation|suppressions|runtimes|operations|audit|transactions|clock)
  redrive          (:90~:147)
    :92 authorization.require(actor, NotificationAdminAuthority.REDRIVE);
    :94 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
    :103 authorization.requireTenant(actor, original.tenantId());
    :104 duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
    :117 return transactions.inWrite(
    :121 recipients.transition(
    :124 Optional.of(clock.instant()));
    :135 audit.record(
    :141 clock.instant(),
    :145 return operations.save(result, actor, "ADMIN_REDRIVE");
  reconcile        (:150~:195)
    :152 authorization.require(actor, NotificationAdminAuthority.RECONCILE);
    :154 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
    :172 reconciliation.reconcile(attemptId);
    :186 audit.record(
    :192 clock.instant(),
    :194 return operations.save(result, actor, "ADMIN_RECONCILE");
  suppress         (:198~:275)
    :200 authorization.require(actor, NotificationAdminAuthority.SUPPRESS);
    :201 authorization.requireTenant(actor, command.tenantId());
    :203 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
    :218 return transactions.inWrite(
    :227 command.tenantId(), command.targetFingerprint(), clock.instant())
    :229 .map(entry -> suppressions.remove(command.tenantId(), entry.id()))
    :236 suppressions.upsert(
    :249 clock.instant(),
    :264 audit.record(
    :270 clock.instant(),
    :272 return operations.save(
  setProviderState (:278~:329)
    :280 authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL);
    :282 Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
    :298 runtimes.setState(command.profileId(), command.desiredState(), command.reason());
    :318 audit.record(
    :324 clock.instant(),
    :328 return operations.save(result, actor, "ADMIN_PROVIDER_STATE");

# 그 경합을 고정한 시험이 어느 레인에서 도는가
  파일 : src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/AdminOperationClaimContractTest.java
    AdminOperationClaimContractTest.java:34  @Tag("jpa-contract")
    persistence-jpa/build.gradle:251  'jpaPlatformContractTest',
    persistence-jpa/build.gradle:252  'jpa-contract',
  그 태스크를 부르는 워크플로 :
    jpa-nightly.yml:49  :adapter:outbound:persistence-jpa:jpaPlatformContractTest
    jpa-pr.yml:86  :adapter:outbound:persistence-jpa:jpaPlatformContractTest
    notification-platform.yml:85  run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest --console=plain
    notification-platform.yml:139  run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain
  그 워크플로가 도는 조건 :
    notification-platform.yml:14 on:
    notification-platform.yml:15   pull_request:
    notification-platform.yml:16     paths:
    notification-platform.yml:21       - 'src/application-core/src/**/notification/**'
    notification-platform.yml:22       - 'src/adapter/outbound/notification/**'
    notification-platform.yml:23       - 'src/adapter/outbound/persistence-jpa/src/**/notification/**'
    notification-platform.yml:24       - 'src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/notification-platform/**'
  NotificationAdminApplicationService 를 이름에 가진 시험 파일 : 0 개
