Files
GodopuandClaude Opus 5 0e74aa1e4e docs(issue-5): record the evidence requirement, the audit lock and the ack rule
Three contract points the round 2 fixes established.

An idempotent re-receipt is an ACK in the table, so a caller must treat it as
success. Only a genuine refusal is non-zero, and an invalid status stays the
usage error it always was. That distinction is what keeps the wrapper's approved
path from aborting under set -e.

Completion attribution now requires evidence to be present, not merely
non-contradictory, and names where it may be read from: a collector envelope
beside the payload, or a non-native source's own payload. Never from inside a
native Herdr payload, because MAM fields are forbidden there and one appearing
anyway is not evidence of anything.

The audit mirror's ordering guard is documented as a locked read-compare-write,
with the reason a bare sequence number was not enough, and the note that the lock
is deliberately taken outside the registry transaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
2026-09-06 20:11:40 +09:00

86 lines
6.9 KiB
Markdown

# 📑 Issue #5 Phase 2a — Shadow Watcher 운영 노트
- **문서 버전**: Rev.1
- **상태**: Phase 2a 완료 (**shadow-only**: 레지스트리 무기록)
---
## 1. 이 단계가 하는 일과 하지 않는 일
| 한다 | 하지 않는다 |
|---|---|
| `herdr agent wait`로 에이전트 상태를 관측 | 관측으로 상태를 바꾸는 일 |
| 증거를 정책에 넣어 "완료 후보인가"를 **판정** | 판정 결과를 레지스트리에 **커밋** |
| 판정을 `.mam/shadow/<job_id>.ndjson`에 기록 | job 레코드 필드를 추가/변경 |
| 4중 귀속 증거를 shadow 저장소에 보관 | 레코드 스키마 변경 (Phase 2b 결정) |
**자동완료는 Phase 2b다.** 본 단계의 `candidate_approved`는 "커밋했을 것"이라는 로그일 뿐이다.
## 2. 운영 스위치
| 변수 | 기본 | 효과 |
|---|---|---|
| `MAM_ENGINE_CAS` | `1` | `0`이면 `update_job_status`/`pick_pending`이 Phase 2a 이전의 보존된 본체로 분기한다. 재배포·revert 불필요. |
Shadow 러너는 애초에 쓰기 경로가 없으므로 **프로세스 종료만으로 흔적이 사라진다.** `.mam/shadow/`는 레지스트리와 분리되어 있어 삭제해도 job 상태에 영향이 없다.
## 2-1. 권한 구분 (교차 리뷰 반영)
| source | 할 수 있는 것 | 할 수 없는 것 |
|---|---|---|
| `publish_event` (에이전트) | 자신의 작업을 `running`/`completed`/`error`/`cancelled`로 전이 | **terminal을 되돌리는 것** (`redispatch`/`finalize` 거부) |
| `registry_cli` (래퍼·운영자) | 위 전부 + `redispatch`(terminal→`pending`, iteration 증가 필수) + `finalize`(terminal→`error`) | iteration을 되돌리는 재생 |
**CLI 종료 코드**: `registry.py status`/`update`는 **진짜 거부**에만 non-zero(4)를 낸다. 잘못된 status 값은 rc 1(2a 이전 계약 유지), **멱등 ACK(이미 그 상태임)는 rc 0**이다 — 래퍼가 `set -e` 아래에서 승인 직후 `--set completed`를 실행하므로 이를 실패로 보고하면 성공 경로가 중단된다(교차 리뷰 F-C/F-D). Phase 2a 이전에는 거부가 불가능해 항상 0이었고, 그 침묵이 반복 위임이 조용히 깨진 것을 감췄다.
## 3. 락 규율 (P2A-01)
```
with registry_lock: 판정 + 레코드 1회 쓰기 + 알림 큐잉 ← 블로킹 호출 금지
락 해제 후: 감사 로그 flush, 발행 등 느린 작업
```
감사 mirror가 락 밖으로 나가면서 순서 보장을 잃을 수 있으므로, 전이마다 단조 증가하는 `transition_seq`를 레코드와 알림에 함께 싣는다. **`update_logged_status`는 전용 per-job 감사 락 아래에서 읽기-비교-쓰기를 수행하고, 기존 `status.json`의 `transition_seq`가 더 크거나 같으면 쓰기를 건너뛴다**(교차 리뷰 R4: 락 없는 비교-후-쓰기는 두 flush가 모두 옛 값을 읽고 느린 쪽이 최신을 덮어쓸 수 있어, 시퀀스 번호만으로는 순서가 강제되지 않는다). 이 락은 **레지스트리 트랜잭션 밖**에서 잡으므로 느린 감사 I/O가 트랜잭션을 늘리지 않는다. 각 쓰기는 고유 임시 파일을 쓴다 — 지연된 flush가 최신 상태를 되돌리지 못하게 하는 것이며, 락이 공짜로 주던 순차 일관성을 복원한다(교차 리뷰 F-B). 이 형태는 새로 만든 것이 아니라 `pick_pending`이 이미 쓰던 패턴을 모든 전이로 일반화한 것이다.
## 4. `herdr agent wait` 사용 규칙
- `--timeout`은 **밀리초**다. 코드의 파라미터명은 `timeout_ms`이며 초 단위 형태는 제공하지 않는다.
- `--until`은 **항상 명시**한다. 생략 시 기본 매칭이 `idle,done,blocked`라 `working`이 빠진다.
- `exit 1`의 페이로드는 **stderr**에 있다. stdout은 비어 있으므로 stdout에서 에러를 읽으려 하면 안 된다.
- **타임아웃은 건강한 응답이다.** 소켓이 답했고 상태가 안 바뀐 것뿐이다. 전송 실패와 구분해야 한다.
- 전송 실패 시 백오프는 1→2→4→8→16초 상한이며, **캐시된 스냅샷을 폐기**한다. 단절 구간 이전의 상태로 추론하면 단절이 완료처럼 보인다.
## 4-1. 완료 귀속에 요구되는 증거 (교차 리뷰 R1)
`candidate_approved`는 **증거의 존재**를 요구한다. 값이 있을 때만 비교하는 방식은, 아무 식별자도 없는 증거를 dispatch 레코드의 자기 선언만으로 승인해 버린다 — 이 검사가 대체하려던 바로 그 자기 선언이다.
| 요구 증거 | 위치 | 없으면 |
|---|---|---|
| `job_id`, `attempt_id`, `agent_session_uuid`, `pane_process_generation` | 타임라인 항목의 **`collector_attribution` 봉투** (또는 event/filesystem 등 **비-herdr** 소스의 payload) | `hold` |
| 리포트 매니페스트의 `attempt_id` | filesystem 소스 payload의 `report_manifest` | `hold` |
**네이티브 경계 유지**: 식별자는 **네이티브 Herdr payload 안에서 읽지 않는다.** Phase 0이 네이티브 페이로드에 MAM 필드 주입을 금지하므로, 거기 나타난 식별자는 조작이거나 계약 위반이며 어느 쪽이든 증거가 아니다. 수집기의 연관 정보는 payload **옆**의 봉투에 둔다.
## 5. 판정 순서 (fail-closed)
1. 전송 이상 → `hold_reconnect` (증거 자체가 무효)
2. 다른 세대/다른 pane 신호 → `reject_stale`
3. `blocked` → `hold_blocked` (사람 대기 중 완료 금지)
4. 4중 귀속 + 실행 앵커 미충족 → `hold`
5. 비-quiescent 또는 도구 실행 중 → `hold`
6. 리포트 미확정(매니페스트/해시) → `hold`
7. 전부 통과 → `candidate_approved`
## 6. 시나리오 계약
Phase 0의 7종 + Phase 2a의 5종 = **12종**이 `expected_policy.decision`으로 판정을 고정한다. 신규 5종도 Phase 0의 `validate_watcher_scenario`를 통과해야 하므로 증거 스키마가 계속 강제된다.
## 7. Phase 2b 인계
1. `attempt_id` 등 귀속 필드를 레코드로 승격할지 결정 (현재는 shadow 저장소).
2. `policy.decide`의 `candidate_approved`를 `engine.transition("completed", expected_attempt=...)`에 연결. 엔진과 CAS 표는 이미 준비되어 있다.
3. Pending Notification 지연 알림·크래시 복구 (`state-transitions.md` §5).
4. `preexec_fn`: Phase 2a는 데몬을 스폰하지 않아 미노출. 2b가 스폰을 도입하면 스레드 안전한 방식으로 교체해야 한다.
5. **`started`의 세션 확인 (O-A)**: `_decide`의 `claim`/`started`는 아직 "작업 할당 세션 일치"를 검사하지 않는다(`pick_pending`은 세션 필터를 하지만 `publish_event`의 `started`는 누구나 보낼 수 있다). 2b에서 `expected_session`을 추가할 것.
6. **`stop_session.sh`의 무조건 `cancelled` (O-B)**: 이미 `completed`인 job에 대해 이제 거부된다(레거시는 덮어썼다). 표상 의도된 동작이며, 운영자가 확정 상태를 바꾸려면 `registry.py status --set`(운영자 권한)을 쓴다. 릴리스 노트 기록 대상.