# spec-feature-flags.md
# Feature Flags 시스템 — 상세 구현 스펙

**작성자:** 아테나 (UX/UI 디자이너, MoAI-ADK)
**작성일:** 2026-03-31
**상태:** DRAFT
**관련 Task:** MoAI-ADK Phase 1

---

## 1. 목적

MoAI-ADK Phase 1의 모든 신규 기능을 Feature Flag로 제어하여 무중단 점진 배포를 지원한다. 각 기능은 독립적으로 활성화/비활성화 가능하며, 플래그 상태는 JSON 파일로 관리된다. 코드 배포 없이 즉각적인 기능 롤백이 가능하다.

**핵심 목표:**
- 기능별 독립적 on/off 제어
- 배포 없는 즉각 롤백 (Layer 1)
- 안전한 파일 읽기/쓰기 (atomic write, 캐시)
- 존재하지 않는 플래그에 대한 안전한 기본값 처리

---

## 2. 상세 스펙

### 2.1 플래그 파일 경로 및 구조

**경로:** `.claude/feature_flags.json`

```json
{
  "progressive_disclosure_enabled": false,
  "rw_isolation_enabled": false,
  "hooks_enforcement_enabled": false,
  "trust5_tagging_enabled": false,
  "model_map_enabled": false,
  "haiku_ab_enabled": false
}
```

### 2.2 플래그 목록 및 의미

| 플래그명 | 기본값 | 관련 Task | 설명 |
|---|---|---|---|
| `progressive_disclosure_enabled` | `false` | P1-1 | build_prompt 3단계 토큰 축소 활성화 |
| `rw_isolation_enabled` | `false` | P1-2 | 읽기/쓰기 에이전트 worktree 격리 활성화 |
| `hooks_enforcement_enabled` | `false` | P1-7 | PostToolUse 훅 pyright/ruff 강제 실행 활성화 |
| `trust5_tagging_enabled` | `false` | P1-4 | Trust Level 5 태그 자동 부여 활성화 |
| `model_map_enabled` | `false` | P1-5 | 태스크별 모델 매핑 활성화 |
| `haiku_ab_enabled` | `false` | P1-6 | Haiku 모델 A/B 테스트 활성화 |

**모든 기본값은 `false`**: 새 환경에서 기존 동작이 보존됨을 보장한다.

### 2.3 Python 로더: utils/feature_flags.py

```python
# utils/feature_flags.py

import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger(__name__)

DEFAULT_FLAGS_PATH = Path(".claude/feature_flags.json")

KNOWN_FLAGS = frozenset({
    "progressive_disclosure_enabled",
    "rw_isolation_enabled",
    "hooks_enforcement_enabled",
    "trust5_tagging_enabled",
    "model_map_enabled",
    "haiku_ab_enabled",
})


class FeatureFlagLoader:
    """
    Feature Flag를 JSON 파일에서 로드하고 캐싱한다.

    특징:
    - mtime 기반 캐시: 파일 변경 시에만 재로드
    - atomic write: tempfile → os.replace
    - JSONDecodeError 복구: 파싱 실패 시 전체 false로 폴백
    - 미등록 플래그 접근 시 False + WARNING 로그
    """

    def __init__(self, flags_path: Path = DEFAULT_FLAGS_PATH):
        self._path = flags_path
        self._cache: dict[str, bool] = {}
        self._cached_mtime: Optional[float] = None

    def _load(self) -> dict[str, bool]:
        """파일에서 플래그를 로드한다. 실패 시 빈 dict 반환."""
        if not self._path.exists():
            logger.warning(
                "feature_flags.json not found at %s. All flags default to False.",
                self._path,
            )
            return {}

        current_mtime = self._path.stat().st_mtime

        # mtime 캐시: 변경 없으면 캐시 반환
        if self._cached_mtime == current_mtime and self._cache:
            return self._cache

        try:
            raw = json.loads(self._path.read_text(encoding="utf-8"))
            if not isinstance(raw, dict):
                raise ValueError("feature_flags.json must be a JSON object")
            self._cache = {k: bool(v) for k, v in raw.items()}
            self._cached_mtime = current_mtime
            return self._cache
        except json.JSONDecodeError as e:
            logger.error(
                "feature_flags.json parse error: %s. All flags fallback to False.", e
            )
            self._cache = {}
            self._cached_mtime = current_mtime
            return {}

    def get(self, flag_name: str) -> bool:
        """
        플래그 값을 반환한다.

        Args:
            flag_name: 플래그 이름
        Returns:
            bool: 플래그 값 (미존재 시 False)
        """
        if flag_name not in KNOWN_FLAGS:
            logger.warning(
                "Unknown feature flag '%s'. Returning False. "
                "Register in KNOWN_FLAGS if intentional.",
                flag_name,
            )
            return False

        flags = self._load()
        value = flags.get(flag_name, False)
        return bool(value)

    def set(self, flag_name: str, value: bool) -> None:
        """
        플래그 값을 파일에 저장한다 (atomic write).

        Args:
            flag_name: 플래그 이름
            value: 설정할 bool 값
        """
        flags = self._load()
        flags[flag_name] = value

        self._path.parent.mkdir(parents=True, exist_ok=True)

        # Atomic write: tempfile → os.replace
        fd, tmp_path = tempfile.mkstemp(
            dir=self._path.parent, suffix=".tmp"
        )
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                json.dump(flags, f, indent=2, sort_keys=True)
                f.write("\n")
            os.replace(tmp_path, self._path)
        except Exception:
            os.unlink(tmp_path)
            raise

        # 캐시 무효화
        self._cache = flags
        self._cached_mtime = self._path.stat().st_mtime

    def all(self) -> dict[str, bool]:
        """현재 모든 플래그 상태를 반환한다."""
        return dict(self._load())
```

### 2.4 캐시 전략

| 전략 | 설명 |
|---|---|
| mtime 캐시 | `Path.stat().st_mtime`으로 파일 변경 감지. 변경 없으면 캐시 반환 |
| 캐시 무효화 | `set()` 호출 직후 즉시 캐시 갱신 |
| 멀티프로세스 | mtime 체크이므로 다른 프로세스가 파일 변경 시 자동으로 재로드 |

### 2.5 Atomic Write 보장

```
1. tempfile.mkstemp()으로 동일 디렉토리에 임시 파일 생성
2. 임시 파일에 JSON 기록
3. os.replace(tmp, target) — 원자적 교체 (POSIX 보장)
4. 실패 시 임시 파일 삭제
```

디스크 장애나 프로세스 종료 시에도 기존 파일은 손상되지 않는다.

### 2.6 JSONDecodeError 복구

```
파싱 실패 시:
  ├─ ERROR 로그 출력 (파싱 오류 내용 포함)
  ├─ 빈 dict {} 반환 (모든 플래그 False로 동작)
  └─ 운영 계속 (halt 없음)
```

### 2.7 미등록 플래그 처리

```
존재하지 않는 플래그명 접근 시:
  ├─ WARNING 로그 출력 ("Unknown feature flag 'xxx'")
  └─ False 반환
```

---

## 3. 인터페이스

### 3.1 파일 구조

```
.claude/
  feature_flags.json     ← 플래그 상태 파일 (JSON)
utils/
  feature_flags.py       ← FeatureFlagLoader 클래스
```

### 3.2 사용 예시

```python
from utils.feature_flags import FeatureFlagLoader

flags = FeatureFlagLoader()

# 단일 플래그 조회
if flags.get("progressive_disclosure_enabled"):
    disclosure_phase = args.disclosure_phase
else:
    disclosure_phase = "full"

# 플래그 변경 (atomic write)
flags.set("rw_isolation_enabled", True)

# 전체 플래그 조회
print(flags.all())
# {"progressive_disclosure_enabled": false, "rw_isolation_enabled": true, ...}
```

### 3.3 CLI로 플래그 변경

```bash
# 직접 편집 (간단한 경우)
# .claude/feature_flags.json을 에디터로 수정

# Python으로 변경
python3 -c "
from utils.feature_flags import FeatureFlagLoader
f = FeatureFlagLoader()
f.set('progressive_disclosure_enabled', True)
print(f.all())
"
```

### 3.4 에러 처리 요약

| 상황 | 처리 | 운영 영향 |
|---|---|---|
| 파일 미존재 | WARNING 로그, 전체 False | 없음 |
| JSONDecodeError | ERROR 로그, 전체 False | 없음 |
| 미등록 플래그 | WARNING 로그, False 반환 | 없음 |
| 디스크 쓰기 실패 | 임시 파일 삭제, Exception propagate | set() 호출자에서 처리 |

---

## 4. 테스트 기준

### 4.1 단위 테스트

```python
# tests/test_feature_flags.py

def test_default_all_false(tmp_path):
    """파일 미존재 시 모든 플래그 False"""
    loader = FeatureFlagLoader(tmp_path / "feature_flags.json")
    assert loader.get("progressive_disclosure_enabled") is False

def test_load_flag_value(tmp_path):
    """파일에 true로 설정된 플래그 정상 로드"""
    flags_file = tmp_path / "feature_flags.json"
    flags_file.write_text('{"progressive_disclosure_enabled": true}')
    loader = FeatureFlagLoader(flags_file)
    assert loader.get("progressive_disclosure_enabled") is True

def test_mtime_cache(tmp_path):
    """mtime 변경 없으면 파일 재읽기 없음"""
    flags_file = tmp_path / "feature_flags.json"
    flags_file.write_text('{"rw_isolation_enabled": false}')
    loader = FeatureFlagLoader(flags_file)
    loader.get("rw_isolation_enabled")  # 첫 로드
    first_mtime = loader._cached_mtime
    loader.get("rw_isolation_enabled")  # 캐시 사용
    assert loader._cached_mtime == first_mtime

def test_json_decode_error_recovery(tmp_path):
    """JSONDecodeError 시 False 반환, 예외 없음"""
    flags_file = tmp_path / "feature_flags.json"
    flags_file.write_text("{invalid json")
    loader = FeatureFlagLoader(flags_file)
    assert loader.get("progressive_disclosure_enabled") is False  # 예외 없이 False

def test_atomic_write(tmp_path):
    """set() 후 파일이 올바르게 기록됨"""
    flags_file = tmp_path / "feature_flags.json"
    flags_file.write_text('{"hooks_enforcement_enabled": false}')
    loader = FeatureFlagLoader(flags_file)
    loader.set("hooks_enforcement_enabled", True)
    data = json.loads(flags_file.read_text())
    assert data["hooks_enforcement_enabled"] is True

def test_unknown_flag_returns_false_with_warning(tmp_path, caplog):
    """미등록 플래그 접근 시 False + WARNING"""
    loader = FeatureFlagLoader(tmp_path / "feature_flags.json")
    with caplog.at_level(logging.WARNING):
        result = loader.get("nonexistent_flag")
    assert result is False
    assert "Unknown feature flag" in caplog.text
```

### 4.2 회귀 테스트

- `set()` 직후 `get()`이 즉시 갱신된 값을 반환하는지 확인
- 다른 프로세스가 파일 수정 후 `get()` 호출 시 새 값 반환 확인 (mtime 변경 검증)

---

## 5. DoD (Definition of Done)

- [ ] `.claude/feature_flags.json` 생성 (6개 플래그 모두 false)
- [ ] `utils/feature_flags.py` 구현 완료
  - [ ] `FeatureFlagLoader` 클래스
  - [ ] `get()` — mtime 캐시, 미등록 플래그 WARNING + False
  - [ ] `set()` — atomic write (tempfile → os.replace)
  - [ ] `all()` — 전체 플래그 반환
  - [ ] JSONDecodeError 복구 (전체 False 폴백)
- [ ] 6개 플래그 모두 KNOWN_FLAGS에 등록
- [ ] 단위 테스트 6건 이상 통과 (pytest)
- [ ] pyright 타입 오류 0건
- [ ] ruff 스타일 경고 0건
- [ ] PR 리뷰 승인 후 merge
