# CloudOps Temporal 적용과 티켓 발행 흐름

> 기준일: 2026-07-31 · 검증: `CODE`, 일부 `E2E-LOCAL`·`TRACE`
> 목적: Temporal이 CloudOps 어디에 적용됐고, Account 등록과 리소스 수집이
> 후처리·티켓까지 어떻게 이어지는지 확인한다.

## 1. 먼저 알아야 할 결론

CloudOps에는 “티켓까지” 도달하는 두 경로가 있다.

```text
경로 A — 리소스 수집

Account 등록 ── credential 확인·저장 ──┐
                                      │ 자동 연결 아님
수동 API 또는 일일 Scheduler ─────────┘
  → Temporal 수집 부모
  → 계정별 plan → collect → upsert → 상태 확정
  → ┬─ Well-Architected 평가 → finding ticket reconcile
    └─ relationship → VPC materialize → mapping


경로 B — CSP event

CSP webhook
  → NATS ingest → source plugin normalize
  → Core finalizer → alert grouping
  → alert state event → alert ticket
```

두 경로를 섞어 읽으면 안 된다.

- Account 등록 성공이 리소스 수집 시작을 뜻하지 않는다.
- 수집 부모 완료가 모든 후처리 완료를 뜻하지 않는다. Well-Architected와 mapping
  child는 부모의 성공 기준에서 분리되어 있다.
- CSP event 경로는 현재 Temporal migration 대상 흐름이 아니라 NATS JetStream의
  at-least-once 이벤트 흐름이다.
- Well-Architected ticket과 alert ticket은 둘 다 ticket 저장소를 사용하지만 생성
  원인과 멱등성 키가 다르다.

### 1.1 Temporal이 적용된 위치

| 적용 지점 | 시작 주체 | 실행 기반 | 결과 |
|---|---|---|---|
| Account credential 확인 | 단건 등록·수정·자격증명 갱신·root 연결 변경, 일괄 검증·등록 | Temporal `CredentialValidationWorkflow` | provider 연결 가능 여부 |
| 수동 리소스 수집 | `POST /resources/collect` | Temporal `ResourceCollectionRun` | 요청 account의 resource 저장 |
| 일일 전체 수집 | 별도 `core-scheduler`, 01:00 KST | APScheduler가 Temporal parent 시작 | active account 전체 수집 |
| 계정 삭제 뒤 관계 정리 | `DeleteCspAccount` | 10초 뒤 Temporal `RelationshipExtractRun` | soft-delete 결과를 반영한 관계 재계산 |
| 계정 삭제 뒤 WA 재평가 | `DeleteCspAccount` | 조건 충족 시 15초 뒤 Temporal `WellArchitectedRun` | 남은 리소스 기준 WA·ticket 재평가 |
| 계정별 plan·collect·upsert | 수집 parent의 account child | Temporal `ResourceCollection` | resource와 account terminal 상태 |
| Relationship·VPC·mapping | collect barrier 이후 | Temporal child Workflow | 관계·VPC·application mapping |
| Well-Architected·finding ticket | collect barrier 이후 | Temporal `WellArchitectedRun` | finding 평가와 ticket reconcile |
| CSP webhook·alert ticket | 외부 CSP event | NATS JetStream consumer chain | alert lifecycle과 alert ticket |

Temporal은 수집·후처리의 **순서와 실행 상태**를 맡는다. CSP event 경로는 독립
event fan-out이 중심이므로 현재도 NATS에 남아 있다. 두 경로는 마지막에 ticket
도메인을 사용하지만 하나의 Workflow로 연결돼 있지는 않다.

### 1.2 실행 프로세스와 연결 관계

```text
Core API / core-scheduler ─┐
Core Temporal Worker ─────┼── gRPC ──> Temporal Service :7233
AWS/Azure/GCP Worker ─────┘                  │
                                            └─ History + Task Queue

CSP webhook → Core/NATS consumers → source plugin → alert → ticket
```

| 프로세스 | 역할 |
|---|---|
| Core API | Account 등록, 수동 수집 요청, CSP webhook 수신 |
| `core-scheduler` | 01:00 KST에 전체 수집 parent 시작. APScheduler·replica 1 전제 |
| Core Temporal Worker | `core-collect`에서 Workflow와 Core DB Activity 실행 |
| Provider Temporal Worker | provider별 collect·plan·heartbeat queue 세 개 실행 |
| Temporal Service | History 보존, retry/timeout, parent-child와 task routing |
| NATS JetStream consumer | webhook normalize, alert grouping, alert ticket 처리 |

## 2. 상태와 표기

### 2.1 검증 표기

| 표기 | 해석 |
|---|---|
| `CODE` | 현재 checkout의 코드 계약을 확인했다. |
| `E2E-LOCAL` | 실제 Core REST·Temporal·DB·ticket 코드를 로컬에서 실행했다. 외부 CSP/KMS/Secrets 경계는 합성 어댑터다. |
| `TRACE` | 실제 Workflow 정의와 Temporal History로 대기·선후 관계를 확인했다. Activity는 stub이다. |
| `OPS-UNKNOWN` | 운영 배포 또는 운영 데이터 없이는 확정할 수 없다. |

### 2.2 Account collection 상태

코드에는 수동 부모 시작 자체가 실패했을 때 쓰는 `failure`와, 계정 child가 실행된
뒤 roll-up하는 `failed`가 모두 존재한다. 이름이 비슷하지만 원인이 다르다.

| 상태 | 의미 |
|---|---|
| `pending` | 수집 요청을 받아 대기 중이다. |
| `running` | plan에 성공했고 실제 collect fan-out에 진입했다. |
| `success` | 실패·경고 task 없이 terminal 처리됐다. 0 task도 현재는 success다. |
| `warning` | ERROR는 없고 WARNING을 가진 task가 하나 이상이다. |
| `failed` | 예외 또는 `summary.errors[].severity=ERROR`인 task가 하나 이상이다. |
| `failure` | 수동 요청에서 Temporal 부모 자체를 시작하지 못해 pending을 되돌린 값이다. |

`last_collected_at`은 `pending`·`running`에서는 건드리지 않고 terminal child
결과를 저장할 때 갱신한다. 부모 start 실패의 `failure`는 실제 수집이 없었으므로
갱신하지 않는다.

---

## 3. Temporal Workflow 시작점 전체 지도

코드에서 `start_workflow`·`execute_workflow`로 Temporal 실행을 만드는 업무
진입점은 다섯 종류다. 이 표는 **어떤 요청이 최상위 Workflow를 새로 만드는지**를
한곳에 모은 지도다. 수집 부모가 내부에서 만드는 account·WA·관계·mapping
Workflow는 이 표의 시작점이 아니라 자식 실행이다.

| 시작점 | 호출 주체 | 시작 방식 | 시작 조건·지연 | 시작 실패가 원 요청에 미치는 영향 |
|---|---|---|---|---|
| 자격증명 검증 | 계정 등록·수정·자격증명 갱신·root 연결 변경, 일괄 검증·등록 | `execute_workflow`로 결과까지 대기 | provider와 credential 입력이 준비됨 | 예외 종류와 관계없이 `DISCONNECTED`로 정규화한다. 등록·수정은 각 유스케이스의 검증 실패로 종료한다. |
| 수동 수집 | `POST /resources/collect` | `start_workflow`로 비동기 시작 | 검증을 통과한 계정이 1개 이상 | 응답 item과 계정 상태를 `failure`로 best-effort 보정한다. |
| 일일 수집 | `core-scheduler`의 `daily-collect` | `start_workflow`로 비동기 시작 | 매일 01:00 KST, 빈 입력 `{}` | 스케줄러 작업 실패 로그를 남긴다. 다음 cron 전 업무 재시도 계약은 없다. |
| 계정 삭제 뒤 관계 정리 | `DeleteCspAccount` | `start_workflow`, `start_delay=10s` | `GENERAL` 계정 삭제 | broad `except Exception`으로 흡수한다. 트리거 실패가 이미 수행한 계정 삭제를 500으로 되돌리지 않는다. |
| 계정 삭제 뒤 WA 재평가 | `DeleteCspAccount` | `start_workflow`, `start_delay=15s` | `GENERAL`이고 `deleted_resource_count > 0` | broad `except Exception`으로 흡수한다. 삭제할 리소스가 없으면 시작하지 않는다. |

### 3.1 삭제 뒤 10초·15초를 기다리는 이유

계정과 리소스의 cascade soft-delete는 REST 요청 transaction 안에서 수행되고
commit은 요청 종료 시점에 일어난다. Workflow를 즉시 시작하면 Worker가 commit
전에 DB를 읽어 삭제한 리소스를 아직 살아 있는 것으로 볼 수 있다.

```text
삭제 transaction
  account·resource soft-delete
  → Temporal 시작 요청 예약
  → REST transaction commit
       ├─ 10초 뒤 relationship 재계산
       └─ 15초 뒤 WA 재평가
```

따라서 delay는 처리 순서를 느슨하게 미루는 임의 대기가 아니라 **DB commit
경계를 건너기 위한 race 완화 장치**다. 다만 시간 기반 완화이므로 DB commit
완료를 직접 확인하는 신호보다 강한 보장은 아니다.

두 starter는 삭제의 후속 정리다. 후속 실행을 시작하지 못했다는 이유로 계정 삭제
transaction을 rollback하면 사용자는 삭제 실패로 보지만 실제 원인은 Temporal
가용성일 수 있다. 그래서 시작 예외를 흡수한다. 반대로 이 설계는 누락된 후처리를
자동 복구할 중앙 원장이 필요하다는 뜻이기도 하다.

### 3.2 최상위 실행 원장과 현재 공백

Core Worker의 History interceptor는 최상위 Workflow에만 보조 원장을 남긴다.

| 시점 | DB 기록 | 실패했을 때 |
|---|---|---|
| Workflow 본문 실행 전 | `workflow_run` UPSERT, `status=running` | 최대 3회 시도 뒤에도 실패하면 업무 Workflow는 계속 실행한다. |
| Workflow 성공·실패 확정 뒤 | 같은 `workflow_run`을 `success` 또는 `failed`로 UPDATE하고 종료 시각·오류 저장 | 최대 3회 시도 뒤에도 실패하면 업무 결과에는 영향을 주지 않는다. |

따라서 위 다섯 시작점으로 만든 최상위 실행은 원장 Activity가 성공한 경우
`workflow_run` 한 행으로 추적된다. 반면 수집 부모가 만든 account·WA·관계·mapping
자식은 interceptor 대상이 아니어서 각각의 행을 만들지 않는다.

- `workflow_run_item` 저장소는 있지만 현재 실행 경로에서 쓰는 코드가 없다.
- `ReconcileWorkflowRunsWorkflow`는 Worker에 등록돼 있지만 시작하거나 예약하는
  코드가 확인되지 않았다.
- 실행 이력 REST router는 등록돼 있지만 공개 endpoint가 0개다. 현재는 Temporal
  UI와 DB 직접 조회가 실질적인 확인 경로다.

원장은 fail-open이므로 `workflow_run` 부재가 업무 미실행을 뜻하지 않고, 반대로
업무 성공이 원장 terminal 상태 기록을 보장하지도 않는다.

---

## 4. Account 등록: 수집 전제조건이지 수집 시작은 아니다

### 4.1 한눈에 보는 순서

```text
POST /csp-accounts
  A1 요청·중복·root/customer 경계 검증
  A2 Temporal CredentialValidationWorkflow 실행
  A3 provider heartbeat Activity 실행
  A4 account row INSERT
  A5 raw credential을 Secret Manager에 저장
  A6 credential_ref=secret_id, credential_status=valid 갱신
  ── 응답 종료 ──

  여기서 ResourceCollectionRun은 시작하지 않는다.
```

그림은 최초 등록을 대표로 보여 주지만 A2–A3의 같은
`CredentialValidationWorkflow`는 계정 수정, 자격증명 갱신, root 연결 변경,
일괄 검증·등록에서도 재사용된다. 주입 지점은 단건 경로 4곳과 일괄 경로
2곳이다. 즉 자격증명 검증은 “등록 전용 Workflow”가 아니라 **credential이
바뀌거나 연결 가능성을 다시 확인하는 공통 경계**다.

### 4.2 단계별 계약

#### A1. 등록 요청과 사전 가드

- **실행 주체:** Core REST → `CreateCspAccount`
- **입력:** customer, provider, account type, cloud account ID, credential
- **다음 단계 조건:** 인증 customer가 있고 alias·중복·delegated root/customer
  검증을 모두 통과한다.
- **실패 동작:** 4xx 정책 오류로 종료한다. Temporal·DB·Secret Manager에는 아직
  쓰지 않는다.
- **이 순서인 이유:** 값싼 정책 검증에서 먼저 차단하고 타 customer root
  credential 접근과 불필요한 외부 호출을 막는다.
- **근거:** `CODE` —
  [create_csp_account.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/identity/csp_account/usecase/create_csp_account.py)

#### A2. credential Workflow 시작

- **실행 주체:** Core의 `TemporalHeartbeatAdapter`
- **Temporal 경로:** `CredentialValidationWorkflow` / `core-collect`
- **다음 단계 조건:** Workflow 결과가 연결 성공을 반환한다.
- **실패 동작:** Temporal 연결 오류, Worker 부재, timeout, Workflow 실패를 모두
  사용자 안전 메시지의 `DISCONNECTED`로 바꾼다. 실제 예외는 서버 WARNING 로그에만
  남는다.
- **이 순서인 이유:** 유효하지 않은 credential을 DB와 Secret Manager에 남기기
  전에 차단한다.
- **근거:** `CODE` —
  [temporal_heartbeat_adapter.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/plugin/heartbeat/adapter/outbound/temporal_heartbeat_adapter.py)

#### A3. provider 연결 확인

- **실행 주체:** AWS/Azure/GCP provider Worker
- **Task queue:** `plugin-{provider}-heartbeat`
- **Activity:** 문자열 계약 `validate_credential`
- **정책:** Schedule-to-Close 8초, 최대 2 attempts
- **다음 단계 조건:** provider Activity가 연결 성공 결과를 반환한다.
- **실패 동작:** 8초 안에 queue wait와 retry를 포함한 성공 결과가 없으면 A2에서
  `DISCONNECTED`로 정규화된다.
- **이 순서인 이유:** 짧은 REST 예산을 지키고, 대량 collect backlog가 heartbeat를
  굶기지 않도록 전용 queue로 격리한다.
- **배포 주의:** `plugin-{provider}-heartbeat` poller를 먼저 배포하고 Core를
  배포해야 한다.
- **계약 주의:** queue 이름은 입력의 `provider` 문자열로 조립된다. 저장소
  constraint는 `onprem`도 허용하고 예약 수집의 active account 조회에는 provider
  filter가 없다. heartbeat 요청 schema는 자유 문자열이며 설명에는 실제 queue 값
  `gcp`와 다른 `google_cloud` 예시가 있다. 수집 입력에는 provider 누락 시 `aws`
  기본값도 있다. 현재 확인한 AWS·Azure·GCP 값에서는 불일치가 없지만, 오탈자나
  신규 provider는 소비자가 없는 queue를 만들 수 있다. A2가 이 경우도 실제
  credential 오류와 같은 `DISCONNECTED`로 바꾸므로 UI만으로 둘을 구분할 수 없다.
- **근거:** `CODE` —
  [credential_validation.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/workflow/credential_validation.py)

#### A4. Account row 생성

- **실행 주체:** Core account repository
- **초기값:** `credential_ref=NULL`, `collection_status=pending`
- **다음 단계 조건:** FK·unique constraint를 포함한 INSERT가 성공한다.
- **실패 동작:** 요청 transaction을 실패시킨다. 아직 secret을 저장하지 않았으므로
  orphan ciphertext가 생기지 않는다.
- **이 순서인 이유:** secret보다 DB row를 먼저 만들어 DB 제약 실패 뒤에 외부
  secret만 남는 경우를 줄인다.

#### A5. raw credential 위탁

- **실행 주체:** Core Secret Manager port
- **다음 단계 조건:** 암호화된 secret 저장이 성공하고 `secret_id`가 반환된다.
- **실패 동작:** 요청 transaction이 rollback된다.
- **이 순서인 이유:** Account table에는 평문 대신 참조만 보관한다. delegated
  account도 root와 합친 effective credential이 아니라 사용자가 보낸 raw input만
  저장해 root rotation 때 stale 복사본을 남기지 않는다.

#### A6. credential 참조 확정

- **실행 주체:** Core account repository
- **변경:** `credential_ref=secret_id`, `credential_status=valid`
- **다음 단계 조건:** 갱신과 요청 transaction commit이 성공한다.
- **실패 동작:** secret ciphertext를 best-effort cleanup한 뒤 원래 예외를 다시
  던져 DB transaction을 rollback한다. cleanup 자체의 실패는 WARNING으로 남긴다.
- **이 순서인 이유:** DB row와 secret 참조를 같은 요청의 최종 상태로 맞추고,
  store와 ref update 사이 race window를 줄인다.
- **근거:** `CODE` —
  [create_csp_account.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/identity/csp_account/usecase/create_csp_account.py)

### 4.3 등록 뒤의 명시적 경계

등록 성공 뒤 자동 수집 호출은 없다. 다음 중 하나가 별도로 일어나야 한다.

1. 사용자가 `POST /resources/collect`를 호출한다.
2. `core-scheduler`의 `daily-collect`가 매일 01:00 Asia/Seoul에 실행된다.

등록 시 `collection_status=pending`은 “Workflow가 이미 예약됐다”는 증거가 아니다.
현재 entity의 초기 상태일 뿐이다. 이 구분은 UI 문구와 장애 판단에 반드시
반영해야 한다.

### 4.4 등록 transaction에서 실제로 기록되는 곳

| 순서 | 쓰기 대상 | 실제 기록 |
|---|---|---|
| A4 | `root_account` 또는 `general_account` | 계정 행 INSERT. `credential_ref=NULL`, `collection_status=pending` |
| A5 | `secret` + 외부 Secret Manager | DB에는 암호화 메타데이터를 INSERT하고 ciphertext는 외부 저장소에 보관 |
| A6 | 같은 account 물리 테이블 | `credential_ref`, `credential_status=valid` UPDATE 후 요청 transaction commit |

`v_cloud_account`는 조회용 view이므로 직접 INSERT·UPDATE하지 않는다. A4와 A6은
하나의 요청 transaction에 묶인다. A5의 외부 ciphertext는 DB transaction과
원자적으로 묶을 수 없으므로, 이후 단계가 실패하면 best-effort로 지운다.

---

## 5. 수집 시작과 부모 Workflow

### 5.1 두 시작점

| ID | 시작점 | 실행 주체 | 입력 계정 | 응답/다음 단계 | 실패 |
|---|---|---|---|---|---|
| C0-M | `POST /resources/collect` | Core REST `StartCollectionBulk` | 요청한 1–100개 | 계정별 job을 만들고 부모 하나를 비동기 start한 뒤 응답 | 계정별 검증 실패는 격리한다. 부모 start 실패 시 CREATED item과 DB 상태를 `failure`로 best-effort 원복한다. |
| C0-S | `daily-collect` | 별도 `core-scheduler`의 APScheduler | Workflow가 active account를 조회 | 부모를 비동기 start하고 scheduler job 종료 | starter 예외가 scheduler job 실패 로그로 남는다. 다음 cron까지 자동 업무 재시작 계약은 코드에서 확인되지 않는다. |

수동 경로는 account마다 서로 다른 scope와 region을 보존한 preassembled job 목록을
부모에게 넘긴다. 스케줄 경로는 빈 입력 `{}`을 넘겨 부모가 active account 전체를
조회한다.

근거:

- `CODE` —
  [start_collection_bulk.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/resource/collection/usecase/start_collection_bulk.py)
- `CODE` —
  [scheduler.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/resource/collection/adapter/inbound/scheduler.py)

### 5.2 C1. Account 입력 확정

- **실행 주체:** `ResourceCollectionRun` / `core-collect`
- **수동:** 전달받은 `accounts`가 있으면 discovery를 생략한다.
- **스케줄:** 입력이 없으면 `list_active_accounts` Activity로 account·secret·meta를
  조립한다. 최대 2분, 최대 3 attempts다.
- **다음 단계 조건:** account 목록이 결정된다.
- **실패 동작:** discovery Activity가 retry 후 실패하면 부모가 실패하고 child는
  시작하지 않는다.
- **이 순서인 이유:** 수동은 요청 범위를 정확히 지키고, 일일 수집은 실행 시점의
  active account와 최신 credential을 기준으로 한다.

### 5.3 C2. 전체 Account를 pending으로 표시

- **실행 주체:** Core bulk status Activity / `core-collect`
- **정책:** 최대 2분, 최대 3 attempts
- **다음 단계 조건:** 유효한 `general_account_id`의 batch update가 끝난다.
- **실패 동작:** retry 후에도 실패하면 account child fan-out 전에 부모가 실패한다.
- **이 순서인 이유:** 최대 20개 동시성 뒤에 대기하는 account도 즉시 “예약됨”으로
  보여야 한다. child 안에서 pending을 찍으면 semaphore 뒤 계정은 이전 상태로
  오래 남는다.
- **참고:** 수동 API는 REST 응답 전에 같은 pending 값을 먼저 기록한다. 부모의
  batch update와 멱등적으로 중복된다.

### 5.4 C3. 계정별 Child fan-out

- **실행 주체:** `ResourceCollectionRun`
- **Child:** account마다 `ResourceCollection`
- **동시성:** 최대 20 account
- **Workflow ID:** `resource-collection-{general_account_id}-{run suffix}`
- **다음 단계 조건:** 각 child가 반환하거나 실패한다. 모든 account가 settle해야
  collect barrier를 통과한다.
- **실패 동작:** `gather(return_exceptions=True)`로 한 account 실패가 sibling을
  취소하지 않는다. 성공 반환한 child만 이후 customer 집합에 포함한다.
- **이 순서인 이유:** 대량 account가 Worker·DB·CSP quota를 한꺼번에 압도하지
  않게 하면서, 한 account 장애의 blast radius를 제한한다.

근거: `CODE` —
[resource_collection.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/workflow/resource_collection.py)

---

## 6. 계정별 ResourceCollection

```text
C4 make_plan
  → C5 account running
  → C6 build_tasks_from_plan
  → task별 병렬 [C7 collect_resources → C8 upsert_resources]
  → C9 결과 roll-up
  → C10 account terminal 상태 저장
```

### 6.1 단계 매트릭스

| ID | 단계 | 실행 주체 / queue | 다음 단계 조건 | 실패 동작 | 이 순서인 이유 |
|---|---|---|---|---|---|
| C4 | `make_plan` | provider Worker / `plugin-{provider}-plan` | 정상 결과이고 `plan.errors`가 비어 있음 | raised failure는 10분·최대 3 attempts 후 account `failed` 저장 및 child 실패. 정상 반환한 `plan.errors`도 `failed` 저장 후 0 task 결과 반환 | scope·region·provider capability로 실행 단위를 먼저 확정한다. 전용 plan lane은 collect 폭주로 인한 starvation을 막는다. |
| C5 | account `running` | Core Activity / `core-collect` | 상태 update 성공 | 1분·최대 3 attempts 후 실패하면 child 실패 | plan abort는 running으로 보이지 않게 하고, 실제 fan-out 직전에만 “수집 중”으로 전이한다. |
| C6 | `build_tasks_from_plan` | Core Activity / `core-collect` | plan이 표준 TaskMessage 목록으로 변환됨 | 2분·최대 3 attempts 후 child 실패. 이 경로에는 별도 terminal status 보정이 보이지 않는다 | provider plan과 Core 공통 task 계약의 경계를 둔다. |
| C7 | `collect_resources` | provider Worker / `plugin-{provider}` | Activity가 resource와 summary를 반환 | task별 30분·최대 3 attempts. 한 task 최종 실패는 sibling을 취소하지 않고 failed task로 집계 | provider API I/O를 plugin이 소유하고 task fan-out으로 처리량과 격리를 확보한다. |
| C8 | `upsert_resources` | Core Activity / `core-collect` | 해당 task의 resource upsert 완료 | 5분 제한. 현재 호출에는 명시적 `RetryPolicy`가 없다. 실패는 해당 task exception으로 집계 | 수집 즉시 task 단위로 저장해 거대한 결과 payload와 History를 피하고 부분 성공을 보존한다. |
| C9 | summary 분류·roll-up | `ResourceCollection` Workflow | 모든 task coroutine이 settle | raised exception 또는 ERROR summary는 failed task, WARNING-only는 warning task | 레거시 plugin이 예외 일부를 정상 summary로 바꾸므로 exception만 세면 빈 데이터 성공이 된다. |
| C10 | account terminal 저장 | Core Activity / `core-collect` | `failed > warning > success` 우선순위로 상태와 시간 저장 | 1분·최대 3 attempts. 실패하면 child 성공 반환 전에 Workflow가 실패 | API/UI 상태를 Temporal 내부 결과와 맞추고 `last_collected_at`을 실제 terminal 시점에만 갱신한다. |

### 6.2 DB 기록 타임라인

| 시점 | transaction 단위 | DB 기록 |
|---|---|---|
| 수동 시작 준비 | 요청 transaction | 대상 `general_account.collection_status=pending` 선반영 |
| 부모 대상 확정 뒤 | bulk status Activity transaction | 전체 대상 `general_account.collection_status=pending` UPDATE |
| plan 성공·fan-out 직전 | account status Activity transaction | 해당 `general_account.collection_status=running` UPDATE |
| task 하나의 수집 직후 | `upsert_resources` Activity transaction | `resource` UPSERT, payload가 있으면 `resource_data` UPSERT, 생성·변경이면 `resource_change` INSERT |
| account task 전체 집계 뒤 | terminal status Activity transaction | `general_account.collection_status`와 `last_collected_at` UPDATE |

unchanged 리소스는 `last_seen`을 갱신하지만 `resource_change` 행을 만들지 않는다.
각 task 결과를 별도 transaction으로 즉시 저장하므로 뒤 task가 실패해도 앞 task의
성공분은 남는다.

`collection_job`·`collection_task`는 현재 경로에서 쓰지 않는다. 코드의 `job`은
DB 행이 아니라 account별 Workflow 입력 dict다. 부모 최상위 실행의
`workflow_run` 기록은 3.2절처럼 별도 interceptor가 담당하며 account 자식은
행을 만들지 않는다.

### 6.3 Provider 내부의 실제 의미

AWS·Azure·GCP provider Worker는 공통적으로 세 queue를 poll한다.

```text
plugin-{provider}-heartbeat  → validate_credential
plugin-{provider}-plan       → make_plan
plugin-{provider}            → collect_resources
```

`make_plan`은 계정 credential과 collection 설정을 해석해 서비스·region별 task를
만든다. `collect_resources` Activity wrapper는 각 provider의 기존
`ExecuteTaskUseCase`를 재사용한다. 이 재사용 때문에 중요한 migration gap이 있다.

- 기존 NATS 흐름은 일부 task 오류를 예외 대신 `summary.errors`로 반환한다.
- Temporal은 raised failure만 자동 retry한다.
- Core Workflow는 `severity=ERROR`를 failed task로 다시 분류해 최종 상태는
  보완한다.
- 그러나 그 task에 대한 Temporal 재시도는 이미 놓친다.

즉 현재 구현은 **상태의 거짓 성공은 막았지만 retry 의미까지 완전히
Temporal-native로 옮긴 것은 아니다.**

Provider 근거:

- [AWS activities.py](https://git.sdlc.megaone.com/cloudops/plugin-aws-resource-collector/src/commit/5b8e034/src/plugin/adapter/inbound/temporal/activities.py)
- [Azure activities.py](https://git.sdlc.megaone.com/cloudops/plugin-azure-resource-collector/src/commit/b907e13/src/plugin/adapter/inbound/temporal/activities.py)
- [GCP activities.py](https://git.sdlc.megaone.com/cloudops/plugin-gcp-resource-collector/src/commit/b50c4d0/src/plugin/adapter/inbound/temporal/activities.py)

### 6.4 Secret과 큰 payload 처리

plan은 `secret_data`와 `account_meta`를 한 번만 갖는다. C6에서 task N개를 만들 때
복제하지 않고 C7 Activity 입력을 만드는 직전에 주입한다.

이 순서인 이유:

- Temporal payload에 credential을 N번 복제하지 않는다.
- Workflow History와 gRPC payload 팽창을 줄인다.
- Core의 공통 task에는 provider secret이 상시 섞이지 않는다.

다만 encryption codec이 비활성인 환경에서는 C7 입력이 History에 평문 payload로
남을 수 있다. 이는 구조 최적화이지 secret 보호 자체가 아니다.

Temporal의 개별 payload 기본 한계는 2 MiB이고, gRPC 메시지는 약 4 MiB 경계도
고려해야 한다. base의 claim-check는 기본 비활성이며 endpoint가 설정된 경우에만
기본 256 KiB 이상 값을 외부 저장한다. dev 데이터에서 계정 하나의 권한 정보
Activity 결과가 두 경계를 넘었는데도 성공한 실행이 관찰됐다. 현재 증거만으로는
claim-check가 실제 활성화됐는지, 서버 한계를 올렸는지 확정할 수 없다
(`OPS-UNKNOWN`).

운영에서는 다음을 한 쌍으로 확인해야 한다.

1. 시작 주체와 모든 Worker의 claim-check endpoint·store·암호화 설정
2. Temporal cluster와 proxy의 payload/gRPC 크기 제한
3. 외부 payload 저장소 장애·보존·삭제 정책과 모니터링

---

## 7. Collect barrier 이후 후처리

### 7.1 실제 선후 관계

```text
모든 account child settle
  │
  ├─ P1 WellArchitectedRun START 확인 ───────────────┐
  │      ParentClosePolicy.ABANDON                  │ 부모는 완료를 기다리지 않음
  │                                                 └─ P2 평가 → P3 ticket reconcile
  │
  └─ P4 RelationshipExtractRun 완료까지 WAIT
         고객 내 account 순차 extract
         → 고객당 promote 1회
         → 고객당 VPC materialize 1회
         │
         └─ 실패해도 catch 후 계속
              → P5 MappingApplyRun START 확인
                   ParentClosePolicy.ABANDON

ResourceCollectionRun 완료
```

현재 `relationship_extract.py` 모듈 상단 설명에는 “ABANDON child”라는 오래된
표현이 남아 있지만, 호출부의 실제 코드는 `execute_child_workflow`로 완료를
기다린다. 정본은 실행 코드의 계약을 따른다.

### 7.2 P1–P3. Well-Architected와 finding ticket

#### P1. WellArchitectedRun 시작

- **실행 주체:** collect 부모
- **입력:** 성공 반환한 account 결과의 distinct `customer_id`
- **다음 단계 조건:** child가 Temporal에 실제로 started 상태가 된다.
- **실패 동작:** child start 자체가 실패하면 부모도 이 지점에서 실패할 수 있다.
  start 이후의 child 실패는 부모 결과에 반영되지 않는다.
- **이 순서인 이유:** 모든 account resource가 저장된 barrier 뒤에 평가해야
  부분 적재 데이터를 평가하지 않는다. 평가는 collection 완료 SLA와 분리한다.

#### P2. 고객별 Well-Architected 평가

- **실행 주체:** `WellArchitectedRun` / Core Activity `run_well_architected`
- **동시성:** 최대 8 customer
- **정책:** customer당 최대 15분, 최대 3 attempts
- **다음 단계 조건:** 평가가 `well_architected_run_id`를 반환한다.
- **실패 동작:** 해당 customer의 P3를 실행하지 않는다. 다른 customer는 계속한다.
- **이 순서인 이유:** ticket은 특정 평가 run의 finding 결과를 reconcile하므로
  평가 성공이 선행되어야 한다.

#### P3. finding ticket reconcile

- **실행 주체:** `WellArchitectedRun` / Core Activity `reconcile_tickets`
- **정책:** 최대 15분, 최대 3 attempts
- **다음 단계 조건:** finding과 기존 ticket 비교·생성·bump·resolve transaction이
  끝난다.
- **실패 동작:** 해당 customer 결과를 실패로 집계한다. collect 부모는 이미
  분리했으므로 성공 상태를 유지할 수 있다.
- **이 순서인 이유:** 같은 finding의 기존 ticket을 먼저 찾아 중복 생성 대신
  상태를 reconcile한다.

근거:

- `CODE` —
  [well_architected.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/workflow/well_architected.py)
- `CODE` —
  [reconcile_well_architected_tickets.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/assessment/well_architected_run/usecase/reconcile_well_architected_tickets.py)

### 7.3 P4. Relationship → promote → VPC materialize

- **실행 주체:** `RelationshipExtractRun` / `core-collect`
- **입력 provider:** AWS·Azure·GCP의 성공 반환 account만
- **동시성:** customer 간 최대 8, 같은 customer 안의 account는 순차
- **순서:**
  1. account마다 `extract_relationship_for_account` — 5분·최대 3 attempts
  2. customer마다 `promote_relationship_for_customer` 한 번 — 2분·최대 3
  3. customer마다 `materialize_vpc_for_customer` 한 번 — 2분·최대 3
- **다음 단계 조건:** child 전체가 반환한다. customer별 실패는 결과의
  `failed`에 격리된다.
- **실패 동작:** child 자체가 exception으로 실패하면 collect 부모가 catch하고
  P5를 계속 시작한다. 이 cycle의 mapping은 stale/missing `vpc_id`를 볼 수 있다.
- **이 순서인 이유:**
  - 같은 customer partition을 동시 extract하면 DB 경합/deadlock 위험이 있다.
  - promote는 customer-global이라 모든 account extract 뒤 한 번만 해야 한다.
  - VPC 조건 mapping이 첫 cycle에 맞으려면 materialize가 mapping보다 앞서야 한다.

### 7.4 P5. Mapping 적용

- **실행 주체:** `MappingApplyRun` / Core Activity `apply_mapping_for_customer`
- **입력:** relationship 지원 provider 여부와 무관한 전체 성공 customer
- **동시성:** 최대 8 customer
- **정책:** customer당 최대 5분, 최대 3 attempts
- **다음 단계 조건:** child start를 Temporal이 기록한다.
- **실패 동작:** start 이후 customer 실패는 child 결과에만 남고 collect 부모에는
  전파되지 않는다.
- **이 순서인 이유:** Temporal upsert 경로에서는 예전
  `core.resource.changed` NATS trigger가 없어졌으므로 collect barrier 뒤 직접
  전체 미매핑 집합을 재평가한다. relationship/VPC를 먼저 기다려 VPC 조건의
  한-cycle 지연을 줄인다.

근거:

- [relationship_extract.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/workflow/relationship_extract.py)
- [mapping_apply.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/workflow/mapping_apply.py)
- [resource_collection.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/workflow/resource_collection.py)

### 7.5 “부모 성공”의 정확한 정의

`ResourceCollectionRun=Completed`가 보장하는 것:

- 모든 account child가 성공 또는 실패로 settle했다.
- 성공 반환 account만으로 customer 집합을 만들었다.
- WA child의 **start**가 기록됐다.
- relationship child를 기다렸거나 그 실패를 catch했다.
- mapping child의 **start**가 기록됐다.

보장하지 않는 것:

- 모든 account가 `success` 상태다.
- WA 평가와 finding ticket reconcile이 끝났다.
- relationship의 모든 customer가 성공했다.
- mapping이 끝났다.

운영 대시보드가 부모 Completed만 세면 후처리 장애를 놓친다.

### 7.6 후처리 DB 기록 타임라인

| 단계 | transaction 단위 | DB 기록 |
|---|---|---|
| P2 WA 평가 | 고객별 평가 Activity 한 transaction | `well_architected_run` INSERT(running), `well_architected_finding` INSERT, `well_architected_score` UPSERT, run `succeeded` UPDATE |
| P3 WA ticket reconcile | 고객별 reconcile Activity 한 transaction | `ticket` INSERT/UPDATE, 상태 전이가 있으면 `ticket_action_log` INSERT |
| P4-1 관계 추출 | 계정별 procedure transaction | `resource_relationship` UPSERT, stale 관계 DELETE |
| P4-2 관계 승격 | 고객별 procedure transaction | 같은 `resource_relationship`의 상태 UPDATE·불필요 관계 DELETE |
| P4-3 VPC materialize | 고객별 procedure 계약 | 호출 계약상 `resource.vpc_id` UPDATE |
| P5 mapping | 고객별 mapping Activity transaction | `resource.application_id`·`mapping_rule_id` UPDATE, `resource_mapping_rule` 실행 시각 UPDATE, `resource_mapping_log` INSERT |

Core는 `p_resource_vpc_materialize`를 호출하지만 고정한 DB schema snapshot에서는
해당 procedure 정의 artifact를 찾지 못했다. 따라서 `resource.vpc_id` 갱신은
Core 호출 계약으로 확인했고, 운영 DB에 실제 procedure가 존재하는지는 배포 전
확인해야 한다(`OPS-UNKNOWN`).

WA 평가 Activity에서 예외가 나면 adapter transaction이 rollback된다. 유스케이스가
run을 failed로 바꾸려 해도 같은 transaction 안에서 실패했다면 failed run 행까지
남지 않을 수 있다. 따라서 “실패 run row가 없다”를 “평가가 시작되지 않았다”로
단정하면 안 된다.

새 open ticket은 `ticket` 행만 생기고 action log가 없을 수 있다.
`ticket_action_log`는 모든 write의 감사 로그가 아니라 상태 전이 기록이다.

---

## 8. CSP event → Alert → Ticket

이 경로는 Temporal이 아니라 NATS JetStream consumer chain이다. 메시지는
at-least-once이므로 각 consumer의 DB 멱등성과 최종 실패 기록이 핵심이다.

```text
E1 Webhook HTTP
 → E2 CORE_EVENTS webhook_received
 → E3 normalize request emitter
 → E4 source plugin normalizer
 → E5 Core finalizer + resource mapping
 → E6 alert_raised
 → E7 alert grouper + outbox
 → E8 alert.state_changed
 → E9 ticket consumer
```

### 8.1 단계 매트릭스

| ID | 단계 | 실행 주체 | 다음 단계 조건 | 실패 동작 | 이 순서인 이유 |
|---|---|---|---|---|---|
| E1 | `POST /integration-hub/webhooks/{webhook_id}/{url_token}/webhook-ingest` | Core REST ingest | URL token이 맞고 body가 1 MiB 이하 JSON object이며 source handler가 event를 반환 | token 없음 404. 크기/JSON 오류 거부. SNS confirmation 같은 source별 종료는 event publish 없이 응답 | public callback을 최소 검증하고 내부 이벤트에 안정적인 event ID를 부여한다. |
| E2 | `webhook_received` publish | Core NATS publisher / `CORE_EVENTS` | JetStream publish ACK 수신 | ACK 실패 시 HTTP 성공을 반환하지 않는다 | 외부 200보다 durable 수신을 먼저 확정해 event 유실을 막는다. `Nats-Msg-Id`로 중복 publish를 줄인다. |
| E3 | normalize request emit | Core normalizer emitter consumer | enabled webhook은 event row `RECEIVED` commit 후 `NORMALIZE_REQ` publish | 최대 5 delivery. disabled는 `REJECTED` 저장 후 ACK. 마지막 실패는 `BACKEND_ERROR/FAILED` 저장 후 ACK | 원본 수신 원장을 먼저 저장하고 source plugin 작업을 분리한다. |
| E4 | source normalize | source plugin consumer | source event를 공통 normalize response로 변환하고 response publish 성공 | 처리/publish 실패 시 request를 ACK하지 않아 redelivery | CSP별 schema·서명·mapping 책임을 plugin에 격리한다. |
| E5 | normalize finalize | Core finalizer | 공통 schema가 유효하고 resource mapping·event state commit 완료 | `SCHEMA_INVALID`는 FAILED 후 ACK, `TRANSIENT`는 throw/redelivery. 마지막 backend 실패는 FAILED 기록 | 공통 alert 계약으로 바꾸기 전에 schema와 resource 귀속을 확정한다. 동일 event ID 재처리를 견딘다. |
| E6 | `alert_raised` publish | Core normalizer publisher | pending alert event publish 성공 | consumer 실패 규칙에 따라 E5 delivery가 재시도됨 | event 저장과 alert grouping의 책임을 분리한다. |
| E7 | alert grouping | Core alert consumer | CREATE/ATTACH/RESOLVE/NOOP 결정, event-alert 연결, DB commit, state event outbox enqueue | 최대 5 delivery. invalid payload는 ACK skip. 처리 실패는 재전송하되 마지막 실패는 로그 후 ACK하며 별도 terminal ledger는 없음 | provider·source·resource·rule 기준으로 반복 event를 한 alert lifecycle로 묶는다. outbox로 DB와 publish 간극을 줄인다. |
| E8 | `core.alert.state_changed.{customer_id}` | outbox relay / state publisher | JetStream publish 성공 | 즉시 flush 실패 시 outbox relay가 재시도하는 backstop | ticket은 alert DB transaction이 commit된 뒤에만 반응해야 한다. deterministic message ID는 중복 publish를 완화한다. |
| E9 | alert ticket 처리 | Core ticket consumer | open/resolved 상태를 멱등 반영하고 DB commit | 최대 5 delivery. invalid payload ACK skip. 마지막 처리 실패는 로그 후 ACK하며 별도 terminal ledger는 없음 | alert lifecycle 하나를 ticket lifecycle 하나로 유지한다. |

### 8.2 E7의 grouping 결정

grouping key는 다음 정보의 조합이다.

```text
provider | source | resource | rule-or-title
```

| 입력 상황 | 결정 |
|---|---|
| 발생 event이고 active alert 없음 | `CREATE` |
| 발생 event이고 active alert 있음 | `ATTACH` |
| recovery event이고 active alert 있음 | `RESOLVE` |
| recovery event이고 active alert 없음 | `NOOP` |

각 webhook event에는 결정된 `alert_id`를 backfill한다. CREATE/RESOLVE처럼 ticket이
알아야 할 상태 변화는 transactional outbox에 먼저 넣고 DB commit 뒤 즉시
best-effort flush한다.

### 8.3 E9의 ticket 멱등성

open event:

- dedup key는 `alert:{alert_id}`다.
- 어떤 상태의 기존 ticket도 먼저 조회한다.
- 없으면 create-or-get, 있으면 open/last_seen을 조정한다.
- 동일 event가 다시 와도 alert당 ticket 하나를 유지한다.

resolved event:

- active ticket이면 compare-and-set 방식으로 resolve한다.
- 이미 terminal이면 no-op한다.
- ticket이 없지만 alert는 존재하면 create-then-resolve backstop을 수행한다.
- commit 뒤에 resolution notification을 보낸다.

### 8.4 이벤트 경로 DB 기록 타임라인

| 시점 | DB 기록 | 다음 전달 |
|---|---|---|
| E3 정규화 요청 준비 | `webhook_event` INSERT(`RECEIVED`) 또는 비활성 webhook이면 `REJECTED` | `NORMALIZE_REQ` publish |
| E5 정규화 결과 확정 | 같은 `webhook_event`를 `OK`·`UNMAPPED`·`FAILED`로 UPDATE/UPSERT | 성공 결과만 `alert_raised` publish |
| E7 grouping | `alert` INSERT/UPDATE, `webhook_event.alert_id` UPDATE | open/resolved 상태 변화만 `outbox` INSERT |
| E8 relay | publish 성공 시 `outbox.published_at` UPDATE, 실패 시 `attempts`·`last_error` UPDATE | `core.alert.state_changed.*` |
| E9 ticket | `ticket` INSERT/UPDATE, 상태 전이면 `ticket_action_log` INSERT | commit 뒤 알림·종료 후속 처리 |

ATTACH는 기존 alert에 event를 연결하지만 ticket 상태 이벤트가 필요하지 않아 outbox
행을 만들지 않는다. 자동 grouping 자체도 `alert_action_log`를 쓰지 않는다.
따라서 action log 행 수만으로 alert가 처리된 횟수를 세면 안 된다.

근거:

- Ingest:
  [rest_router.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/integration_hub/ingest/adapter/inbound/rest_router.py),
  [nats_event_publisher.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/integration_hub/ingest/adapter/outbound/nats_event_publisher.py)
- Normalize:
  [nats_router.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/integration_hub/normalizer/adapter/inbound/nats_router.py),
  [emit_normalize_request.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/integration_hub/normalizer/usecase/emit_normalize_request.py),
  [finalize_normalize_response.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/integration_hub/normalizer/usecase/finalize_normalize_response.py)
- Alert:
  [nats_consumer.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/alert/alert/adapter/inbound/nats_consumer.py),
  [group_event.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/alert/alert/usecase/group_event.py),
  [alert_state_event_publisher.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/alert/alert/adapter/outbound/alert_state_event_publisher.py)
- Ticket:
  [nats_consumer.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/ticket/ticket/adapter/inbound/nats_consumer.py),
  [handle_alert_state_event.py](https://git.sdlc.megaone.com/cloudops/core/src/commit/c86c6f59/src/cloudops/core/ticket/ticket/usecase/handle_alert_state_event.py)

---

## 9. 실패 전파를 한 표로 보기

| 실패 위치 | 자동 재시도 | 격리 경계 | 사용자/DB에 남는 결과 | 뒤 단계 |
|---|---|---|---|---|
| credential Activity | 최대 2 attempts / 총 8초 | 등록 요청 | `DISCONNECTED`, account 미생성 | 중단 |
| 수동 부모 start | starter 호출 수준 | 요청 내 account별 | item FAILED, account `failure` best-effort | 중단 |
| 삭제 뒤 relationship start | starter 호출 수준 | 계정 삭제 요청과 분리 | WARNING 로그, 별도 누락 원장 없음 | 삭제는 commit, 관계 정리는 시작되지 않음 |
| 삭제 뒤 WA start | starter 호출 수준 | 계정 삭제 요청과 분리 | WARNING 로그, 별도 누락 원장 없음 | 삭제는 commit, WA 재평가는 시작되지 않음 |
| account discovery | Activity 최대 3 | 부모 전체 | 부모 Failed | child 없음 |
| `make_plan` raised | 최대 3 | account child | account `failed`, child Failed | 해당 account 후처리 입력 제외 |
| `plan.errors` 정상 반환 | 엔진 retry 없음 | account child | account `failed`, child Completed/0 task | 현재 성공 반환 결과로 부모가 받을 수 있으므로 운영 시 상태도 함께 봐야 함 |
| `build_tasks_from_plan` | 최대 3 | account child | child Failed. terminal status 보정 공백 가능 | 해당 account 후처리 입력 제외 |
| `collect_resources` raised | 최대 3 | task | failed task로 roll-up | sibling 계속, account terminal `failed` |
| ERROR summary 정상 반환 | 엔진 retry 없음 | task | failed task로 roll-up | sibling 계속 |
| `upsert_resources` | SDK 기본 Activity retry 계약에 의존하며 호출부 명시값 없음 | task | failed task로 roll-up | sibling 계속 |
| account terminal status update | 최대 3 | account child | child Failed, DB 상태 stale 가능 | 해당 account 후처리 입력 제외 |
| WA 평가/reconcile | 각각 최대 3 | customer, detached child | WA child의 failed count/History | collect 부모에는 미전파 |
| relationship customer | 각 Activity 최대 3 | customer | child result의 failed count | 다른 customer 계속 |
| relationship child exception | child 내부/Activity 정책 | collect 부모가 catch | warning History, VPC stale 가능 | mapping 계속 |
| mapping | 최대 3 | customer, detached child | mapping child의 failed count/History | collect 부모에는 미전파 |
| NATS E3/E5 | 최대 5 delivery | event | 마지막 실패를 event FAILED로 기록 | ACK 후 중단 |
| NATS E7/E9 | 최대 5 delivery | event | 마지막 실패는 로그 후 ACK, terminal DB ledger 없음 | 중단 |

가장 위험한 공백은 “실패는 격리됐는데 중앙에서 찾을 수 없는 상태”다. 특히 detached
child와 E7/E9 최종 ACK는 부모 성공률이나 stream backlog만 봐서는 놓칠 수 있다.

추적 식별자도 경로마다 다르다. APScheduler와 NATS consumer 진입점은
`create_transaction`으로 `trace_id`를 만들어 로그 문맥에 넣지만, Temporal
Activity adapter와 Worker 실행 경로는 같은 transaction context로 감싸지 않는다.
따라서 Temporal History에서 Workflow ID·Run ID·Activity ID를 확인한 뒤에도 Core
로그의 단일 `trace_id`로 전체 Activity를 이어 찾을 수 없다. 실패 격리 정책을
운영하려면 이 식별자를 구조화 로그와 DB 원장에 함께 남기는 설계가 필요하다.

---

## 10. 실제 로컬 실행으로 확인한 범위

### 10.1 깨끗한 대표 실행

| 항목 | 실제 값 | 확인 결과 |
|---|---|---|
| 합성 account | `gac-9b0bf4b8e8aadec0` | local cloud account `126612661267` |
| credential | `heartbeat-aws-1ce13929` | run `019fb67a-e149-74fd-be73-b9786040a05b`, `CONNECTED` |
| 수집 부모 | `resource-collection-run-e6beb746` | run `019fb67b-2806-7226-bbd0-2507b31ad11c`, 약 1.54초 |
| account child | `resource-collection-gac-9b0bf4b8e8aadec0-e6beb746` | saved 1, created 1, failed 0 |
| 후처리 | `well-architected-run-e6beb746`, `relationship-extract-run-e6beb746`, `mapping-apply-run-e6beb746` | 부모-child와 대기 관계 확인 |
| WA ticket | 동일 WA child | finding 6, 기존 3 bump, 신규 3 생성; 신규 ID `378`–`380` |

`E2E-LOCAL`에서 실제 사용한 것:

- Account REST use case와 Temporal heartbeat
- 실제 `ResourceCollectionRun`·`ResourceCollection`·후처리 Workflow
- 실제 Core Activity, Postgres resource upsert
- 실제 Well-Architected 평가와 ticket reconcile

대체한 경계:

- 실제 AWS credential 및 AWS API
- AWS KMS/Secrets Manager
- 운영 Kubernetes/Temporal/NATS

따라서 이 실행은 Workflow·DB·ticket integration 증거지만 실제 CSP 운영 E2E
증거는 아니다.

### 10.2 실패를 통해 드러난 schema drift

첫 실행에서는 로컬 DB에 현행 코드가 기대한 구조가 빠져 있었다.

- `alembic_version` row 비어 있음
- `workflow_run`·`workflow_run_item` table 누락
- active ticket dedup partial unique index 누락

Workflow 원장 Activity는 fail-open이라 원장 table 누락 뒤에도 업무 Workflow가
계속됐다. 반면 ticket index 누락은 `reconcile_tickets`를 최대 3회 실패시켰다.
기존 DB를 reset하지 않고 현행 ORM table과 정확한 index만 보강한 뒤 새로운 합성
Account로 재실행해 10.1의 성공 결과를 얻었다.

이 결과는 다음 운영 요구를 만든다.

- migration version과 필수 table/index 검사를 Worker startup/readiness에 둔다.
- fail-open 보조 원장 실패와 업무 DB 실패를 별도 alert로 구분한다.
- Retry exhausted가 “외부 일시 장애”인지 “영구 schema 불일치”인지 분류한다.

### 10.3 화면 증거

| 화면 | 파일 |
|---|---|
| 전체 실행 목록 | [01-workflow-list.png](assets/screenshots/01-workflow-list.png) |
| Account credential validation | [02-account-credential-validation.png](assets/screenshots/02-account-credential-validation.png) |
| 부모와 후처리 Relationships | [03-collection-parent-relationships.png](assets/screenshots/03-collection-parent-relationships.png) |
| 계정 child Activities | [04-resource-collection-activities.png](assets/screenshots/04-resource-collection-activities.png) |
| WA와 ticket reconcile | [05-well-architected-ticket-reconcile.png](assets/screenshots/05-well-architected-ticket-reconcile.png) |
| task queue Worker | [06-worker-task-queues.png](assets/screenshots/06-worker-task-queues.png) |
| retry exhausted와 migration gap | [07-retry-exhausted-migration-gap.png](assets/screenshots/07-retry-exhausted-migration-gap.png) |
| 선후 관계 trace | [08-workflow-trace-timeline.png](assets/screenshots/08-workflow-trace-timeline.png) |

스크린샷에서 credential 입력은 접혀 있다. 이것은 UI에 secret이 없다는 증거가
아니며, payload encryption 여부는 배포 설정으로 별도 확인해야 한다.

---

## 11. Acceptance Criteria 대응

| Acceptance Criteria | 충족 위치 |
|---|---|
| 수집 시작부터 티켓 발행까지 단계별 순서 | 5–8절의 수집·후처리·이벤트 경로 |
| 각 단계 실행 주체와 다음 단계 조건 | 3–8절의 시작점 지도·단계별 계약·매트릭스 |
| 각 단계 실패 시 동작 | 각 단계의 실패 항목, 9절 실패 전파 표 |
| CSP 이벤트 수신부터 티켓까지 | 8절 E1–E9 |
| 팀 접근 위치에 공유 | 이 `resource-flow/` 폴더가 정본. 실제 사내 문서 저장소 게시 여부는 저장소 반영/공유 절차가 추가로 필요 |
| 읽는 문서와 클릭형 흐름도 | 이 문서와 [index.html](index.html) |

## 12. 변경 시 함께 갱신할 계약

다음 코드가 바뀌면 이 문서와 HTML의 같은 단계 ID를 한 변경으로 갱신한다.

- Workflow/child 대기: `C1–C10`, `P1–P5`
- 최상위 Workflow starter·삭제 후 지연: 3절의 다섯 시작점
- provider queue 또는 timeout: `A3`, `C4`, `C7`
- account 상태 enum/roll-up: 2.2절과 `C9–C10`
- NATS subject/delivery/ACK: `E1–E9`
- ticket dedup/reconcile: `P3`, `E9`
- DB write·원장 계약: 3.2, 4.4, 6.2, 7.6, 8.4절
- 배포 branch/commit: [README.md](README.md)의 source snapshot
