# P0-B real wake 결선 설계 (task-2729+8) — audit-first hardening

- 작성일: 2026-06-06 (KST)
- 작성자: 헤르메스(dev1 팀장) / 구현 위임: 불칸(백엔드), 테스트: 아르고스
- 레벨: Lv.3 (시스템 hardening 구현 + isolated 검증 — production ACTIVE 전환 아님)
- 완료 판정 목표: `REAL_WAKE_READINESS_VERIFIED_ACTIVE_FALSE`
- 기준 main: `c6aacb5e` (fresh origin/main, #181 hardening + #182 base isolation 포함. r2 Option A replacement)

## 0. 한 줄 목표

driver가 `WAKE_BUILT` 반환 시 surface 만 하던 wake argv(`cokacdir --cron … --key <ANU_KEY>`)를
**안전하게 실 실행**하는 결선을 설계·구현하되, **real ANU spawn 실행은 dispatch 전 0 /
mock launcher decision-only 로 readiness 만 검증**. canonical 무오염 · production ACTIVE=false 고정 ·
systemctl enable 0 · raw key 0.

## 1. 현재 미결선 지점 (단일 소스 확인)

### 1.1 wake argv 생성 위치 (read-only 재사용)
- `dispatch/anu_result_pickup_runner.py::pickup_once` — 6단계 통과 시
  `anu_runner_pickup_and_fire`(`dispatch/anu_owned_callback_enforcement.py`) 재사용으로
  argv 빌드. argv = `cokacdir --cron … --key <ANU_KEY>` (sealed key 런타임 로드).
  비-ANU key(executor self-key) → `anu_runner_pickup_and_fire` refuse → `r.ok=False`
  → `pickup_once` 가 `PICKUP_FAIL`(argv=None) 반환 → **WAKE_BUILT 미도달**(fail-closed).
- 반환 `PickupResult` 필드: `verdict, task_id, sha256, argv(Optional[List[str]]), wake_built,
  classification, reasons, marker_path`.
- WAKE_BUILT 직전 `pickup_once` 가 dedupe ledger(`memory/events/callback_4tuple_index.jsonl`)에
  `event=PICKUP_WAKE_BUILT, task_id, sha256` 1줄 기록 + done marker(`{task_id}.pickup.done`) 작성.

### 1.2 실행 미결선 위치 (★ 본 task 결선 대상)
- `dispatch/anu_pickup_driver.py::process_one` WAKE_BUILT 분기 (현 471–482줄):
  ```python
  if pv == _PICKUP_WAKE_BUILT:
      # ★ driver 는 argv 를 실행하지 않음 (P0-a dry_run, 실제 wake 0).
      move_err = _move_processed(path, root, processed_dir)
      return _emit(DriverRecord(..., verdict=VERDICT_WAKE_BUILT, fire_cron_id=None, ...))
  ```
  driver 가 `res.argv` 를 **읽지도 실행하지도 않음**. `fire_cron_id=None` 고정.

## 2. 설계 — 관심사 분리 (decision-only ↔ 실행)

| 컴포넌트 | 책임 | 본 task 변경 |
|---|---|---|
| `anu_result_pickup_runner.pickup_once` | argv 빌드 + pickup dedupe + done marker | **수정 0** (재사용) |
| `anu_owned_callback_enforcement.anu_runner_pickup_and_fire` | self-key refuse + argv 조립 | **수정 0** (재사용) |
| `anu_pickup_driver` | scan/6조건/quarantine/processed move = **decision-only** | launcher_fn 주입 결선(W2) |
| **`anu_pickup_wake_launcher`** (신규) | WAKE_BUILT argv **실 실행** = 유일 spawn 책임 | 신규(W1) |

→ real spawn 실행 책임 = **신규 launcher 모듈**. driver=decision-only, launcher=실행. (검증 항목 #2)

### W1. 신규 launcher 모듈 `dispatch/anu_pickup_wake_launcher.py`

단일 책임: WAKE_BUILT argv(이미 sealed key 포함)를 권한 경로로 실 실행(cokacdir cron 발사).

#### 공개 API
```python
@dataclass
class LaunchRecord:
    ts: str
    task_id: str
    sha256: str
    decision: str            # 아래 decision 상수 중 하나
    dry_run: bool
    reason: Optional[str] = None
    returncode: Optional[int] = None
    argv_len: Optional[int] = None   # ★ argv 길이만. argv/key literal 절대 미저장
    def to_json(self) -> dict: ...    # argv/key 미포함 보장

def launch_wake(
    argv,                    # PickupResult.argv (list[str] or None)
    *,
    task_id: str,
    sha256: str,
    dry_run: bool = True,    # ★ 기본 True = 실행 0, 기록만(audit-neutral)
    root: str = CANONICAL_ROOT,
    launch_ledger_path: Optional[str] = None,   # 기본 memory/p0b_state/wake_launch_ledger.jsonl
    audit_path: Optional[str] = None,           # 기본 memory/p0b_state/wake_launch_audit.jsonl
    anu_key_verifier=None,   # Optional[Callable[[str], bool]] — argv 의 --key 값 검증(fail-closed)
    subprocess_runner=None,  # Optional[Callable[[list], int]] — 기본 subprocess.run; 테스트 mock 주입
    clock=None,
) -> LaunchRecord: ...
```

#### decision 상수
- `FAIL_CLOSED_NO_ARGV` — argv None/빈 리스트 → wake 0
- `FAIL_CLOSED_MALFORMED` — argv 에 `--cron`/`--key` 부재 or 구조 이상 → wake 0
- `FAIL_CLOSED_NON_ANU_KEY` — anu_key_verifier 제공 시 --key 값 검증 실패 → wake 0
- `FAIL_CLOSED_LEDGER_ERROR` — dedupe 확인 중 예외 → wake 0 (fail-closed)
- `SKIP_DEDUPE` — 동일 (task_id, sha256) WAKE_LAUNCHED 기록 존재 → 중복 wake 0
- `DRY_RUN` — dry_run=True → **실행 0, audit-neutral**(production ledger write 0)
- `LAUNCHED` — dry_run=False + 모든 게이트 통과 → subprocess 실 실행

#### 결정 순서 (fail-closed 우선)
1. argv None/empty → `FAIL_CLOSED_NO_ARGV`.
2. argv 구조 검증: list[str] AND `--cron` 포함 AND `--key` 포함 + 그 값 비어있지 않음. 실패 → `FAIL_CLOSED_MALFORMED`.
3. `anu_key_verifier` 주어지면 --key 값 검증. False → `FAIL_CLOSED_NON_ANU_KEY`. (미주입 시 구조검증만 — argv 는 이미 runner 의 self-key refuse 통과 산물.)
4. dedupe 재확인: launch_ledger 에서 `event=WAKE_LAUNCHED` + 동일 (task_id, sha256) → `SKIP_DEDUPE`. 확인 예외 → `FAIL_CLOSED_LEDGER_ERROR`.
5. `dry_run=True` → `DRY_RUN` 반환. **production ledger/ audit write 0**(audit_path 가 명시 주입된 isolated temp 일 때만 기록 — OWNER_TRIGGER_DRY_RUN_LEDGER_CONTAMINATION 교훈).
6. `dry_run=False` → subprocess_runner(argv) 실행 → returncode 수집 → launch_ledger 에 `WAKE_LAUNCHED`(task_id, sha256, ts; **argv/key 미기록**) append → `LAUNCHED`.

#### 안전 불변식
- **raw key 0**: argv 는 stdout/log/audit/ledger 어디에도 미출력. `_redact()` 로 길이만 노출.
  subprocess args 로만 전달. `LaunchRecord.to_json()` 에 argv/key 필드 부재.
- **dedupe**: launcher 자체 ledger(`WAKE_LAUNCHED`)로 중복 실행 0. (pickup 의 `PICKUP_WAKE_BUILT` ledger 와 분리 — pickup ledger 는 WAKE_BUILT 직전 항상 존재하므로 dedupe 기준 부적합.)
- **fail-closed**: argv None / malformed / non-ANU / ledger 확인 실패 → launch 0.
- **dry-run isolation**: dry_run=True 는 production ledger 무기록. 호출자가 isolated temp audit_path 주입 시에만 기록(테스트/감사 격리).

### W2. driver launcher_fn 주입 결선 `dispatch/anu_pickup_driver.py`

- `process_one` / `scan_once` 에 `launcher_fn=None`(기본) kwarg 추가. **default None = 현행 동작 보존(surface only)**.
- WAKE_BUILT 분기 수정:
  ```python
  if pv == _PICKUP_WAKE_BUILT:
      fire_cron_id = None
      launch_err = None
      if launcher_fn is not None:
          try:
              lr = launcher_fn(getattr(res, "argv", None),
                               task_id=task_id, sha256=getattr(res, "sha256", ""))
              fire_cron_id = getattr(lr, "decision", None)   # 라벨만(키/ argv 0)
          except Exception as exc:
              launch_err = f"launcher 예외: {exc}"   # fail-safe: 크래시 0
      move_err = _move_processed(path, root, processed_dir)
      err = "; ".join(x for x in (launch_err, move_err) if x) or None
      return _emit(DriverRecord(..., verdict=VERDICT_WAKE_BUILT,
                                fire_cron_id=fire_cron_id, error=err, ...))
  ```
- `launcher_fn` 미주입 → 현행(launcher 미호출, fire_cron_id=None). 미도달 분기
  (LEDGER_WRITE_FAILED/legacy skip/MAX defer/terminal SKIP/NOOP_*) → launcher 미호출 보존.
- `scan_once` 는 받은 `launcher_fn` 을 `process_one` 으로 그대로 전달.

## 3. 회장 verbatim 검증 매핑 (12)

1. WAKE_BUILT 에서만 launcher 호출 — `pv == _PICKUP_WAKE_BUILT` 분기 내부에서만 호출.
2. legacy cutoff 대상 launcher 0 — legacy skip 은 pickup_once 단계에서 WAKE_BUILT 미반환 → 미호출.
3. terminal marker 존재 시 launcher 0 — done/acked 존재 → 조건6 PICKUP_SKIP → WAKE_BUILT 아님.
4. MAX_FILES 초과 defer 시 launcher 0 — NOOP_NOT_READY/defer → WAKE_BUILT 아님.
5. ledger/marker failure 시 launcher 0 — pickup_* quarantine/FIRE_FAILED → WAKE_BUILT 아님.
6. duplicate wake 0 — launcher `SKIP_DEDUPE`(WAKE_LAUNCHED 재확인) + pickup_once dedupe 이중.
7. raw ANU key 출력 0 — argv 미출력, LaunchRecord argv/key 필드 부재, redaction.
8. 실제 spawn 0 (검증 단계) — mock subprocess_runner 주입 + dry_run 기본 True.
9. dry-run/mock isolated temp root — 테스트가 tmp_path root/ledger/audit 주입.
10. canonical ledger/events/state 영향 0 — dry_run 무기록 + 테스트 isolated.
11. ACTIVE=false 유지 — activation flag 미수정, systemd 미터치.
12. 기존 decision-only 동작 보존 — launcher_fn=None 기본 → 기존 회귀 무영향.

## 4. 중단 조건 점검 (8 — 해당 0 확인)

1. expected_files 5 초과 → 정확히 5파일. 2. real spawn 없이 검증 불가 → mock 으로 가능. 3. canonical write 필요 → dry_run/isolated 로 불필요. 4. raw key 노출 위험 → redaction 으로 0. 5. duplicate wake 방지 불명확 → 이중 dedupe 명확. 6. ACTIVE=true 필요 → 불필요. 7. systemd enable 필요 → 불필요. 8. fresh HIGH/CRITICAL → 검증 단계 점검.

## 5. EXPECTED FILES (정확히 5)

1. `dispatch/anu_pickup_wake_launcher.py` (신규, W1)
2. `dispatch/anu_pickup_driver.py` (launcher_fn 주입, W2)
3. `tests/regression/test_anu_pickup_real_wake_wiring_2729p8.py` (회귀+mock)
4. `memory/reports/task-2729+8.md`
5. `memory/plans/p0b-pickup/real_wake_wiring_design_260606.md` (본 문서)

runner/callback_enforcement 수정 0 — 기존 회귀(test_anu_pickup_driver_2721,
test_anu_pickup_activation_hardening_2729p7) 무영향 확인만.

## 6. r2 remediation (Option A replacement — Gemini HIGH/SECURITY)

PR #183(13f1a8fc) SUPERSEDED. fresh origin/main(c6aacb5e) base 위에서 아래 3개 Gemini 적발을 수정 재구현:

1. HIGH (`_iso`): timezone-aware clock 입력을 `dt.astimezone(timezone.utc)` 로 UTC 정규화 후 Z suffix. naive datetime 은 명시적 UTC 간주 정책을 코드+테스트에 고정.
2. SECURITY-HIGH (`anu_key_verifier`): None 아닌데 callable 아니면 검증 불가 → `FAIL_CLOSED_NON_ANU_KEY`(fail-closed). 검증 false/예외도 동일 fail-closed.
3. MEDIUM (try-block hygiene): `_dedupe_launched` 를 단일 `with open(...)` 으로 일원화.

driver(W2 launcher_fn 결선)/runner/callback_enforcement 수정 0. real spawn 0 / ACTIVE=false 유지.
