0090: P2.0 (D-minus) — staging self-ship 강제모델 실측 + manifest 계약 정의 (emission·머지는 P2.2)
0090: P2.0 (D-minus) — staging self-ship 강제모델 실측 + manifest 계약 정의 (emission·머지는 P2.2)
- Status: proposed (D-minus code · manifest 계약 ·
protection_verified구현은 #1698로 완료, branch protection 설정 VERIFIED; full accept는 Gate-3 e2e 후 — Verification 참조) - Date: 2026-05-31
- Issue: #1697
- Related: 0088 (P1 opt-in stub lane), 0085 (
EXECUTE_SHIP=0기본), 0086 (lane sandbox), 0087 (opt-in omc runner)
TL;DR
- ADR 0088 P1 fail-closed stub 레인의 강제 모델을 실측 코드로 구현한다 (P2.0 D-minus).
- P1의 env-trust(
BIDMATE_SHIP_PROTECTION_VERIFIED/BIDMATE_SHIP_TOKEN_SEPARATED) 플래그를 제거하고,protection_verified를 실제gh api브랜치 보호 쿼리로 교체한다. ship_manifest.json계약 함수(write_ship_manifest/read_ship_manifest/archive_ship_manifest,_staging_ship.py, 유닛 테스트 포함)를 정의한다. 루프가 manifest를 자동 기록하는 seam은 이 PR에 없다 —EXECUTE_SHIP=0으로 루프는 게이트된 변경을 커밋하지 않으므로source_sha=HEAD가 stale/무의미해진다. manifest emission seam은 실제 커밋/HEAD-binding이 의미 있는 P2.2에서 구현한다.scripts/_ship_env.py단일 출처로 모든 runner subprocess 레인의 env 격리를 통일한다._staging_ship.py의main()은 verify-and-refuse pre-flight 하네스: manifest가 있으면 읽고(idempotent, 소비 아님), 헌법불변 가드 + 라이브 protection 검증을 실행한 뒤, 항상 rc 2(blocked-on-user)를 반환한다 — 이 PR에서 PR 열기·머지는 일어나지 않는다.- 자율 머지 오케스트레이션(PR 생성·병합, 권한분리 토큰, cap 스토어 트랜잭션, cross-worktree 잠금, 직렬 promotion) 및 manifest 자동 emission seam은 P2.2로 유보한다.
배경
ADR 0088은 make 시작-ship을 도입하되 실제 ship()을 실행하지 않는 fail-closed stub로 P1을 마쳤다. P1 시점에 외부 강제(branch protection + 권한분리 토큰)가 아직 운영자 준비 단계였기 때문이다.
P1 stub의 검증 플래그 구조는 다음 취약점을 내포했다:
- Env-trust 우회(ouroboros).
BIDMATE_SHIP_PROTECTION_VERIFIED=1/BIDMATE_SHIP_TOKEN_SEPARATED=1을 환경변수로 두면,workspace-write~danger-full-access권한으로 subprocess를 생성하는 runner의 자식 에이전트가 동일 env를 상속해 보호 검증을 우회할 수 있다. 헌법불변의 강제가 in-process env에 의존하면 자기 안전장치를 스스로 해체하는 경로가 열린다. - 단일-프로세스 ship() 호출. P1은
agent_loop.py안에서 ship()을 직접 호출하는 경로를 예비 설계로 남겼다. 루프 프로세스가 ship() 권한을 직접 보유하면 분리 토큰의 격리 효과가 무너진다.
P2.0 D-minus 목표: 외부 강제를 코드에 실제로 경유하게 하고, env-trust를 제거하며, manifest 계약 함수와 verify-and-refuse pre-flight 하네스를 정의한다. 루프의 manifest 자동 emission seam은 EXECUTE_SHIP=0 때문에 게이트된 변경이 커밋되지 않아 source_sha=HEAD가 stale해지므로 이 PR에 포함하지 않는다 — HEAD-binding이 의미 있는 P2.2에서 구현한다. 자율 머지 자체는 이 PR에 없다.
적대리뷰에서 멀티 worktree 환경의 안전한 자율 머지를 위해서는 트랜잭션 cap 강제 + cross-worktree 공유 잠금 + 직렬 promotion이 함께 필요하다는 점이 확인됐다. 이는 P2.0보다 P2.2에 적합한 결합 단위다.
결정
P2.0 D-minus — 강제모델 실측 + manifest 계약 정의:
-
ship_manifest.json계약 함수 정의 (신규 측정/계약 표면)._staging_ship.py에write_ship_manifest()/read_ship_manifest()/archive_ship_manifest()세 함수를 구현하고 유닛 테스트로 계약을 고정한다._staging_ship.py의main()은 manifest 파일이 있으면read_ship_manifest()로 idempotent하게 읽는다 — 소비/아카이브하지 않는다(.consumed아카이브는 P2.2 머지 성공 후). 운영자가--source인자를 전달해 live protection-verify 하네스를 수동으로 실행할 수 있다.agent_loop.py에 manifest 자동 기록 seam(=_maybe_write_ship_manifest)은 이 PR에 없다: 루프는EXECUTE_SHIP=0으로 실행되므로 게이트된 변경이 커밋되지 않아source_sha=HEAD가 stale/무의미해진다. manifest emission은 실제 커밋이 일어나는 P2.2에서 구현한다.agent_loop.py는 ship()을 직접 호출하지 않는다. - 라이브 protection_verified (
_RealGitOps). P1의BIDMATE_SHIP_PROTECTION_VERIFIEDenv-trust는 제거한다._staging_ship.py는gh api repos/:owner/:repo/branches/autopilot/integration/protection을 실제 호출해 4개 조건을 모두 검증한다:required_status_checks.strict == true— branches must be up-to-date before merge; absent/false/non-bool은 fail-closed (stale source가 required check를 통과한 뒤 merge될 수 있는 경로 차단, issue #1697 Fix 1);required_status_checks.contexts또는.checks[].context에staging-self-ship-guard포함 — 아무 required-check나가 아니라 그 가드 워크플로가 반드시 listed여야 True;allow_force_pushes.enabled == false— absent/true/non-dict는 fail-closed;enforce_admins.enabled == true— admins의 required-check 우회 차단; absent/false는 fail-closed. 슬래시 포함 브랜치명은 URL-encode된다. 검증 실패 시exit 2(blocked-on-user).BIDMATE_SHIP_TOKEN_SEPARATEDenv-trust도 동시에 제거한다.
-
BIDMATE_SHIP_*env 격리 — 단일 출처scripts/_ship_env.py.strip_ship_secret_env()단일 함수(프리픽스 deny)가 모든 runner subprocess 레인을 경유한다: claude write + codex patch ×2 write 레인, read/review 레인 2곳(agent_loop_codex_turn.py/agent_loop_claude_turn.py), omc 레인(_OMC_ENV_ALLOWLISTpositive allowlist로 이미 제외). 프리픽스 literal"BIDMATE_SHIP_"은_ship_env.py한 곳에만 존재(whack-a-mole 방지). PATH/HOME/auth는 보존(over-tight 아님). -
Makefile
시작-ship업데이트. loop sub-make 호출 전에 (a) kill-switch(BIDMATE_SHIP_KILL_SWITCHenv 또는$(ACTIVE_SHIP_STATE_DIR)/KILL파일)를 사전 검사하고, (b) prefix-strip 셸 루프로BIDMATE_SHIP_*전부(control signalBIDMATE_SHIP_KILL_SWITCH제외 — 미래/미지BIDMATE_SHIP_*도 포함)를unset한 뒤$(MAKE) 시작을 실행한다. D-minus에서는 manifest 자동 emission이 없으므로BIDMATE_SHIP_MANIFEST_DIR을 sub-make에 주입하지 않는다(emission은 P2.2). 머지 토큰·cap 스토어·시크릿은 workspace-write 루프에 주입하지 않는다. 기본make 시작타겟은 불변 — byte-identical 보존. main()= verify-and-refuse pre-flight 하네스._staging_ship.py의main()은 manifest를 읽고, 헌법불변 가드를 실행하고, 라이브 protection 쿼리를 수행한 뒤 항상 rc 2를 반환한다._RealGitOps.open_pr/merge는 명시적 P2.2-deferred stub으로, 호출 시 raise한다. cap 스토어, 머지 토큰 처리, cross-worktree 잠금, bounded check poll은 이 PR에 없다.
P2.2 유보 사항
다음은 P2.0 D-minus에서 구현하지 않으며 P2.2에서 별도 ADR이 필요하다. 적대리뷰에서 멀티 worktree 환경의 안전한 자율 머지는 이 항목들이 트랜잭션 단위로 묶여야 한다는 점이 확인됐다:
agent_loop.pymanifest emission seam (_maybe_write_ship_manifest). 루프가local-gate-complete완료 시<state-dir>/ship_manifest.json을 자동 기록하는 seam.EXECUTE_SHIP=0으로 루프가 실행되는 한 게이트된 변경이 커밋되지 않아source_sha=HEAD가 stale/무의미해진다. emission seam은 실제 커밋/HEAD-binding이 의미 있는 P2.2 머지 오케스트레이션과 함께 구현한다.- 자율 PR 생성·병합. 라이브
gh pr create/gh pr merge호출 — 현재open_pr/merge는 P2.2-deferred stub(호출 시 raise). - 권한분리 머지 토큰.
BIDMATE_SHIP_MERGE_TOKEN— runner workspace-write 도메인 밖 별도 프로비저닝. ambientGH_TOKEN/GITHUB_TOKEN차단 후 이 토큰만 사용하는merge()구현. - Daily-cap 트랜잭션 + cross-worktree 공유 잠금.
_FileMergeCapStore(BIDMATE_SHIP_CAP_STORE) + T4 self-immutable cap 스토어 하드닝. malformed cap 파일 → fail-closed. 동시 실행 worktree 간 직렬 카운터. - 직렬 promotion + source_sha→PR head binding.
source_sha를 PR head에 묶어 게이트된 커밋의 귀속을 1:1 강제(--match-head-commit). serialized promotion과 함께 구현. - Bounded check poll.
BIDMATE_SHIP_CHECK_ATTEMPTS/BIDMATE_SHIP_CHECK_INTERVAL_SECONDS로 PR 생성 후 체크 대기 재시도. - PR idempotency.
open_pr에서 동일source→base오픈 PR 재사용(중복 PR 방지).
P2.1+ 예약
SELF_IMMUTABLE_PATHSeligibility 이중차단: T4 self-immutable cap 스토어 경로가 ship 대상 PR에 포함되면 lane 차단 (P2.3)._ship_env.py와 manifest seam도 P2.1의SELF_IMMUTABLE_PATHS에 추가할 것.- SLA 카운터 표면: 일일 merge count / revert rate / main-green rate (P2.2).
결과
Wins
- env-trust 우회 경로 제거 — ouroboros 차단이 in-process가 아닌 실제 GitHub 서버 상태에서 경유됨.
- manifest 계약(
write_ship_manifest/read_ship_manifest/archive_ship_manifest)과 verify-and-refuse pre-flight 하네스가 실코드로 정의됨 —agent_loop.py는 manifest를 emit하지 않으며(emission seam =_maybe_write_ship_manifest는 P2.2) ship()도 직접 호출하지 않아 2-프로세스 권한 분리 구조의 기반이 마련됨. - 모든 runner 레인의
BIDMATE_SHIP_*env 격리가 단일 출처로 통일 — whack-a-mole 누락 경로 차단. - 기본
make 시작(EXECUTE_SHIP=0) byte-identical 보존 — CI/데모/멀티 worktree 회귀 0. - manifest 계약 + live protection 검증이 실코드로 존재 — P2.2 머지 오케스트레이션의 기반.
Costs
- 현재 레인은 항상 rc 2로 종료한다. 실제 PR 생성·머지는 P2.2 전까지 일어나지 않는다.
- manifest 자동 기록 seam이 없으므로
_staging_ship.py main()을 exercise하려면--source인자를 수동으로 전달해야 한다. gh api호출은 네트워크 의존 — rate-limit / API 장애 시 fail-closed(verify 안 됨).
blocked-on-user (이 ADR의 PR에서 명시적으로 하지 않는 것)
- 권한분리 머지 토큰 provisioning (
BIDMATE_SHIP_MERGE_TOKEN, runner write domain 밖). (P2.2) - T4 self-immutable cap 스토어 하드닝 (
BIDMATE_SHIP_CAP_STORE). (P2.2) - 자율 PR 생성·병합 — P2.2 구현 전까지
open_pr/mergestub은 호출 시 raise. - Gate-3 라이브 e2e (① 헌법불변 파일 변경 PR → required check fail; ② 토큰 PUT protection → 403) — full accept 조건.
(완료: autopilot/integration 브랜치 보호 실설정 = 2026-06-01 설정 VERIFIED; 이 ADR Status proposed → accepted 전환 = Gate-3 e2e 후. Verification 참조.)
Supersession / 관계
- ADR 0088 P1 env-trust 조항을 부분 supersede —
BIDMATE_SHIP_PROTECTION_VERIFIED/BIDMATE_SHIP_TOKEN_SEPARATED제거. P1의 나머지 불변(staging-only / force-push 금지 / ship-arm 상호배제 / 데이터 경계)은 보존. - ADR 0085(
EXECUTE_SHIP=0기본)는 보완 — 기본시작은 불변, opt-in lane만 활성화. - ADR 0001·0003·0005는 보존.
검토한 대안
- env-trust 유지 (P1 그대로). Reject: workspace-write runner 자식이 env를 상속해 보호 검증 우회 가능 — 헌법불변 강제의 근거가 될 수 없음. ouroboros와 구조적으로 동일.
- P2.0에서 자율 머지까지 한 번에 구현. Reject: 멀티 worktree 환경에서 안전한 자율 머지는 트랜잭션 cap + cross-worktree 잠금 + 직렬 promotion이 함께 필요하다. 이 세 가지가 분리된 채 머지 오케스트레이션만 도입하면 cap racing / ambient-credential spoof / 중복 PR 문제가 잔존한다. D-minus에서 기반(env 격리 + manifest 계약 정의 + live verify)을 굳히고 P2.2에서 emission seam + 트랜잭션 단위로 묶는 것이 안전하다.
- manifest emission seam을 D-minus에 포함. Reject:
EXECUTE_SHIP=0으로 루프는 게이트된 변경을 커밋하지 않는다. 이 상태에서source_sha=HEAD를 기록하면 stale commit을 가리킨다. manifest가 가리키는 SHA는 실제로 병합할 커밋과 1:1로 binding되어야 한다 — 이 binding은 P2.2의 source_sha→PR head 묶음과 함께 구현해야 의미가 있다. - 단일 env var로 protection 스킵 허용. Reject: 외부 강제의 의미를 무력화. fail-closed가 올바른 동작.
Verification
write_ship_manifest / read_ship_manifest / archive_ship_manifest 세 함수가 scripts/_staging_ship.py에 존재하며, manifest 계약을 유닛 테스트로 고정한다. read_ship_manifest는 idempotent하게 읽는다 — D-minus에서는 파일을 소비/아카이브하지 않는다(단일-소비는 P2.2 머지 성공 후 archive_ship_manifest 호출 시). manifest path: $(ACTIVE_SHIP_STATE_DIR)/ship_manifest.json.
protection_verified의 VERIFIED contract (4개 조건 전부 fail-closed):
required_status_checks.strict is True—_staging_ship.py행 502 (if required.get("strict") is not True: return False);tests/test_staging_ship_regression.py::test_real_protection_verified_false_when_strict_absent+test_real_protection_verified_false_when_strict_false회귀 테스트로 고정.staging-self-ship-guardrequired check 존재 —_staging_ship.py행 515 (if _REQUIRED_STAGING_CHECK not in names: return False).allow_force_pushes.enabled is False—_staging_ship.py행 522.enforce_admins.enabled is True—_staging_ship.py행 527. 이 4개 조건 중 하나라도 absent/false/비dict이면 즉시return False(fail-closed). 코드가strict조건을 이미 검증하고 있으므로 별도 코드 변경 없음 — 이 항목은 contract 텍스트와 코드를 명시적으로 정렬한다.
manifest emission seam의 의도된 부재(P2.2 유보)는 다음으로 확인한다:
grep -n "_maybe_write_ship_manifest" scripts/agent_loop.py결과가 비어 있어 루프에 자동 기록 seam이 없음을 보인다.git diff origin/main -- scripts/agent_loop.py에서local-gate-complete완료 블록(cycle["completion_decision"] = "local-gate-complete"부근)이 변경되지 않아 origin/main과 byte-identical임을 보인다 — 루프는 게이트된 변경을 기록만 하고 manifest를 emit하지 않는다.make -n 시작-ship출력의 loop sub-make(make 시작) 라인에BIDMATE_SHIP_MANIFEST_DIR=주입이 없음을 확인한다(emission이 없으므로 sub-make에 manifest dir을 주입하지 않는다).
반면 manifest path 와이어링은 post-step _staging_ship.py 호출에만 존재한다: make -n 시작-ship 출력에 scripts/_staging_ship.py ... --manifest-dir "$(ACTIVE_SHIP_STATE_DIR)" 가 포함됨을 확인할 수 있다(verify-and-refuse 하네스가 manifest가 있으면 idempotent하게 읽는 경로 — 루프 emission이 아님).
추가 검증 표면 (운영자 준비 완료 후):
make 시작(인자 없음) 출력이 변경 전후 byte-identical (BIDMATE_SHIP_MANIFEST_DIR unset → no-op).gh api보호 쿼리가 required-check 부재 시exit 2반환 확인._staging_ship.py main()이 manifest/source 없이 또는 protection 미검증 상태에서 항상 rc 2(blocked-on-user)를 반환하고 PR 생성·머지를 하지 않는 것을 확인.bash scripts/test.sh통과.
구현 완료 현황 + full accept 잔여 조건 (2026-06-01)
구현 완료 (코드·설정): P2.0 D-minus 강제모델 + manifest 계약 함수 + protection_verified 구현 코드가 #1698로 안착했고, operator branch protection 설정이 실존함을 gh api로 확인했다(설정 존재 VERIFIED, 2026-06-01): staging-self-ship-guard required check + enforce_admins=true는 autopilot/integration 한정(워크플로가 on: pull_request: branches: ["autopilot/**"] 전용 → main은 이 check를 리포트할 수 없으므로 미적용, 의도된 설계); require_code_owner_reviews + allow_force_pushes=false는 integration + main 양쪽(main은 required_checks=null).
full accept 잔여 조건 (Status가 proposed인 이유): 이 ADR의 핵심인 live enforcement 작동 — Gate-3 e2e(① 헌법불변 파일 변경 PR → required check fail로 실제 머지 차단; ② 머지 토큰 PUT protection → 403)는 아직 실증되지 않았다. 설정 존재 ≠ 강제 작동이므로 Status는 proposed로 유지하며, Gate-3 e2e를 integration 레인 운영 중 캡처(명령/결과 artifact 기록)한 뒤 accepted로 승격한다. P2.2 유보분(loop emission seam, 자율 머지, 권한분리 토큰, cap 스토어)도 후속 ADR 0093+가 이어받는다. codex adversarial pre-commit(freq 3/8, 2/3)이 “accepted가 미검증 강제를 숨긴다”고 지적해 proposed로 유지하고 완료 현황만 본문에 기록한다.