diff --git a/dispatch/__init__.py b/dispatch/__init__.py
index a41bd062..2fa96e68 100644
--- a/dispatch/__init__.py
+++ b/dispatch/__init__.py
@@ -3442,6 +3442,13 @@ def dispatch(
 
     logger.info(f"위임 시작: team={team_id}, task_id={task_id}")
 
+    # [v2.14 S2] §31 Phase-1 gate shadow (report-only, fail-open). 차단·예외 전파 0, dispatch 흐름 무영향.
+    try:
+        from dispatch.gate_shadow_runner import shadow_evaluate
+        shadow_evaluate(task_id=task_id, level=level, task_type=task_type)
+    except Exception:
+        pass
+
     # ── 세션 건강 경량 체크: token-ledger.json에서 대상 팀의 critical 세션만 확인 ──
     if _SESSION_RESILIENCE_AVAILABLE and _SessionResilience is not None:
         try:
diff --git a/dispatch/gate_shadow_runner.py b/dispatch/gate_shadow_runner.py
new file mode 100644
index 00000000..8b4f3fc1
--- /dev/null
+++ b/dispatch/gate_shadow_runner.py
@@ -0,0 +1,186 @@
+"""
+gate_shadow_runner.py — v2.14 S1/S2: §31 Phase-1 gate shadow report-only wrapper.
+
+회장 승인: ANU_V2_14_PHASE1_GATE_SHADOW_S1_S2_IMPLEMENTATION_APPROVED_ACTIVE_FALSE (2026-06-20).
+
+원칙 (엄수):
+- report-only / fail-open. 어떤 verdict 도 block/exit/raise 로 연결하지 않는다.
+- dispatch 흐름·반환값·기존 부작용·운영 파이프라인 동작을 변경하지 않는다(호출자는 None 만 받는다).
+- 로그에는 raw prompt / credential / owner key / secret / env / 토큰 / 민감 args 를 기록하지 않는다.
+  verdict / reason / touched_axes / rollback_cost + task_id / goal_id / level 수준 최소 메타만 기록한다.
+- runtime_state 로 민감값이 들어와도 그 내용은 로그에 기록하지 않는다(classify_gate 입력으로만 사용).
+- log write 실패도 fail-open(무시).
+- 실제 차단 0 · production write 0 · callback/wake/fire 0 · systemd/always-on 무관. ACTIVE=false.
+
+이 모듈은 §31 게이트(dispatch/v2_13_gate.classify_gate)를 dispatch 경로에서 report-only 로
+1회 관측하기 위한 fail-open 래퍼다. 실제 차단(fail-closed) 전환은 별도 Chair 승인(S4) 대상이다.
+"""
+from __future__ import annotations
+
+import json
+import datetime
+from pathlib import Path
+
+# 기록되어도 안전한(비민감) 메타 필드 화이트리스트
+_ALLOWED_LOG_KEYS = {
+    "ts", "source", "task_id", "goal_id", "level", "task_type",
+    "verdict", "reason", "touched_axes", "rollback_cost", "mode", "active",
+    "contract_source",
+}
+_REASON_CAP = 200
+# repo root 단일소스. gate_shadow_runner.py 는 <repo>/dispatch/ 안에 있으므로 parent.parent = <repo>.
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_DEFAULT_LOG_DIR = _REPO_ROOT / "memory" / "state" / "gate_shadow_log"
+
+# strict_template goal_contract — 회장 승인 결정론 매핑만(자유 서술 0). provenance 고정.
+import re as _re
+_SAFE_TASK_ID = _re.compile(r"^[A-Za-z0-9._+-]+$")
+
+
+def build_strict_template_contract(task_id, workspace=None):
+    """task md allowed_resources 에서 결정론적으로 strict_template goal_contract 를 구성한다.
+
+    회장 승인 매핑만 사용(자유 서술/추론 0):
+      goal_id ← task_id (task_id 에서만)
+      expected_files ← allowed_resources.paths (paths 에서만)
+      forbidden_operations ← forbidden_paths + commands (결정론 합)
+      budget ← ttl_hours (있을 때만; 없으면 "UNSPECIFIED")
+      rollback_floor ← dispatch.v2_13_gate.rollback_floor([]) 재사용
+      scope · blast_radius · allowed_capability_delta · allowed_runtime_changes · exit_criteria = "UNSPECIFIED"
+      provenance = "strict_template" 고정 — chair_injected 는 절대 사용하지 않는다(Trust Root #1).
+
+    allowed_resources / paths 부재(불완전) → None 반환(기존 INVALID_CONTRACT_PROVENANCE 관측 유지).
+    어떤 예외도 None 으로 흡수(fail-open). task md 원문/task_desc 는 contract 에 넣지 않는다.
+    """
+    try:
+        tid = str(task_id) if task_id is not None else ""
+        if not tid or ".." in tid or "/" in tid or not _SAFE_TASK_ID.match(tid):
+            return None
+        ws = Path(workspace) if workspace else _REPO_ROOT
+        md = ws / "memory" / "tasks" / f"{tid}.md"
+        if not md.exists():
+            return None
+        from dispatch import _parse_allowed_resources
+        ar = _parse_allowed_resources(md.read_text(encoding="utf-8"))
+        if not isinstance(ar, dict):
+            return None
+        paths = ar.get("paths") or []
+        if not paths:
+            return None
+        forbidden = list(ar.get("forbidden_paths") or []) + list(ar.get("commands") or [])
+        ttl = ar.get("ttl_hours")
+        from dispatch.v2_13_gate import rollback_floor
+        return {
+            "goal_id": tid,
+            "scope": "UNSPECIFIED",
+            "blast_radius": "UNSPECIFIED",
+            "allowed_capability_delta": "UNSPECIFIED",
+            "expected_files": list(paths),
+            "allowed_runtime_changes": "UNSPECIFIED",
+            "forbidden_operations": forbidden,
+            "rollback_floor": rollback_floor([]),
+            "budget": {"ttl_hours": ttl} if ttl is not None else "UNSPECIFIED",
+            "exit_criteria": "UNSPECIFIED",
+            "provenance": "strict_template",
+        }
+    except Exception:
+        return None
+
+
+def _shadow_contract(task_id, level):
+    """dispatch 시점엔 표준 goal_contract 가 없으므로 best-effort 로 구성한다.
+
+    provenance 를 비표준 표지(dispatch_runtime_shadow)로 둬, classify_gate 가
+    INVALID_CONTRACT_PROVENANCE 를 관측하게 한다 — 이는 '현재 dispatch 가 표준
+    goal_contract 를 만들지 않는다'는 사실의 가시화일 뿐 차단이 아니다.
+    """
+    return {
+        "goal_id": task_id or "unknown",
+        "scope": level or "unknown",
+        "blast_radius": None,
+        "allowed_capability_delta": None,
+        "expected_files": None,
+        "allowed_runtime_changes": None,
+        "forbidden_operations": None,
+        "rollback_floor": None,
+        "budget": None,
+        "exit_criteria": None,
+        "provenance": "dispatch_runtime_shadow",
+    }
+
+
+def _safe_reason(r):
+    try:
+        return str(r)[:_REASON_CAP]
+    except Exception:
+        return ""
+
+
+def _append_log(rec, log_dir=None):
+    """최소 메타 1줄을 jsonl 로 append. 실패해도 fail-open(무시)."""
+    try:
+        # 화이트리스트 외 키 차단(이중 안전: 민감 키 유입 방지)
+        safe = {k: v for k, v in rec.items() if k in _ALLOWED_LOG_KEYS}
+        d = Path(log_dir) if log_dir else _DEFAULT_LOG_DIR
+        d.mkdir(parents=True, exist_ok=True)
+        day = datetime.datetime.now().strftime("%Y%m%d")
+        with open(d / f"gate_shadow_{day}.jsonl", "a", encoding="utf-8") as f:
+            f.write(json.dumps(safe, ensure_ascii=False) + "\n")
+    except Exception:
+        return None
+
+
+def shadow_evaluate(*, task_id=None, level=None, task_type=None,
+                    runtime_state=None, log_dir=None, source="dispatch",
+                    contract=None) -> None:
+    """§31 게이트를 report-only 로 1회 평가하고 최소 메타만 기록한다.
+
+    항상 None 을 반환하며, 어떤 예외도 호출자에게 전파하지 않는다(fail-open).
+    호출자(dispatch / finish_task)의 흐름·반환값·부작용을 변경하지 않는다.
+
+    source: 관측 경로 표지("dispatch" 기본 / "finish_task" 등). 로그 메타에만 기록.
+    contract: optional. 명시 시 사용(단 provenance="strict_template" 만 허용 — chair_injected
+            자칭은 거부). 미지정이면 task md 에서 strict_template contract 빌드를 시도하고,
+            만들 수 없으면 best-effort(_shadow_contract)로 기존 INVALID_CONTRACT_PROVENANCE 관측 유지.
+            ★ 실제 차단 0 — provenance 통과로 다른 verdict 가 나와도 report-only 일 뿐이다.
+    """
+    try:
+        from dispatch.v2_13_gate import classify_gate
+
+        # contract 결정: explicit(strict_template만) → strict_template 빌드 → best-effort
+        c = contract
+        contract_source = "explicit"
+        if isinstance(c, dict) and c.get("provenance") != "strict_template":
+            # chair_injected 등 자칭 provenance 거부(Trust Root #1) → strict_template 경로만 허용.
+            c = None
+            contract_source = "explicit_rejected"
+        if c is None:
+            c = build_strict_template_contract(task_id)
+            contract_source = "strict_template" if isinstance(c, dict) else None
+        if c is None:
+            c = _shadow_contract(task_id, level)
+            contract_source = "best_effort"
+
+        rs = runtime_state if isinstance(runtime_state, dict) else {
+            "source": source, "task_type": task_type, "level": level,
+        }
+        res = classify_gate(c, "", [], rs) or {}
+        _append_log({
+            "ts": datetime.datetime.now().astimezone().isoformat(),
+            "source": source,
+            "task_id": task_id,
+            "goal_id": task_id,
+            "level": level,
+            "task_type": task_type,
+            "verdict": res.get("verdict"),
+            "reason": _safe_reason(res.get("reason")),
+            "touched_axes": res.get("touched_axes"),
+            "rollback_cost": res.get("rollback_cost"),
+            "contract_source": contract_source,
+            "mode": "report_only_shadow",
+            "active": False,
+        }, log_dir)
+    except Exception:
+        # fail-open: 어떤 예외도 dispatch 흐름에 전파하지 않는다.
+        return None
+    return None
diff --git a/scripts/finish-task.sh b/scripts/finish-task.sh
index ae1168f6..aad2adb3 100755
--- a/scripts/finish-task.sh
+++ b/scripts/finish-task.sh
@@ -2007,4 +2007,13 @@ if [ "${DISABLE_ANU_FOLLOWUP:-0}" != "1" ]; then
         || echo "[WARN] anu-followup send failed (non-fatal)"
 fi
 
+# [v2.14 S3] §31 gate shadow (report-only, fail-open). finish-task 동작/exit/merge/callback 판단 변경 0.
+#   verdict 기록만(memory/state/gate_shadow_log/*.jsonl). 어떤 verdict 도 block/exit/raise 0.
+#   argv 로 TASK_ID 전달(injection-safe). 2>/dev/null + || true 로 errexit·예외 완전 격리.
+( cd "$WORKSPACE" && PYTHONPATH="$WORKSPACE" python3 -c \
+'import sys
+from dispatch.gate_shadow_runner import shadow_evaluate
+shadow_evaluate(task_id=sys.argv[1], source="finish_task")' \
+"$TASK_ID" 2>/dev/null ) || true
+
 echo "[OK] finish-task.sh completed for $TASK_ID"
diff --git a/teams/content/qc/qc_verify.py b/teams/content/qc/qc_verify.py
new file mode 100644
index 00000000..774f117b
--- /dev/null
+++ b/teams/content/qc/qc_verify.py
@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+"""
+teams/content/qc/qc_verify.py — content 팀 전용 lightweight 콘텐츠 QC (C-minimal).
+
+회장 승인 2026-06-22: 코드 QC SKIP + content 전용 lightweight QC.
+finish-task.sh 무수정 — finish-task 가 teams/<team>/qc/qc_verify.py 가 있으면 자동으로 팀별 QC 를 쓰는
+기존 구조(scripts/finish-task.sh:649)를 그대로 활용한다. shared/dev/design QC 무접촉.
+
+★ self-contained: stdlib 만 사용(re/json/sys/os/argparse/pathlib). workspace PYTHONPATH 의존 0.
+   코드 verifier(shared/verifiers) 미사용. verifiers 심볼릭 미요구.
+
+전제: `## 레벨: 문서만`(또는 동의어) + allowed_resources.paths 가 문서 산출물/보고서 한정 + 코드/설정/인프라 변경 0.
+  → 위 전제가 깨지면 content QC 부적격으로 FAIL(코드 task QC 로 새지 않게).
+
+검사 4종 (fail-closed):
+  1. 디스클레이머 포함 여부      — 없으면 FAIL
+  2. 특정 상품/가입 권유문 부재  — 감지 시 FAIL
+  3. 고객정보/개인정보(PII) 부재 — 주민번호/전화/이메일 감지 시 FAIL
+  4. 외부발송/게시/업로드 확정 문구 부재 — 감지 시 FAIL
+
+--gate 모드: memory/events/<task_id>.qc-result 에 {"qc_result": "PASS"|"FAIL", ...} 생성
+            (.done 은 finish-task.sh 가 담당). qc_result 키는 finish-task.sh 가 읽는 계약.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+# ── self-contained 경로 (PYTHONPATH 비의존) ──
+# teams/content/qc/qc_verify.py → parents[3] = <workspace>
+_DEFAULT_WORKSPACE = Path(__file__).resolve().parents[3]
+
+# 코드/설정/인프라로 간주하는 확장자·경로 패턴 (섞이면 content QC 부적격)
+_CODE_EXT = (".py", ".sh", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
+             ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env",
+             ".sql", ".c", ".cpp", ".h", ".rb", ".php")
+_CODE_PATH_HINT = ("dispatch/", "scripts/", "config/", "teams/", "deploy/",
+                   ".github/", "utils/", "prompts/", "memory/state/", "memory/schemas/")
+
+# 1. 디스클레이머: "정보성 맥락" + "권유 아님/상담 권유" 둘 다 있어야 인정
+_DISC_INFO = re.compile(r"(일반\s*정보|정보\s*제공|정보성|정보입니다|정보이며)")
+_DISC_INTENT = re.compile(r"(권유가?\s*아니|상담을?\s*권|전문가\s*상담|디스클레이머|최신.{0,6}확인)")
+
+# 2. 특정 상품/가입 적극 권유 (명령형). "상담을 권/가입 전/가입 자격/가입할 수 있" 등 정보성은 제외.
+_SOLICIT = re.compile(
+    r"(가입하세요|가입하십시오|가입하시기?\s*바랍|가입하시길|지금\s*(바로\s*)?가입|"
+    r"가입\s*신청하세요|가입을?\s*추천(?!.{0,6}아니)|꼭\s*가입|반드시\s*가입|"
+    r"이\s*상품에?\s*가입|당장\s*가입)"
+)
+
+# 3. PII: 주민번호 / 휴대폰 / 이메일
+_PII_RRN = re.compile(r"\b\d{6}[-\s]?\d{7}\b")
+_PII_PHONE = re.compile(r"\b01[0-9][-\s]?\d{3,4}[-\s]?\d{4}\b")
+_PII_EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
+
+# 4. 외부발송/게시/업로드 "확정(완료)" 문구
+_EXT_SEND = re.compile(r"(발송|게시|업로드|전송|발행)\s*(완료|했습니다|됐습니다|되었습니다|함\b)")
+
+
+def _parse_allowed_paths(task_md_text: str) -> list[str]:
+    """allowed_resources.paths 의 - "..." 항목만 추출(forbidden_paths 등 다른 블록 제외)."""
+    m = re.search(r"allowed_resources:\s*\n(.*?)(?=\n[^\s#]|\Z)", task_md_text, re.DOTALL)
+    block = m.group(1) if m else task_md_text
+    pm = re.search(r"paths:\s*\n((?:\s*-\s*.*\n)+)", block)
+    if not pm:
+        return []
+    out = []
+    for line in pm.group(1).splitlines():
+        s = line.strip()
+        if s.startswith("-"):
+            v = s[1:].strip().strip('"').strip("'")
+            if v:
+                out.append(v)
+    return out
+
+
+def _has_level_doc_only(task_md_text: str) -> bool:
+    m = re.search(r"## 레벨\s*\n(.*?)(?=\n## |\Z)", task_md_text, re.DOTALL)
+    if not m:
+        return False
+    return bool(re.search(r"코드 수정 없음|문서 업데이트만|문서만|리서치만", m.group(1)))
+
+
+def _is_code_path(p: str) -> bool:
+    pl = p.lower()
+    if pl.endswith(_CODE_EXT):
+        return True
+    return any(h in pl for h in _CODE_PATH_HINT)
+
+
+def _check_document(text: str) -> dict:
+    checks = {}
+    # 1. 디스클레이머
+    has_disc = bool(_DISC_INFO.search(text)) and bool(_DISC_INTENT.search(text))
+    checks["disclaimer_present"] = "PASS" if has_disc else "FAIL"
+    # 2. 권유문 부재
+    sol = _SOLICIT.search(text)
+    checks["no_solicitation"] = "FAIL" if sol else "PASS"
+    # 3. PII 부재
+    pii = _PII_RRN.search(text) or _PII_PHONE.search(text) or _PII_EMAIL.search(text)
+    checks["no_pii"] = "FAIL" if pii else "PASS"
+    # 4. 외부발송 확정 문구 부재
+    ext = _EXT_SEND.search(text)
+    checks["no_external_send_confirmation"] = "FAIL" if ext else "PASS"
+    return checks
+
+
+def run_gate(task_id: str, team: str, workspace: Path) -> dict:
+    events = workspace / "memory" / "events"
+    events.mkdir(parents=True, exist_ok=True)
+    result_path = events / f"{task_id}.qc-result"
+
+    reasons = []
+    checks_all = {}
+
+    task_md = workspace / "memory" / "tasks" / f"{task_id}.md"
+    if not task_md.exists():
+        reasons.append(f"task md 없음: {task_md}")
+        return _write(result_path, "FAIL", team, checks_all, reasons)
+
+    md_text = task_md.read_text(encoding="utf-8")
+
+    # 전제 1: `## 레벨: 문서만`
+    if not _has_level_doc_only(md_text):
+        reasons.append("## 레벨 문서만 마커 부재 — content QC 부적격(코드 task 가능성)")
+        return _write(result_path, "FAIL", team, checks_all, reasons)
+
+    paths = _parse_allowed_paths(md_text)
+    if not paths:
+        reasons.append("allowed_resources.paths 없음")
+        return _write(result_path, "FAIL", team, checks_all, reasons)
+
+    # 전제 2: 코드/설정/인프라 파일 섞임 0
+    code_mixed = [p for p in paths if _is_code_path(p)]
+    if code_mixed:
+        reasons.append(f"코드/설정/인프라 파일 혼입 — content QC 부적격: {code_mixed}")
+        return _write(result_path, "FAIL", team, checks_all, reasons)
+
+    # 산출물 검사 (각 문서)
+    any_doc = False
+    for p in paths:
+        fp = workspace / p
+        if not fp.exists():
+            # 산출물 미생성 = 작업 미완 → fail-closed
+            checks_all[p] = {"exists": "FAIL"}
+            reasons.append(f"산출물 미존재: {p}")
+            continue
+        any_doc = True
+        text = fp.read_text(encoding="utf-8", errors="ignore")
+        c = _check_document(text)
+        checks_all[p] = c
+        for name, st in c.items():
+            if st == "FAIL":
+                reasons.append(f"{p}: {name} FAIL")
+
+    if not any_doc:
+        reasons.append("검사 가능한 산출물 0")
+
+    overall = "FAIL" if reasons else "PASS"
+    return _write(result_path, overall, team, checks_all, reasons)
+
+
+def _write(result_path: Path, overall: str, team: str, checks: dict, reasons: list) -> dict:
+    payload = {
+        "qc_result": overall,           # ★ finish-task.sh 가 읽는 키
+        "team": team,
+        "mode": "content_lightweight_qc",
+        "checks": checks,
+        "reasons": reasons,
+        "verifier": "teams/content/qc/qc_verify.py",
+        "fail_closed": True,
+    }
+    result_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(f"[CONTENT-QC] {overall} — {result_path} (reasons={len(reasons)})", file=sys.stderr)
+    return payload
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="content 팀 전용 lightweight 콘텐츠 QC")
+    parser.add_argument("--task-id", required=True)
+    parser.add_argument("--team", default="content")
+    parser.add_argument("--gate", action="store_true")
+    parser.add_argument("--workspace", default=None)
+    # shared qc_verify 와 호출 인터페이스 호환: 모르는 인자는 무시
+    args, _unknown = parser.parse_known_args()
+
+    workspace = Path(args.workspace) if args.workspace else _DEFAULT_WORKSPACE
+    res = run_gate(args.task_id, args.team, workspace)
+    # gate 모드: qc_result FAIL 이어도 exit 0 (finish-task.sh 가 .qc-result 읽어 판정).
+    #   단 .qc-result 는 항상 생성(없으면 finish-task 가 exit 1).
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tests/regression/test_content_qc_2769.py b/tests/regression/test_content_qc_2769.py
new file mode 100644
index 00000000..222cb5f9
--- /dev/null
+++ b/tests/regression/test_content_qc_2769.py
@@ -0,0 +1,144 @@
+"""
+test_content_qc_2769.py — content 팀 전용 lightweight QC 회귀.
+
+검증: PASS/FAIL(디스클레이머/권유/PII/외부발송) · 코드혼입 부적격 · self-contained(PYTHONPATH 독립) ·
+      finish-task.sh 무수정 · shared/dev/design QC 무변경.
+회장 승인: C-minimal — teams/content/qc/qc_verify.py 1파일, finish-task 무수정.
+"""
+from __future__ import annotations
+
+import importlib.util
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[2]
+_QC = _ROOT / "teams" / "content" / "qc" / "qc_verify.py"
+
+_spec = importlib.util.spec_from_file_location("content_qc_2769", _QC)
+cqc = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(cqc)
+
+_GOOD_DOC = """# 연금 정보
+연금저축과 IRP는 노후 대비 제도입니다(일반 정보).
+어떤 상품을 권하는 글이 아니라 제도 이해용입니다.
+
+## 디스클레이머
+본 글은 일반 정보이며 특정 상품 권유가 아닙니다. 가입 전 본인 상황에 맞는 전문가 상담을 권합니다.
+세법은 매년 개정될 수 있으므로 최신 기준을 확인하시기 바랍니다.
+"""
+
+_TASK_MD = """# task-x
+
+## 레벨
+문서만
+
+## allowed_resources
+allowed_resources:
+  paths:
+    - "memory/work/insurance_content/doc.md"
+  merge_policy: "none"
+"""
+
+
+def _mk(tmp_path, task_md=_TASK_MD, doc=_GOOD_DOC, doc_rel="memory/work/insurance_content/doc.md", tid="task-ct-2769"):
+    (tmp_path / "memory" / "tasks").mkdir(parents=True, exist_ok=True)
+    (tmp_path / "memory" / "tasks" / f"{tid}.md").write_text(task_md, encoding="utf-8")
+    if doc is not None:
+        dp = tmp_path / doc_rel
+        dp.parent.mkdir(parents=True, exist_ok=True)
+        dp.write_text(doc, encoding="utf-8")
+    return tid
+
+
+def _run(tmp_path, tid):
+    return cqc.run_gate(tid, "content", tmp_path)
+
+
+def test_good_doc_pass(tmp_path):
+    tid = _mk(tmp_path)
+    r = _run(tmp_path, tid)
+    assert r["qc_result"] == "PASS", r["reasons"]
+
+
+def test_missing_disclaimer_fail(tmp_path):
+    tid = _mk(tmp_path, doc="# 글\n연금저축과 IRP 비교(일반 정보).\n끝.")
+    r = _run(tmp_path, tid)
+    assert r["qc_result"] == "FAIL"
+    assert any("disclaimer" in x for x in r["reasons"])
+
+
+def test_solicitation_fail(tmp_path):
+    bad = _GOOD_DOC + "\n지금 가입하세요! 이 상품에 가입하시기 바랍니다."
+    tid = _mk(tmp_path, doc=bad)
+    r = _run(tmp_path, tid)
+    assert r["qc_result"] == "FAIL"
+    assert any("no_solicitation" in x for x in r["reasons"])
+
+
+def test_pii_fail(tmp_path):
+    for pii in ("주민번호 900101-1234567", "연락처 010-1234-5678", "메일 hong@example.com"):
+        tid = _mk(tmp_path, doc=_GOOD_DOC + "\n" + pii, tid="task-pii")
+        r = _run(tmp_path, tid)
+        assert r["qc_result"] == "FAIL", f"PII 미탐: {pii}"
+        assert any("no_pii" in x for x in r["reasons"])
+
+
+def test_external_send_fail(tmp_path):
+    bad = _GOOD_DOC + "\n고객에게 발송 완료했습니다. 블로그 게시 완료."
+    tid = _mk(tmp_path, doc=bad)
+    r = _run(tmp_path, tid)
+    assert r["qc_result"] == "FAIL"
+    assert any("external_send" in x for x in r["reasons"])
+
+
+def test_code_file_mixed_rejected(tmp_path):
+    md = _TASK_MD.replace('- "memory/work/insurance_content/doc.md"',
+                          '- "memory/work/insurance_content/doc.md"\n    - "dispatch/foo.py"')
+    tid = _mk(tmp_path, task_md=md)
+    r = _run(tmp_path, tid)
+    assert r["qc_result"] == "FAIL"
+    assert any("혼입" in x or "부적격" in x for x in r["reasons"])
+
+
+def test_no_level_marker_rejected(tmp_path):
+    md = _TASK_MD.replace("## 레벨\n문서만\n\n", "")
+    tid = _mk(tmp_path, task_md=md)
+    r = _run(tmp_path, tid)
+    assert r["qc_result"] == "FAIL"
+
+
+def test_qc_result_file_written(tmp_path):
+    tid = _mk(tmp_path)
+    _run(tmp_path, tid)
+    rp = tmp_path / "memory" / "events" / f"{tid}.qc-result"
+    assert rp.exists()
+    data = json.loads(rp.read_text(encoding="utf-8"))
+    assert "qc_result" in data and data["mode"] == "content_lightweight_qc"
+
+
+def test_self_contained_no_pythonpath(tmp_path):
+    # ★ PYTHONPATH 미설정 + cwd=tmp 에서도 ModuleNotFoundError 0 (self-contained)
+    tid = _mk(tmp_path)
+    env = {"PATH": "/usr/bin:/bin"}  # PYTHONPATH 의도적 제거
+    proc = subprocess.run(
+        [sys.executable, str(_QC), "--gate", "--task-id", tid, "--team", "content",
+         "--workspace", str(tmp_path)],
+        capture_output=True, text=True, env=env, cwd=str(tmp_path),
+    )
+    assert proc.returncode == 0, proc.stderr
+    assert "ModuleNotFoundError" not in proc.stderr
+    assert (tmp_path / "memory" / "events" / f"{tid}.qc-result").exists()
+
+
+def test_finish_task_and_shared_qc_unchanged():
+    # 무수정 증거 (해시 비교는 보고 단계에서 — 여기선 파일 존재/내용 무관 가드)
+    assert (_ROOT / "scripts" / "finish-task.sh").exists()
+    assert (_ROOT / "teams" / "shared" / "qc_verify.py").exists()
+    # content qc 가 shared verifiers / utils 를 import 하지 않음(self-contained).
+    #   주석의 설명 문자열은 허용 — 실제 import 문만 검사.
+    import re as _re
+    src = _QC.read_text(encoding="utf-8")
+    assert "from utils" not in src
+    assert not _re.search(r"^\s*(from|import)\s+\S*verifiers", src, _re.M)
diff --git a/tests/regression/test_gate_shadow_finish_task_2766.py b/tests/regression/test_gate_shadow_finish_task_2766.py
new file mode 100644
index 00000000..2a3dec70
--- /dev/null
+++ b/tests/regression/test_gate_shadow_finish_task_2766.py
@@ -0,0 +1,98 @@
+"""
+test_gate_shadow_finish_task_2766.py — v2.14 S3 §31 gate shadow finish-task report-only 결선 회귀.
+
+검증: source="finish_task" 기록 / dispatch source 무변경(default) / fail-open / no-block /
+      finish-task.sh shadow 결선 fail-open(|| true) 구조 정합.
+회장 승인: A(S3 report-only) — finish-task path 1곳 report-only 결선. fail-closed 0.
+"""
+from __future__ import annotations
+
+import importlib.util
+import json
+import re
+import sys
+from pathlib import Path
+
+# tests/dispatch shadow 회피 (task-2640 동일 패턴).
+_ROOT = Path(__file__).resolve().parents[2]
+_TESTS_DIR = str(_ROOT / "tests")
+sys.path[:] = [p for p in sys.path if p != _TESTS_DIR]
+if str(_ROOT) not in sys.path:
+    sys.path.insert(0, str(_ROOT))
+for _m in [k for k in list(sys.modules) if k == "dispatch" or k.startswith("dispatch.")]:
+    _cached = sys.modules.get(_m)
+    _f = getattr(_cached, "__file__", "") or ""
+    if "/tests/dispatch" in _f or _f == "":
+        del sys.modules[_m]
+
+
+def _load_real(modname: str, relpath: str):
+    existing = sys.modules.get(modname)
+    if existing is not None and getattr(existing, "__file__", "") and str(existing.__file__).endswith(relpath):
+        return existing
+    spec = importlib.util.spec_from_file_location(modname, _ROOT / relpath)
+    assert spec is not None and spec.loader is not None, f"spec not found: {relpath}"
+    mod = importlib.util.module_from_spec(spec)
+    sys.modules[modname] = mod
+    spec.loader.exec_module(mod)
+    return mod
+
+
+_load_real("dispatch", "dispatch/__init__.py")
+_load_real("dispatch.v2_13_gate", "dispatch/v2_13_gate.py")
+gsr = _load_real("dispatch.gate_shadow_runner", "dispatch/gate_shadow_runner.py")
+
+
+def _last_rec(tmp_path):
+    f = list(Path(tmp_path).glob("gate_shadow_*.jsonl"))[0]
+    return json.loads(f.read_text(encoding="utf-8").strip().splitlines()[-1])
+
+
+def test_finish_task_source_logged(tmp_path):
+    # S3: source="finish_task" 가 로그 메타에 기록
+    assert gsr.shadow_evaluate(task_id="ft1", source="finish_task", log_dir=str(tmp_path)) is None
+    rec = _last_rec(tmp_path)
+    assert rec["source"] == "finish_task"
+    assert rec["mode"] == "report_only_shadow"
+    assert rec["active"] is False
+
+
+def test_dispatch_source_unchanged_default(tmp_path):
+    # 기존 dispatch 호출(source 생략) = "dispatch" 그대로 (S2 무변경)
+    gsr.shadow_evaluate(task_id="d1", level="normal", task_type="coding", log_dir=str(tmp_path))
+    rec = _last_rec(tmp_path)
+    assert rec["source"] == "dispatch"
+
+
+def test_finish_task_fail_open(tmp_path, monkeypatch):
+    import dispatch.v2_13_gate as g
+
+    def boom(*a, **k):
+        raise RuntimeError("intentional")
+
+    monkeypatch.setattr(g, "classify_gate", boom)
+    assert gsr.shadow_evaluate(task_id="ft2", source="finish_task", log_dir=str(tmp_path)) is None
+
+
+def test_no_sensitive_fields_finish_task(tmp_path):
+    gsr.shadow_evaluate(
+        task_id="ft3", source="finish_task",
+        runtime_state={"prompt": "RAWPROMPT", "credential": "ghp_X"},
+        log_dir=str(tmp_path),
+    )
+    rec = _last_rec(tmp_path)
+    allowed = {"ts", "source", "task_id", "goal_id", "level", "task_type",
+               "verdict", "reason", "touched_axes", "rollback_cost", "contract_source", "mode", "active"}
+    assert set(rec.keys()) <= allowed
+    blob = json.dumps(rec, ensure_ascii=False)
+    assert "RAWPROMPT" not in blob and "ghp_X" not in blob
+
+
+def test_finish_task_sh_wiring_is_fail_open():
+    # finish-task.sh 결선이 report-only fail-open 구조인지(소스 정합)
+    sh = (_ROOT / "scripts" / "finish-task.sh").read_text(encoding="utf-8")
+    assert "gate_shadow_runner" in sh, "finish-task.sh 에 shadow 결선 부재"
+    # shadow 호출 블록이 || true 로 errexit 격리 + source=finish_task
+    assert 'source="finish_task"' in sh
+    m = re.search(r"shadow_evaluate\(task_id=sys\.argv\[1\], source=\"finish_task\"\)'[^\n]*\n\"\$TASK_ID\" 2>/dev/null \) \|\| true", sh)
+    assert m is not None, "finish-task.sh shadow 결선이 argv-safe + 2>/dev/null + || true 패턴이어야 함"
diff --git a/tests/regression/test_gate_shadow_runner_2765.py b/tests/regression/test_gate_shadow_runner_2765.py
new file mode 100644
index 00000000..3d9d47e2
--- /dev/null
+++ b/tests/regression/test_gate_shadow_runner_2765.py
@@ -0,0 +1,103 @@
+"""
+test_gate_shadow_runner_2765.py — v2.14 S1/S2 §31 gate shadow report-only wrapper 회귀.
+
+검증: fail-open / no-block / no-secret-log / verdict 기록 / log write 실패 fail-open /
+      dispatch behavior unchanged(호출 계약 = 항상 None·raise 0).
+회장 승인: ANU_V2_14_PHASE1_GATE_SHADOW_S1_S2_IMPLEMENTATION_APPROVED_ACTIVE_FALSE.
+"""
+from __future__ import annotations
+
+import importlib.util
+import json
+import sys
+from pathlib import Path
+
+# tests/dispatch shadow 회피 (task-2640 동일 패턴). 실 dispatch 패키지 + 신규 모듈 강제 로드.
+_ROOT = Path(__file__).resolve().parents[2]
+_TESTS_DIR = str(_ROOT / "tests")
+sys.path[:] = [p for p in sys.path if p != _TESTS_DIR]
+if str(_ROOT) not in sys.path:
+    sys.path.insert(0, str(_ROOT))
+for _m in [k for k in list(sys.modules) if k == "dispatch" or k.startswith("dispatch.")]:
+    _cached = sys.modules.get(_m)
+    _f = getattr(_cached, "__file__", "") or ""
+    if "/tests/dispatch" in _f or _f == "":
+        del sys.modules[_m]
+
+
+def _load_real(modname: str, relpath: str):
+    existing = sys.modules.get(modname)
+    if existing is not None and getattr(existing, "__file__", "") and str(existing.__file__).endswith(relpath):
+        return existing
+    spec = importlib.util.spec_from_file_location(modname, _ROOT / relpath)
+    assert spec is not None and spec.loader is not None, f"spec not found: {relpath}"
+    mod = importlib.util.module_from_spec(spec)
+    sys.modules[modname] = mod
+    spec.loader.exec_module(mod)
+    return mod
+
+
+_load_real("dispatch", "dispatch/__init__.py")
+_v213 = _load_real("dispatch.v2_13_gate", "dispatch/v2_13_gate.py")
+gsr = _load_real("dispatch.gate_shadow_runner", "dispatch/gate_shadow_runner.py")
+
+
+def test_returns_none_no_raise(tmp_path):
+    # report-only: 항상 None, 예외 0
+    assert gsr.shadow_evaluate(
+        task_id="t1", level="normal", task_type="coding", log_dir=str(tmp_path)
+    ) is None
+
+
+def test_fail_open_when_classify_raises(tmp_path, monkeypatch):
+    # classify_gate 가 raise 해도 호출자에게 전파 0 → None
+    def boom(*a, **k):
+        raise RuntimeError("intentional")
+
+    monkeypatch.setattr(_v213, "classify_gate", boom)
+    assert gsr.shadow_evaluate(task_id="t2", level="normal", log_dir=str(tmp_path)) is None
+
+
+def test_verdict_logged(tmp_path):
+    gsr.shadow_evaluate(task_id="t3", level="normal", task_type="coding", log_dir=str(tmp_path))
+    files = list(Path(tmp_path).glob("gate_shadow_*.jsonl"))
+    assert files, "shadow log 파일이 생성돼야 함"
+    rec = json.loads(files[0].read_text(encoding="utf-8").strip().splitlines()[-1])
+    assert "verdict" in rec
+    assert rec["mode"] == "report_only_shadow"
+    assert rec["active"] is False
+    # dispatch 시점 표준 contract 부재 → INVALID_CONTRACT_PROVENANCE 관측(차단 아님)
+    assert rec["verdict"] == "INVALID_CONTRACT_PROVENANCE"
+
+
+def test_no_sensitive_fields_in_log(tmp_path):
+    # runtime_state 로 민감값을 줘도 로그엔 최소 메타만, 그 값/키는 기록 0
+    gsr.shadow_evaluate(
+        task_id="t4", level="normal", task_type="coding",
+        runtime_state={"prompt": "RAWPROMPT", "credential": "ghp_X", "env": {"K": "V"}},
+        log_dir=str(tmp_path),
+    )
+    rec = json.loads(Path(list(Path(tmp_path).glob("*.jsonl"))[0]).read_text(encoding="utf-8").strip().splitlines()[-1])
+    allowed = {"ts", "source", "task_id", "goal_id", "level", "task_type",
+               "verdict", "reason", "touched_axes", "rollback_cost", "contract_source", "mode", "active"}
+    assert set(rec.keys()) <= allowed, f"화이트리스트 외 키 유입: {set(rec.keys()) - allowed}"
+    blob = json.dumps(rec, ensure_ascii=False)
+    assert "RAWPROMPT" not in blob
+    assert "ghp_X" not in blob
+    assert "credential" not in blob
+    assert '"env"' not in blob
+
+
+def test_log_write_failure_fail_open():
+    # 쓰기 불가 경로여도 raise 0 → None
+    assert gsr.shadow_evaluate(
+        task_id="t5", level="normal", log_dir="/proc/forbidden_nonexistent_xyz_2765"
+    ) is None
+
+
+def test_minimal_meta_only_no_task_desc():
+    # 호출 계약상 task_desc/prompt 를 받지 않는다(시그니처에 부재) — 구조적 no-secret 보장
+    import inspect
+    params = set(inspect.signature(gsr.shadow_evaluate).parameters)
+    assert "task_desc" not in params
+    assert "prompt" not in params
diff --git a/tests/regression/test_goal_contract_strict_template_2767.py b/tests/regression/test_goal_contract_strict_template_2767.py
new file mode 100644
index 00000000..21ed0857
--- /dev/null
+++ b/tests/regression/test_goal_contract_strict_template_2767.py
@@ -0,0 +1,190 @@
+"""
+test_goal_contract_strict_template_2767.py — v2.14 goal_contract Option A strict_template builder 회귀.
+
+검증: 결정론 매핑(자유서술 0) / provenance 항상 strict_template(chair_injected 자칭 거부) /
+      allowed_resources 불완전 시 None→INVALID 유지 / strict_template 주입 시 verdict 변화(provenance 통과) /
+      task md 원문 미주입·미로그 / fail-open.
+회장 승인: goal_contract Option A strict_template pure builder + shadow report-only 주입(실차단 0).
+"""
+from __future__ import annotations
+
+import importlib.util
+import json
+import sys
+from pathlib import Path
+
+# tests/dispatch shadow 회피 (task-2640 동일 패턴).
+_ROOT = Path(__file__).resolve().parents[2]
+_TESTS_DIR = str(_ROOT / "tests")
+sys.path[:] = [p for p in sys.path if p != _TESTS_DIR]
+if str(_ROOT) not in sys.path:
+    sys.path.insert(0, str(_ROOT))
+for _m in [k for k in list(sys.modules) if k == "dispatch" or k.startswith("dispatch.")]:
+    _cached = sys.modules.get(_m)
+    _f = getattr(_cached, "__file__", "") or ""
+    if "/tests/dispatch" in _f or _f == "":
+        del sys.modules[_m]
+
+
+def _load_real(modname: str, relpath: str):
+    existing = sys.modules.get(modname)
+    if existing is not None and getattr(existing, "__file__", "") and str(existing.__file__).endswith(relpath):
+        return existing
+    spec = importlib.util.spec_from_file_location(modname, _ROOT / relpath)
+    assert spec is not None and spec.loader is not None, f"spec not found: {relpath}"
+    mod = importlib.util.module_from_spec(spec)
+    sys.modules[modname] = mod
+    spec.loader.exec_module(mod)
+    return mod
+
+
+_load_real("dispatch", "dispatch/__init__.py")
+_load_real("dispatch.v2_13_gate", "dispatch/v2_13_gate.py")
+gsr = _load_real("dispatch.gate_shadow_runner", "dispatch/gate_shadow_runner.py")
+
+_TASK_MD = """# task-tpl-2767
+
+```yaml
+allowed_resources:
+  paths:
+    - "dispatch/foo.py"
+    - "tests/regression/test_foo.py"
+  forbidden_paths:
+    - "memory/**"
+  commands:
+    - "pytest"
+  ttl_hours: 24
+```
+"""
+
+
+def _mk_ws(tmp_path, tid="task-tpl-2767", body=_TASK_MD):
+    (tmp_path / "memory" / "tasks").mkdir(parents=True, exist_ok=True)
+    (tmp_path / "memory" / "tasks" / f"{tid}.md").write_text(body, encoding="utf-8")
+    return tmp_path
+
+
+def test_builder_deterministic_mapping(tmp_path):
+    _mk_ws(tmp_path)
+    c = gsr.build_strict_template_contract("task-tpl-2767", workspace=str(tmp_path))
+    assert c is not None
+    assert c["goal_id"] == "task-tpl-2767"
+    assert c["expected_files"] == ["dispatch/foo.py", "tests/regression/test_foo.py"]
+    assert "memory/**" in c["forbidden_operations"] and "pytest" in c["forbidden_operations"]
+    assert c["budget"] == {"ttl_hours": 24}
+    assert c["rollback_floor"] == "LOW"  # rollback_floor([]) 재사용
+    # 자유서술 0 — 결정론 불가 필드는 UNSPECIFIED
+    for f in ("scope", "blast_radius", "allowed_capability_delta", "allowed_runtime_changes", "exit_criteria"):
+        assert c[f] == "UNSPECIFIED"
+
+
+def test_builder_provenance_always_strict_template(tmp_path):
+    _mk_ws(tmp_path)
+    c = gsr.build_strict_template_contract("task-tpl-2767", workspace=str(tmp_path))
+    assert c["provenance"] == "strict_template"  # chair_injected 절대 미사용
+
+
+def test_builder_none_when_no_paths(tmp_path):
+    _mk_ws(tmp_path, body="# empty\n```yaml\nallowed_resources:\n  commands: [pytest]\n```\n")
+    assert gsr.build_strict_template_contract("task-tpl-2767", workspace=str(tmp_path)) is None
+
+
+def test_builder_none_when_no_md(tmp_path):
+    (tmp_path / "memory" / "tasks").mkdir(parents=True, exist_ok=True)
+    assert gsr.build_strict_template_contract("task-absent", workspace=str(tmp_path)) is None
+
+
+def test_builder_path_traversal_rejected(tmp_path):
+    _mk_ws(tmp_path)
+    assert gsr.build_strict_template_contract("../etc/passwd", workspace=str(tmp_path)) is None
+    assert gsr.build_strict_template_contract("a/b", workspace=str(tmp_path)) is None
+
+
+def test_shadow_explicit_strict_template_passes_provenance(tmp_path):
+    # strict_template contract 주입 시 provenance 첫 관문 통과(INVALID_CONTRACT_PROVENANCE 탈출)
+    c = gsr.build_strict_template_contract("task-tpl-2767", workspace=str(_mk_ws(tmp_path)))
+    log = tmp_path / "log"
+    gsr.shadow_evaluate(task_id="task-tpl-2767", contract=c, log_dir=str(log))
+    rec = json.loads(list(Path(log).glob("*.jsonl"))[0].read_text(encoding="utf-8").strip().splitlines()[-1])
+    assert rec["verdict"] != "INVALID_CONTRACT_PROVENANCE", "strict_template provenance 가 통과해야 함"
+    assert rec["contract_source"] == "explicit"
+    assert rec["active"] is False and rec["mode"] == "report_only_shadow"
+
+
+def test_shadow_rejects_chair_injected_selfclaim(tmp_path):
+    # chair_injected 자칭 contract 는 거부 → 빌드/best-effort 로 강등(자기결재 방지)
+    fake = {"goal_id": "x", "scope": "s", "blast_radius": "LOW", "allowed_capability_delta": "x",
+            "expected_files": [], "allowed_runtime_changes": "x", "forbidden_operations": [],
+            "rollback_floor": "LOW", "budget": "x", "exit_criteria": "x", "provenance": "chair_injected"}
+    log = tmp_path / "log"
+    gsr.shadow_evaluate(task_id="task-no-md-xyz", contract=fake, log_dir=str(log))
+    rec = json.loads(list(Path(log).glob("*.jsonl"))[0].read_text(encoding="utf-8").strip().splitlines()[-1])
+    assert rec["contract_source"] in ("best_effort",)  # chair_injected 거부 후 md 없음 → best_effort
+    assert rec["verdict"] == "INVALID_CONTRACT_PROVENANCE"  # best-effort 는 dispatch_runtime_shadow → INVALID
+
+
+def test_shadow_no_md_falls_back_invalid(tmp_path):
+    # task md 없으면 기존 INVALID 관측 유지(차단 0)
+    log = tmp_path / "log"
+    assert gsr.shadow_evaluate(task_id="task-absent-xyz", contract=None, log_dir=str(log)) is None
+    rec = json.loads(list(Path(log).glob("*.jsonl"))[0].read_text(encoding="utf-8").strip().splitlines()[-1])
+    assert rec["contract_source"] == "best_effort"
+    assert rec["verdict"] == "INVALID_CONTRACT_PROVENANCE"
+
+
+def test_no_task_md_content_in_log(tmp_path):
+    c = gsr.build_strict_template_contract("task-tpl-2767", workspace=str(_mk_ws(tmp_path)))
+    log = tmp_path / "log"
+    gsr.shadow_evaluate(task_id="task-tpl-2767", contract=c, log_dir=str(log))
+    rec = json.loads(list(Path(log).glob("*.jsonl"))[0].read_text(encoding="utf-8").strip().splitlines()[-1])
+    allowed = {"ts", "source", "task_id", "goal_id", "level", "task_type",
+               "verdict", "reason", "touched_axes", "rollback_cost", "contract_source", "mode", "active"}
+    assert set(rec.keys()) <= allowed
+
+
+def test_builder_fail_open_on_garbage(tmp_path):
+    # 깨진 yaml 도 None(예외 흡수)
+    _mk_ws(tmp_path, body="# x\n```yaml\nallowed_resources: [::: not valid\n```\n")
+    assert gsr.build_strict_template_contract("task-tpl-2767", workspace=str(tmp_path)) is None
+
+
+def test_repo_root_is_workspace_not_memory():
+    # ★ 경로버그 회귀 가드: _REPO_ROOT 가 <repo>(memory/tasks 존재 위치)여야 한다.
+    #   과거 버그: _DEFAULT_LOG_DIR.parent.parent = <repo>/memory 로 잘못 감 → memory/memory/tasks.
+    assert gsr._REPO_ROOT == gsr._DEFAULT_LOG_DIR.parents[2]
+    assert (gsr._REPO_ROOT / "memory" / "tasks").is_dir(), "_REPO_ROOT/memory/tasks 가 실재해야 함"
+    assert not str(gsr._REPO_ROOT).endswith("/memory")
+
+
+def test_default_workspace_finds_task_md(monkeypatch, tmp_path):
+    # ★ workspace 인자 없이(=런타임 경로) build 가 실제 task md 를 찾는지 검증.
+    #   이 테스트가 과거 갭(빌더 테스트가 전부 workspace 명시 인자라 런타임 경로 미검증)을 닫는다.
+    monkeypatch.setattr(gsr, "_REPO_ROOT", tmp_path)
+    _mk_ws(tmp_path, tid="task-rt-2767")
+    c = gsr.build_strict_template_contract("task-rt-2767")  # workspace 미지정 = 런타임 경로
+    assert c is not None, "런타임 기본 경로에서 task md 를 찾아야 함(경로버그 해소)"
+    assert c["provenance"] == "strict_template"
+    assert c["expected_files"] == ["dispatch/foo.py", "tests/regression/test_foo.py"]
+
+
+def test_default_workspace_verdict_changes(monkeypatch, tmp_path):
+    # workspace 미지정 런타임 경로 → strict_template 주입 → verdict GOAL_ENABLING_CORRECTION_ALLOWED
+    monkeypatch.setattr(gsr, "_REPO_ROOT", tmp_path)
+    _mk_ws(tmp_path, tid="task-rt-2767")
+    log = tmp_path / "log"
+    gsr.shadow_evaluate(task_id="task-rt-2767", log_dir=str(log))
+    rec = json.loads(list(Path(log).glob("*.jsonl"))[0].read_text(encoding="utf-8").strip().splitlines()[-1])
+    assert rec["contract_source"] == "strict_template"
+    assert rec["verdict"] == "GOAL_ENABLING_CORRECTION_ALLOWED"
+    assert rec["active"] is False  # report-only, 차단 0
+
+
+def test_default_workspace_no_md_stays_invalid(monkeypatch, tmp_path):
+    # 런타임 경로에 task md 없으면 기존 INVALID 관측 유지(차단 0)
+    monkeypatch.setattr(gsr, "_REPO_ROOT", tmp_path)
+    (tmp_path / "memory" / "tasks").mkdir(parents=True, exist_ok=True)
+    log = tmp_path / "log"
+    gsr.shadow_evaluate(task_id="task-absent-rt", log_dir=str(log))
+    rec = json.loads(list(Path(log).glob("*.jsonl"))[0].read_text(encoding="utf-8").strip().splitlines()[-1])
+    assert rec["contract_source"] == "best_effort"
+    assert rec["verdict"] == "INVALID_CONTRACT_PROVENANCE"
