# [DRAFT v2 — HOLD] task-2724 TERMINAL_STATE_CALLBACK_CONTRACT

> ★ 설계 **초안 v2**입니다. 코드 수정·dispatch·PR 생성 금지. `finish-task.sh`/dispatch runtime = 자동 dispatch 금지 영역(회장 승인 필수). PR #169(task-2723+3) governance 와 분리 — watcher 유지.

## 회장 인가 (2026-06-03, v2 보완 7항 반영)
봇 종료 경로 3분류: ① NORMAL_SUCCESS callback ② FAILURE_OR_BLOCKED callback ③ MISSING_OR_SPAWN_FAIL fallback. 현재 ①·일부 ③만 존재, **②(실패/차단)가 비어 있음** — task-2723+3 EXTERNAL_DIRTY_BLOCKER 실증. EXIT trap 기반 단일 emitter 방향 수용. v2 보완: idempotency/dedupe·success 우선순위·self-callback 검증·enum 축소·finish-task 최소수정·artifact 관계·PR분리.

## 근본 원인 (ANU read-only 확정)
`scripts/finish-task.sh` P2-A prereg(line 16-30)는 `{TASK_ID}-normal-completion.json` 존재 시에만 발사 = **NORMAL_SUCCESS 전용**. 실패/차단 경로(GIT-GATE EXTERNAL_DIRTY line 707~723, SCOPE, QC fail)는 blocker JSON 을 **디스크에만 기록·ANU push 0**. EXIT trap(line 51)은 timer end 만. → terminal 데이터는 디스크 존재, **ANU-owned push 결선만 부재.**

---
## 산출물 1. terminal_state_envelope_v1 schema
```json
{
  "schema": "terminal_state_envelope_v1",
  "task_id": "task-NNNN",
  "attempt_id": "<dispatch schedule_id>",        // dispatch marker.schedule_id
  "terminal_state": "<enum>",                     // 산출물 4
  "success": false,
  "done_created": false,                          // .done 존재 여부(위조 금지)
  "cause": "FINISH_TASK_GIT_GATE_BLOCKED ...",    // callback-cause.json source
  "remediation": "origin sync 또는 dirty 정리",
  "head_sha": "<worktree HEAD>",
  "pr_number": null,
  "evidence_paths": ["...external-dirty-blocker.json","...result.json"],  // source evidence 참조(복사 아님)
  "collector_role": "ANU",
  "owner_key_sealed": true,                        // ANU key literal 노출 0
  "callback_registered": true,                     // cron 등록 성공 여부
  "dedupe_key": "<task_id>:<attempt_id>:<terminal_state>",
  "emitted_by": "finish-task EXIT trap → terminal_state_callback.py",
  "ts": "<iso>"
}
```
원칙: 기존 source JSON(callback-cause/external-dirty-blocker/normal-completion/result) **schema 불변·파괴 0**. envelope 는 이들을 **참조(evidence_paths)로 wrapping** 하는 상위 계약.

## 산출물 2. expected_files 확정 후보 (3 — 회장 승인 필수)
1. `scripts/finish-task.sh` ← **최소 수정**: EXIT trap 에 hook 1줄 + 최소 context(task_id/events_dir/terminal_state hint) 전달만. 로직 금지.
2. `scripts/harness/v36/terminal_state_callback.py` (신규) ← envelope 생성 + dedupe + ANU-owned callback 등록(`dispatch.normal_fallback_callback_helper.launch_callback` 경유, 헬퍼 수정 0). priority/dedupe/self-key guard 전부 여기.
3. `tests/regression/test_terminal_state_callback_2724.py` (신규) ← 산출물 6 회귀.
- (관계만 기록, 1차 미수정) `dispatch/**` lifecycle classifier 의 terminal_state enum 매핑 = 회장 승인 영역 → 1차 분리.

## 산출물 3. idempotency / dedupe 설계
- **one-shot envelope**: `{events}/{task_id}.terminal-state.json` 존재 시 **재생성 0**.
- **one-shot registration**: `{events}/{task_id}.terminal-callback-registered.json` 존재 시 **재등록 0**.
- **dedupe_key = `task_id + attempt_id + terminal_state`** (attempt_id = dispatch marker.schedule_id). 같은 key 면 no-op.
- **trap 멱등 lock**: EXIT trap 은 ERR/EXIT 중복 호출 가능 → `{task_id}.terminal-emit-lock` 으로 1회 보장(flock 또는 atomic create).
- emitter 는 항상 envelope **디스크 기록 먼저(durable)** → cron 등록(2-write). cron 실패해도 envelope 잔존 → fallback pickup.

## 산출물 4. enum (1차 축소 — 회장 명시)
1차 필수 6: `NORMAL_SUCCESS` · `FINISH_BLOCKED_EXTERNAL_DIRTY` · `FINISH_BLOCKED_SCOPE_VIOLATION` · `FINISH_BLOCKED_GIT_GATE` · `QC_FAILED` · `UNKNOWN_FINISH_FAILURE`(분류 안 되는 종료 = fail-closed 기본값).
★ `SPAWN_FAILED_OR_MISSING_HEARTBEAT` = 봇이 emit 못 하는 영역 → **ANU fallback classifier 소관**. contract 에 **관계만 기록**, 1차 emitter 구현 대상 **제외**. (PR_GATE_BLOCKED/RUNTIME_ERROR/CANCELLED 는 2차 확장 후보로 보류.)

## 산출물 5. success/failure priority table
emitter 가 종료 시점에 아래 **우선순위 1회** 판정 (NORMAL_SUCCESS > explicit blocked/failed > UNKNOWN; fallback 은 별도 영역):
| 우선순위 | 조건 | terminal_state | failure envelope |
|---|---|---|---|
| 1 | `.done` 존재 (NORMAL_SUCCESS) | NORMAL_SUCCESS | ★ trap 이 **failure envelope 생성 안 함** |
| 2 | `.done` 없음 + external-dirty-blocker.json 존재 | FINISH_BLOCKED_EXTERNAL_DIRTY | emit |
| 2 | `.done` 없음 + scope-violation.json 존재 | FINISH_BLOCKED_SCOPE_VIOLATION | emit |
| 2 | `.done` 없음 + GIT-GATE blocker | FINISH_BLOCKED_GIT_GATE | emit |
| 2 | `.done` 없음 + QC fail marker | QC_FAILED | emit |
| 3 | `.done` 없음 + 위 marker 모두 없음 + 봇 종료 | UNKNOWN_FINISH_FAILURE | emit (fail-closed) |
| (영역밖) | 봇 미spawn/429/heartbeat 없음 | SPAWN_FAILED_OR_MISSING_HEARTBEAT | ANU fallback classifier |
★ 원칙: `.done`/NORMAL_SUCCESS 가 이미 있으면 **failure envelope 절대 생성 금지**(success 최우선). `.done` 위조 금지.

## 산출물 6. regression plan (+ self-callback acceptance)
- enum 6종 emit 단위(각 terminal_state → 올바른 envelope 1회).
- **success 우선순위**: `.done` 존재 시 failure envelope 0 (NORMAL_SUCCESS 만).
- **dedupe**: 같은 dedupe_key 2회 호출 → envelope/registration 각 1개(중복 0).
- **trap 멱등**: ERR+EXIT 중복 → emit 1회.
- **`.done` 무위조**: 실패/차단 시 `.done` 미생성, `.done.blocked`/terminal envelope 만.
- **self-callback 차단 (acceptance criteria)**:
  - registered cron `owner_key == ANU key` (bot self-key 사용 **0** — cron-history owner 교차검증)
  - **ANU key literal source 노출 0** (코드/envelope grep `c119085…` = 0, sealed 참조)
  - **callback owner 검증**: launch_callback 가 self-key 면 argv=None → 등록 0(기존 guard 재사용)
  - **sendfile-only fallback 금지**: 반드시 cron 등록(sendfile 만으로 종료 시 NOT_REGISTERED fail-closed)
  - **argv 존재 확인**: launch_callback argv non-None 검증(있어야 등록)
- (관계 기록) SPAWN_FAILED 는 ANU fallback classifier 테스트로 분리(이 task 회귀 대상 아님).

## 산출물 7. risk / rollback plan
- **위험도 MEDIUM-HIGH**: finish-task.sh = 모든 task 종료 공유 경로. trap 오결선 시 정상 완료 콜백 영향 가능.
- **격리 완화**: finish-task 수정은 trap hook 1줄 + context 전달로 **최소화**(로직은 terminal_state_callback.py). 정상 path(.done 생성) 동작 무변경 회귀 필수.
- **rollback**: terminal_state_callback.py 는 신규 파일 → 제거 시 원복. finish-task.sh hook 은 단일 라인/가드 → revert 용이. envelope 기록 실패해도 기존 .done/blocker JSON 경로 무영향(additive, fail-open on emitter error — emitter 가 죽어도 finish-task 정상 종료).
- **feature flag 권장**: `TERMINAL_CALLBACK_ENABLED`(default off) 로 단계적 활성 — pilot 후 on.

## 기존 artifact 관계 (산출물 6 보완)
`callback-cause.json`(cause) · `external-dirty-blocker.json`(blocked detail) · `normal-completion.json`(NORMAL prereg) · `result.json`(evidence) = **source evidence 유지·schema 불변**. terminal_state_envelope_v1 = 이들을 evidence_paths 로 참조하는 **상위 wrapping**. 기존 파일 파괴/스키마 변경 0.

## 금지 / 분리
- 코드 수정·dispatch·PR 금지(현재). finish-task.sh/dispatch runtime = 회장 승인 필수.
- `.done` 위조 금지 · self-key 콜백 금지 · credential/activation/wake 무관 · ANU key literal 노출 0.
- **PR #169 와 혼입 금지** — watcher `19273AA4` 유지, MERGE_READY_CANDIDATE/CHAIR_REQUIRED 도달 시 별도 보고.

## ★ 구현 범위 확정 (ANU read-only, 회장 1순위 결정 2026-06-03)
**finish-task.sh hook 위치 = EXIT trap `cleanup_timer()` (현재 line 41-50)**. 최소 수정:
- `cleanup_timer()` 끝(또는 병렬 trap 함수)에 **1줄 hook** 추가:
  `[ "${TERMINAL_CALLBACK_ENABLED:-0}" = "1" ] && python3 "$WORKSPACE/scripts/harness/v36/terminal_state_callback.py" emit --task-id "$TASK_ID" --events-dir "$EVENTS_DIR" --workspace "$WORKSPACE" --done-file "$DONE_FILE" 2>/dev/null || true`
- 전달 context(이미 finish-task 에 존재하는 변수): `TASK_ID` · `EVENTS_DIR` · `WORKSPACE` · `DONE_FILE`. **finish-task 에 신규 로직 0** — emit 호출만.
- ★ feature flag `TERMINAL_CALLBACK_ENABLED`(default 0/off) — pilot 후 on. 비활성 시 기존 동작 무변경(rollback 즉시).

**terminal_state_callback.py (신규) 책임** (state 판정 marker 는 모두 디스크에 이미 존재):
- 우선순위 판정: `DONE_FILE` 존재 → NORMAL_SUCCESS(failure envelope 생성 0) > `${TASK_ID}.external-dirty-blocker.json` → FINISH_BLOCKED_EXTERNAL_DIRTY > `${TASK_ID}.scope-violation.json` → FINISH_BLOCKED_SCOPE_VIOLATION > GIT-GATE(commit 0/uncommitted marker) → FINISH_BLOCKED_GIT_GATE > `${TASK_ID}.qc-result`=FAIL → QC_FAILED > else → UNKNOWN_FINISH_FAILURE.
- `cause`/`remediation` source = `${TASK_ID}.callback-cause.json`. evidence = result.json/blocker JSON 참조.
- dedupe: `${TASK_ID}.terminal-state.json`(envelope) + `.terminal-callback-registered.json` + `.terminal-emit-lock`(trap 멱등).
- ANU-owned cron = `dispatch.normal_fallback_callback_helper.launch_callback`(task_id/owner_key=ANU/chat_id) — self-key 면 argv=None → 미등록(기존 guard). sendfile-only 금지.

**launch_callback 시그니처 확인**: `launch_callback(task_id, owner_key, chat_id, ...)` — owner 검증 후에만 (ANU-keyed) cokacdir --cron argv 생성. 헬퍼 **수정 0**(재사용).

## ★★ 회장 구현 승인 (2026-06-03) — 코드+테스트 한정
승인 범위 = expected_files 3개 코드/테스트 구현 + PR 생성까지. **TERMINAL_CALLBACK_ENABLED 활성화 / flag on / activation / pilot / 실운영 적용은 merge 이후 별도 회장 승인** (이번 PR 금지).

## worktree (fresh origin/main)
- **fresh origin/main worktree** 에서 시작 (`task/task-2724-dev1`). 기존 task-2723 branch/PR #169 미접촉. dev1(헤르메스) — dev4 는 공유 main dirty 얽힘으로 제외.

## 필수 acceptance / regression (회장 15 — 전부 PASS)
1. NORMAL_SUCCESS(`.done` 존재) → failure envelope 생성 **0**.
2. external-dirty-blocker.json + `.done` 없음 → `FINISH_BLOCKED_EXTERNAL_DIRTY` envelope.
3. scope-violation marker → `FINISH_BLOCKED_SCOPE_VIOLATION` envelope.
4. git gate blocker(commit 0/uncommitted) → `FINISH_BLOCKED_GIT_GATE` envelope.
5. qc-result FAIL → `QC_FAILED` envelope.
6. 알 수 없는 finish failure → `UNKNOWN_FINISH_FAILURE` envelope.
7. 같은 `task_id+attempt_id+terminal_state` 중복 호출 → envelope/registration **1회만**.
8. ERR trap + EXIT trap 중복 → **1회만** emit.
9. bot self-key 사용 **0**.
10. ANU key literal source 노출 **0** (코드/envelope grep `c119085…`=0).
11. callback owner 검증 (owner_key==ANU 아니면 미등록).
12. argv 존재 확인 (launch_callback argv non-None 일 때만 등록).
13. sendfile-only fallback 금지 (cron 미등록 시 NOT_REGISTERED fail-closed).
14. emitter 실패 시 finish-task 기존 exit 흐름 영향 **0** (fail-open).
15. 기존 callback-cause/external-dirty-blocker/normal-completion/result.json schema 파괴 **0**.

## 구현 지시 (회장 9 원칙 준수)
- finish-task.sh: EXIT trap `cleanup_timer()`(line 41-50)에 **flag-guarded 1줄 hook** (구현범위 섹션 참조). enum 판정/JSON 조립/callback 등록 로직 **삽입 금지**.
- terminal_state_callback.py(신규): 전 로직(priority 판정·envelope·dedupe·ANU-owned 등록). `TERMINAL_CALLBACK_ENABLED` default-off. fail-open.
- `.done` = NORMAL_SUCCESS 전용·위조 금지. 실패/차단 = terminal-state envelope. one-shot/idempotent.
- 1차 enum 6종만. SPAWN_FAILED 제외(fallback classifier 영역).

## 검증 조건
1. 15 acceptance PASS. 2. `git diff --name-only origin/main` = expected_files **3파일** 내부. 3. forbidden 0. 4. ANU key full literal 0. 5. `TERMINAL_CALLBACK_ENABLED` default-off 유지. 6. emitter fail-open(finish-task 흐름 무영향).
- smoke: `python3 -m pytest tests/regression/test_terminal_state_callback_2724.py -q` + `bash -n scripts/finish-task.sh`.

## doctrine
- same-PR post-Gemini push 금지. bot `/gemini review` 무효(인간 OWNER 1회). long polling 금지(watcher 위임).

## finalize (PR 생성 → MERGE_READY_CANDIDATE 까지 — merge 금지)
1. 구현 → 15 regression PASS → `git add` 3파일 → commit → `worktree finish --action pr` 로 **PR 생성**(fresh origin/main base).
2. `memory/reports/task-2724.md` 보고서(L1 + flag default-off 확인 명시).
3. `memory/events/task-2724.done` 생성.
4. ANU normal callback cron 강제 등록 — collector_role=ANU, ANU_KEY=c119085addb0f8b7(sealed, literal 노출 금지). self-key 금지(SELF_COLLECTOR_FORBIDDEN). cron 0+sendfile only = NOT_REGISTERED fail-closed. ★ 본 PR 의 emitter 와 무관하게 봇 자신의 완료 콜백은 기존 경로로 발사.
5. ★ merge 금지 — ANU/watcher 가 새 head OWNER /gemini review → CI/Gemini watcher → CI GREEN + fresh unresolved 0 + diff 3파일 + forbidden 0 + ANU key 0 + **flag default-off 유지** 시 MERGE_READY_CANDIDATE 보고.

## allowed_resources
```yaml
allowed_resources:
  paths:
    - "scripts/finish-task.sh"
    - "scripts/harness/v36/terminal_state_callback.py"
    - "tests/regression/test_terminal_state_callback_2724.py"
    - "memory/reports/task-2724.md"
    - "memory/events/task-2724.done"
  forbidden_paths:
    - "teams/shared/verifiers/critical_gap.py"   # task-2725 혼입 금지
    - "dispatch/normal_fallback_callback_helper.py"  # 재사용만, 수정 0
    - "deploy/systemd/**"
    - ".github/**"
  commands:
    - "pytest"
    - "python3 -m pytest"
    - "python3 -m py_compile"
    - "bash -n"
  merge_policy: "none"
  ttl_hours: 48
```

## 금지 (회장 verbatim)
- expected_files 3개 밖 수정 금지 · task-2725(critical_gap) 혼입 금지 · PR #169 commit 금지 · dispatch runtime 광범위 수정 금지 · pickup/systemd 수정 금지 · activation flag 생성/systemctl/actual wake 금지 · credential 확장 / Work 착수 / merge 금지 · **TERMINAL_CALLBACK_ENABLED 활성화 금지 · pilot 실행 금지** · finish-task.sh 복잡 로직 삽입 금지.

## goal_assertions (auto)
- `python3 -m pytest tests/regression/test_terminal_state_callback_2724.py -q`
- `bash -n scripts/finish-task.sh`
- `python3 -c "import sys; s=open('scripts/harness/v36/terminal_state_callback.py').read(); sys.exit(1 if 'c119085addb0f8b7' in s else 0)"`
- `python3 -c "import sys; s=open('scripts/finish-task.sh').read(); sys.exit(0 if 'TERMINAL_CALLBACK_ENABLED' in s else 1)"`

## 상태
CHAIR_APPROVED_IMPLEMENTATION (code+test only) — dev1 위임. flag on/activation/pilot = merge 이후 별도 승인. PR #169(task-2725) 분리·HOLD 유지.