Files
Godopu a9934ad104 docs(messaging): add NATS vs MQTT feasibility report, private broker guide, and update IMPROVEMENTS backlog
- Synthesize collaborative multi-agent architectural analysis in NATS_REPORT.md
- Establish Option C: retain MQTT client protocol while adopting nats-server as dedicated broker
- Add private server deployment and configuration guide in PRIVATE_SERVER.md
- Update IMPROVEMENTS.md with latent defect findings (B-14, B-15, B-16, O-5) and 4-track priority roadmap
- Archive durable loop planning and review reports in .agents/reports/
2026-08-20 10:58:02 +09:00

326 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 📐 심층 분석 계획서 Rev.2 — MAM 메시징 백플레인: MQTT → NATS 전환 타당성
- **Job ID**: `f1956d2e` (Rev.1 = `641929ab`)
- **Planner**: claude (session: `herdr:canary-projects-multi-agent-mux-creator-claude`)
- **Role**: Planner (`MULTI_AGENT_RULES.md` §1 — 본 작업에서 저장소 코드 **0건 수정**)
- **반영 대상 Challenge**: `10003692` (agy, Worker / Plan Reviewer) — `[VERDICT: PASS WITH CHALLENGE]`
- **기준 커밋**: `ac82f9b` (`refactor`, 작업 트리 clean)
- **테스트 베이스라인**: **276 tests collected** (실측)
---
## 0. Challenge 판정 요약
Challenge 는 지적 **1건(C1)** 을 제기했고, 나머지 6개 섹션은 승인했습니다. C1 을 **실측으로 판정**한 결과 **결론은 채택, 근거·메커니즘·심각도는 정정**입니다.
| # | 지적 | 판정 | 실측 근거 |
|---|---|---|---|
| **C1-a** | `job_subscriber.py` 가 위임 경로에서 **블로킹 대기 대상**이며 Rev.1 이 이를 누락 | ✅ **전면 인정 — Rev.1 §1.2 표가 틀렸습니다** | `multi-agent-mux-delegate-job:227` `wait "$sub_pid"` 실재. `run_loop.sh`**전 호출부가 `--type direct`** 로 이 경로를 탐 |
| **C1-b** | `job_subscriber.py` 에 디스크 폴백이 없음 | ✅ **전면 인정** | 이벤트 대기는 `watcher.events.get(timeout=wait)` 단일 경로. `reconcile.sh``exit 3` 폴백에 해당하는 것이 없음 |
| **C1-c** | 메커니즘: "publish_event 가 디스크를 갱신하고 종료 → 와이어 메시지만 없음" | ⚠️ **현행 코드와 불일치 — 정정** | **현행은 디스크도 갱신되지 않습니다**(F-1). C1-c 는 Track 0 수정 **이후**의 상태를 기술한 것. 즉 C1 은 *기존 버그*가 아니라 **Track 0 수정의 잔여 결함** |
| **C1-d** | "idle_timeout(120s) 까지 블록 → **최소 2분** 지연" | ❌ **실측 반증 — 기각** | 브로커 도달 불가 시 구독자는 **40초에 rc=1 로 사망**(traceback), 접속 거부 시 **15.1초**. 5초 핸드셰이크 창을 넘겨 죽으므로 에이전트는 정상 실행되고, `wait` 도달 시점엔 이미 종료 → **추가 지연 0초** |
| **C1-e** | 해결책: 디스크 터미널 상태 확인 후 정상 종료 | ✅ **채택 — 단, 더 강한 사유로** | 지연이 아니라 **거짓 실패 판정**이 진짜 피해. `read_logged_status``mqtt_common.py:559` 에 실재함(인용 정확) |
**추가로, Challenge 가 놓친 결함 2건을 발견했습니다** (§3). 그중 **F-4 는 C1 이 지적한 것보다 심각합니다.**
> ### **[VERDICT: DO NOT MIGRATE THE CLIENT PROTOCOL — ADOPT `nats-server` AS THE BROKER INSTEAD]**
>
> **판정 불변.** C1 은 전략 판정이 아니라 Track 0 의 범위를 확장시킵니다. Challenge 도 §3 표에서 판정 자체는 전항목 승인했습니다.
---
## 1. C1 정밀 판정 (실측)
### 1.1 인정 — Rev.1 §1.2 표의 오류
Rev.1 은 `job_subscriber.py` 를 이렇게 분류했습니다:
> | `job_subscriber.py` | 라이브 이벤트 tail | ❌ **`run_loop.sh` 가 호출하지 않음** (호출처: `BOOTSTRAP.md:170` 문서, `test_tier4_e2e.py`) | 영향 없음 |
**이는 틀렸습니다.** 원인은 방법론 오류입니다 — 저는 `grep -rln --include="*.sh" --include="*.py" --include="*.md"` 로 호출처를 찾았는데, 위임 실행 파일 `multi-agent-mux-delegate-job`**확장자가 없어** include 필터에서 제외되었습니다. 실제 호출 사슬은:
```
run_loop.sh:378 delegate_job_safe submit --type "direct" ...
└→ run_loop.sh:132 bash "$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
└→ :164 job_subscriber.py ... & (background)
└→ :227 wait "$sub_pid" || true (blocking join)
```
`run_loop.sh``--type "direct"` 지정은 `:378`, `:412`, `:440`, `:498`, `:554`, `:597`**전 호출부**입니다 (`TYPE` 기본값도 `:96` 에서 `direct`). 따라서 **`job_subscriber.py` 는 run_loop 의 제어 경로 안에 간접적으로 존재합니다.** Challenge 의 지적이 정확합니다.
**단, Rev.1 §1.1 의 핵심 측정은 그대로 유효합니다**: `run_loop.sh` 자체의 MQTT 참조는 `:889` 1건뿐이고, 잡 완료 판정은 `wait_for_job()` 의 3초 파일 폴링입니다. 즉 **잡 결과 판정은 여전히 브로커와 무관**하며, 브로커가 관여하는 것은 **join 시점의 대기**뿐입니다. 이 구분이 §1.3 의 심각도 산정을 좌우합니다.
### 1.2 정정 — C1-c 의 메커니즘은 현행 코드와 다릅니다
Challenge §2.2 step 3:
> `publish_event.py` updates the on-disk job file (`.mam/jobs/<id>.json`) and exits. The network publish fails, so **no MQTT message is delivered over the wire**.
**현행 코드는 디스크도 갱신하지 않습니다.** `publish_event.py:186-190` 이 레지스트리 동기화 **이전에** `return 2` 하기 때문입니다 — 이것이 Rev.1 §4 의 F-1 이고, 실측으로 재현했습니다 (`status=running` 불변, `last_seq` 0→1 소모, `events=0`).
즉 **C1 이 기술한 상태는 Track 0 수정이 적용된 *이후*에만 성립**합니다. 이 순서를 바로잡는 것이 중요한 이유:
- C1 은 "지금 존재하는 별도 버그"가 아니라 **"F-1 을 고쳐도 남는 잔여 결함"** 입니다.
- 따라서 **F-1 수정만으로는 위임 경로가 완성되지 않는다**는 Challenge 의 결론은 옳으며, 두 수정은 **같은 트랙에서 함께** 이루어져야 합니다. Challenge 의 실행 권고(§4-1)는 정확합니다.
- 반대로, C1 을 먼저 고치고 F-1 을 놔두면 **아무 효과가 없습니다** — 디스크에 터미널 상태가 없으므로 폴백이 읽을 것이 없습니다. **순서 의존성이 존재하며 §4 에 명시했습니다.**
### 1.3 기각 — "최소 2분 지연"은 실측으로 성립하지 않습니다
Challenge §2.2 step 6: *"hangs on `wait "$sub_pid"` for **at least 2 minutes**"*.
**실측 1 — 브로커 도달 불가 (`10.255.255.1:1883`, 라우팅 블랙홀)**
```
exit_rc=1 elapsed=40s
socket.timeout: timed out ← 미포착 예외로 사망
SUBSCRIBED 출력 횟수: 0
```
**실측 2 — 브로커 접속 거부 (`127.0.0.1:1`, 즉시 RST)**
```
exit_rc=1 elapsed=15101ms
WARNING ... attempt 4/5 failed: [Errno 61] Connection refused; retrying in 8.0s
ConnectionRefusedError: [Errno 61] Connection refused
```
핵심 타이밍 3개를 대조하면 C1-d 가 성립하지 않는 이유가 드러납니다:
| 구간 | 값 | 출처 |
|---|---|---|
| 핸드셰이크 대기 창 | **5.0초** (`for ((i=0; i<25; i++))` × `sleep 0.2`) | `multi-agent-mux-delegate-job:171-190` |
| 구독자 접속 재시도 총 시간 | **최소 15초** (`attempts=5, base_delay=1.0` → 1+2+4+8) | `job_subscriber.py:200-203` |
| 구독자 실제 사망 시점 | **15.1초 / 40초** (실측) | 위 |
따라서 브로커가 처음부터 죽어 있으면:
1. t=5s — 구독자는 **아직 살아 있음**`sub_ready=0``WARNING: subscriber subscribe handshake timed out — falling back to proceed`**에이전트 정상 실행**
2. t=15~40s — 구독자가 traceback 과 함께 rc=1 로 사망
3. 에이전트 종료 후 `:227` `wait "$sub_pid"` 도달 → **이미 종료된 프로세스 → 즉시 반환**
**추가 지연 0초입니다.** "최소 2분"이 아니라 **최대 0초**입니다.
C1 이 기술한 120초 대기가 성립하려면 **SUBSCRIBE 성공 이후 브로커가 중도 유실**되어야 합니다. 이 경우에도:
- `idle_timeout` 은 **마지막 수신 이벤트**부터 계산됩니다 (`job_subscriber.py``last_event = time.monotonic()`).
- 에이전트는 보통 `started` 발행 후 **수 분** 동작합니다. 그러면 idle 은 에이전트 실행 **도중** 만료되어 구독자가 먼저 죽고, `wait` 은 다시 즉시 반환됩니다.
- 실제 블로킹은 **에이전트가 마지막 성공 이벤트로부터 120초 이내에 끝나는 짧은 잡**에서만 발생합니다.
**정정된 심각도**: 추가 지연은 **"항상 최소 120초"가 아니라 "최대 약 120초, 통상 0초"** 입니다.
### 1.4 그럼에도 C1-e 를 채택하는 이유 — 진짜 피해는 지연이 아니라 거짓 판정
`:227``wait "$sub_pid" || true` 로 **종료 코드를 폐기**합니다. 따라서 run_loop 경로에서 구독자의 rc=1/rc=2 는 잡 판정에 영향을 주지 않습니다(잡 판정은 `wait_for_job` 의 디스크 폴링). 그러나:
- 감사 산출물인 `$REGISTRY_DIR/$JOB_ID.subscriber.out` 에는 **성공한 잡에 대해 `socket.timeout` traceback 또는 `ERROR: idle timeout (120s, no events)`** 가 남습니다.
- `:228` 이 이를 그대로 표준출력에 덤프합니다 (`echo "subscriber output:"; cat "$logf"`).
- 즉 **정상 완료된 잡의 감사 기록이 실패로 오염**됩니다. 이것이 지연보다 실질적 피해가 큽니다.
**그리고 rc 를 폐기하지 않는 경로가 존재합니다 — §3 의 F-4.**
---
## 2. 판정에 영향 없음 — 전략 결론 불변
Challenge §3 은 Option (C), asyncio 마찰, F-1 발견, F-2/F-3, 스파이크 매트릭스를 **전항목 승인**했습니다. C1 은 브로커 제품 선택과 직교하는 Track 0 범위 확장이므로, Rev.1 §0 의 판정표는 그대로 유지됩니다.
| 선택지 | 코드 변경 | 테스트 변경 | A-2 해소 | 판정 |
|---|---|---|---|---|
| (A) 현행 유지 (공개 HiveMQ) | 0 | 0 | ❌ | 기각 |
| (B) 네이티브 NATS (`nats-py`) | 4개 호출부 재작성 | 46건 | ✅ | **기각** |
| **(C) `nats-server` + MQTT 프로토콜 유지** | **0** | **0** | ✅ | ✅ **채택** |
**오히려 C1 은 판정을 보강합니다**: `job_subscriber.py` 가 제어 경로에 (간접적으로) 있다는 사실은, 이 파일을 **네이티브 NATS 로 재작성하는 것의 위험을 키웁니다**. Rev.1 §1.3 에서 이 파일은 raw paho 클라이언트 구동 9줄로 4개 호출부 중 최다입니다. 선택지 (B)는 **제어 경로 위의 파일을 재작성**하게 되며, (C)는 건드리지 않습니다.
---
## 3. Challenge 가 놓친 결함 2건
### F-4 (Critical) — `loop`/`discuss` 경로에서 구독자 종료 코드가 **잡 판정 그 자체**
`multi-agent-mux-delegate-job:331-341`:
```bash
local sub_rc=0
wait "$sub_pid" || sub_rc=$?
echo "subscriber output:"; cat "$logf" || true
local job_status="running"
if [[ $sub_rc -eq 0 ]]; then job_status="completed"
elif [[ $sub_rc -eq 1 ]]; then job_status="error" # ← 브로커 도달 불가 = rc 1 (실측)
else job_status="timeout" # ← idle timeout = rc 2
fi
echo "Job role $display_role finished with status: $job_status"
```
`:227``|| true` 와 달리 여기서는 **rc 가 잡 상태로 직결**됩니다. 그리고 실측했듯 **브로커 도달 불가 시 구독자는 미포착 예외로 rc=1** 을 냅니다.
`job_subscriber.py` 가 rc=1 을 내는 정상 경로는 **"터미널 `error` 이벤트를 수신했다"** 하나뿐입니다(`return 1` at 말미). 그런데 파이썬 미포착 예외도 rc=1 입니다. 따라서:
> **"에이전트가 error 를 보고했다" 와 "브로커에 접속하지 못했다" 가 구분 불가능하며, 후자가 전자로 보고됩니다.**
성공한 잡이 `job_status="error"` 로 판정됩니다. 이는 지연 문제가 아니라 **오케스트레이션 정확성 결함**이며, C1 이 지적한 `:227` 경로보다 심각합니다 — `:227` 은 rc 를 버리므로 피해가 로그 오염에 그치지만, `:331` 은 **잘못된 판정을 하류로 전파**합니다.
**적용 범위 주의**: `run_loop.sh` 는 전 호출부가 `--type direct` 이므로 이 경로를 타지 않습니다. F-4 는 `multi-agent-mux-delegate-job loop|discuss`**직접 호출**할 때 발현합니다 (`:362`, `:375` 에서 `TYPE` 분기). 즉 **잠재 결함이지 현재 run_loop 회귀는 아닙니다.** 그러나 Track 0 이 `job_subscriber.py` 를 손대는 김에 함께 닫아야 하며, **디스크 폴백만 추가하고 rc 매핑을 놔두면 다른 예외 경로에서 동일 혼동이 남습니다.**
### F-5 (Medium) — 문서가 주장하는 persistent session 이 코드상 **구성 불가**
`MESSAGING.md:64`:
> Subscribers connect with **persistent session flags** to ensure the broker buffers QoS 1 messages during temporary network drops.
그러나 `mqtt_common.py:258-262`:
```python
client_id = f"{config.client_id_prefix}-{role}-{uuid.uuid4().hex[:8]}" # ← 매 실행 랜덤
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=client_id,
) # ← clean_session / clean_start 미지정
```
durable session 은 **안정적인 client_id** 를 전제합니다. 현재는 매 프로세스 기동마다 client_id 가 바뀌므로, clean-session 플래그를 켜더라도 **브로커가 이전 세션을 인식할 수 없습니다.** 즉 문서의 주장은 코드로 뒷받침되지 않습니다.
**이것이 C1 판정에 미치는 영향**: "durable session 을 켜면 중도 유실 문제가 해결된다"는 대안 경로는 **client_id 안정화 없이는 불가능**합니다. 따라서 C1-e 의 **디스크 폴백이 올바른 해법**이며, 이 발견은 Challenge 의 결론을 보강합니다. (client_id 안정화는 동시 실행 구독자 충돌 위험을 낳으므로 별도 과제로 분리합니다 — §5 비-목표.)
---
## 4. 개정된 Track 0 (F-1 + C1 + F-4) — 최우선
> 브로커 제품 선택과 **완전히 독립**이며 우선순위가 더 높습니다.
### 4.0 순서 의존성 (필수)
```
Step 1 (publish_event.py) → Step 2 (job_subscriber.py) → Step 3 (rc 매핑)
디스크에 터미널 상태를 디스크를 읽어 조기 종료 판정 혼동 제거
"쓰게" 만든다 (Step 1 없이는 읽을 것이 없음)
```
**Step 2 를 단독 시행하면 효과가 0입니다.** §1.2 에서 판정한 대로, 현행은 브로커 실패 시 디스크에도 아무것도 남지 않기 때문입니다.
### 4.1 Step 1 — `publish_event.py` 실패 순서 재구성 (Rev.1 대비 불변)
`publish_event.py:186-208` 재구성:
1. `publish(...)` 실패를 `publish_ok = False` 로 표시하되 **`return` 하지 않음**.
2. 감사 로그·레지스트리 이벤트·상태 동기화를 **발행 성공 여부와 무관하게 항상 수행**. 감사 레코드에 `"published": publish_ok``"publish_error": str(exc)` 포함.
3. 종료 코드 계약 유지 — 발행 실패 시 **여전히 `return 2`**. 단 **상태는 이미 기록된 뒤**.
4. seq 소모 정책: 현행(실패해도 소모) **유지**. 재생방지(`> highest accepted`)에 무해하고, (2)의 실패 레코드가 gap 을 설명 가능하게 만들기 때문. **이 결정을 주석으로 명문화.**
### 4.2 Step 2 — `job_subscriber.py` 디스크 폴백 (C1-e 채택)
이벤트 대기 루프의 `queue.Empty` 분기(`job_subscriber.py:233-239`)에서, **pending 잡별로** 디스크 터미널 상태를 확인합니다.
**설계 결정 4가지** (Challenge 가 명시하지 않은 부분):
| 항목 | 결정 | 사유 |
|---|---|---|
| **조회 순서** | `registry.load_job()` → 없으면 `mqtt_common.read_logged_status()` | 레지스트리가 라이브 레코드(권위), 감사 로그는 레지스트리가 정리되어도 남는 보조 사본 |
| **조회 주기** | 매 `queue.Empty` 마다가 아니라 **최소 3초 간격 스로틀** | 대기 루프는 `wait = min(..., 1.0)` 로 최대 1초마다 깨어남. 잡당 파일 2개를 초당 읽으면 불필요한 I/O. `wait_for_job` 의 3초 폴링 주기와 정렬 |
| **합성 이벤트** | 디스크 상태로 터미널 판정 시 `_format_line` 과 동일 형식으로 stdout 에 출력하되 `"source": "disk-fallback"` 표기 | 감사 로그에서 와이어 수신분과 폴백분이 **구분 가능해야** 함. 무표기 합성은 F-3(HMAC) 우회 통로가 됨 |
| **HMAC 검증** | 디스크 폴백분은 **HMAC 검증 대상 아님** | 로컬 파일시스템은 이미 신뢰 경계 안. 단 위 표기로 출처를 명시 |
| **종료 코드** | 디스크가 `completed`**0**, `error`**1**, `cancelled`**1** | 와이어 수신 시의 기존 매핑과 동일하게 유지 (호출부 계약 불변) |
**주의 — 조기 종료가 아닌 경우**: `--wait-any` 로 다중 잡을 감시 중이면 **모든 pending 잡이 터미널에 도달했을 때만** 종료합니다. 일부만 디스크 터미널이면 나머지는 계속 대기합니다.
### 4.3 Step 3 — rc → job_status 매핑 명확화 (F-4)
`multi-agent-mux-delegate-job:333-341` 의 3분기 매핑은 "구독자가 정상적으로 판정했다"를 전제하지만, 미포착 예외도 rc=1 을 냅니다. 두 가지를 분리합니다:
1. `job_subscriber.py``main()` 을 최상위 `try/except` 로 감싸 **인프라 실패는 전용 코드(예: rc=3)** 로 반환하고, `rc=1`**"터미널 error 이벤트 수신"에만** 예약합니다.
2. `:333-341``rc=3` 분기를 추가해 `job_status="broker_unavailable"` 로 판정하고, **`wait_for_job` 과 동일하게 디스크를 재확인**하도록 합니다.
3. `:180-187``sub_exit != 0 → exit 1` 조기 중단 경로도 `rc=3` 을 **중단 사유에서 제외**합니다 (브로커 부재로 위임 전체를 죽여서는 안 됨). — 실측상 이 경로는 재시도 최소 15초 > 핸드셰이크 창 5초라 **현재 도달 불가**이나, `attempts`/`base_delay` 변경 시 살아나는 잠복 경로이므로 함께 닫습니다.
### 4.4 회귀 가드 (mutation 기준 — 결함을 되살렸을 때 반드시 실패해야 함)
| ID | 가드 | Mutation (이걸 되돌리면 FAIL 해야 함) |
|---|---|---|
| **G-1** | 도달 불가 브로커로 `--event completed` 발행 → rc=2 **이면서 레지스트리 `status == "completed"`** | `return 2` 를 상태 동기화 앞으로 이동 |
| **G-2** | 동일 상황 감사 로그에 `published: false` + `publish_error` 레코드 존재 | `append_event` 를 성공 경로로만 한정 |
| **G-3** | 브로커 정상 시 rc=0 + `status == "completed"` + 감사 `published: true` (무회귀) | — |
| **G-4** | 발행 실패 후 `last_seq` 1 증가, 후속 성공 발행이 **더 큰 seq** 사용 | seq 롤백 도입 |
| **G-5** | 레지스트리에 `status=completed`**미리 써 두고** 도달 불가 브로커로 `job_subscriber.py` 실행 → **rc=0 으로 3~5초 내 종료** | 디스크 폴백 제거 → idle/연결실패로 rc≠0 |
| **G-6** | 동일 조건에서 stdout 합성 라인에 **`disk-fallback` 표기** 존재 | 표기 누락 시 FAIL (F-3 우회 통로 방지) |
| **G-7** | 레지스트리 `status=error` → 폴백 종료 코드 **1** / `status=completed`**0** | 매핑 반전 |
| **G-8** | `--wait-any` 로 2개 잡 감시 중 **1개만** 디스크 터미널 → **종료하지 않음** | 부분 종료 도입 시 FAIL |
| **G-9** | 브로커 도달 불가 + 디스크에 터미널 상태 **없음** → rc **3** (rc 1 아님) | rc=1 로 되돌리면 FAIL (F-4) |
| **G-10** | `loop` 경로에서 rc=3 수신 시 `job_status``"error"`**아님** | 3분기 매핑으로 되돌리면 FAIL |
**통합 검증 (가장 중요)**: 브로커 정지 상태에서 `--type direct` 위임 1건을 끝까지 돌려, ① `wait_for_job` 이 3900초가 아니라 **3초 내 return 0**, ② `$JOB_ID.subscriber.out` 에 traceback 이나 `idle timeout`**없을 것**, ③ 전체 벽시계 시간이 브로커 정상 시와 **유의미하게 다르지 않을 것**.
### 4.5 예상 테스트 증분
가드 10건 → 베이스라인 **276 → 286**. 전량 신규이며 기존 276건 수정은 **0건**을 목표로 합니다 (기존 rc 계약을 `rc=1`/`rc=0`/`rc=2` 범위에서 유지하고 `rc=3` 만 신설하기 때문).
---
## 5. Track 1 이후 (Rev.1 대비 불변)
### Track 1 — 브로커 선택 스파이크
격리 클론(`git clone --local --no-hardlinks . "$SCRATCH/nats-spike"`)에서 수행, 종료 후 삭제.
| ID | 검증 | 통과 기준 |
|---|---|---|
| **S-1** | nats-server 가 MAM MQTT 클라이언트 수용 | rc=0, `status=completed`, `last_seq` 정상 |
| **S-2** | paho `CallbackAPIVersion.VERSION2` + MQTT 3.1.1 호환 | CONNACK rc=0 |
| **S-3** | **Retained terminal event** ← 최고 위험 | 신규 구독자가 **즉시** 최종 이벤트 수신 |
| **S-4** | QoS 1 발행 ACK | `is_published()` True |
| **S-5** | 와일드카드 구독 | `SUBSCRIBED` 출력 + 이벤트 수신 |
| **S-6** | 인증 + TLS | 자격증명 누락 시 거부 |
| **S-7** | subject 단위 권한 (A-2 목표) | publisher 구독 거부 / subscriber 발행 거부 |
| **S-8** | 전체 회귀 | **286 passed, 0 failed** (Track 0 반영 후) |
| **S-9** 🆕 | **Track 0 폴백이 nats-server 에서도 유효** | G-5 · 통합 검증을 nats-server 정지 상태에서 재실행 |
**S-3 실패 시** → 선택지 (C) 기각, mosquitto 로 진행. **S-3 은 판정 번복의 유일한 조건입니다.**
### Track 2 — A-2 해소 (F-2 + F-3), 브로커 확정 후
1. **F-3**: `registry.register_job()` 에서 `auth_token` **항상 발급**(`secrets.token_hex(32)`). 기존 `None` 잡 하위호환은 `verify_hmac` 의 현행 경로가 담당하되, **신규 잡에서는 그 경로가 발생하지 않음**을 가드로 고정.
2. **F-2**: `DEFAULT_TOPIC_ROOT` 를 지문 기반(`mam/<sha256[:12]>/jobs`)으로 전환. 순서 엄수 — **① 발행측 전환 → ② 동작 확인 → ③ `reconcile.sh:237` legacy 구독 제거**(별도 커밋, 롤백 보존).
3. S-7 에서 검증한 subject 단위 권한을 배포 설정에 반영.
### Track 3 — 문서 동기화
| 문서 | 변경 |
|---|---|
| `MESSAGING.md` | §1.2 브로커 제품 갱신. §5 한계에 **F-1·C1 해소** 기록. **§1.2.4 의 persistent session 서술을 F-5 실측에 맞게 정정** 🆕 |
| `IMPROVEMENTS.md` | A-2 갱신, **F-1·F-4·F-5 신규 등재**, F-2·F-3 상태 갱신 |
| `VERSIONS.md` | 브로커 런타임 버전 등재 |
| `deploy/install.sh:484-492`, `install_mam.sh:306-314` | `requirements.txt` **변경 없음**(paho 유지). 브로커 기동 안내만 추가 |
### 비-목표 (명시적 제외)
-`nats-py` 도입 및 클라이언트 프로토콜 재작성
-`.mam/jobs/*.json` 의 JetStream KV 대체 — 상태 계층 교체는 전송 교체와 **별개 결정**
-**client_id 안정화 / durable session 도입** 🆕 — F-5 의 근본 해결이나, 동시 구독자 client_id 충돌 위험을 새로 낳음. Track 0 의 디스크 폴백이 같은 문제를 **부작용 없이** 해결하므로 별도 과제로 분리
-`requirements.txt``paho-mqtt>=2.0.0` 변경
---
## 6. Cross-Review 대비 — 반론 선제 대응
Rev.1 §8 의 6개 항목은 유효하며, C1 관련 2개를 추가합니다.
| 예상 반론 | 응답 |
|---|---|
| "C1-d 를 기각했으면서 C1-e 를 채택하는 것은 모순" | 아닙니다. **지적된 결함(디스크 폴백 부재)은 실재하고, 제시된 피해(120초 지연)만 실측 반증**되었습니다. 채택 사유를 지연에서 **거짓 실패 판정·감사 기록 오염**(§1.4)과 **F-4 의 오판정**(§3)으로 교체했으며, 이는 원래 사유보다 **강한** 근거입니다 |
| "Rev.1 이 틀렸다면 판정 전체를 재검토해야 한다" | 틀린 것은 **§1.2 표의 한 행**(호출처 누락, 원인은 grep include 필터)이며, 판정의 토대인 **§1.1 측정(`run_loop.sh` MQTT 참조 1건, `wait_for_job` 파일 폴링)은 재확인 결과 그대로 유효**합니다. 게다가 C1 은 `job_subscriber.py` 를 제어 경로에 넣음으로써 **선택지 (B)의 위험을 키워 판정을 보강**합니다(§2) |
| "F-4 는 run_loop 가 안 쓰는 경로이니 무시해도 된다" | 현재 회귀는 아니지만, `loop`/`discuss``:80` usage 에 문서화된 **공개 인터페이스**이며 `:362`/`:375` 에서 실제 분기합니다. 무엇보다 Track 0 이 `job_subscriber.py` 를 이미 여는 이상, rc 계약을 함께 정리하지 않으면 **디스크 폴백을 넣고도 다른 예외 경로에서 같은 혼동이 남습니다** |
---
## 7. 산출물 및 다음 단계
- **Creator**: 본 Rev.2 를 종합해 `NATS_REPORT.md` 로 저장합니다. §0 Challenge 판정표, §1 C1 실측 판정, §3 F-4·F-5, §4 개정 Track 0(순서 의존성 + 가드 10건)이 필수 포함 항목입니다.
- **Reviewer 전원** — 다음 4건을 **재현 검증**해 주십시오:
1. `run_loop.sh` 전 호출부가 `--type "direct"` 인가 (§1.1 — C1 인정의 근거)
2. **§1.3 타이밍 반증**: 도달 불가 브로커에서 `job_subscriber.py`**40초에 rc=1**, 접속 거부에서 **15.1초에 rc=1**. 핸드셰이크 창은 5초
3. **§3 F-4**: `:333-341` 의 rc→`job_status` 매핑에서 브로커 실패(rc=1)가 `"error"` 로 판정되는가
4. **§5 S-3** nats-server retained message 지원 — **판정 번복의 유일한 조건**
- **Track 0 은 브로커 결정과 무관하게 즉시 착수 가능**하며, Step 1 → Step 2 → Step 3 **순서를 반드시 지켜야 합니다**(§4.0).
---
## 8. 판정 재확인
> **[VERDICT: DO NOT MIGRATE — ADOPT `nats-server` AS BROKER, KEEP MQTT CLIENT PROTOCOL]**
>
> 조건: §5 **S-3(retained terminal event)** 및 **S-8(286 tests green)** 통과. S-3 실패 시 mosquitto 로 회귀하며, **어느 경우에도 클라이언트 프로토콜은 변경하지 않습니다.**
>
> **선행 필수**: Track 0 (F-1 + C1 + F-4) — 브로커 선택과 독립이며 우선순위가 더 높습니다. **Step 순서 의존성 존재**(§4.0).