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

6.9 KiB

📑 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(운영자 권한)을 쓴다. 릴리스 노트 기록 대상.