# 🔎 문서 정합성 검증 및 동기화 계획서 Rev.2 (Job `eb04e918`)

- **작성일**: 2026-08-23
- **역할**: Planner (`.agents/MULTI_AGENT_RULES.md` §1 — Planner 는 저장소 코드/문서를 **수정하지 않으며**, 산출물은 본 보고서입니다)
- **기준 커밋**: `916185c`, 작업 트리 clean, `main` 은 `origin/main` 보다 **ahead 2**
- **선행 리비전**: `1fa7183a` (Rev.1) ← 본 문서가 대체합니다
- **판정 대상 리뷰**: `b93680ab` (agy, `[VERDICT: PASS WITH CHALLENGE]`) — CI 서브모듈 인증 / D-31 스코프 / B-17 fail-closed
- **검증 대상**: `MESSAGING.md`, `IMPROVEMENTS.md`, `implementation_plan.md`

---

## A. 리뷰 판정 (Adjudication of Challenge `b93680ab`)

### A-0. 판정 요약

| 챌린지 | 판정 | 핵심 근거 |
|---|---|---|
| **C1** 서브모듈 인증·URL 제약 | 🟢 **전제 확증 — 다만 처방 형태는 틀림** | `laa/nats-docker` 는 실제로 **비공개**(익명 `ls-remote` → `Failed to authenticate user`). 그러나 제안된 `url = ../nats-docker` 는 **`tmpl/nats-docker`** 로 해석되어 **잘못된 조직**을 가리킴(실측). 올바른 형태는 `../../laa/nats-docker` |
| **C2** D-31 과도한 제약 | ✅ **전면 수용 — Rev.1 의 논거가 틀렸음** | `lint-shell`/`lint-python` 은 `.agents/`·`deploy/` 만 훑으며 서브모듈 경로를 읽지 않음(실측). Rev.1 이 내세운 "비대칭" 논거는 성립하지 않음 |
| **C3** 명시적 `MAM_ENV_FILE` fail-closed | ✅ **원칙 수용 — 다만 차단 지점을 옮겨야 함** | `_load_dotenv()` 는 **import 시점**에 호출되고(`mqtt_common.py:112`) 테스트 3개 파일이 `mqtt_common` 을 import 함. 여기서 예외를 던지면 스위트 자체가 붕괴 |

리뷰어의 세 지적은 모두 실재하는 맹점을 짚었고, 그중 둘은 **Rev.1 의 처방을 직접 교정**합니다. 다만 C1 의 구체적 처방과 C3 의 차단 지점은 그대로 구현하면 각각 서브모듈을 깨뜨리거나 테스트 스위트를 깨뜨립니다. 아래에서 측정으로 교정합니다.

---

### A-1. C1 — 전제는 옳다. 처방의 형태가 틀렸고, 처방만으로는 부족하다

#### (1) 전제 확증: 서브모듈은 실제로 비공개다

익명(자격증명 없이) `ls-remote` 실측:

| 대상 | 결과 |
|---|---|
| `https://git.godopu.com/laa/nats-docker` | 🔴 `remote: Failed to authenticate user` → **비공개** |
| `https://git.godopu.com/tmpl/multi-agent-mux` (상위 저장소) | 🟢 `629a67f… HEAD` 응답 → **공개** |

리뷰어가 가정한 "비공개 서브모듈이면 토큰이 전파되지 않아 실패" 시나리오는 **가정이 아니라 현실**입니다. Rev.1 의 T-1(`submodules: recursive` 한 줄 추가)만으로는 CI 가 여전히 실패합니다. 이 지적은 Rev.1 의 실질적 결함을 잡아냈습니다.

더 나아가 실측이 드러낸 구조는 리뷰어가 알던 것보다 까다롭습니다: **상위 저장소는 공개, 서브모듈은 비공개, 게다가 서로 다른 조직**(`tmpl/` vs `laa/`). 즉 CI 러너가 상위 저장소를 익명으로 받을 수 있어도 서브모듈에는 별도 권한이 필요합니다.

#### (2) 처방 형태 교정: `../nats-docker` 는 잘못된 저장소를 가리킨다

git 의 상대 서브모듈 URL 은 **상위 저장소의 origin URL 기준**으로 해석됩니다. 실측(임시 저장소에 origin 을 동일하게 설정하고 `git submodule init` 으로 해석 결과 확인):

```
origin = https://git.godopu.com/tmpl/multi-agent-mux

url = ../nats-docker        ->  https://git.godopu.com/tmpl/nats-docker      ❌ 조직 불일치
url = ../nats-docker.git    ->  https://git.godopu.com/tmpl/nats-docker.git  ❌ 조직 불일치
url = ../../laa/nats-docker ->  https://git.godopu.com/laa/nats-docker       ✅ 정확
```

실제 저장소는 `laa/` 아래에 있으므로, 리뷰어가 제시한 두 형태(`../nats-docker`, `../nats-docker.git`)를 그대로 적용하면 **존재하지 않는 경로**를 가리켜 서브모듈이 아예 클론되지 않습니다. 상위 저장소와 서브모듈이 같은 조직에 있다는 암묵적 가정이 이 인스턴스에서는 성립하지 않습니다.

#### (3) 처방 충분성 교정: 상대 URL 은 인증을 해결하지 않는다

상대 URL 이 물려받는 것은 **프로토콜과 호스트**이지 **권한**이 아닙니다. SSH 로 상위를 클론하면 서브모듈도 SSH 로 가므로 키가 재사용되는 이점은 실재하지만, HTTPS + 토큰 조합에서는 토큰의 스코프가 `laa/nats-docker` 를 포함해야 합니다. 상위가 공개이고 서브모듈이 비공개인 현 구조에서는 **상대 URL 로 바꿔도 자격증명은 여전히 별도로 공급**해야 합니다.

따라서 T-1 은 한 줄 추가가 아니라 세 부분으로 확장됩니다(§4 T-1a/T-1b/T-1c).

#### (4) 실측으로 드러난 제3의 선택지 — 서브모듈 공개 전환

`nats-docker` 가 추적하는 파일은 **10개뿐이며 비밀을 담은 파일이 0개**입니다.

```
.agents/skills/env-generator/SKILL.md      docker/.env.example
.agents/skills/env-generator/scripts/…     docker/README.md
.gitignore                                 docker/docker-compose.yaml
NATS_REPORT.md                             docker/nats.conf
PRIVATE_SERVER.md
README.md
```

- `.gitignore` 가 `.env` / `*.env` 를 제외하고 `!*.env.example` 만 허용 — 실제 시크릿은 추적 대상이 아님.
- `docker/.env.example` 은 설계상 **빈 값**(D-25(d) 가 봉인).
- `docker/nats.conf` 는 모든 `password:` 가 `$VAR` 참조(D-25(e) 가 봉인).

즉 이 저장소를 공개해도 유출되는 비밀은 없습니다. 남는 것은 "배포 토폴로지를 공개할 것인가"라는 **정책 판단**이므로 일방적으로 처방하지 않고 §4 에서 3개 선택지로 제시합니다. 다만 공개 전환은 CI 인증 문제를 **완전히 소멸**시키는 유일한 선택지입니다.

#### (5) 부수 실측 — 폭발은 아직 안 터졌을 뿐이다

`git status -sb` → `## main...origin/main [ahead 2]`. 즉 `12ba30b`(문서 서브모듈 이전)와 `916185c` 는 **아직 푸시되지 않았고**, 원격 HEAD 는 `629a67f` 입니다. CI 는 아직 이 변경을 본 적이 없습니다. **다음 푸시 순간 S-1 이 발현**하므로 T-1 은 푸시 이전에 완료되어야 합니다.

---

### A-2. C2 — 전면 수용. Rev.1 의 논거가 틀렸다

Rev.1 은 "test 잡만 고치면 lint/compile 잡이 서브모듈 없는 트리를 훑는 **비대칭**이 남는다"는 이유로 세 checkout 전부에 `submodules` 를 요구했습니다. 실측 결과 이 논거는 성립하지 않습니다.

| 잡 | 실제로 읽는 경로 | 서브모듈 필요 |
|---|---|:---:|
| `lint-shell` | `.agents/skills/**`, `.agents/hooks/…`, `deploy/*.sh` (shellcheck 대상 15개 파일 명시) | ❌ |
| `lint-python` | `.agents/skills/multi-agent-mux-delegate-job/scripts/`, `.agents/skills/lib_py/` (flake8·py_compile) | ❌ |
| `test` | `pytest tests/ -q` → D-11~D-19, D-22~D-30 이 `nats-docker/**` 를 읽음 | ✅ |

lint 잡들은 서브모듈 경로를 **한 번도 참조하지 않습니다**. 없는 트리를 훑는 "비대칭"은 관측 가능한 결과를 낳지 않으므로 교정 대상이 아니었습니다. 리뷰어의 두 지적(불필요한 네트워크 I/O, 향후 경량 워크플로에서의 false positive)이 옳습니다.

**다만 리뷰어 처방에 한 가지를 더합니다 — 공허 통과 방지.** "pytest 를 실행하는 잡"으로 스코프를 좁히면, 잡 이름을 바꾸거나 `pytest` 를 래퍼 스크립트(`make test`, `bash deploy/run-tests.sh`) 뒤로 숨기는 순간 가드가 **검사 대상 0건으로 조용히 통과**합니다. 따라서 D-31 은 테스트 수행 잡을 **하나도 못 찾으면 실패**해야 합니다. 이것이 없으면 스코프 축소가 곧 가드 무력화 경로가 됩니다.

**구현 실측 참고**: PyYAML 로 `deploy/gitea-ci.yml` 을 파싱하면 최상위 키가 `['name', True, 'jobs']` 로 나옵니다 — YAML 1.1 이 `on:` 을 불리언 `True` 로 해석하는 알려진 함정입니다. D-31 은 `jobs` 만 읽으므로 영향은 없으나, Creator 가 `d["on"]` 에 접근하면 `KeyError` 를 만납니다. 현재 세 잡 모두 checkout 스텝 1개 · `with` 는 `None` 이며, `pytest` 가 포함된 잡은 `test` **하나**입니다.

---

### A-3. C3 — 원칙 수용. 그러나 "기동 차단"을 import 시점에 두면 스위트가 죽는다

#### (1) 리뷰어가 옳은 부분

Rev.1 의 처방은 "`MAM_ENV_FILE`(존재할 때만) → `MAM_REAL_ROOT` → … → `walk_up(cwd)`" 순서였습니다. 이는 사용자가 **명시적으로 지정한** 경로가 없을 때 상위 디렉터리의 다른 `.mam.env` 를 임의로 집어 든다는 뜻이고, 리뷰어 지적대로 **명시적 설정 우선 원칙 위반**입니다. 다른 프로젝트의 브로커/계정으로 조용히 붙을 위험이 실재합니다. 이 부분은 Rev.1 의 설계 오류이며 수정합니다.

#### (2) 그러나 차단 지점은 옮겨야 한다

`mqtt_common.py:112` 는 모듈 최상위에서 `_load_dotenv()` 를 호출합니다 — 즉 **import 부작용**입니다. 그리고 `mqtt_common` 을 import 하는 테스트 파일이 3개 있습니다.

```
tests/test_tier1_unit.py
tests/test_tier2_component.py
tests/test_deploy_freshness.py     ← D-19/D-27 이 DEFAULT_TOPIC_ROOT 만 읽으려고 import
```

여기서 예외를 던지면, 낡은 `MAM_ENV_FILE` 이 환경에 남아 있는 **모든** 상황에서 `import mqtt_common` 이 실패하고 스위트가 수집 단계에서 붕괴합니다. 브로커에 접속할 의도가 전혀 없는 소비자(상수 하나 읽는 테스트)까지 함께 죽습니다.

#### (3) 종합 처방 — 기록은 import 에서, 거부는 접속 지점에서

| 단계 | 동작 |
|---|---|
| **import (`_load_dotenv`)** | `MAM_ENV_FILE` 이 설정됐는데 파일이 없으면 → `logger.error("MAM_ENV_FILE is set to %s but no such file; refusing to auto-discover", path)` 후 **모듈 전역 플래그** `_env_file_missing = True` 설정. **자동 탐색을 시도하지 않음**(리뷰어 요구 반영). **예외를 던지지 않음** |
| **`MAM_ENV_FILE` 미설정** | 순서 있는 탐색 수행: `MAM_REAL_ROOT` → `WORKSPACE_ROOT` → `walk_up(__file__)` → `walk_up(cwd)` |
| **접속 지점 (`make_client()` / 브로커 설정 확정)** | ① `_env_file_missing` 이면 **명시적 예외로 거부**(fail-closed). ② 해석된 호스트가 내장 공개 기본값(`broker.hivemq.com`)과 같으면 **눈에 띄는 보안 경고** 출력 |

이 배치가 두 요구를 모두 만족시킵니다: 명시적 설정이 깨졌을 때 조용히 다른 환경으로 새지 않고(리뷰어 C3-1), 자동 탐색이 아무것도 못 찾아 공개 브로커로 떨어질 때 반드시 경고가 나오며(리뷰어 C3-2), 그러면서도 읽기 전용 소비자의 import 를 깨뜨리지 않습니다.

**보조 실측** — `_parse_env_file` 은 `if key and key not in os.environ` 로 기록하므로 **OS 환경변수가 파일보다 우선**합니다. 따라서 사용자가 `MQTT_BROKER` 를 직접 export 한 경우에는 공개 기본값으로 떨어지는 일이 애초에 없습니다. 위 ②의 조건을 "`MAM_ENV_FILE` 부재"가 아니라 "**해석 결과가 공개 기본값과 일치**"로 잡은 이유이며, 이 편이 탐색 경로 전체를 한 번에 덮습니다.

---

## B. Rev.1 → Rev.2 변경 요약

| # | 변경 | 출처 |
|---|---|---|
| C-1 | **T-1 을 T-1a/T-1b/T-1c 로 분할** — `.gitmodules` 상대 URL은 `../../laa/nats-docker`(리뷰어 제시 형태는 오답), 비공개 서브모듈 자격증명 공급, 3개 선택지 비교 | A-1 |
| C-2 | **D-31 스코프 축소** — "모든 checkout" → "테스트 수행 잡의 checkout". **공허 통과 방지 단언 추가** | A-2 |
| C-3 | **T-9(B-17) 처방 재설계** — import 시점 기록 + 접속 지점 거부의 2단 구조. 명시적 경로 실패 시 자동 탐색 금지 | A-3 |
| C-4 | 신규 발견 **S-13**(미푸시 2커밋 — S-1 발현 시점), **S-14**(공개 상위 / 비공개 서브모듈 비대칭) | A-1(5), A-1(1) |
| C-5 | Rev.1 의 T-1 논거(“lint 잡 비대칭”) **철회** — 실측상 성립하지 않음 | A-2 |
| C-6 | D-31 구현 주의 추가 — PyYAML 이 `on:` 을 `True` 키로 파싱 | A-2 |

Rev.1 의 판정, 실측 원장(V-1~V-15), 발견 S-1~S-12, 작업 T-2~T-8·T-10~T-14, 가드 D-32 는 리뷰에서 전면 동의를 받았으며 변경 없이 유지합니다.

---

## 0. 판정

테스트는 전건 통과하나 **문서 동기화 목표는 여전히 미충족**입니다(구현이 아직 수행되지 않았으므로 Rev.1 판정 유지).

- `MESSAGING.md` 는 NATS·JetStream·Docker·원격·Tailscale 을 **0건** 언급하며, 확정 표준(`nats-server` MQTT **3.1.1**)과 모순되는 서술(`MQTT 5.0` / `Mosquitto·EMQX`)을 프로덕션 표준으로 제시합니다.
- `IMPROVEMENTS.md` 는 해결된 B-14/B-15 를 미해결로 집계하고, Track 1R·D-22~D-30·서브모듈 전환을 0건 반영했습니다.
- CI 는 서브모듈을 받지 않아 배포 신선도 가드 29건 중 **18건이 실패**하며(실측), 서브모듈이 **비공개**이므로 `submodules: recursive` 한 줄로는 해결되지 않습니다(신규).

**[VERDICT: NOT PASS]**

---

## 1. 테스트 실행 결과

| 명령 | 결과 |
|---|---|
| `.venv/bin/python -m pytest tests/test_deploy_freshness.py tests/test_sanity.py -q` | **31 passed in 21.43s** |
| `.venv/bin/python -m pytest tests/ -q` (전체) | **306 passed in 375.81s** (exit 0) |
| `pytest tests/ -q --collect-only` | **306 collected** |

문서 회귀 0건. 양호 항목(조치 불필요): `_resolve_private_server_doc()`·`_resolve_docker_dir()` 3-후보 폴백 구현 ✅ / D-16 구멍 교정(`assert "alpine" in tag`) ✅ / `requirements.txt` 에 `PyYAML>=6.0` 추가 ✅ / CI 의 PyYAML 은 스킬 `requirements.txt` 의 `pyyaml` 로 확보되어 **결함 아님** ✅ / `implementation_plan.md` §5 P0.5·R-1~R-13 및 서브모듈 링크(`:7`, `:147`) 갱신 ✅.

---

## 2. 실측 원장

Rev.1 의 V-1 ~ V-15 는 유지하며, 본 리비전에서 다음을 추가 측정했습니다.

| # | 검증 | 방법 | 결과 |
|---|---|---|---|
| **V-16** | 서브모듈 공개 여부 | 자격증명 없이 `git ls-remote https://git.godopu.com/laa/nats-docker` | 🔴 `remote: Failed to authenticate user` → **비공개** |
| **V-17** | 상위 저장소 공개 여부 | 동일 방식 `…/tmpl/multi-agent-mux` | 🟢 ref 목록 응답 → **공개** (원격 HEAD `629a67f`) |
| **V-18** | 상대 URL 해석 | 임시 저장소에 동일 origin 설정 후 `git submodule init` | `../nats-docker` → `tmpl/nats-docker` ❌ / `../../laa/nats-docker` → `laa/nats-docker` ✅ |
| **V-19** | 서브모듈 비밀 노출 | `git -C nats-docker ls-files` + `.gitignore` | 추적 파일 **10개, 비밀 파일 0개**. `.env` 제외, `.env.example` 빈 값, `nats.conf` 전부 `$VAR` |
| **V-20** | 미푸시 커밋 | `git status -sb` | `## main...origin/main [ahead 2]` — `12ba30b`, `916185c` 미푸시 |
| **V-21** | lint 잡의 서브모듈 의존 | `deploy/gitea-ci.yml:15-80` 의 shellcheck/flake8/py_compile 대상 경로 | `.agents/**`, `deploy/*.sh` 만 — **서브모듈 참조 0건** |
| **V-22** | CI YAML 파싱 | PyYAML `safe_load` | 최상위 키 `['name', True, 'jobs']` (`on:` → 불리언). `pytest` 포함 잡 = `test` **1개**, 세 잡 모두 checkout 1개 · `with` 는 `None` |
| **V-23** | `_load_dotenv` 호출 시점 | `mqtt_common.py:112` | **모듈 최상위 = import 부작용** |
| **V-24** | `mqtt_common` import 소비자 | `grep -rln "import mqtt_common" tests/` | `test_tier1_unit.py`, `test_tier2_component.py`, `test_deploy_freshness.py` — **3개** |
| **V-25** | 환경변수 우선순위 | `_parse_env_file`: `if key and key not in os.environ` | **OS 환경변수가 `.mam.env` 보다 우선** |

---

## 3. 발견 사항

Rev.1 의 S-1 ~ S-12 를 유지하고, S-1 을 갱신하며 S-13/S-14 를 신설합니다. (S-2 ~ S-12 상세는 Rev.1 과 동일하므로 요지만 재수록합니다.)

### 🔴 S-1 (P1, CI 차단) — **갱신**: 서브모듈 미체크아웃 + 비공개 저장소 인증

`deploy/gitea-ci.yml` 의 checkout 3곳(`:21`, `:53`, `:87`)이 옵션 없이 `actions/checkout@v3` 를 씁니다. 트리를 복제해 `nats-docker/` 를 비운 시뮬레이션에서 **18 failed, 11 passed**(D-11~D-19, D-22~D-30 전멸)를 실측했습니다.

**Rev.2 갱신**: `submodules: recursive` 추가만으로는 부족합니다. 서브모듈이 **비공개**(V-16)이고 상위 저장소는 **공개**(V-17)이며 **서로 다른 조직**이므로, 러너에 `laa/nats-docker` 읽기 권한이 별도로 공급되어야 합니다. §4 T-1a/T-1b/T-1c 참조.

### 🔴 S-13 (P1, 타이밍) — **신설**: 아직 푸시되지 않았을 뿐이다

`main` 이 `origin/main` 보다 **ahead 2**(V-20). 원격 HEAD 는 `629a67f` 이고, 서브모듈 문서 이전 커밋 `12ba30b`·`916185c` 는 로컬에만 있습니다. CI 는 아직 이 상태를 본 적이 없으며, **다음 푸시 순간 S-1 이 발현**합니다. T-1 은 푸시 이전에 완료되어야 하며, 그렇지 않으면 `main` 브랜치 CI 가 즉시 빨간불이 됩니다.

### 🟠 S-14 (P2, 구조) — **신설**: 공개 상위 / 비공개 서브모듈 비대칭

상위 저장소는 누구나 클론할 수 있으나(V-17) 서브모듈은 자격증명을 요구합니다(V-16). 결과적으로 **외부 사용자가 `deploy/install.sh` 경로로 이 프레임워크를 받으면 `nats-docker/` 는 빈 디렉터리**가 됩니다. 현재는 `install.sh` 가 `docker/` 나 `PRIVATE_SERVER.md` 를 배포하지 않으므로(Rev.1 D-8) 실사용에 지장은 없지만, 저장소를 클론해 테스트를 돌리려는 외부 기여자는 **18건 실패**를 만나게 됩니다. §4 T-1c 의 선택지 A(공개 전환)가 이 문제까지 함께 해소합니다.

### 나머지 발견 (Rev.1 유지, 요지)

| ID | 요지 |
|---|---|
| 🔴 **S-2** (P1) | `MESSAGING.md` 에 nats/jetstream/docker/remote/tailscale **0건**. §1.2 가 "MQTT **5.0** … Mosquitto or EMQX" 를 프로덕션 표준으로 제시 — NATS 는 MQTT 5.0 미지원이므로 단순 구식이 아니라 모순. §1.3 은 Mosquitto 설정을 유일한 레퍼런스로 제시 |
| 🔴 **S-3** (P1) | `MESSAGING.md` §6.1-3 이 이미 해결된 B-15 를 현재 제약으로 서술("it exits, leaving the running herdr agent orphaned"). 실제로는 `job_subscriber.py:60 _check_disk_fallback`, `:230`, `:244`, `return 3` 존재. §4.2 도 B-14 수정 미반영 |
| 🟠 **S-4** (P2) | `MESSAGING.md` 가 `broker_config_from_env` 파싱 10종 중 8종만 문서화 — `MQTT_CLIENT_ID_PREFIX`, `MQTT_KEEPALIVE` 누락. `.mam.env` 해석 순서(`_load_dotenv`) 절 부재 |
| 🔴 **S-5** (P1) | `IMPROVEMENTS.md:3-6` 이 `276/276`, 미해결 5건(B-14·B-15 포함), 완료 24건. 실제로는 306/306, B-14/B-15 는 `c6b6c77` 에서 해결·G-1~G-10 봉인. 제목의 `✅ 완료` 마커도 이 둘만 누락(다른 42개는 보유) → 미해결 **3건**, 완료 **26건**. **A-2 는 M3 미완이므로 미해결 유지** |
| 🔴 **S-6** (P1) | `IMPROVEMENTS.md` 에 `D-22`~`D-30`, `nats-docker`, `submodule`, `Track 1R` **0건**. 커밋 5종(`3523b9b`, `b09d420`, `629a67f`, `12ba30b`, `916185c`)의 성과가 백로그에 부재 |
| 🔴 **S-7** (P1, 보안) | `B-17`/`B-18` 미등록(`implementation_plan.md:143` 은 등록 요구). HEAD 재현: `MAM_ENV_FILE=<오타경로>` → `broker.hivemq.com 1883 tls=False`, 대조군 → `vm-ubuntu 1883`. `.mam.env` 가 이미 사설 브로커를 가리키므로 지금이 더 위험 |
| 🟠 **S-8** (P2) | `implementation_plan.md:3-5` 헤더가 `v1.0.0` / `a9934ad` / `276/276` — 실제 HEAD `916185c`, 306/306 |
| 🟠 **S-9** (P2) | `:23` Track 1R 변경 지점이 구 경로. `:13-16` 트랙 다이어그램에 Track 1R 부재(§2 마일스톤 도식과 불일치). `:39` 테스트 수 `276 -> 280` |
| 🟠 **S-10** (P2) | 서브모듈 전환(`629a67f`, `12ba30b`)이 로드맵에 기록 없음 |
| 🟠 **S-11** (P2) | `:177` `.mam.env` 전환 미체크인데 실제로는 `MQTT_BROKER=vm-ubuntu`, `MQTT_USERNAME=mam_agent` 로 전환 완료 — 추적기가 현실보다 뒤처짐 |
| 🟡 **S-12** (P3) | `:172` 의 `PRIVATE_SERVER.md:73`, `:146` 행 번호 인용이 낡음 → 절 번호로 교체 |

---

## 4. 동기화 작업 명세 (Creator 범위)

**T-1 계열은 CI 를 되살리는 작업이며 S-13 때문에 다음 푸시 이전에 완료되어야 합니다.**

### T-1a — `.gitmodules` 상대 URL 전환 (선택지 C 를 택할 경우 필수, 그 외에는 권고)

```ini
[submodule "nats-docker"]
	path = nats-docker
	url = ../../laa/nats-docker
```

⚠️ **`../nats-docker` 를 쓰지 마십시오.** 상위 origin 이 `tmpl/multi-agent-mux` 이므로 `tmpl/nats-docker` 로 해석되어 존재하지 않는 저장소를 가리킵니다(V-18). 변경 후 반드시 검증:

```bash
git submodule sync --recursive
git config --get submodule.nats-docker.url   # → https://git.godopu.com/laa/nats-docker
```

효과는 **프로토콜·호스트 상속**(SSH 클론 시 서브모듈도 SSH, 미러/포크 이전 시 자동 추종)이며, **권한 문제는 해결하지 않습니다**.

### T-1b — CI checkout 에 서브모듈 활성화

`test` 잡의 checkout 스텝(`deploy/gitea-ci.yml:87`)에만 적용합니다(A-2).

```yaml
      - name: Checkout Code
        uses: actions/checkout@v3
        with:
          submodules: recursive
```

`lint-shell`/`lint-python` 은 **변경하지 않습니다** — 서브모듈 경로를 읽지 않음이 실측되었습니다(V-21).

### T-1c — 비공개 서브모듈 접근 확보 (택 1, 정책 판단 필요)

| 선택지 | 방법 | 장점 | 단점 |
|---|---|---|---|
| **A. `nats-docker` 공개 전환** 🏆 | Gitea 에서 저장소 visibility 를 public 으로 | CI 인증 문제 **완전 소멸**. 외부 기여자 S-14 도 동시 해소. 추적 파일에 비밀 0건이 실측됨(V-19) | 배포 토폴로지(포트·계정 구조)가 공개됨. 단, 비밀은 없으며 보안은 시크릿에 의존하지 모호성에 의존하지 않음 |
| **B. 러너에 읽기 토큰 주입** | `test` 잡에 `laa/nats-docker` 읽기 스코프 토큰을 secret 으로 두고, checkout 앞에 `git config --global url."https://<user>:${{ secrets.SUBMODULE_TOKEN }}@git.godopu.com/".insteadOf "https://git.godopu.com/"` | 저장소 비공개 유지 | 토큰 수명 관리 필요. 토큰이 CI 로그에 노출되지 않도록 주의. 외부 기여자는 여전히 실패 |
| **C. 배포 키 + SSH URL** | `.gitmodules` 를 SSH 로 두고 러너에 read-only deploy key 배치 (T-1a 와 병행) | 스코프가 저장소 단위로 최소화됨 | 러너 이미지에 키 배치·`known_hosts` 관리 필요. 사설 도메인 DNS/인증서 이슈는 별도 |

**권고: A.** 실측(V-19)상 공개해도 잃을 비밀이 없고, 세 선택지 중 유일하게 CI·외부 기여자·미래 미러 문제를 한 번에 없앱니다. 비공개 유지가 조직 정책이라면 B 를 택하고, 그 경우 §5 의 D-31 은 "checkout 이전에 자격증명 설정 스텝이 존재하는가"까지 검사하도록 확장하십시오.

**검증**: Rev.1 의 시뮬레이션(트리 복제 후 `nats-docker/` 를 비우고 `pytest tests/test_deploy_freshness.py -q`)을 재실행하여 `18 failed` → `0 failed` 확인. 가능하면 실제 CI 에서 `test` 잡 1회 통과까지 확인.

### T-2 ~ T-14 (Rev.1 유지, T-9 만 재설계)

| ID | 파일 | 작업 |
|---|---|---|
| **T-2** | `MESSAGING.md` §1.2 / §1.3 | 프로덕션 브로커 표준을 `nats-server`(MQTT **3.1.1**)로 재작성. mermaid 노드·ACL 예시를 `MAM` 계정 / `mam_agent`·`mam_observer` / NATS `permissions` 문법으로 교체. Mosquitto 설정은 §1.4 "대안"으로 강등하고 상세는 `nats-docker/PRIVATE_SERVER.md` 링크 |
| **T-3** | `MESSAGING.md` 신설 절 | JetStream 요구(MQTT 리스너 전제), retained=MQTT 전용 경계(N-1), MQTT-over-WebSocket `/mqtt`(N-7), 원격 노출 모델(모델 T/P) 요약 + 서브모듈 링크 |
| **T-4** | `MESSAGING.md` §4.2 / §4.3 / §6.1-3 | B-14(발행 실패와 무관한 상태 동기화), B-15(`_check_disk_fallback`), F-4(rc=3) 반영. §6.1-3 은 "해결됨" 처리하되 잔여 제약(자동 재연결 루프 부재)만 유지 |
| **T-5** | `MESSAGING.md` §4.4 | `MQTT_CLIENT_ID_PREFIX`·`MQTT_KEEPALIVE` 추가. `.mam.env` 해석 순서 절 신설, **OS 환경변수 우선**(V-25) 명기, **B-17 미해결 경고** 포함 |
| **T-6** | `IMPROVEMENTS.md` 헤더 | 갱신일 2026-08-23, `306/306`, 미해결 **3건**(A-2, B-16, O-5), 완료 **26건** |
| **T-7** | `IMPROVEMENTS.md` `:76`, `:81` | B-14·B-15 제목에 `✅ 완료` 마커 + 해결 커밋(`c6b6c77`)·가드(G-1~G-10) 기록 |
| **T-8** | `IMPROVEMENTS.md` 신설 | `O-6 (✅ 완료): 원격 프로덕션 브로커 자산 정본화 및 nats-docker 서브모듈 분리` — 커밋 5종, D-22~D-30, 동적 경로 해석기, 297→306 |
| **T-9** 🔄 | `IMPROVEMENTS.md` 신설 + 처방 | **`B-17 (P1)`** 등록. 처방을 **2단 구조**로 명시(아래 상세). `B-18` 도 함께 등록 |
| **T-10** | `implementation_plan.md` `:3-5` | 문서 버전 상향, 기준 커밋 `916185c`, `306/306` |
| **T-11** | `implementation_plan.md` `:13-16`, `:23`, `:39` | 트랙 다이어그램에 Track 1R 포함, 변경 지점을 `nats-docker/…` 경로로, 마일스톤 표 테스트 수 갱신 |
| **T-12** | `implementation_plan.md` §5, §8 | `P0.6 서브모듈 분리` 단계 + 체크리스트 3행(2행 완료, **CI 1행 미완료**) |
| **T-13** | `implementation_plan.md` `:177` | `.mam.env` 전환 실태 반영 — 체크 처리하거나 절차 미이행 사실 기록 |
| **T-14** | `implementation_plan.md` `:172` | 행 번호 인용을 절 번호로 교체 |

#### T-9 상세 — B-17 처방 (C3 반영 재설계)

```python
# mqtt_common.py — import 시점: 기록만, 예외 없음
_env_file_missing: Optional[str] = None

def _load_dotenv(workspace_dir=None):
    global _env_file_missing
    explicit = os.environ.get("MAM_ENV_FILE")
    if explicit:
        if os.path.isfile(explicit):
            _parse_env_file(explicit)
        else:
            _env_file_missing = explicit
            logger.error(
                "MAM_ENV_FILE is set to %s but no such file exists; "
                "refusing to auto-discover another .mam.env", explicit)
        return                      # 명시적 지정 시 자동 탐색 금지 (리뷰어 C3-1)
    # 미설정일 때만 순서 있는 탐색 (first-hit-wins)
    for cand in (_from_env("MAM_REAL_ROOT"), _from_env("WORKSPACE_ROOT"),
                 _walk_up(os.path.dirname(os.path.abspath(__file__))),
                 _walk_up(os.getcwd())):
        if cand and os.path.isfile(cand):
            _parse_env_file(cand); return
```

```python
# 접속 지점(make_client 또는 설정 확정 함수) — 여기서 거부한다
def make_client(role, cfg):
    if _env_file_missing:
        raise RuntimeError(
            f"MAM_ENV_FILE points to a missing file ({_env_file_missing}); "
            "refusing to connect with an unverified broker identity")
    if cfg.host == "broker.hivemq.com":
        logger.error("SECURITY: falling back to the PUBLIC broker "
                     "broker.hivemq.com — job payloads will be world-readable")
    ...
```

**왜 import 에서 던지지 않는가**: `_load_dotenv()` 는 `mqtt_common.py:112` 의 import 부작용이고(V-23), 테스트 3개 파일이 브로커 접속 의도 없이 이 모듈을 import 합니다(V-24). import 에서 예외를 던지면 낡은 `MAM_ENV_FILE` 하나로 스위트 전체가 수집 단계에서 붕괴합니다.

**왜 경고 조건이 "공개 기본값과 일치"인가**: OS 환경변수가 파일보다 우선하므로(V-25), `MQTT_BROKER` 를 직접 export 한 사용자는 파일이 없어도 공개 브로커로 떨어지지 않습니다. 호스트 결과값을 기준으로 삼으면 탐색 경로 전체를 한 조건으로 덮습니다.

---

## 5. 권고 신규 가드

| ID | 단언 | 공허 통과 방지 | 잡아내는 회귀 |
|---|---|---|---|
| **D-31** 🔄 | `deploy/gitea-ci.yml` 을 YAML 파싱 → 각 잡의 `run` 블록을 합쳐 `pytest` 또는 `tests/` 가 등장하면 **테스트 수행 잡**으로 판정 → 그 잡의 모든 `actions/checkout` 스텝이 `with.submodules` 를 truthy 로 가질 것. **`.gitmodules` 가 존재할 때만 활성**(서브모듈 제거 시 자동 무력화) | **테스트 수행 잡이 0건이면 FAIL** — 잡 이름 변경이나 래퍼 스크립트로 `pytest` 를 숨겨 가드를 조용히 비활성화하는 경로를 차단 | S-1 재발. 린트 잡은 검사 대상에서 제외되므로 경량 워크플로 추가를 방해하지 않음(A-2) |
| **D-32** | `MESSAGING.md` 가 문서화한 `MQTT_*` 집합 ⊇ `mqtt_common.broker_config_from_env` 가 파싱하는 집합 | 코드에서 변수 0개 추출 시 FAIL | S-4 재발. D-11 이 `PRIVATE_SERVER.md` 에 대해 하는 검사를 `MESSAGING.md` 로 확장 |

**D-31 구현 주의**: PyYAML 은 `on:` 을 불리언 `True` 키로 파싱합니다(V-22). `d["jobs"]` 만 읽으면 무해하나 `d["on"]` 접근은 `KeyError` 입니다. 현재 상태에서 이 가드는 `test` 잡 1개를 대상으로 삼고 **즉시 FAIL** 합니다(`with` = `None`) — 착수 시점에 공허 통과가 아님이 자동 증명됩니다.

**뮤테이션 수용 기준**: ① `test` 잡의 `submodules: recursive` 제거 → D-31 FAIL. ② `test` 잡 이름을 `verify` 로 변경 → **여전히 FAIL 해야 함**(`run` 내용 기준 판정). ③ `pytest tests/ -q` 를 `bash deploy/run-tests.sh` 로 감싸고 `tests/` 문자열 제거 → D-31 이 대상 0건을 만나 **FAIL**(공허 통과 방지 단언). ④ `MESSAGING.md` 에서 `MQTT_PORT` 삭제 → D-32 FAIL.

---

## 6. 열린 질문

| # | 질문 | 기본값(무응답 시) |
|---|---|---|
| **Q-1** | `.mam.env` 전환(S-11)이 §9.5 드레인 절차를 밟은 것인가? | 밟지 않은 것으로 간주, T-13 에서 사후 잔여 스캔을 과제로 기록 |
| **Q-2** | A-2 를 완료로 전환할 시점은? | M3(지문 토픽 + 무조건 토큰) 이후 유지. 사설 브로커 전환만으로는 종결하지 않음 |
| **Q-3** | `MESSAGING.md` 의 Mosquitto 절을 삭제할 것인가? | **남김**(§1.4 로 강등). `PRIVATE_SERVER.md` §4.2 가 mosquitto 를 여전히 대안으로 제시하므로 삭제하면 두 문서가 어긋남 |
| **Q-4** | D-31 / D-32 를 이번 커밋에 포함할 것인가? | 포함 권고 |
| **Q-5** 🆕 | **T-1c 선택지 — `nats-docker` 를 공개로 전환할 것인가?** | **A(공개 전환) 권고**. 추적 파일에 비밀 0건 실측(V-19). 비공개 유지가 정책이면 B(토큰 주입) |
| **Q-6** 🆕 | B-17 의 접속 지점 거부를 예외로 할 것인가 종료 코드로 할 것인가? | **예외**(`RuntimeError`). `publish_event.py` 는 이미 B-14 로 예외를 잡아 디스크 상태를 동기화한 뒤 rc 를 매핑하므로, 예외가 루프를 멈추지 않고 fail-closed 만 달성 |

---

## 7. 결론

- **테스트**: 충족. 요청 명령 31/31, 전체 306/306, 문서 회귀 0건.
- **`MESSAGING.md`**: 미충족(S-2, S-3, S-4).
- **`IMPROVEMENTS.md`**: 미충족(S-5, S-6, S-7).
- **`implementation_plan.md`**: 부분 충족 — 서브모듈 링크는 갱신되었으나 헤더·트랙표·다이어그램·전환 기록·상태 드리프트 잔존(S-8 ~ S-12).
- **최우선**: S-1 + S-13. CI 는 서브모듈을 받지 않고, 서브모듈은 비공개이며, 문제를 발현시킬 커밋 2개가 아직 푸시되지 않은 상태입니다. **푸시 이전에 T-1a~T-1c 를 완료하십시오.**

리뷰어 `agy` 의 세 지적은 모두 실재하는 맹점이었고, C2·C3 는 Rev.1 의 처방을 직접 교정했습니다. C1 은 전제가 옳았으나 제시된 상대 URL 형태(`../nats-docker`)가 잘못된 조직을 가리키므로 `../../laa/nats-docker` 로 교정하여 반영했습니다.

[VERDICT: NOT PASS]
