diff --git a/brain.md b/brain.md index 45dbe28..18b2327 100644 --- a/brain.md +++ b/brain.md @@ -1,7 +1,7 @@ # Fleet — 코드베이스 브레인 (자동 생성) > `npm run brain` 로 `src/` 에서 자동 추출한 구조 지도다. **코드를 탐색하기 전에 이 파일을 먼저 읽어** 토큰을 아껴라. -> 100 files · 242 import wires · 47 IPC channels · 생성 2026-08-10T16:31 UTC +> 102 files · 246 import wires · 47 IPC channels · 생성 2026-08-13T10:46 UTC > 표기: `파일 — 역할 · →의존 · ←피의존`. id 는 `main/core/` 생략(예: `session/manager`). ## 레이어 (위 → 아래로 흐름) @@ -108,19 +108,23 @@ - **workbench/locks** - →의존: workbench/authority, workbench/coord-area, workbench/ulid · ←피의존: workbench/__testing__/lock-backend-fake, workbench/active-instance, workbench/authority, workbench/journal, workbench/lock-backend-uds, workbench/lock-order · 467줄 - **workbench/authority** - - →의존: shared/types, workbench/durable-fs, workbench/locks · ←피의존: workbench/journal, workbench/locks · 1664줄 + - →의존: shared/types, workbench/durable-fs, workbench/locks · ←피의존: workbench/journal, workbench/locks, workbench/recovery · 1901줄 - **workbench/journal** - - →의존: workbench/authority, workbench/durable-fs, workbench/locks, workbench/ulid · ←피의존: — · 1014줄 + - →의존: workbench/authority, workbench/durable-fs, workbench/locks, workbench/ulid · ←피의존: workbench/recovery · 1038줄 - **workbench/active-instance** - →의존: workbench/instance-marker, workbench/locks, workspace/path-guard · ←피의존: — · 397줄 - **workbench/coord-area** - →의존: workspace/git, workspace/path-guard · ←피의존: workbench/locks · 372줄 - **workbench/durable-fs** - →의존: — · ←피의존: workbench/__testing__/durable-fs-fake, workbench/authority, workbench/journal · 214줄 +- **workbench/recovery** + - →의존: workbench/authority, workbench/journal, workbench/result-ref · ←피의존: — · 834줄 +- **workbench/ulid** + - →의존: — · ←피의존: workbench/journal, workbench/locks, workbench/result-ref · 86줄 - **workbench/instance-marker** - →의존: — · ←피의존: workbench/active-instance, workbench/instance-marker-proc · 116줄 -- **workbench/ulid** - - →의존: — · ←피의존: workbench/journal, workbench/locks · 86줄 +- **workbench/result-ref** + - →의존: workbench/ulid · ←피의존: workbench/recovery · 125줄 - **workbench/__testing__/durable-fs-fake** - →의존: workbench/durable-fs · ←피의존: — · 277줄 - **workbench/__testing__/lock-backend-fake** diff --git a/docs/superpowers/plans/2026-07-23-issue251-workbench-core.md b/docs/superpowers/plans/2026-07-23-issue251-workbench-core.md index bf1b0d2..fa87433 100644 --- a/docs/superpowers/plans/2026-07-23-issue251-workbench-core.md +++ b/docs/superpowers/plans/2026-07-23-issue251-workbench-core.md @@ -89,7 +89,8 @@ | ~~**PR3**~~ | ~~`GitRepo` · 통합 WAL 저널 · 복구 판정~~ | ~~T11~T13~~ | ~~1,200~1,500~~ | ~~없음~~ | | **PR3a** | `GitRepo` 완성(열거·프로브 포함 11메서드) · 능력 프로브 · R-5 삭제 없는 재시도 | T11·T12 | **실측 1,384**(프로덕션 348 · 배수 3.98) | 없음(상한 내 · 73%) | | **PR3b** | 통합 WAL **저널 코어만** — 레코드·파서·내구 쓰기·tmp 문법/배치·열거 필터·전이 술어 | T13a′(정정 159 축소판) | **리뷰 중 실측 2,342**(프로덕션 919 · 배수 **2.55**) — 최종은 머지 직후 재측정 | **상한 23% 초과**(정정 181 · 은폐하지 않는다) | -| **PR3c** | **ref-앵커 + 복구 판정**(한 축·한 커밋) — `highestIntegrationTxnId` · strict 전진 · 전이 불변식 계층 · 판정 함수 · 회수 어휘 | T13b′(+정정 70·85 이월분 · §3-T34·T18b·I11·T33 **문면 개정 포함**) | ~1,400(프로덕션 목표 ≤400) | 착수 전 라운드에서 재도출 | +| **PR3c** | **revision-앵커 + 복구 판정**(한 축·한 커밋) — 앵커 정렬 키 = 권위 `revision`(정정 190) · `resultRef` 문법 소유(정정 192) · 전이 불변식 계층 · 판정 함수 | T13b′(+정정 70·85 이월분 · §3-T34·T18b·I11·T33 **문면 개정 포함**) | ~1,400(프로덕션 목표 ≤400) | 착수 전 라운드가 재도출 완료 → **정정 195 가 크레덴셜 축을 PR3c′ 로 분리** | +| **PR3c′** | 구역 capability 민팅 · 크레덴셜 관측 통합 상태 결속 · 저널 `append` 인가 복원 | 정정 187 이관분(§3-T69 재문안) | ~900(프로덕션 목표 ≤300) | 없음 · **PR3c 위에 스택**(운영감사 ③ 시험 대상) | | **PR3d** | 크래시 tmp 수확기(**2표면** · per-bench) · 스테일 ref 락 관측 | T13c(+정정 78ⓒ·86ⓑ 이월분 · PR3a 실증 라운드 2 이월) | ~1,300(프로덕션 목표 ≤400) | 없음 | > **순서 = PR3b → PR3c · PR3b → PR3d**(병렬 불가). **PR3b 의 정체성은 「새 모듈만 · 착지 계약 무변경」이며 @@ -102,7 +103,7 @@ | **PR6** | 파생 · 인가 전수 · 크로스 프로세스 | T22~T24 | 1,200~1,500 | 없음 | | **PR7** | 엔진 배선 · 상한 · 자식 수명 · 마감 | T25~T30 | 1,600~1,900 | T28 종료 >1,450 → T29~T30 을 PR7b | -**총 12 PR**(PR2 3분할 + **PR3 4분할** 반영 · 스펙 §7 "5~6" 대비 +6~7). **은폐하지 않는다** — 스펙 §5 의 1,900행 상한이 §7 의 PR 수 추정보다 +**총 13 PR**(PR2 3분할 + **PR3 5분할** — PR3c 가 정정 195 로 PR3c/PR3c′ 로 갈렸다 · 스펙 §7 "5~6" 대비 +7~8). **은폐하지 않는다** — 스펙 §5 의 1,900행 상한이 §7 의 PR 수 추정보다 **상위 규범**이고, §3 계약 테스트 74행 중 **66행이 #251 코어 귀속**(UI 3 · nightly 6)이라 UI·deploy 를 떼도 테스트 질량이 거의 줄지 않는다는 실측 때문이다. @@ -112,8 +113,9 @@ > (win32 전용 코드가 분모에만 들어가는 문제의 주 원인이 npipe 백엔드였다). > ~~**재산정 결과 7 PR** — PR2 를 PR1 로 흡수 가능한지는 착수 시 실측으로 판정한다.~~ > **착수 시 실측 결과는 반대였다**(정정 51): 흡수는커녕 PR2 자체가 3분할을 요구했다 — 형제 모듈 배수 모델로 -> 계산한 총량이 상한의 1.3~2배였다. 현재 집계는 위 표(**12 PR** — PR2 3분할 + PR3 4분할)가 권위다 -> (CodeRabbit PR#269: 이 줄이 「10 PR」로 남아 있어 같은 절 안에서 105행과 충돌했다). +> 계산한 총량이 상한의 1.3~2배였다. 현재 집계는 위 표(**13 PR** — PR2 3분할 + PR3 5분할)가 권위다 +> (CodeRabbit PR#269: 이 줄이 「10 PR」로 남아 있어 같은 절 안에서 105행과 충돌했다 — **같은 드리프트를 +> 정정 195 의 재분할에서 반복하지 않도록 두 줄을 함께 갱신했다**). ### PR0 을 신설한 이유 (판사 A 프레임 전복 채택) @@ -1177,6 +1179,354 @@ rename EPERM 재시도 실측 완료)·tmp 문법과 배치·열거 필터(접 | 189 | ⚠ **리뷰가 나중에 넣은 방어가 기존 뮤테이션 자기검사를 가렸다** — 정정 183 의 스윕(30 뮤턴트) **이후** 정정 184ⓐ가 `previousAuthorityStage !== from` 을 전이 게이트 **바로 아래**에 넣었고, 두 검사가 **같은 종별**(`invariant-violation`)을 돌려주는데 기존 게이트 테스트 2개가 **종별만** 단언해서 가려졌다. 실측: `if (!canAdvanceStage(...))` 를 `if (false)` 로 통째 무력화해도 104건 전부 GREEN. 같은 축에서 `0o700/0o600 → 0o777/0o666` 도 GREEN 이었다(`args[0]` 만 봐서 mode 인자 무신호 — 형제 `authority-store.test.ts:1182` 는 이미 `toEqual([path, mode])`) | 테스트 전용 수정: ⓐ게이트 행들의 `previousAuthorityStage` 를 **디스크와 정직하게 맞춰** 결속 검사를 통과시키고 **위반 메시지**까지 단언 ⓑ미겨냥 축 2행 신설(**부재 → published** = WAL 선기록 우회 · **자기 전이** `composed → composed`) ⓒmode 인자 전량 단언. 재실측: 두 뮤턴트 **RED**(4건·1건 실패), 기존 18방어 + 이웃 2개(결속·포기 재생) **전부 잡힘**. ⚠ **교훈**: 뮤테이션 자기검사는 **그 시점의 방어 집합**에 대한 측정이다 — 리뷰 라운드가 방어를 추가하면 그 라운드 뒤에 **다시 돌려야** 한다. 새 방어가 기존 방어와 **산출 종별을 공유**하면 가림이 기본값이고, 그래서 종별이 아니라 **메시지**를 단언한다 | | 183 | **뮤테이션 자기검사가 단언 공백 5건을 찾았다**(30 뮤턴트 · 29 잡힘) — 그중 둘은 **실재 결함**이었다: ⓐ리스 identity 대조에서 **benchId 축만 단언 부재**(뚫리면 A 의 리스로 B 의 디렉터리에 쓴다 — 경로가 draft 에서 유도되므로) ⓑ`Omit` 의 초과 프로퍼티 검사가 객체 리터럴에만 걸려 **draft 가 `schemaVersion` 을 실어 올 수 있었다**(형제 정정 73 동형 · 스프레드 순서 때문에 store 배정을 덮어썼다) | 둘 다 코드·단언으로 폐쇄(스탬프를 스프레드 뒤로 + 키 존재 자체 거부). **후속 규율**: 이 슬라이스에서 「전 게이트 GREEN + 첫 구현이 전 테스트 통과」는 **테스트 공백의 신호로 취급**한다 — 뮤테이션 1회차 3건 생존이 그 실증이다. 도달 불가로 확인된 방어 2건(왕복 파싱 · 배열 정준화)은 **삭제**했다(도달 불가 arm 은 커버리지에 안 잡히면서 「검증한다」는 인상만 준다) | +#### PR3c 착수 전 라운드 (2026-08-12 · 계획:97-99·1136 이 요구한 미결 결정 닫기) + +계획 표:92 가 PR3c 의 분할점을 「착수 전 라운드에서 재도출」로 남겼고, 정정 166·169·149 가 **서로 다른 +곳에서 각각 「확정」된 세 벌의 앵커 문안**으로 남아 있었다. 여기서 하나로 접는다. + +| # | 미결 → 확정 | 근거 | +|---|---|---| +| 190 | **앵커 정렬 키 = 권위 `revision`**(ULID 사전순·`integrationGeneration` 기각) | ⓐULID 는 **정렬 키로 부적격**이 실측으로 확정됐다(PR3b 실측 ⓐ: 같은 ms 20,000건 중 **50.1% 역전** · monotonic counter 부재). 정정 149 가 `max` 를 폐기하고 strict 전진으로 간 것은 이 사실의 우회였는데, 우회가 아니라 **키를 바꾸는 것**이 정답이다. ⓑ`integrationGeneration`(정정 166)은 `prepared` 마다만 +1 이라 **통합 시도 사이의 권위 롤백에 침묵**한다 — 활동 시작·종료·회수 CAS 가 전부 그 구간에 있다. ⓒ`revision` 은 `authority.ts:1307`(`minted.observedRevision + 1`)이 **CAS 마다 정확히 +1** 로 단조를 이미 기계 집행하므로 시계 역행 계열(정정 146·149·153 을 파생시킨 축)이 **원천 소멸**한다 — 정정 169(Codex 3R)가 이 안의 성립 조건을 이미 확정해 줬다. ⚠ `integrationGeneration` 은 **폐기하지 않는다** — 그것은 §W-8 세대 모델(supersede·stale-attempt)의 축이지 앵커가 아니다. 정정 166 은 「앵커 = 세대」 부분만 정정 169 로 **교체**된다 | +| 191 | **§W-7:842-845 의 현행 확정 판정식(「귀속 없는 결과 ref 존재 = reconciliation」)을 폐기**하고 **`refRevision > record.revision` 비교**로 대체 | 현행 판정식은 **영구 오탐**이다 — 포기는 결과 ref 를 **보존**하고(spec:861) §3-T29 는 형제 시도 ref 의 **영구 공존을 정상 시나리오로 요구**하므로, `abandoned`·`superseded` 잔여분이 두 슬롯(`current`·`completed`) 어디에도 귀속되지 않은 채 영원히 남는다. 정정 150ⓐ 가 이 모순을 「카디널리티」로 제기했고 정정 163 이 「ref 예외 조항」이라는 **덧대기**로 부분 대응했는데, revision 비교로 바꾸면 **보존된 과거 ref 의 revision 은 항상 현재 이하**라 그 모순이 **구조적으로 사라진다**(예외 조항 불요 → 정정 163 의 처방도 함께 폐기). 비교 정의역 = **보존된 전 결과 ref**(정정 169) · 권위 부재 = revision 0 취급(모든 잔존 ref 가 발화) · **열거 실패도 fail-closed**(git 실패·문법 오류·D/F 충돌은 「결과 없음」이 아니다 · 정정 169) | +| 192 | **`resultRef` 문법 확정** = `refs/fleet/integrated//-` · **10진 가변폭 · 선행 0 금지 · 파싱 실패 fail-closed**. 정정 166 의 「**고정폭** 세대」는 **폐기** | 우리는 ref 이름을 **문자열 정렬하지 않는다** — 파싱해 숫자로 비교한다. 그러면 고정폭은 이득이 0 이면서 **자릿수 상한**(초과 시 문법 붕괴)이라는 실패 모드만 새로 만든다. 선행 0 금지는 `01`·`1` 두 이름이 같은 revision 을 가리키는 **비단사 매핑**을 막는다(PR1a `ulid.ts` 의 「정규화 금지·거부」와 같은 규율). ⚠ **ref 이름에 실을 값의 정의는 정정 196 이 대체한다**(초안은 `expectedAuthorityRevision` 이라 적었고 그것이 off-by-one 이었다) — ⚠ **그리고 196 을 다시 정정 223**(stage 전수 식 `expected + 2 - stageIndex` · 절대식 `R0 + 2`)**과 정정 233**(`finalized` 은 등식 아닌 상한)**이 대체했다**. 196 만 따라가면 폐기된 균일 `+1` 에 착지한다. spec §W-7:753-754 의 「문법 소유 = PR3c」 주석을 이 문법으로 치환 | +| 193 | **회수 어휘 `never-applied` 신설 기각**(정정 164 번복) | 그 어휘가 필요했던 유일한 이유는 **귀속 기반 판정**이었고, 정정 191 이 귀속 판정을 폐기하면 통합 WAL 복구 verdict 는 복구표의 기존 어휘(`no-mutation`·`promote-published`·`RESULT_REF_UNATTRIBUTED`·`RESULT_REF_MISMATCH`·`REF_NAMESPACE_CONFLICT`·`reconciliation-required`)로 **전수 표현된다**. `never-applied` 는 §W-9 **생성 저널 전용**으로 유지한다(spec:898 — 거기서는 3채널 열거가 결정론적이라 정당하다). ⚠ 정정 164 의 「**회수는 삭제가 아니라 기록**이며 §W-7:778 「복구 중 삭제 금지」에 걸리지 않는다」 문면은 **유지**한다(어휘가 아니라 행동 규율이다) | +| 194 | **활성 저널 집합 = stage 단독 유지**(PR3b `isActiveJournalStage` **무변경** · 정정 164 의 「귀속 조건 도입」 기각) | 귀속은 활성 집합의 **정의**가 아니라 판정 함수의 **입력 특성**이다. 정정 164 가 이중 정의를 지적한 것은 옳았으나 처방 방향이 반대였다 — ⓕ 를 ⓓ 에 맞추는 게 아니라 **ⓓ 를 유지하고 ⓕ 를 판정 함수로 흡수**한다. 그러면 정정 154ⓐ 가 닫은 영구 고착이 `delete-record` 표면에서 재현되지 않는다(활성 판정이 여전히 순수·stage 단독). §W-7 I11·§3-T33 문면 개정은 **범위를 좁혀** 수행: 「귀속 조건」이 아니라 **「권위가 가리키지 않는 활성 stage 엔트리 = 고아 저널 → 정정 167 의 승격가능/reconciliation-required 2분」** | +| 195 | ⚠ **범위 재분할(사용자 결정 2026-08-12) — PR3c 를 PR3c / PR3c′ 로 쪼갠다.** 정정 187 이 크레덴셜·임계 구역 결속 축을 PR3b→PR3c 로 이관했는데, 그것을 앵커·복구 판정과 합치면 **PR3c 가 불변식 표면을 둘** 진다 | 정정 187 자신이 내건 기준(「분량이 근거가 아니다 — 근거는 **한 PR 이 지는 불변식 표면**이다」)의 **일관 적용**이다. 그 축은 PR3b 에서만 Codex P1 16건을 냈고 **매 라운드가 직전 라운드에서 넣은 방어의 구멍**이었다 — 발산 패턴이 관측된 축을 다른 비가역 축과 합장하지 않는다. 분할이 성립하는 이유: 크레덴셜 축의 **유일한 소비자가 이미 착지한 저널 `append`** 라 자기 revert 단위가 선다(PR0 이 확립한 기준). **PR3c** = 앵커(revision) · 복구 판정 · 전이 불변식 계층 · `resultRef` 문법 소유 · §3-T34·T18b·I11·T33 문면 개정 / **PR3c′** = 구역 capability 민팅 · 크레덴셜 관측 통합 상태(txn·stage·세대) 결속 · §3-T69 재문안 · 저널 `append` 인가 복원. **순서 = PR3c → PR3c′**(크레덴셜이 결속할 관측 상태 필드를 PR3c 가 소유). **총 PR 13.** 부수: 이 둘이 **진짜 stacked PR** 이라 운영감사 ③(stacked PR 시험 1회 · Codex 봇이 스택 각 PR 에 정상 트리거되는지 확인)의 대상으로 삼는다 | + +**이 라운드가 폐기한 것**(재유입 금지 — PR3b 승인 시 Codex 가 명시 경고한 「폐기된 API 이름 재유입」 가드의 +연장): `highestIntegrationTxnId` 필드명 · ULID 사전순 앵커 · 「고정폭 세대」 ref 문법 · +「귀속 없는 ref = reconciliation」 판정식 · 정정 163 의 ref 예외 조항 · 통합 WAL 의 `never-applied` 어휘 · +**`refRevision = expectedAuthorityRevision`**(정정 196 이 off-by-one 으로 폐기). ⚠ **이 폐기의 정의역은 +`composed` 저널이다**(정정 223) — stage 별 식에서 `published`(stageIndex 2)의 **정상** 결속은 정확히 +`refRevision = expected` 이므로, 이 줄을 문자대로 집행해 그 등식을 「폐기 어휘 재유입」으로 읽으면 안 된다. + +#### 체크포인트 리뷰 1R 반영 (2026-08-12 · `Changes requested` P1×1 · 나머지 전부 승인) + +Codex 판정: 190·191·192(문법)·193·194·195 **승인** + 「앵커 → 고아 저널 판정 순서」 승인. +**P1 1건**이 정정 192 의 값 정의에 있었고 **실재**했다. 판정 2·3·4 는 조건부 승인이라 그 조건을 아래에 고정한다. + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 196 | **P1 — `refRevision = expectedAuthorityRevision` 은 off-by-one 이다.** 확정: **`refRevision = resultingRevision = expectedAuthorityRevision + 1`** (= 후속 권위 CAS 가 성공하면 **기록할** revision) | **코드 실측으로 재현**: `journal.ts:115` 는 `expectedAuthorityRevision` 을 「이어질 CAS 가 **맞출** revision」(= 현재값 `N`)으로 정의하고, `authority.ts:1307` 은 `minted.observedRevision + 1` 로 **`N+1`** 을 발행한다. 내 초안대로 ref 를 `N-` 로 발행하면 ⓐrecord 가 `N+1` 로 전진 → ⓑ`file-only` 크래시로 권위만 `N` 으로 롤백 → ⓒ판정이 `N > N` = **false** → **ref 발행에 결속된 바로 그 CAS 가 사라졌는데 앵커가 침묵**한다. 정정 169 가 막으려던 revision 재사용의 **가장 짧은 형태**를 내 정의가 그대로 열어놨다. ⚠ 대안(「`expectedAuthorityRevision` 의 의미를 resulting 으로 재정의」)은 **기각** — 저널·권위의 expected-revision CAS 의미와 충돌하고 착지한 레코드 문면을 뒤집는다. **조립 시점에 `+1` 을 명시**하는 쪽이 안전하다. 함께 고정할 불변식 6: ①`resultingRevision === expectedAuthorityRevision + 1`(⚠ **정정 223 이 ①·②의 정의역을 `composed` 저널로 좁혔다** — 전 stage 식은 `expected + 2 - stageIndex`, 절대식은 `R0 + 2`, `finalized` 는 등식이 아니라 상한(정정 233), `abandoned` 는 정의역 밖) ②안전 정수 ∧ 현재 revision 보다 **정확히 1 큼** ③**ref 발행이 CAS 보다 먼저 가능**하므로 발행 후 CAS 실패 = 정상 대기가 아니라 **reconciliation 대상** ④**ref 생성 실패 시 후속 권위 CAS 를 실행하지 않는다** ⑤CAS 성공 시 `record.revision === parsedRefRevision` ⑥`refRevision > record.revision` 비교는 **보존된 전 결과 ref** 에 적용 | +| 197 | **앵커 검사의 시점·형태 확정**(판정 1ⓐ·4 조건부 승인분) — 「부팅 시 한 번」은 **불충분**하다 | ⓐ앵커는 **모든 권위 CAS 보다 먼저**이며 **새 리스 획득 후 fresh read 와 같은 임계 구역**에 결속한다(통과 캐시의 장기 재사용 금지 — 다른 프로세스·복구·파일 교체 후 revision 재사용이 되살아난다). ⓑ**계층형 판정**(first-match 표 금지 · 정정 150ⓐ 가 지적한 위험의 재발 지점): `authority fresh read → 결과 ref 전수 열거·문법·identity 검증 → global anchor gate → (통과 후에만) 저널 전수 열거 → 귀속 판정 → 고아 활성 저널의 승격가능/reconciliation 2분`. 근거 = **앵커 실패는 권위 레코드의 시간 기준 자체를 신뢰할 수 없다는 증거**라, 그 상태에서 「승격 가능한가」를 먼저 물으면 **잘못된 revision·stage 를 기준으로 승격**한다. ⓒverdict 를 단일 문자열이 아니라 **blockers 집합**으로 수집해도 되지만, **anchor blocker 존재 중에는 행동 인가가 항상 전부 차단**이어야 한다. ⓓ앵커 통과 후에는 정정 194 대로 stage 단독 활성 집합 유지 | +| 198 | **PR3c ↔ PR3c′ 사이 커밋의 안전 조건 6**(판정 3 조건부 승인분) — 분할 방향은 「생산자 선착지」로 정상이라 정정 159 안티패턴의 역방향 재발은 없다 | ①PR3c 가 추가한 관측 상태 필드는 PR3c′ 전까지 **인가 capability 로 간주되지 않는다** ②PR3c 의 복구 판정은 PR3c′ 크레덴셜의 **존재를 가정하지 않는다** ③현행 lease-only 인가가 새 필드를 부분적으로 읽어 **「절반 집행」하지 않는다** ④PR3c′ 크레덴셜이 증언할 것 = bench identity · 관측 authority revision · current txnId · current stage · 세대(또는 sourceGeneration) · 임계 구역 identity/liveness ⑤크레덴셜 검증 실패는 **append 이전**에 나며 tmp 생성·rename 등 **부수효과 0** ⑥stacked PR 로 운영하면 **PR3c′ 의 base 를 PR3c head 에 명시 결속**. ⚠ **「PR3c′ 까지 저널 인가는 lease-only 로 남는다」를 PR3c 본문에 정직 표기**한다 | + +**이 라운드가 신설한 §3 행 10건**(§1 전제 1 게이트 · RED 목록 — Codex 가 최소 회귀로 열거한 것을 그대로 +계약화): **T73** 앵커 발화(권위 `N` → ref `` 발행 → CAS 로 `N+1` → 권위만 `N` 으로 롤백 → +`refRevision > record.revision` 참 → reconciliation) · **T74** 음성 통제(권위가 `N+1` **이상**이면 같은 ref 가 +오탐 0) · **T75** 잘못된 `-` ref = 저널 `expectedAuthorityRevision` 과 **산술 결속 불일치** → +fail-closed(⚠ **stage 마다 정의역이 다르다** — ``(= expected 그대로)은 `composed`·`prepared` 에서 위반이지만 +`published` 에서는 **정상**이고 `finalized` 는 상한이라 통과한다 · 정정 223·233) · **T76** 보존된 **전** 결과 ref 에 동일 판정 적용 · **T77** 권위 부재 + ref 잔존 = revision `0` +→ reconciliation · **T78** **ref 열거 실패를 empty set 으로 축소하지 않음**(D/F 충돌·문법 오류·git 실패 +포함) · **T79** 앵커 게이트가 저널 승격 판정보다 **먼저**(순서 뒤집으면 롤백된 stage 로 승격) · **T80** +anchor blocker 존재 중 **행동 인가 전부 차단** · **T81** 앵커 검사가 **새 리스 획득 후 fresh read 와 같은 +구간**(통과 캐시 재사용 시 RED) · **T82** 신·구 레코드 **전이 불변식 계층**(현행 `checkInvariants` 는 +단일 레코드 전용이라 `published → prepared` 역행·`abandoned → composed` 부활이 어느 층에서도 안 막힌다 · +정정 142 가 저널 쪽에만 세운 것을 권위 쪽에 세우는 행). + +#### 체크포인트 리뷰 2R 반영 (2026-08-12 · 회부한 모순 확인 + 내 유력안 2종 전부 기각) + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 199 | **회부한 모순은 실재**(`promote-published` 도달 불가) — 확정 처방은 **「현재 txn 기계적 제외」가 아니라 「유효한 `composed` 저널이 정확히 설명하는 단 하나의 next-revision ref 만 조건부 면제」** | ⓐ**내 유력안 1(`currentIntegrationTxnId` 제외) 기각** — 면제 대상을 **롤백 가능한 권위 파일 하나로** 결정한다. 실패 경로: T1 정상 완료 → 권위가 T2 를 current 로 전진 → 파일 롤백으로 current 가 **다시 T1** → 복구기가 롤백된 슬롯을 믿고 T1 ref 를 면제 → **롤백을 증명하는 가장 높은 ref 가 바로 그 이유로 숨는다**. 「정의역 축소는 앵커 값이 아니다」라는 내 논증은 틀렸다 — **정의역 축소도 판정의 일부**이고 그 입력이 롤백 가능하다는 사실을 무시할 수 없다. ⓑ**내 유력안 2(`record.revision + 1` 하나만 면제) 기각** — `+1` 은 필요조건이지 충분조건이 아니다(`-T1`·`-T2` 동시 주장 · 손상 ref 의 우연 일치). ⓒ**확정 면제 조건 10항**: 대응 저널 존재·런타임 검증 통과 / stage 정확히 `composed` / `journal.resultRef === R.refName` / `journal.resultOid === R.oid` / `parsedRefRevision === journal.expectedAuthorityRevision + 1` / `journal.expectedAuthorityRevision === authority.revision` / `previousAuthorityStage`·`nextAuthorityStage`·`integrationGeneration`·`draftDigest` 가 현재 권위에서 **재구성되는 후속 전이와 일치** / superseded·abandoned·finalized 아님 / **같은 revision 을 주장하는 promotable ref 가 정확히 하나** / repo 락 + bench 리스 재획득 후 **fresh read 재검증**. ⓓ**계속 전역 blocker 인 것**: 대응 저널 없는 높은 ref · stage 가 `published`/`finalized`/`abandoned` 인 높은 ref · OID 불일치 · expected revision 불일치 · **두 단계 이상 높은 ref** · 복수 주장 · superseded txn ref · digest 불일치 · 열거·파싱·identity 검증 실패 | +| 200 | **정정 197 의 판정 순서를 개정한다** — 저널을 앵커 **앞에서 읽되(읽기·검증만)**, 변이는 앵커 게이트 뒤 | 정정 197 은 「앵커 판정 **전에 저널 바이트조차 읽지 않는다**」로 고정했는데, 그러면 **정상 크래시와 롤백을 구분할 정보가 3단계에 없다**. 안전 목표는 「앵커보다 먼저 저널을 **변이**하지 않는다」이지 「읽지 않는다」가 아니었다. 개정 순서: `①권위 fresh read → ②결과 ref 전수 열거·문법·identity·OID 검증 → ③저널 전수 열거·schema·identity 검증 → ④ref↔저널 설명 관계 **순수 분류** → ⑤anchor gate(설명 불가능한 refRevision > authority.revision 존재 = reconciliation · 정확히 하나의 valid composed pending ref 만 promote 후보) → ⑥귀속·전이 불변식 재검증 → ⑦repo 락 + 리스 아래 fresh read·재열거 → ⑧조건 유지 시에만 promote-published CAS`. 「롤백된 권위 위에서 먼저 승격한다」는 위험은 재도입되지 않는다 — 승격 **변이**는 여전히 ⑤ 뒤다 | +| 201 | **정정 196 불변식 ③ 의 의미를 좁힌다** — 「무조건 reconciliation」이 아니라 **「복구 개입 없이 `published` 정상 대기로 취급하지 않는다」** | `promote-published` 도 **적극적 복구 개입**이므로 ③ 을 만족한다. ③ 을 「항상 reconciliation」으로 읽으면 복구표의 그 arm 을 폐기하는 결과가 되어 정정 167 의 2분과 충돌한다. 결속이 정확하면 `promote-published`, 아니면 `reconciliation-required` | + +**§3 신설 행을 T73~T82 에서 확대**(Codex 2R 이 열거한 12축) — 기존 10행에 다음을 명시 추가: +**T83** 정상 crash window(권위 `N` · valid `composed` 저널 expected `N` · matching ref `N+1` → `promote-published` +이며 전역 reconciliation **아님**) · **T84** 저널 없는 `N+1` ref → reconciliation · **T85** result OID 불일치 → +reconciliation · **T86** expected revision 불일치(`N-1`·`N+1`) → promote **금지** · **T87** 두 단계 이상 롤백 +(권위 `N-1` · 저널 expected `N` · ref `N+1`) → reconciliation · **T88** 복수 next-revision ref → reconciliation · +**T89** **롤백된 current 슬롯** — current 라는 이유만으로 면제되지 않음(정정 199ⓐ 의 회귀 핀) · **T90** +superseded/abandoned ref(현재 이하 = 정상 보존 · 현재 초과 = current 여부 무관 reconciliation) · **T91** +anchor-before-**mutation**(저널은 판정 전에 읽지만 권위 CAS·저널 stage 변이는 게이트 전 **0건**) · **T92** +재검증 race(최초 분류 후 락·리스 획득 사이 변경 → fresh 재검증 실패 · promotion 0건) · **T93** 정확한 +promotion(CAS 성공 후 `record.revision === parsedRefRevision` · 저널은 **그 뒤에만** `published` 전진) · +**T94** CAS 실패 시 ref 보존 · 정상 대기로 숨기지 않음. + +#### 체크포인트 리뷰 3R — ✅**Approved with required wording fixes · 추가 P0/P1 없음** (2026-08-12) + +회부한 질문 3건 전부 닫혔다. **착수 전 라운드 종료** — 총 3라운드 · P1 2건(196·199) · 문면 확정 3건. + +| # | 확정 | +|---|---| +| 202 | **인터리브 = (A) 확정.** `composed` 전이는 일반 규율의 예외가 아니라 **그 단계가 소유하는 명시적 3단계 프로토콜**이다: **`composed` 저널 acknowledged → 결과 ref create-only 발행 → `composed` 권위 CAS.** 결과 ref 발행은 「composed CAS 가 커밋됐다」가 아니라 **「그 CAS 에 필요한 결과 증거와 외부 부수효과가 준비됐다」**는 뜻이다. (B) 는 조건 6(`journal.expected === authority.revision`)을 **구조적으로 거짓**으로 만들어 면제 조건과 공존 불가. (A) 는 「`resultOid` 는 `composed` 부터 필수」인 현행 권위 불변식과도 양립한다 — ref 를 만들 결과 증거는 composed 저널에 이미 내구 기록됐고 권위의 composed 전이만 pending 인 상태다. **일반 WAL 문구도 함께 좁힌다**: 「각 단계는 원칙적으로 저널 선기록 후 권위 CAS 로 커밋한다. 단 `composed` 단계는 결과 ref 발행을 그 CAS **앞**에 두며, 이 사이의 크래시는 조건부 면제 검증 후 **동일 composed CAS 를 재개**한다」 | +| 203 | **verdict 이름 `promote-published` → `resume-composed-cas`**(지금 변경 — 폐기 어휘 재유입 가드가 걸리기 전에). 그 verdict 가 받는 디스크 상태에서 복구기가 당장 커밋할 것은 `published` 권위 상태가 아니라 **저널이 이미 선기록한 `composed` 권위 CAS** 다. ⚠ **내가 제시한 ⓑ(이름 유지 + 「published 로 가는 여정의 승격」으로 문면에 못박기)는 기각** — 그 설명은 「composed CAS 를 **건너뛰고** 곧바로 published 권위를 기록」하는 구현도 **이름상 허용**하고, 그러면 `previousAuthorityStage`·`nextAuthorityStage`·draft digest 결속이 통째로 무력화된다. 저널이 두 단계를 별도로 두고 `publishedAt` 을 `composed` 에서 금지하는 이상 **이름도 그 단계성을 보존**해야 한다 | +| 204 | **ref 가 권위보다 두 단계 앞서는 창 = 정상으로 인정하지 않는다**(내 질문 ⓐ 채택 · **T87 그대로 유지**). ⚠ 다만 문안은 「같은 revision 을 다시 겨냥한다」보다 엄밀해야 한다 — **CAS 가 실제로 성공했는데 응답·후속 durability acknowledgement 만 유실**됐을 수 있기 때문이다. **확정 수렴 규칙 3분기**: ⓐ권위가 여전히 `N` ∧ 전 결속 동일 → **동일 composed CAS 재실행** ⓑ권위가 이미 `N+1` ∧ composed 전이가 **정확히** 반영 → 재실행 없이 **완료 확인** 후 다음 stage ⓒ그 외(revision > `N+1` · stage/txn/digest/resultOid 불일치) → 자동 전진 금지 · **reconciliation-required**. **추가 불변식 6**: ①stage `S` 의 권위 커밋이 확인되기 전 stage `S+1` 저널 기록 금지 ②**txn 당 결과 ref 단일** ③current txn 이 composed pending 이면 새 prepare/re-prepare 시작 금지 ④같은 resulting revision 을 주장하는 promotable ref 가 둘 이상 = reconciliation ⑤**composed CAS 의 실패·불확실은 published 전진의 인가가 아니다** ⑥retry 는 이전 응답을 믿지 않고 fresh read 후 `expected N` 또는 **정확한** `N+1` 중 하나로만 수렴. **정상 프로토콜에서 ref 는 txn 당 한 번만 만들어지므로**(published 로 갈 때 새 `N+2` ref 를 만들지 않는다) 완료 후에는 오히려 `refRevision(N+1) < authority.revision(N+2)` 이다 — 두 단계 앞섬은 위 4경로를 금지하면 발생하지 않는다 | + +**T83 픽스처의 확정 인터리브**(구현자가 창작하지 않도록 10단계로 고정): ①권위 `N` fresh read → +②composed next draft·전이 불변식 **선검증** → ③composed 저널(`expectedAuthorityRevision=N`) acknowledged → +④결과 ref `-` create-only 발행 성공 → ⑤**composed 권위 CAS 호출 직전 crash 주입** → ⑥재부팅 → +⑦`권위 N` + `저널 composed expected N` + `matching ref N+1` 관측 → ⑧면제 조건 1~10 교차검증 → +⑨verdict = **`resume-composed-cas`** → ⑩같은 composed 권위 CAS 를 수행해 revision `N+1`. +⚠ **(B) 로 만들면 안 된다** — (B) 면 ④ 시점에 권위가 이미 `N+1` 이라 T83 의 「권위 `N`」과 모순이다. + +**폐기 어휘에 추가**(재유입 금지): **`promote-published`**(→ `resume-composed-cas`). + +**교훈(3R)**: **verdict 이름이 계약이다.** 내가 낸 ⓑ(이름 유지 + 문면 보강)는 「문서로 막는다」의 전형이고, +이 레포가 이미 세 번 학습한 실패 형태다([[static-guard-form-over-name]] · 정정 44 「배포 계약으로 명문화한다는 +문장은 집행이 아니다」). 이름이 두 구현을 모두 허용하면 **문면은 그중 하나만 금지할 뿐 컴파일러도 리뷰어도 +나머지를 못 잡는다.** 단계성을 이름에 넣으면 그 오답이 **말이 안 되는 코드**가 된다. + +**교훈(2R)**: **내가 낸 두 대안이 전부 「검사의 입력이 검사 대상과 같은 매체」라는 같은 결함을 공유했다.** +앵커의 존재 이유가 「권위 파일 밖의 독립 증거」인데, 면제 규칙을 그 권위 파일에서 읽어오면 독립성이 +그 지점에서만 조용히 끊긴다. 처방은 **면제를 독립 매체 두 개(ref + 저널)의 교차검증으로 세우는 것**이었다. + +**교훈**: **「두 매체가 같은 값을 증언한다」고 적을 때 그 값의 정의를 양쪽 소스에서 각각 확인하라.** +나는 저널 필드 주석(`journal.ts:115`)과 CAS 배정(`authority.ts:1307`)을 **둘 다 이번 정찰에서 읽고도** +`+1` 경계를 놓쳤다 — 두 곳을 읽은 것과 두 값을 **뺄셈해 본 것**은 다르다. 이 결함은 코드 0행 시점에 +잡혔으므로 비용이 문면 한 줄이었지만, 구현 후였다면 「전 게이트 GREEN + 앵커는 영원히 침묵」이라는 +**가장 나쁜 위양성**(PR3b 정정 189 와 같은 형태)이 됐을 것이다. + +#### PR3c 착지 실측 (2026-08-12 · 구현 중 확정 3건 + 뮤테이션 자기검사) + +**분량** — ⚠ **아래 초기 수치는 stale 이었다**(로컬 적대 리뷰 L5 가 지적 · 정정 139 가 세운 +「푸시 시점 수치를 최종으로 적지 않는다」 규율의 4회차 재발). 라운드 반영 8커밋이 더 들어간 +**현 head 기준 재측정**이 권위다: + +| 시점 | 순증(src) | 프로덕션 | 상한 1,900 대비 | 목표 ≤400 대비 | +|---|---:|---:|---:|---:| +| 착지 직후(초기 기록) | 2,031 | 744 | +7% | +86% | +| **현 head(리뷰 7라운드 반영 후)** | **3,469** | **1,067** | **+83%** | **+167%** | + +프로덕션 내역: `recovery.ts` 718 · `authority.ts` +208 · `result-ref.ts` 118 · `journal.ts` +25. +**은폐하지 않는다**(PR3b 정정 181 과 같은 규율). 초과의 성격은 「기능 추가」가 아니라 **리뷰 7라운드가 +요구한 방어와 그 정직 표기**다 — Codex P1 23건 + 로컬 P1 1·P2 3 + 테스트 반증력 14건이 전부 코드·주석· +테스트로 착지했다. ⚠ 최종 수치는 머지 직후 `origin/master` 기준으로 다시 재측정해 갱신한다. + +⚠ **분할 재검토를 하지 않은 이유를 여기서 다시 적는다**(초과가 2배를 넘었으므로): 정정 233 이 확정한 +결속 축은 **정정 215·218·223 을 되돌리는 수정**이라 그 세 정정과 같은 revert 단위여야 하고, 그것들은 +이미 이 PR 안에 있다. 새 PR 로 빼면 「정상 상태가 영구 reconciliation 되는 코드」가 master 에 한 커밋 +동안 존재한다 — PR0 이 확립한 「유일한 비가역 ripple 이 자기 revert 단위를 갖는다」의 정반대다. + +**추가 분할을 하지 않은 근거**: 정정 159·161 이 세운 축 논거가 그대로 유효하다 — 앵커 필드·전이 불변식 +계층·판정 함수가 **한 커밋에서 함께 태어나지 않으면** `anchor-unavailable` 유령 상태가 되살아난다. +분량은 분할 근거가 아니고 **한 PR 이 지는 불변식 표면**이 근거라는 정정 187·195 의 기준을 적용하면 +이 PR 의 표면은 여전히 하나다(크레덴셜 축은 이미 PR3c′ 로 나갔다). + +| # | 구현 중 확정 | 근거 | +|---|---|---| +| 205 | **앵커를 `durability` 값으로 게이트하지 않는다** — 판정은 **항상** 돈다 | 발화가 필요한 표면은 `file-only`(C3)이지만 그 값은 **롤백 대상인 권위 파일 안에** 있다(`writtenBy.durability`). 검사의 **활성화 조건**을 검사 대상과 같은 매체에서 읽으면 독립성이 그 지점에서 조용히 끊긴다 — 2R 교훈(「면제 규칙을 권위 파일에서 읽어오면 독립성이 끊긴다」)의 **동형**이고, T13b 문면의 「발화 조건은 `durability === 'file-only'`」는 그래서 폐기한다. `file+dir` 표면에서는 정상 프로토콜상 **발화 입력 자체가 만들어지지 않는다** | +| 206 | **면제 조건 ⑩(promotable 정확히 하나)은 이 PR 에서 독립 반증력이 0 이다** — 남기되 **정직 표기** | 뮤테이션 자기검사 실측: `promotable.length === 1` 을 `>= 1` 로 바꿔도 **테스트가 잡지 못한다**(16 뮤턴트 중 유일한 생존). 조건 ⑧(전이 불변식)이 「`composed` 는 새 txn 을 시작할 수 없다」를 강제해 통과 후보가 **현재 txn 하나**로 이미 좁혀지기 때문이다. 정정 183 의 「도달 불가 arm 금지」와 정면으로 부딪히지만 **삭제하지 않는다** — ⑩ 은 Codex 가 승인한 계약 조항(정정 199ⓒ)이고 ⑧ 이 약해지는 미래 편집에서 마지막 방어다. 대신 **「가림」을 코드 주석·PR 본문에 계상**하고, §3-T88 의 **관측 가능한** 계약은 「면제되지 않은 모든 후보가 blocker」라는 루프가 진다는 것을 명시한다 | +| 207 | **고아 활성 저널 2분의 조작화**(정정 194 의 문면을 값으로) — benign = `stage === 'prepared'` **∧** 그 txn 의 결과 ref 부재 | (A) 프로토콜에서 「권위가 가리키지 않는 활성 엔트리」가 정상일 수 있는 창은 **하나뿐**이다 — 저널 선기록 후 `prepared` CAS 전. 그 창의 엔트리는 결과 ref 를 **가질 수 없다**(ref 는 composed 저널 뒤에 발행). 따라서 `composed` 고아·ref 를 동반한 `prepared` 고아는 전부 reconciliation 이고, 활성 집합의 **정의**는 stage 단독으로 유지된다(정정 194 가 요구한 방향) | + +**이 PR 에서 vacuous 한 것**(사전 계상 · 머지 직전 적발을 만들지 않는다): §3-**T80**(복구기가 인가를 +차단하는 **행동**) · **T81**·**T92**(락·리스 아래 fresh 재검증의 **행동** — 여기 있는 것은 증거 봉인 +seam 과 동치 판정뿐) · **T91**(변이 순서 — 판정 함수가 **순수**하다는 사실이 그 구조적 형태다) · +**T93**·**T94**(CAS 성공·실패 후 상태) · §3-**T18b** 후반 연언(「되돌아간 revision 위에 새 CAS 가 +커밋되지 않는다」). 전부 **생산자가 PR5(시퀀서)·PR7(부팅)** 이며 그 사실을 스펙 행 문면에도 적었다. + +**뮤테이션 자기검사**: 16 뮤턴트 중 **15 잡힘**. 생존 1건이 위 정정 206 이다. ⚠ 첫 실행에서는 **2건이 +생존**했고 그중 하나(면제 조건 ⑥ 산술 결속 삭제)는 **실제 테스트 공백**이었다 — 「⑥ 위반」을 검증하던 +픽스처가 `refRevision ≤ revision` 이라 **복구표 경로**로 빠져, 면제 조건 자신의 반증력이 한 번도 +관측되지 않았다. 후보 경로(`refRevision > revision`)용 행을 추가해 닫았다. + +**교훈 — 제어문자 오염이 착지했고, 그 진단을 내가 두 번 틀렸다.** `recovery.ts` 의 템플릿 리터럴 안에 +U+0000 이 섞였고 발견 경로는 게이트가 아니라 `grep` 이 그 파일을 「binary file」로 답한 것이었다. + +⚠ **여기서 내가 「`npm run verify` 는 NUL 을 보지 않는다」고 적었는데 그것은 거짓이다**(아래 정정 214 +에서 실측으로 반박). `scripts/repo-hygiene.test.ts` 의 「원시 NUL 0건」 게이트가 **이미 존재하고 +실제로 잡는다** — 다만 나는 grep 으로 먼저 발견해 **고친 뒤에** 전체 게이트를 처음 돌렸으므로 그 +게이트가 발화할 기회가 없었다. 「내가 먼저 찾았다」를 「게이트가 못 잡는다」로 읽은 것이 오진이다. +진짜 구멍은 **NUL 외 C0/C1 제어문자**이며 그것은 정정 214 가 실측으로 확정한다. + +#### PR#289 Codex 리뷰 1R 반영 (2026-08-12 · P1×5 · **5건 전부 실재 · 전부 수용**) + +**두 건이 「다른 층이 막는다」는 내 검증 안 된 주장이었다**(정정 133·152·156 이 닫은 것과 **같은 형태의 +재발**). 나머지 셋은 관측 계약의 구멍이다. + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 208 | **최초 레코드(`prev` 부재)도 시작 stage 를 본다** — `prepared` 외로 태어나는 첫 CAS 거부 | 내 주석은 「최초인데 미종결 통합을 들고 태어나는 것은 **단일 레코드 불변식 ③ 계열의 소관**」이라며 침묵했는데 **거짓이었다** — `checkInvariants` 의 ③·③b·③c·③d 어디에도 「최초 stage 는 prepared」가 없다. 첫 CAS 가 `composed` 로 태어나면 **WAL 1단계와 그 크래시 안전 순서를 통째로 건너뛴다.** ⚠ 파급으로 기존 스토어 테스트 5건이 깨졌는데, 전부 **첫 CAS 로 목표 stage 를 만드는 픽스처 지름길**이었고 그 지름길이 성립한다는 사실 자체가 결함이었다. `abandoned` 초기 레코드 기대는 **뒤집었다**(정정 177: 권위는 `abandoned` 를 갖지 않는다 — 이전 기대는 **어떤 생산자도 만들지 않는 상태**를 정상으로 고정하고 있었다) | +| 209 | **`completedIntegrationTxnId` 소거 금지의 예외 = 보관 전이** | 불변식 ②가 「존재 ⟺ `lifecycle==='integrated'`」이므로 `integrated → archived` 는 **반드시** 그 필드를 소거한다. 내 규칙은 그 정상 전이를 전부 `invariant-violation` 으로 답해 **보관 워크플로를 통째로 막았다**(착지 커밋 `b572a6c` 의 회귀 · 테스트가 0건이라 무신호였다). 예외는 「소거」가 아니라 **lifecycle 에 결속**시킨다 — 귀속 교체는 계속 거부 | +| 210 | **복구 판정의 identity 는 호출자가 독립으로 싣는다**(`benchId` → `BenchAuthorityIdentity`) | 초안은 레포 identity 를 **권위 레코드에서 꺼내** 저널과 대조했다. 권위 부재·손상이면 그 대조가 **통째로 사라져** 다른 레포에서 복사된 저널(같은 benchId · 영역 통째 복사)이 benign → `no-mutation` = 재준비 적격으로 통과한다. **정정 205 와 같은 논거의 동형**(검사 기준을 롤백 가능한 매체에서 읽지 않는다)인데 내가 같은 파일 안에서 한쪽만 적용했다 | +| 211 | **권위 stage ≤ 저널 stage ≤ 권위 stage + 1** 교차 결속 신설 | 복구표를 **저널 stage 만 보고** 분기했더니, 권위가 앞선 관측(= 저널 단독 롤백·손상)이 `no-mutation` 으로 통과했다 — 손상 위에서 포기·재준비가 인가되는 경로다. 두 부등식의 출처는 각각 WAL 선기록과 정정 204 불변식 ①이고, **두 방향을 다른 종별로** 답한다(진단 분해능 · 각각 독립 반증) | +| 212 | **고아 `prepared` benign 창은 「권위에 미종결 통합 없음」에서만 열린다** | 전이 불변식이 「미종결 T1 위에 T2 시작」을 금지하므로 T2 저널은 **애초에 쓰이지 말았어야 한다**. 그 존재 자체가 이상 신호인데 benign 규칙이 삼켰고, 그 상태에서 T1 의 `resume-composed-cas` 까지 그대로 나갔다 | + +**뮤테이션 재실측**: 뮤턴트를 16 → **23** 으로 늘려 **22 잡힘**. 생존은 여전히 정정 206 한 건뿐이다. +새 방어 5개(M17~M21)와 authority 축 2개(M22·M23) 전부 독립 반증력이 확인됐다. + +**CodeRabbit 동시 라운드(같은 커밋 `670af27`) — Major 1 · Minor 4.** Major 는 Codex P1(보관 전이)과 +**동일 건**이라 이미 닫혔다(처방은 내 쪽이 더 좁다 — CodeRabbit 안은 「`next.lifecycle !== 'integrated'` +면 소거 허용」이라 `open` 으로의 소거까지 열린다). 나머지에서 **2건이 실재**였다. + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 213 | **완결 귀속 txn 의 활성 저널은 blocker 다** — 「귀속돼 있으니 고아가 아니다」 제외를 폐기 | 완결된 txn 의 저널은 정상 상태에서 `finalized` 다. 활성 stage 로 남아 있으면 **매체 손상·순서 위반의 증거**인데 초안이 그것을 조용히 통과시켰다. 같은 계열의 매체 불일치를 `current-txn-journal-missing` 으로 발화하면서 이쪽만 면제한 **비대칭**이었다 → 신설 종별 `completed-txn-journal-active` | +| 214 | **raw 제어문자 오염이 재발했다**(U+0001 · `sealEvidence` 구분자) — 이스케이프 표기로 치환하고, **기존 게이트의 실제 경계를 실측으로 확정**했다 | 발견자는 게이트가 아니라 **외부 리뷰어**였다. ⚠ 여기서 위 착지 실측 절의 「verify 는 NUL 을 보지 않는다」를 **반박**한다 — `src/main/core/workbench/__ctrl_probe.ts` 에 문자를 하나씩 심어 `scripts/repo-hygiene.test.ts` 를 돌린 결과: **U+0000 = RED(잡는다)** · **U+0001 · U+001F · U+007F = 통과(무신호)**. 즉 게이트는 **NUL 만** 보고 나머지 C0/C1 은 전부 통과시킨다. 구분자 자체는 **의도적으로 유지**한다(붙여 이으면 `["ab","c"]` 와 `["a","bc"]` 가 같은 다이제스트를 낸다) | + +**후속 후보 갱신(#251 밖)** — **기존 NUL 게이트의 문자 범위 확장**(신설 스크립트가 아니다 · 정정 214 의 +실측이 위치를 특정했다): `scripts/repo-hygiene.test.ts` 의 「원시 NUL 0건」을 **C0/C1 전반**으로 넓힌다 +(허용 = 탭·LF·CR). 탐지식은 `[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]` 이고 스캔 루트·확장자는 기존 것을 그대로 쓴다. +⚠ 스캐너 자신의 패턴을 **이스케이프 문자열**로 쓸 것 — 문자 클래스에 raw 제어문자를 넣으면 그 스캐너가 +같은 오염을 재생산한다(이 정정을 적는 편집이 실제로 8건을 재생산했고 스캐너가 잡았다). + +**교훈(정정 214) — 「게이트가 못 잡는다」는 결론에도 관측 경로 검증이 필요하다.** 부정 결론(「무응답」)을 +근거로 워크플로를 바꾸기 전에 관측 경로를 의심하라는 규율을 이미 학습해 놓고(Codex 무응답 오판 사고), +이번에는 **게이트의 무신호**에 같은 실수를 했다 — 「내가 먼저 찾았다」와 「게이트가 못 잡는다」는 다른 +명제인데, 게이트를 **한 번도 그 상태로 돌려보지 않고** 후자를 단언했다. 문자별 프로브 실측은 5분이 +걸렸고 결론을 정반대로 좁혔다. + +**Codex 3R — P1×3 · 전부 실재 · 전부 수용**(2R 은 clean 이었는데 3R 에서 새 축이 나왔다). + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 215 | **stage 가 같아도 결속을 본다** — 현재 txn 저널은 `pre-cas`(권위 = `previousAuthorityStage` ∧ expected === revision) 또는 `post-cas`(권위 = `nextAuthorityStage` ∧ revision === expected+1 ∧ 결과·세대 일치) **둘 중 하나**여야 한다 | 정정 211 의 순서 검사는 **인덱스만** 비교해서 「둘 다 composed 인데 resultOid·revision·세대가 어긋난」 손상 쌍이 복구표에 그대로 도달했고, ref 가 없으면 `no-mutation`(= 포기·재준비 적격)이 나왔다. ⚠ 이 결속을 세우면서 `classifyComposed` 의 `reflected` 재조립은 **삭제**했다 — 같은 조건을 두 곳에 적으면 한쪽이 무력화돼도 다른 쪽이 가린다(정정 189) | +| 216 | **새 txn 시작 인가를 `lifecycle` 에 건다**(stage 종결만으로는 부족) | `integrated` ∧ current `finalized` 레코드가 pending 검사를 통과해 **종결된 bench 를 다시 열 수 있었다.** 스펙 §5 「lifecycle 회귀(재개) 없음 · `integrated` 후 추가 작업은 **새 bench**」와 정면 충돌이고, 단일 레코드 불변식은 이것을 보지 않는다 | +| 217 | **완결 귀속의 「도입」도 검사한다** — 신규 `completedIntegrationTxnId` 는 직전 레코드의 current txn 이어야 한다 | 교체·소거 금지만 있어서 **첫 도입이 무제한**이었다: current 가 T1 인 레코드를 `completedIntegrationTxnId: T2` 로 커밋해도 단일 레코드·전이 검사가 **둘 다 통과**하고, 무관한 txn 이 완결로 기록되며 **부분 통합이 숨는다**. §W-8 의 「완결은 권위 시도만 · 관측 CAS 는 **txn 동일**」을 코드로 세운다. ⚠ 정직 표기: **도달성·stage 전제는 여기서 보지 않는다**(`resultOid` 의 base 도달성은 외부 관측이고 이 순수 함수의 입력에 없다 — §W-8 소관) | + +**뮤테이션 3차**: 새 방어 6개(M24~M29) 중 **5 잡힘 · 1 생존**. 생존한 M26(`binding === 'post-cas'` +재검사)은 **도달 불가**임이 실측으로 드러나 **삭제**했다(도달성 증명은 코드 주석에 남겼다) — 정정 206 +에서 「승인된 계약 조항이라 남긴다」고 판단한 ⑩ 과 달리, 이쪽은 내가 방금 만든 **중복 방어**였으므로 +직전 커밋의 「자기 전이 금지 삭제」와 같은 처분을 적용했다. 두 생존을 **다르게 처분한 근거는 「계약 +조항인가 내 중복인가」** 다. + +**Codex 4R — P1×5 · 4건 완전 수용 · 1건 부분 수용**(3R 이 clean 이 아니라 또 새 축이었다). + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 218 | **post-CAS 결속에 `draftDigest` 대조를 넣는다** | stage·revision·세대·`resultOid` 만 보면 **draft 투영 안의 나머지**(`lifecycle`·`sourceGeneration`·`activeActivity` …)가 롤백·손상돼도 통과하고, 결과 ref 까지 맞으면 `normal-wait` 이 나온다. 저널이 그 전체를 이미 증언하므로(`draftDigest`) **추가 입력 0** 으로 닫힌다 | +| 219 | **저널이 든 `resultRef` **이름 자체**를 검증한다**(문법·bench·txn 결속) — ⚠ **revision 산술은 `composed` 에서만**(⚠ **이 면제는 정정 223 이 폐기했다** — 정답은 면제가 아니라 stage 별 식 `expected + 2 - stageIndex` 였고, 이 행대로 두면 223 이 닫은 구멍(잘못된 `prepared` ref 이름이 그대로 발행)이 되살아난다) | 그 값은 **불변 필드**라, git ref 가 아직 없는 `prepared` 저널의 잘못된 이름이 `no-mutation` 을 타고 살아남아 나중에 **그대로 발행**된다. PR3c 가 문법 소유자가 된 이상 여기서 잡아야 한다. ⚠ **부분 수용**: 지적은 「모든 저널에 `expected + 1`」을 요구했는데 그 등식은 **`composed` 에서만 성립**한다 — ref 는 composed CAS **앞**에서 발행되므로 같은 파일이 `published` 로 전진하면 `expected` 가 올라가 등식이 깨진다. 전 stage 에 걸면 **정상 published 저널이 전부 RED** 다 | +| 220 | **현재 txn 의 ref 이름·OID 대조를 복구표 **앞**으로 끌어올린다** | `published` 저널의 ref 는 revision 이 권위 이하라 앵커 후보가 아닌데, 그 분기가 OID 를 **한 번도 대조하지 않고** `normal-wait` 을 답했다 — 교체·손상된 ref 위에서 시퀀서가 게시를 계속한다. 한 곳에서만 판정하도록 올리고 `classifyComposed` 의 중복은 삭제했다 | +| 221 | **증거 봉인에 저널 레코드 **전체** 다이제스트를 싣는다**(`digestJournalRecord` 신설 · journal.ts) | `draftDigest` 는 **권위 draft 투영**만 덮어서, 저널의 통합 입력(`sourceSnapshot`·`targetBranch`·`targetHeadBeforeIntegration`·`resultTree`)이 관측과 락 사이에 바뀌어도 **같은 증거**가 나온다 — 소비자가 **바뀐 통합 의도**를 같은 것으로 받는다 | +| 222 | **`abandoned` 권위 상태는 종결이다** — 같은 txn 의 되살림 거부 | 파서는 그 값을 여전히 읽는데(레거시·손상) `AUTHORITY_STAGE_ORDER.indexOf('abandoned')` 가 **-1** 이라 `abandoned → prepared` 가 `fi=-1 · ti=0` 으로 역행·건너뛰기 **두 검사를 모두 통과**했다. 소거(청소 복구)는 앞선 분기가 처리하므로 막다른 길이 되지 않는다 | + +**이 라운드가 만든 연쇄**: 219 가 서면서 면제 조건 ⑥(관측 ref 의 산술)과 `classifyComposed` 의 산술 +검사가 **완전히 가려졌다**(저널 이름이 등식을 강제 + ④ 가 관측 ref 를 그 이름에 묶는다) → 둘 다 +**삭제**하고 도달성 증명을 주석으로 남겼다. **뮤테이션 4차 7/7 잡힘** — 생존했던 M33(전-stage ref +**이름** 대조)은 「이름과 OID 를 한 픽스처에서 함께 어긋낸」 탓이라 **축을 분리한 행**을 추가해 닫았다. + +**교훈(4R) — 픽스처의 비현실성이 방어의 공백을 가린다.** 이번 라운드에서 깨진 테스트 7건은 전부 +**결속상 존재할 수 없는 쌍**을 정상으로 고정하고 있었다(권위는 post-CAS 인데 저널의 draftDigest 는 +딴 것 · `composed` 저널의 expected 와 resultRef 가 어긋남). 그런 픽스처 위에서는 새 방어가 **오탐처럼 +보이고**, 반대로 그 방어가 없을 때의 구멍은 **테스트가 증명해 주지 않는다.** 픽스처는 「통과시키려고 +맞추는 값」이 아니라 **프로토콜이 실제로 만들 수 있는 상태**여야 한다. + +**Codex 5R — P1×4 · 전부 실재 · 전부 수용**(4R 에서 내가 **부분 수용으로 좁힌 축**이 정확한 식으로 +되돌아왔다). + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 223 | **결과 ref 의 revision 산술은 stage 전수에 고정된다** — `ref = expected + 2 - stageIndex` | 4R 에서 나는 「`expected+1` 은 composed 에서만 성립」이라며 **다른 stage 를 면제**했는데, 정확한 답은 면제가 아니라 **stage 별 식**이었다. `resultRef` 는 불변이고 ref 는 composed CAS 앞에서 한 번만 발행되므로, `prepared` 저널이 관측한 revision 을 `R0` 라 하면 **ref = R0 + 2 고정**이고 `expected = R0 + stageIndex` 다. ⚠ `abandoned` 만 정의역 밖이다(포기 시점에 따라 expected 가 달라진다). ⚠ **정정 236(7R)이 이 면제를 폐기했다** — 포기의 식은 `previousAuthorityStage` 가 준다(`ref = expected + 1 - idx(P)`). 「식이 하나로 고정되지 않는다」는 관찰은 이 행 자신의 논거(면제가 아니라 식을 찾으라)에 걸린다. ⚠ **정정 233 이 이 「만」을 좁혔다** — `finalized` 는 정의역 안이지만 **등식이 아니라 상한**(`ref ≤ expected`)이다. 이 행만 읽고 `finalized → expected - 1` 등식을 세우면 그것이 233 이 적발한 설계급 결함(정상 상태의 영구 `reconciliation-required` · `resume-composed-cas` 도달 불가)이다 | +| 224 | **ref 발행 이후의 상태는 ref 를 요구한다** — post-CAS composed · `published` · `finalized` | 발행이 composed CAS **앞**이므로 그 상태에서 ref 가 **사라진 것**은 프로토콜상 불가능한데, 초안은 `no-mutation`(포기·재준비 적격)을 답해 **ref 롤백·손상을 그대로 숨겼다** → 신설 종별 `result-ref-missing`. `abandoned` 는 제외(`prepared` 에서 포기하면 ref 가 존재한 적 없다) | +| 225 | **완결 귀속은 결과 증거를 전제한다** — `prev.currentIntegrationResultOid` 부재면 거부 | 3R 정정 217 은 **txn identity 만** 봤다. `prepared` 상태의 T1 을 그대로 `integrated`+`completed: T1` 로 커밋해도 두 층이 통과해 **결과가 존재한 적 없는 bench 가 완결로 기록**된다. ⚠ 도달성 자체는 여전히 §W-8 소관(정직 표기 유지) | +| 226 | **lifecycle 은 통합 축과 독립으로 단조다**(`open → integrated → archived`) | 통합 축을 건드리지 않는 CAS 는 그 축의 검사를 **통째로 건너뛴다**(정정 195 의 no-op 규칙). 그래서 archived 레코드를 `archivedBranch` 만 떼고 `open` 으로 되돌리는 제출이 **어느 층에도 걸리지 않았다** — 종결된 bench 가 되살아난다. lifecycle 검사를 통합 축 **밖**에 세운다 | + +**이 라운드가 만든 연쇄**: 223 이 서면서 복구표의 「`prepared` ∧ ref 존재」 행이 **도달 불가**가 됐다 — +그 저널이 현재 txn 을 가리키려면 결속이 post-CAS 여야 하고(`revision = expected + 1`), 그 ref 는 +`expected + 2 = revision + 1` 이라 **항상 앵커 후보**다. 전용 arm(`result-ref-unattributed`)을 제거하고 +「표가 요구하는 **결과**는 앵커가 보장한다」를 코드·스펙에 적었다. **뮤테이션 5차 6/6 잡힘.** + +**교훈(5R) — 「stage 마다 다르다」는 관찰은 면제의 근거가 아니라 식을 찾으라는 신호다.** 4R 에서 나는 +`published` 저널이 RED 가 되는 것을 보고 **검사를 면제**했는데, 옳은 처방은 **stage 를 변수로 넣은 하나의 +식**이었다. 면제는 그 자리에 구멍을 남기고(잘못된 `prepared` ref 이름이 통과했다) 식은 남기지 않는다. +픽스처도 같은 축에서 정리됐다 — stage 별 산술을 **`walBound` 헬퍼 한 곳**으로 옮기니 「존재할 수 없는 쌍」이 +구조적으로 만들어지지 않는다. + +**Codex 6R — P1×6 · 전부 실재 · 전부 수용.** 이번 라운드는 **직전 라운드가 만든 방어의 정의역**에서 +전부 나왔다(면제 평가 안에만 있던 검사 · pending 만 보는 benign · abandoned 통째 면제 · `!postCas` 가지). + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 227 | **최초 레코드의 lifecycle 은 `open`** | `prev === undefined` early return 이 완결 귀속 검사들을 **통째로 건너뛴다** — `integrated` + 임의 `completedIntegrationTxnId` 를 든 첫 CAS 가 두 층을 다 통과해 **txn 도 결과도 없이 완결을 주장하는 종결 bench** 를 만들었다. 최초 쓰기에는 대조할 출처가 없으므로 **형태**로 막는다 | +| 228 | **새 txn 시작은 출발지·목적지 **둘 다** `open`** | `prev` 만 보면 **한 CAS 가** T2 를 시작하면서 bench 를 `integrated` 로 넘길 수 있고, 이후 같은-T2 단계 전이는 그 게이트를 **아예 지나지 않는다** | +| 229 | **종결 증거(`abandonedAt`·`abandonReason`)는 admitted 저널 전수에서 본다** | 초안은 그 검사를 **앵커 면제 평가 안에만** 뒀는데, post-CAS `composed` 저널의 ref 는 revision 이 권위와 같아 **후보가 되지 않으므로 그 경로에 영영 도달하지 않는다** → 모순 레코드가 `normal-wait` 을 받았다. ⚠ `publishedAt` 은 `published` 이상에서 **정상**이라 전수 대상이 아니고 면제 평가에 남는다(stage 전제 위에서만 모순) | +| 230 | **pre-CAS 도 결과 상속·draft 결속을 본다** | `!postCas` 가지가 **세대만** 봐서 「결과 A 를 든 composed 권위 + 결과 B 를 든 published 저널」이 순서·산술·ref 대조를 전부 통과했다 — 같은 txn 이 **불변 결과를 갈아치웠다**. 면제 조건 ⑧ 의 draft 재구성을 **전 stage 로 일반화**(`reconstructNextDraft`)해 닫는다 | +| 231 | **`composed` 이후의 포기는 ref 를 요구한다** | `abandoned` 를 통째로 면제하면, 이미 발행된 ref 가 사라진 상태에서 청소 CAS 가 남은 통합 상태를 지우며 **불변 결과의 소실을 숨긴다**. 면제는 「`prepared` 에서의 포기」에만 | +| 232 | **종결 bench 에는 고아 benign 창이 없다** | `integrated`·`archived` 는 current txn 이 없어 `authorityPending` 이 거짓이지만 그 위에서 새 txn 을 **시작할 수 없으므로**(정정 216) 그 저널은 애초에 쓰일 수 없었다 | + +**뮤테이션 6차 7/7 잡힘** — 처음엔 M46·M47 이 **생존**했는데, 원인은 픽스처가 **두 축을 동시에** 어긋내 +서로 가린 것이었다(결과 B 를 넣으면서 digest 도 함께 어긋났다). 축을 분리한 행을 각각 세워 닫았다. +⚠ 이것은 4R 의 M33 과 **정확히 같은 형태의 재발**이다 — 「한 픽스처에 한 축만」이 규율이 돼야 한다. + +**교훈(6R) — 검사의 **위치**가 곧 정의역이다.** 229 는 검사 자체는 옳았는데 **면제 평가 안**에 있어서 +그 경로에 닿지 않는 입력을 통과시켰고, 230·231·232 는 전부 「직전 라운드가 세운 가지의 **반대편**」이 +비어 있던 경우다. 방어를 추가할 때는 **그 방어가 도달하는 입력 집합**을 함께 적어야 한다. + +#### 로컬 적대 리뷰 (2026-08-12 · Codex 리뷰 한도 소진 대체 · 5렌즈 find → refuter verify) + +Codex 가 「usage limits for code reviews」로 7R 을 거부해 `fleet-pr-review` 스킬(대체 모드 = 풀 렌즈)로 +검증했다. **find 5렌즈 → 후보 약 50건 → refuter 4 + 측정 검증**. 결과: **설계급 P1 1건** · P2 3건 · +측정 확정 테스트 공백 14건 · P3 다수 · REFUTED 4건. + +| # | 확정 | 근거 | +|---|---|---| +| 233 | **설계급 P1 — 결속은 「창」에 따라 강도가 달라야 한다.** 열린 창(`published`·`finalized`)의 post-CAS 는 `revision >= expected + 1` 이고 draft **전체** digest 대조는 **폐기**한다. `finalized` 의 ref revision 은 등식이 아니라 **상한**이다 | 정정 215(3R)·218(4R)·223(5R)가 쌓은 결속이 「통합 단계 사이에 다른 CAS 가 없다」를 전제했는데 `revision` 은 **모든 CAS** 가 +1 하는 전역 카운터다. 스펙은 그 개입을 **요구**한다 — C6(「문자대로 구현하면 모든 integration-ready bench 가 즉시 차단 → D3 자기모순」) · §3-T33(그 창에서 실행 가능) · I8(`archived ∧ integration-ready` 정상). 재현 레코드가 불변식 ④⑤⑥⑦·전이 검사를 **전부 통과**해 커밋 가능하고, 결과는 **정상 상태의 영구 `reconciliation-required`** + 핵심 판정 `resume-composed-cas` 의 **도달 불가**였다. ⚠ 아이러니: `draftBound` 는 4R 에서 「`lifecycle`·`sourceGeneration`·`activeActivity` 롤백을 잡으려고」 넣었는데, 무기한 창에서는 **그 세 필드가 정상적으로 변한다**. ⚠ **닫힌 창은 완화하지 않는다** — 활성 저널이라 실행이 차단되고 복구 판정이 **모든 권위 CAS 보다 먼저** 돈다(정정 197ⓐ). `resultRef` 가 prepare 시점에 revision 을 실을 수 있다는 정정 196 의 전제도 그 사실 위에 선다 | +| 234 | **정리 4건**: ⓐ완결 귀속 검사를 **활성 게이트 밖**으로(종별 `completed-txn-journal-unfinalized` 로 개명) ⓑ면제 ⑨ 의 `publishedAt` 검사 **삭제**(강제자는 `journal.ts` 형태 층 · 측정에서 판정 무변화) ⓒ`abandonedAfterCompose` 의 잉여 disjunct 제거(증거 동결이 둘을 동치로 만들어 **분리 반증 픽스처가 존재하지 않는다**) ⓓblocker 종별 `expected-revision-mismatch` 삭제(생산자 0) | 전부 이 파일이 `arithmetic-binding` 을 지운 잣대(「어휘만 남기면 검사한다로 읽힌다」)의 일관 적용. ⓐ는 6R 정정 229 의 「검사의 위치가 곧 정의역」과 같은 형태였다 | +| 235 | **승격 경로도 현재 txn ref 대조를 받는다** — 대조를 blocker 수집 구간으로 올린다 | 4R 정정 220 이 그 대조를 「표 앞」으로 올렸는데 그 「앞」이 **면제 반환 뒤**였다. 승격 경로에서만 같은 txn 의 여분·손상 ref 가 무검사로 통과했고, 그 상태가 `sealEvidence` 봉인에 실려 **손상이 정당한 증거로 고정**됐다 | + +**측정으로 확정된 테스트 반증력 공백 14건 → 전부 해소**(9건 테스트 추가 · 4건 코드 삭제로 소멸 · +1건 정직 표기). 유형: disjunct/conjunct **가림** 4(종결 증거 2 · 포기 ref 요구 2 · 세대 동치 1) · +**도달 불가** 1 · 미검증 경로 4(benign lifecycle · finalized 예외 · 정확이름 대조 · 열거성공 가드) · +봉인 잉여 1 + **미핀** 1 · 진단 미단언 2. + +⚠ **남긴 1건(봉인 구분자)의 정직 표기**: 제거해도 테스트가 잡지 못한다 — 봉인에 실리는 값이 전부 +고정 길이·고정 문법(ULID 26 · OID 40 hex · sha256 64 hex)이라 **유효 입력으로 경계 충돌을 구성할 수 +없다**. 6R 에서 내가 그 논리를 주석에 적고 테스트는 만들지 않은 자리이며, 지금은 「미래에 가변 길이 +필드가 추가될 때를 위한 방어이고 현재 독립 반증력은 0」으로 계상한다. + +**REFUTED 4건**(근거와 함께 기록 — 재평가 트리거 포함): ⓐ`previousAuthorityStage` 의미 분기로 인한 +「탈출 경로 0」 — 그 상태는 정정 204 수렴 규칙을 위반하는 생산자를 요구하고, reconciliation 에서는 +`reconcile` 액션이 정의상 존재한다(T80 오독). **트리거**: PR5 가 composed-pending 에서 abandon 을 +선행 수렴 없이 착지시키면 즉시 재론 → 그 전에 **정정 204 불변식 ③ 에 `abandon` 을 열거**한다(아래) +ⓑ권위 `abandoned` 미차단 — 소거(청소 복구)는 **설계된 탈출구**이고 blocker 0 경로는 4중 동시 손상을 +요구한다 ⓒ중복 `txnId` — 저장 계약(1 txn = 1 파일)상 표현 불가이며 방어 추가는 도달 불가 arm 이다 +(**트리거**: PR3d 가 복수 매체를 병합하면 재론) ⓓ앵커 감도 저하 — 「현재 이하 ref = 정상 보존」의 계이며 +이미 「확률적 보조 신호 · 안전 근거로 인용하지 않는다」로 정직 표기돼 있다. + +**정정 204 불변식 ③ 보강**(REFUTED ⓐ 의 선제 봉쇄): 「current txn 이 composed pending 이면 새 +prepare/re-prepare 시작 금지」에 **`abandon` 도 포함**한다 — 포기는 정정 204ⓐ 수렴(동일 composed CAS +재실행) **이후에만** 인가된다. 그러지 않으면 저널의 `previousAuthorityStage`(직전 저널 stage)와 복구가 +읽는 권위 stage 가 갈려 판정이 오탐한다. + +**교훈(로컬 리뷰) — 「내가 고른 뮤턴트」는 반증력의 증거가 아니다.** 6라운드 동안 뮤턴트 42개로 +「41/42 잡힘」을 보고했지만, 새로 추가한 방어의 **각 conjunct·disjunct 를 독립으로** 겨냥하지 않았다. +외부 렌즈가 지정한 14개를 돌리자 **14/14 생존**이었다. 규율: 방어를 추가할 때 **그 방어의 최소 단위마다 +한 픽스처씩**(한 픽스처에 한 축만) — 4R M33·6R M46/M47·이번 14건이 전부 같은 형태의 재발이다. + +**교훈 — 「다른 층이 막는다」는 문장은 그 층을 열어 확인하기 전까지 주장이 아니라 가설이다.** 이번에 +두 번 다 내가 **같은 파일 안의 함수**(`checkInvariants`)를 열어보지 않고 적었다. 이 레포는 이미 같은 +형태로 세 번 학습했다(정정 133·152·156 의 「검증 안 된 안전 주장의 문서화」 · [[static-guard-form-over-name]]). +**규율**: 방어를 **생략**하는 근거로 다른 층을 인용할 때는 그 층의 **규칙 이름과 줄**을 주석에 적는다 — +적을 수 없으면 그 층은 없는 것이다. + +**Codex 7R — P1×5 · 전부 실재 · 전부 수용.** 이번 라운드의 축은 **면제와 조기 반환**이다 — 정의역을 +좁힌 것이 셋(`abandoned` 산술 면제 · 존재 전제 · `current` 한정), 5연언 중 빠진 항이 하나다. + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 236 | **`abandoned` 도 산술 정의역 안이다** — 결속은 **밴드**다: `expected + 1 - idx(P) ≤ ref ≤ expected + 2 - idx(P)`(P = `previousAuthorityStage` · 열린 창은 **하한을 뗀다**) · `P` 부재는 **fail-closed**. ⚠ **정정 223 의 「`abandoned` 만 정의역 밖」을 폐기한다.** ⚠ **초안의 등식은 로컬 적대 리뷰가 P1 으로 되돌렸다** — `P` 는 권위 stage 가 아니라 **직전 저널 stage** 이고 WAL 은 선기록이라, 저널이 P 일 때 권위는 P(post-CAS) **또는 P-1**(pre-CAS 크래시 창 · §3-T83 이 정상으로 고정)이다. 등식은 그 창의 포기를 1 어긋나게 만들어 **영구 `reconciliation-required`** 로 고착시켰다(저널은 삭제 금지·바이트 동일 재기록만 허용). 정정 233 과 동형의 재발이며 이번엔 상한으로도 부족해 **밴드**여야 했다. ⚠ **정직 표기**: 열린 창 arm 은 상한만 남아, 형제(비-current) 포기 저널이 `P = finalized` 를 주장하면 임의로 낮은 revision 이 통과한다(그 저널에는 `expected` 를 대조할 권위 축이 없다) — 면제보다 좁지만 닫히지 않았다 | 면제의 근거였던 「포기 시점에 따라 expected 가 달라져 식이 하나로 고정되지 않는다」는 5R 정정 223 이 이미 답한 형태다 — **「stage 마다 다르다」는 면제의 근거가 아니라 식을 찾으라는 신호다.** 포기 저널은 `previousAuthorityStage` 를 반드시 싣고(`canAdvanceStage` + append 의 디스크 stage 대조) 포기 CAS 는 권위가 P 를 커밋한 뒤 pending 이므로 `expected = R0 + idx(P) + 1` 이고, 절대식 `ref = R0 + 2` 를 대입하면 식이 나온다. 무검사 대역은 **`refRevision ≤ record.revision` 전체**였다(높은 쪽만 앵커가 잡는다) — 낡은 이름이 청소를 통과하면 이후 권위 롤백을 증언할 유일한 증인이 침묵한다. ⚠ 이 면제가 **존재할 수 없는 픽스처 2건**(닫힌 창에 개입 CAS 3회)을 정상으로 고정해 온 것이 그 자체로 방증이다 — 4R 교훈(「픽스처의 비현실성이 방어의 공백을 가린다」)의 재발 | +| 237 | **포기 저널의 상속 증거 형태를 복구가 독립 요구한다**(신설 종별 `journal-abandon-evidence-invalid`) | 초안은 종결 필드의 **존재**만 봤다. 결과·게시 증거가 `previousAuthorityStage` 와 정합하는지는 **쓰기 경로**(`journal.ts` 의 `EVIDENCE_FIELDS` 도입 단계 제한)에만 있는데, **복구는 착지한 레코드만 본다** — 그 방어가 돌지 않은 디스크 상태에서 `composed` 포기가 날조된 `publishedAt` 을, `published` 포기가 소거된 게시 시각을 들고도 `idempotent-cleanup` 을 받았다. 6R 정정 229 의 「종결 증거는 admitted 전수에서」와 **반대 방향**이라 종별을 나눈다 | +| 238 | **완결 귀속 txn 의 저널 **부재**도 blocker 다** | 정정 234ⓐ 가 검사를 활성 게이트 밖으로 옮겼지만 `!== undefined` **존재 전제**가 남아, 저널 열거가 비면 검사를 건너뛰고 `current === undefined` 반환이 `no-mutation` 을 답했다. 저널은 **감사 보존**이라(포기조차 파일을 남긴다) 부재는 삭제·롤백이라는 손상이다. 「검사의 위치가 곧 정의역」(229)의 변주 — 이번엔 **존재 전제**가 정의역을 좁혔다 | +| 239 | **결과 ref 요구를 완결 txn 에도 건다** | 5R 정정 224 의 요구가 `current` 정의역에만 있어, 완결 txn 이 더 이상 current 가 아니면 `current === undefined` 조기 반환보다 뒤라 **한 번도 평가되지 않았다**. 게시는 필연적으로 finalization 에 선행하므로(§W-8) 그 불변 ref 의 소실은 손상이다 | +| 240 | **완결 귀속은 현 소스 세대를 대표해야 한다** — 판정은 **저널의 불변 `sourceGeneration` 과 권위 `sourceGeneration` 의 대조**이고 위치는 **복구 판정 층**이다(신설 종별 `completed-txn-stale-generation`) | §W-8 은 완결 CAS 를 「txn 동일 ∧ valid/current ∧ **현 소스 세대 대표** ∧ 활성/시작-중 편집 활동 없음 ∧ 전이 가능」의 5연언으로 규정하는데, 3R(정정 217 txn 동일)·5R(정정 225 결과 증거) 뒤로 **세대 항만 비어 있었다**. 도달 경로는 전 단계가 합법이다: gen 3 준비·게시 → **열린 창에서 실행**(C6·§3-T33 이 정상으로 요구)이 `sourceGeneration` 을 올림 → 활동 종료(세대는 되돌리지 않는다) → 완결 관측 ⇒ 세대 4 작업이 통합된 적 없는데 bench 가 `integrated` 로 기록돼 **부분 통합이 숨는다**. ⚠ 단일 레코드 불변식 ④(`<=`)는 그 **격차 상태**를 허용하지만 **스키마가 상태를 허용하는 것과 전이가 완결을 허가하는 것은 다른 질문**이다 — 로컬 검증의 한 렌즈가 정확히 이 혼동으로 REFUTED 를 냈다. ⚠ **초안은 위치와 값을 둘 다 틀렸고 로컬 적대 리뷰가 P1 으로 적발했다** — 전이 불변식 층에 `currentIntegrationTxnGeneration === sourceGeneration` 을 걸었는데, ⓐ그 필드는 저널 `integrationGeneration`(「`prepared` 마다 +1」= **시도 카운터** · `recovery.ts` 의 stage 결속·면제 조건이 그렇게 묶는다)과 같은 값이라 활동 카운터와 등치하면 **「활동 2회 뒤 첫 통합」 같은 정상 흐름이 막히고**(실측), ⓑ완결 CAS 가 통합 축을 소거하는 형태에서는 next 측 조건이 **구조적으로 만족 불가**였다. 「어느 세대의 결과인가」를 싣는 값은 저널의 **불변** `sourceGeneration`(T71 불변 12필드)이고 그것은 권위 레코드 두 장만 보는 전이 층에서 **보이지 않는다**. ⚠ CAS 시점의 강제는 완결 관측 생산자(PR5)의 몫이며, 이 PR 은 **착지한 레코드의 감사**를 맡는다 — 「생산자 부재」가 방어를 면제하지는 않지만, **틀린 층에 이름만 남기면 「검사한다」로 읽힌다**(정정 234 의 잣대). ⚠ 막다른 길이 아니다 — txn 은 `finalized` 로 남고 lifecycle 은 `open` 이라 재준비(§W-8 「명시 액션 = 새 txn」)가 가능하다 | +| 241 | **관측 실패를 손상 사실로 승격하지 않는다** — 완결 귀속의 저널·ref 검사에 `kind === 'ok'` 가드 | 열거가 `failed` 여도 `journals`·`parsedRefs` 는 비므로, 가드 없이 「부재 = 손상」을 주장하면 **읽지 못한 파일에 대한 사실 주장**이 된다(§3-T78 「실패를 빈 집합으로 축소하지 않는다」 위반 · 정정 189 가 금지한 가림). 진단이 「IO 복구」에서 「매체 손상 대응」으로 갈린다. 로컬 적대 리뷰 P2×2 | + +**Codex 8R — P1×3 · 전부 실재 · 전부 수용.** 축은 **결속의 형태**다 — 존재 술어(∃)로 세운 검사, +관측을 여러 번 하는 검증, 정의역에 제약을 안 건 arm. + +| # | 지적 → 확정 | 근거 | +|---|---|---| +| 242 | **완결 txn 의 보존 ref 도 이름·OID 로 전수(∀) 결속한다** | 정정 239 가 넣은 요구가 **존재 술어**라 ⓐ이름은 맞고 **OID 가 교체**된 ref ⓑ**다른 이름**의 ref ⓒ정상 ref **옆의 여분 손상 ref** 를 전부 통과시켰다 — 완결 txn 은 앵커가 **구조적으로** 답하지 않으므로(완결 CAS 가 revision 을 올려 보존 ref 는 항상 `revision` 이하) 이 층 말고는 그 교체를 볼 곳이 없다. current txn 은 정정 235 의 전수 루프가 같은 손상을 이미 잡고 있었다 — **비대칭이 곧 결함**이었다. ⚠ `completedTxnId === currentTxnId` 면 중복 발화하므로 정의역에서 뺀다. ⚠ **정직 표기**: `integrated → archived` 는 `completedIntegrationTxnId` 를 소거하므로(정상 전이) 보관 이후 보존 ref 는 이 검사의 정의역 밖이다 | +| 243 | **CAS 는 제출 draft 를 한 번만 관측한다** — 사전조건 직후 `{...next}` 스냅숏 | `runCas` 가 `next` 를 사전조건·전이 검증·직렬화에서 **각각 다시 읽었다**. getter·`Proxy` 는 층마다 다른 값을 줄 수 있고 유일한 사후 관문인 왕복 검증은 `checkInvariants`(**단일 레코드**)만 태우므로, **전이 계층이 `prepared` 를 검증한 CAS 가 `published` 를 착지시켰다**(실측 재현). 전이 계층 전체가 「제출된 값을 본다」는 전제 위에 서 있으므로 그 전제를 코드로 세운다 | +| 244 | **미종결 통합의 소거는 lifecycle 을 전진시킬 수 없다** | 소거 arm 이 목적지 lifecycle 에 아무 제약도 안 걸어, 한 CAS 가 미종결 통합을 지우면서 동시에 `archived` 로 넘어갈 수 있었다 — 그러면 보존된 그 txn 의 저널이 **종결 bench 위의 활성 고아**가 되어 복구가 즉시 reconciliation 을 답한다. 6R 정정 232 가 복구 쪽에서 잡던 상태를 **전이 검증기 자신이 제조**한다. 정정 228(「새 txn 은 open bench 에서만」)의 **대칭**이다. ⚠ 정의역은 미종결에만 — `finalized` txn 의 소거는 완결·보관 경로의 정상 동반 변경이다 | +| 245 | **거짓 근거 주석 정정**(동작 변화 0) | 6R 정정 232 의 주석이 「종결 bench 위의 그 저널은 **애초에 쓰일 수 없었다**」고 적었는데 거짓이다 — 그 저널은 bench 가 `open` 이던 시절에 합법으로 쓰였을 수 있고, 전이 층이 막는 것은 **새 txn 시작**뿐이다(그리고 244 이전에는 종결화 자체도 안 막았다). 참인 근거는 「종결 bench 위에서는 재개·재준비가 인가되지 않으므로 처분을 사람에게 넘긴다」. **검증 안 된 안전 주장의 문서화**를 지우는 정정 133·152·156 계열 | + +**교훈(8R) — 「검사가 있다」와 「검사가 전수다」는 다르다.** 242 는 내가 7R 에서 **존재 술어**로 세운 +검사였고, 같은 파일 안에 이미 **전수 루프**(정정 235)가 있었는데 그 형태를 복제하지 않았다. 규율: +**같은 손상 축에 이미 판정이 있으면 그 형태를 그대로 일반화**한다 — 새 술어를 쓰는 순간 정의역이 갈린다. + +**교훈(7R·2) — 반영이 만든 P1 이 3건이다(236 등식 · 240 위치·값 · 241 승격).** 셋 다 **로컬 적대 리뷰가 +잡았고 봇 지적 자체는 옳았다** — 틀린 것은 내 **처방**이었다. 특히 240 은 봇이 제안한 필드 +(`currentIntegrationTxnGeneration`)를 그대로 받아 쓴 결과다. **규율**: 지적이 실재해도 **처방은 별도로 +검증한다** — 지적이 지목한 필드가 그 의미를 정말 싣는지(생산자·소비자 양쪽에서) 확인하고, 새 검사가 +**어느 정의역에서 참인지**를 픽스처로 고정하기 전에는 착지시키지 않는다. + +**교훈(7R) — 「정의역을 좁히는 세 가지 형태」가 한 라운드에 다 나왔다.** ⓐ**면제**(`stageIndex < 0` +단락) ⓑ**존재 전제**(`!== undefined` 뒤에 검사를 건다) ⓒ**조기 반환보다 뒤**(`current` 한정). 셋 다 +「검사는 있다」는 인상을 남기면서 실제 정의역은 비어 있다. **규율**: 방어를 세울 때 그 검사의 +**정의역을 문장으로 적고**(「어떤 입력 집합에 대해 참인가」) 그 집합의 **경계마다 픽스처를 하나씩** 둔다. + ### PR3 — `GitRepo` 완성 · 통합 WAL 저널 > **PR3a 착지분(T11·T12)은 위 「PR3 착수 전 실측 정정」 절이 확정한 형태로 이미 랜딩했다.** 아래 원문은 @@ -1222,7 +1572,13 @@ rename EPERM 재시도 실측 완료)·tmp 문법과 배치·열거 필터(접 엔트리는 권위가 참조할 때만 활성이고, 귀속 없는 `prepared`/`composed` = **`never-applied`** 회수 적격. 매체 비교 정의역 = **current txn 1건**. 그리고 **저널 tmp 문법 `.json..tmp` + 「저널 쓰기는 bench 리스 아래에서만」을 이 PR 의 계약으로 못박는다**(PR3d 의 선행조건). -- **T13b ref-앵커 (PR3c)** — `BenchAuthorityRecord.highestIntegrationTxnId` 신설 · 갱신 시점 **`prepared`** · +> ⚠ **아래 T13b 원문은 정정 190~207 이 대체했다**(폐기 어휘 재유입 금지 — 읽는 순서를 여기서 못박는다): +> 앵커 정렬 키는 `highestIntegrationTxnId`(ULID)가 **아니라 권위 `revision`** 이고(정정 190), 따라서 +> **레코드 필드 신설도 · `prepared` 시점 선갱신도 · strict 전진 강제도 없다**. 「발화 조건 = +> `durability === 'file-only'`」도 **폐기**됐다(정정 205 — 판정은 항상 돈다). 실제 착수 지시서는 +> **정정 190~204 + 착지 실측 205~207** 이다. + +- ~~**T13b ref-앵커 (PR3c)**~~ — 원문 보존: `BenchAuthorityRecord.highestIntegrationTxnId` 신설 · 갱신 시점 **`prepared`** · **연산 = strict 전진 강제**(정정 149 가 `max` 를 폐기했다 — `T_new > old.highest` 를 **전이 불변식**으로 요구하고 위반 시 fail-closed) + **ULID 워터마크 클램프 seam**(`now = max(Date.now(), decodeUlidTime(highest)+1)` — 시계 역행을 흡수) · **전이 불변식 계층 신설**(신·구 레코드 비교 — 현행 `checkInvariants` 는 단일 레코드 diff --git a/docs/superpowers/specs/2026-07-23-ade-workbench-spec.md b/docs/superpowers/specs/2026-07-23-ade-workbench-spec.md index 77ead29..cc05b3a 100644 --- a/docs/superpowers/specs/2026-07-23-ade-workbench-spec.md +++ b/docs/superpowers/specs/2026-07-23-ade-workbench-spec.md @@ -71,7 +71,7 @@ | # | 설계 원문 | 실측 | 정정 계약 | |---|---|---|---| -| **C1** | 결과 ref = `refs/fleet/integrated/`(428·440·388·803·824행) **와** `...//`(456행) 공존 | git 은 같은 이름이 ref이자 ref-디렉터리일 수 없다 — `fatal: '…/' exists; cannot create '…//'` **exit 128**(loose·packed·reftable 전부). `check-ref-format` 은 둘 다 통과시켜 문법검증으로 못 잡음 | **`refs/fleet/integrated//` 단일 문법.** `` 는 **ref 로 절대 생성하지 않는 디렉터리 전용 접두사**. 발행 전 프로브에 "`` 가 ref 로 해석됨" 검사 추가 → 존재 시 `REF_NAMESPACE_CONFLICT` fail-closed(자동 삭제 금지). 소비자 명령·§5 완료정의 문자열 전부 txn 형태로 정정 | +| **C1** | 결과 ref = `refs/fleet/integrated/`(428·440·388·803·824행) **와** `...//`(456행) 공존 | git 은 같은 이름이 ref이자 ref-디렉터리일 수 없다 — `fatal: '…/' exists; cannot create '…//'` **exit 128**(loose·packed·reftable 전부). `check-ref-format` 은 둘 다 통과시켜 문법검증으로 못 잡음 | **`refs/fleet/integrated//-` 단일 문법**(PR3c 가 leaf 를 revision 결속으로 확정 — 계획 정정 192·196). `` 는 **ref 로 절대 생성하지 않는 디렉터리 전용 접두사**. 발행 전 프로브에 "`` 가 ref 로 해석됨" 검사 추가 → 존재 시 `ref-namespace-conflict` fail-closed(자동 삭제 금지). 소비자 명령·§5 완료정의 문자열 전부 txn 형태로 정정 | | **C2** | "OS 자문 락은 프로세스 종료 시 자동 해제 → 획득 가능 = 소유자 부재 증명"(408-410행), 메커니즘 = "파일 락" | Node 24.16.0 에 `fs.flock`/`fs.constants.LOCK_EX`/`O_EXLOCK` **전부 부재**. 의존성 6종 전부 순수 JS. `wx`/mkdir 은 자동 해제 없음 → "연령 삭제 금지"와 결합 시 영구 고착. **POSIX AF_UNIX 는 SIGKILL 후 소켓 파일이 남아 `listen` 이 계속 EADDRINUSE** — 설계가 실제로 의존하는 **역명제(획득 실패 ⇒ 소유자 생존)가 거짓** | 메커니즘 = **`node:net` 엔드포인트 배타 바인딩**(win32 named pipe / POSIX 유닉스 도메인 소켓). 부재 증명식을 백엔드별로 분리(§W-3). stale 회수 근거는 **연령이 아니라 커널의 ECONNREFUSED(거절 증거)** — 408행 금지 원칙 유지 | | **C3** | "파일 + **부모 디렉터리** fsync 를 요건으로 한다"(697-700행) | win32: `fs.openSync(dir,'r')` 은 성공하나 `fsyncSync(dirFd)` = **EPERM**. libuv `fs__fsync`=`FlushFileBuffers`, `O_DIRECTORY` 부재. rename 도 `MOVEFILE_WRITE_THROUGH` 미지정 | **`DurabilityLevel = 'file+dir' | 'file-only'`** 를 레코드에 기록. 부팅 1회 실측 프로브로 결정, win32 = `'file-only'` **명시 강등**(조용한 스킵 금지·UI/런북 노출). **복구 4번째 분기 신설**(§W-7-복구). ⚠ **이는 Codex 3항의 충족이 아니라 회피이며 win32 의 안전 등급은 실제로 낮다** — 그 파급(revision 단조 미보장·탐지 앵커를 권위 파일 밖 git ref 로 이동)을 §W-4·§W-7 에 명시한다 | | **C4** | "확인-응답 쓰기 = 원자적 rename + 오류 전파"(665-667행) | win32 `rename` 은 **대상에 열린 핸들이 하나라도 있으면 EPERM**(읽기 전용·동일 프로세스 포함). 설계가 요구하는 다중 부팅 표면 공유 저널이 정확히 그 조건 | rename 실패 코드 `{EPERM,EBUSY,EACCES}` **유한 재시도**(4회·백오프) 후 `io-failure` 승격. **리더 규율 불변식**: 권위·저널 파일은 `readFileSync` 즉시-close 만 허용, 장기 핸들·watch 금지 | @@ -724,8 +724,23 @@ worktree-less 프라이빗 계산**으로 축소한다(설계 대비 더 강한 ② baseRef OID 캡처 → targetHeadBeforeIntegration ③ merge-tree --write-tree → resultTree (충돌 시 값으로 보고 · **ref 변이 0**) ④ commit-tree resultTree -p -p → resultOid -⑤ casUpdateRef(refs/fleet/integrated//, resultOid, null) ← create-if-absent CAS +⑤ casUpdateRef(refs/fleet/integrated//-, resultOid, null) + ← create-if-absent CAS · **composed 권위 CAS 앞** ``` +**`composed` 단계의 3단계 프로토콜(A · 계획 정정 202 · Codex 3R 확정)** — 위 ⑤ 는 「composed CAS 가 +커밋됐다」가 아니라 **「그 CAS 에 필요한 결과 증거와 외부 부수효과가 준비됐다」**는 뜻이다. + +```text +composed 저널 acknowledged → 결과 ref create-only 발행 → composed 권위 CAS +``` +- 일반 WAL 문구도 이 단계에서만 좁아진다: 「각 단계는 원칙적으로 저널 선기록 후 권위 CAS 로 커밋한다. + 단 **`composed` 단계는 결과 ref 발행을 그 CAS 앞**에 두며, 이 사이의 크래시는 **조건부 면제 검증 후 + 동일 composed CAS 를 재개**한다(`resume-composed-cas`)」. +- 반대 배치(ref 발행을 composed CAS **뒤**에 두는 (B))는 **채택하지 않는다** — 면제 조건 6 + (`journal.expectedAuthorityRevision === authority.revision`)을 구조적으로 거짓으로 만들어 + 조건부 면제와 공존할 수 없다. +- (A) 는 「`resultOid` 는 `composed` 부터 필수」인 현행 권위 불변식과 양립한다: ref 를 만들 결과 증거는 + composed 저널에 **이미 내구 기록**됐고 권위의 composed 전이만 pending 인 상태다. - `cherry-pick` 계열 기각: 증분·N커밋·sequencer 잔존. `merge --squash` 기각: 프라이빗 worktree 필요 · `MERGE_HEAD` 미기록으로 `merge --abort` 불가 → reset-only 복구 = 설계 696행 금지 조항과 충돌. - 결과가 2-parent 머지 커밋이므로 소비자 `merge --ff-only ` 가 성립하고, bench 전체 스냅숏 @@ -750,8 +765,16 @@ export interface IntegrationTxnRecord { readonly sourceBranch: string; readonly sourceSnapshot: string // auto-keep OID readonly sourceGeneration: number readonly targetBranch: string; readonly targetHeadBeforeIntegration: string - readonly resultRef: string // 문법 소유 = PR3c(정정 166 이 세대 결속으로 바꾼다) — - // 저널은 비어 있지 않은 문자열까지만 본다(정정 174) + readonly resultRef: string // `refs/fleet/integrated//-` + // **문법 소유 = PR3c**(`result-ref.ts`) · 10진 가변폭 · 선행 0 거부. + // **불변 필드**라 값은 `prepared` 때 한 번 정해진다 — 그 저널의 + // `expectedAuthorityRevision` 을 `R0` 라 하면 `resultingRevision = R0 + 2` + // **고정**(= 이 ref 에 결속된 composed CAS 가 **기록할** revision)이고, + // 관측 저널 기준 상대식은 `expected + 2 - stageIndex` 다(정정 223). + // ⚠ 정정 196 의 고정식 `+1` 은 **`composed` 한 stage 로 정의역이 좁혀졌다** + // · `finalized` 는 등식이 아니라 상한(정정 233 · 아래 「저널이 든 + // `resultRef` 이름 자체를 검증한다」 문단이 권위). + // 저널(PR3b)은 비어 있지 않은 문자열까지만 본다(정정 174) readonly startedAt: number; readonly ownerEngineId: string // 진단용 readonly stage: IntegrationStage // 단계 전진 = **같은 파일 덮어쓰기**(txn 당 1파일 · 정정 178) readonly resultTree?: string; readonly resultOid?: string // stage >= 'composed' 필수 @@ -823,26 +846,169 @@ export interface IntegrationTxnRecord { **복구 판정(순수 함수 · 무변이 관찰만 · git 변이 0)** -| stage | resultRef 값 | 판정 | +**계층형이며 first-match 표가 아니다**(PR3c · 계획 정정 197·200). 순서 자체가 계약이다: + +```text +① 권위 fresh read ⑤ anchor gate ← 여기까지 **변이 0** +② 결과 ref 전수 열거·문법·identity·OID ⑥ 귀속·전이 불변식 재검증 +③ 저널 전수 열거·schemaVersion·identity ⑦ repo 락 + 리스 아래 fresh read·재열거 +④ ref↔저널 설명 관계 **순수 분류** ⑧ 조건 유지 시에만 resume-composed-cas CAS +``` +저널은 앵커 **앞에서 읽되 읽기·검증만** 한다 — 안전 목표는 「앵커보다 먼저 저널을 **변이**하지 않는다」 +이지 「읽지 않는다」가 아니다(안 읽으면 정상 크래시와 롤백을 구분할 정보가 ⑤ 에 없다). +**①~⑥ 은 순수 함수**(`recovery.ts` · 무변이 관찰 · git 변이 0)이고 **⑦⑧ 은 그 소비자**(PR5 시퀀서 · +PR7 부팅)의 계약이다. + +**⑤ anchor gate — 판정식 = `refRevision > record.revision`**(계획 정정 190·191 이 「귀속」식을 폐기). +권위 부재 = 비교 기준 `0`. 정렬 키가 `revision` 인 이유: CAS 마다 정확히 `+1` 이라 단조가 **이미 기계 +집행**돼 시계 역행 계열이 원천 소멸한다(ULID 사전순은 같은 ms 20,000건 중 50.1% 역전 — 실측 폐기). + +| 저널 stage | 결과 ref | 판정 | |---|---|---| -| — | (`refs/fleet/integrated/` 가 ref 로 존재) | `REF_NAMESPACE_CONFLICT` **최우선** fail-closed | +| — | `refs/fleet/integrated/` 가 ref 로 존재 | `ref-namespace-conflict` **최우선** fail-closed | | `prepared` | 부재 | `no-mutation`(포기·재준비 적격) | -| `prepared` | 존재 | `RESULT_REF_UNATTRIBUTED` → reconciliation | -| `composed` | 부재 | `no-mutation` | -| `composed` | `=== resultOid` | `promote-published` | -| `composed` | `!== resultOid` | `RESULT_REF_MISMATCH` → reconciliation | +| `prepared` | 존재 | **앵커가 답한다**(전용 종별 없음) — 그 ref 는 구조적으로 `revision + 1` 이라 항상 앵커 후보다. ⚠ 도출은 **post-CAS 결속**으로만 선다: 이 표의 정의역인 **현재 txn** 에서 `prepared` 저널은 `previousAuthorityStage` 가 `undefined` 라 pre-CAS 결속이 **표현 불가**이고(권위 불변식 ③ 이 `currentIntegrationStage` 를 TxnId 와 묶는다), 따라서 `expected = revision - 1` ∧ `ref = expected + 2`(정정 223) ⇒ `revision + 1` 이다. `result-ref-unattributed` 는 **폐기 어휘**다(재유입 금지) | +| `composed` | 부재 | `no-mutation`(ref 발행 전 크래시) | +| `composed` | `=== resultOid` ∧ **면제 10조건 충족** | **`resume-composed-cas`**(= 저널이 이미 선기록한 composed 권위 CAS 재개) | +| `composed` | `=== resultOid` ∧ 권위가 이미 `N+1` ∧ 전이 **정확 반영** | **정상 대기**(재실행 아님 · 완료 확인 · 정정 204ⓑ) | +| `composed` | `!== resultOid` | `result-ref-mismatch` → reconciliation | | `published` | — | **정상 대기**(차단 아님 · C6) | -| `finalized`/`abandoned` 잔존 | — | 청소 복구(멱등) | -| **ref-앵커 재조정 불일치** | — | **reconciliation(win32 `file-only` 전용 4번째 분기 · C3/U4)** | +| `finalized`/`abandoned` 잔존 | — | 청소 복구(**멱등 종결** · 삭제 아님) | +| **설명되지 않는 `refRevision > revision`** | — | **reconciliation-required**(⑤ 가 표보다 먼저 답한다) | + +⚠ **`promote-published` 는 폐기 어휘다**(정정 203 · 재유입 금지). 그 verdict 가 받는 디스크 상태에서 +복구기가 당장 커밋할 것은 `published` 권위 상태가 아니라 **저널이 이미 선기록한 `composed` 권위 CAS** 다. +이름이 두 구현을 모두 허용하면 문면은 그중 하나만 금지할 뿐 컴파일러도 리뷰어도 나머지를 못 잡는다. + +**조건부 면제 10항**(정정 199ⓒ — 이 전부를 만족하는 **단 하나의** next-revision ref 만 ⑤ 를 통과한다): +①대응 저널 존재 ②런타임 검증 통과 ③stage 정확히 `composed` ④`journal.resultRef === refName` +⑤`journal.resultOid === ref oid` ⑥`refRevision === journal.expectedAuthorityRevision + 1` +⑦`journal.expectedAuthorityRevision === authority.revision` ⑧`previousAuthorityStage`· +`nextAuthorityStage`·`integrationGeneration`·`draftDigest` 가 **현재 권위에서 재구성되는 후속 전이와 +일치**(전이 불변식 계층 재사용) ⑨superseded·abandoned·finalized 아님 ⑩같은 revision 을 주장하는 +promotable ref 가 **정확히 하나** — 그리고 ⑪**repo 락 + bench 리스 재획득 후 fresh read 재검증**(⑦⑧ 단계 · +소비자 계약). + +**계속 전역 blocker 인 것**(정정 199ⓓ): 대응 저널 없는 높은 ref · stage 가 `published`/`finalized`/ +`abandoned` 인 높은 ref · OID 불일치 · expected revision 불일치 · **두 단계 이상 높은 ref** · 복수 주장 · +superseded txn ref · digest 불일치 · 열거·파싱·identity 검증 실패. + +**관측 입력의 identity 는 호출자가 독립으로 싣는다**(Codex PR#289 P1) — 판정은 `benchId` 뿐 아니라 +`commonGitDir`·`benchRoot` 를 **관측자에게서** 받아 ⓐ권위 레코드 ⓑ**모든 저널 엔트리**를 그것과 대조한다. +레포 identity 를 **권위 레코드에서 꺼내** 저널과 대조하면 권위가 부재·손상일 때 대조가 통째로 사라져 +**다른 레포에서 복사된 저널**(같은 benchId · 영역 통째 복사)이 재준비 적격으로 통과한다. 권위가 +없을수록 더 엄격해야 하고, 대조 기준은 **롤백 가능한 매체 밖**에 있어야 한다(정정 205 와 같은 논거). + +**권위 stage ≤ 저널 stage ≤ 권위 stage + 1**(Codex PR#289 P1 · 현재 txn 정의역). 앞 부등식은 WAL +선기록에서, 뒤 부등식은 정정 204 불변식 ①에서 나온다. **저널 stage 만 보고 복구표를 분기하면** +권위가 앞선 관측(= 저널 단독 롤백·손상)이 `no-mutation` 으로 통과해 **손상 위에서 포기·재준비가 +인가**된다. 어긋난 두 방향은 각각 다른 종별로 답한다. ⚠ 저널 `abandoned` 는 종결 기록이라 이 결속의 +정의역 밖이다(포기 CAS 가 pending 일 뿐이다). + +**stage 가 같아도 결속은 따로 본다**(Codex PR#289 3R P1) — 순서 비교는 **인덱스만** 보므로 「둘 다 +`composed` 인데 `resultOid`·expected revision·세대가 어긋난」 손상 쌍이 그대로 통과한다. 현재 txn 의 +저널은 **두 배치 중 하나**를 만족해야 한다: ⓐ**CAS 전**(권위가 아직 `previousAuthorityStage` ∧ +`expectedAuthorityRevision === revision`) ⓑ**CAS 후**(권위가 `nextAuthorityStage` ∧ +`revision === expected + 1` ∧ 권위가 든 `resultOid`·세대가 저널의 증언과 일치). 그 외는 손상이며, +ref 유무와 **무관하게** reconciliation 이다(초안은 ref 가 없으면 `no-mutation` 을 답했다). + +**결속은 「창」에 따라 강도가 다르다**(로컬 적대 리뷰 P1 · 계획 정정 233) — 이 절의 revision·draft +결속은 **개입 CAS 가 불가한 구간에서만** tight 하다. + +| 창 | stage | 개입 CAS | 결속 | +|---|---|---|---| +| **닫힌 창** | `prepared`·`composed` | 불가(활성 저널 = 실행 차단 · 복구 판정이 모든 권위 CAS 보다 먼저) | `expected === revision`(pre) · `revision === expected + 1`(post) · draft **전체** digest | +| **열린 창** | `published`·`finalized` | **정상**(C6 · §3-T33 · I8) | pre 는 동일 · post 는 **`revision >= expected + 1`** · draft 전체 digest **폐기** | + +⚠ `revision` 은 **모든 CAS** 가 +1 하는 전역 카운터이고, 통합 4필드를 보존하는 CAS(활동 시작/종료 · +gated-orphan 회수 · lifecycle 변경)는 전이 검사의 **no-op** 으로 통과한다. 그 개입을 스펙이 **요구**하므로 +(C6 「`published` 저널은 소비자 완결까지 무기한 열려 있는 정상 상태」) 열린 창에 tight 등식을 걸면 +**정상 상태가 영구 `reconciliation-required`** 가 되고 `resume-composed-cas` 가 도달 불가가 된다. +⚠ 열린 창의 draft 전체 digest 대조를 **폐기한 것은 탐지력 감소**다(정직 표기): 그것으로 잡으려던 +「`lifecycle`·`sourceGeneration`·`activeActivity` 롤백」은 무기한 창에서 **정상 변화와 구분 불가**다. +그 축의 탐지는 revision 단조(§W-4)와 앵커가 나눠 진다. + +**저널이 든 `resultRef` 이름 자체를 검증한다**(Codex PR#289 4R·5R P1 · 로컬 적대 리뷰 P1) — 문법·bench·txn 결속에 더해 +**revision 산술을 stage 전수에 건다**: `resultRef` 는 불변이고 ref 는 composed CAS **앞**에서 한 번만 +발행되므로, `prepared` 저널이 관측한 revision 을 `R0` 라 하면 **ref = `R0 + 2` 고정**이고 +`expected = R0 + stageIndex` 다 — 즉 **`ref = expected + 2 - stageIndex`**(prepared +2 · composed +1 · +published +0). ⚠ **`finalized` 는 등식이 아니라 상한**(`ref ≤ expected`)이다 — 그 저널은 외부 소비자 머지 관측 **이후**에 기록되고 그 사이 개입 CAS 가 `expected` 를 올린다. ⚠ **`abandoned` 도 정의역 안**이다(계획 정정 236 이 초안의 면제를 폐기했다) — 정의역은 `previousAuthorityStage`(=P)가 주고 결속은 **밴드**다: **`expected + 1 - idx(P) ≤ ref ≤ expected + 2 - idx(P)`**. 밴드인 이유는 `P` 가 권위 stage 가 아니라 **직전 저널 stage** 이기 때문이다 — WAL 선기록상 저널이 P 일 때 권위는 P(post-CAS) **또는 P-1**(pre-CAS 크래시 창 · §3-T83)이라 `expected` 가 두 값을 가진다. 열린 창(`P ∈ {published, finalized}`)은 개입 CAS 가 `expected` 를 올리므로 **하한을 뗀다**(그 arm 은 상한만 남아 형제 저널의 낮은 revision 을 닫지 못한다 — 정직 표기). **`P` 부재는 fail-closed**. +그 값은 **불변 필드**라 git ref 가 아직 없어도 잘못된 이름이 살아남아 **나중에 그대로 발행**된다. + +**ref 발행 이후의 상태는 ref 를 요구한다**(Codex PR#289 5R P1) — post-CAS `composed`·`published`· +`finalized` 에서 결과 ref 가 **없는 것**은 프로토콜상 불가능하므로 `no-mutation` 이 아니라 +`result-ref-missing` 이다(`abandoned` 제외 — `prepared` 에서 포기하면 ref 가 존재한 적 없다). + +⚠ **복구표의 「`prepared` ∧ ref 존재」 행은 전용 분기 없이 답한다**(위 산술의 연쇄): 그 상태의 ref 는 +구조적으로 `revision + 1`(현재 txn 정의역에서 그 결속은 **post-CAS 뿐**이라 `expected = revision - 1` ∧ +`ref = expected + 2`)이라 **항상 앵커 후보**이고, 표가 요구하는 결과(reconciliation)를 앵커가 +그대로 보장한다. 도달 불가 분기를 남기지 않는다(정정 183·189). + +**현재 txn 의 ref 이름·OID 대조는 stage 와 무관하다**(Codex PR#289 4R P1) — `published` 저널의 ref 는 +revision 이 권위 이하라 앵커 후보가 아니므로, 그 분기에서 대조를 빼면 **교체·손상된 ref 위에서 게시가 +계속**된다. + +**post-CAS 결속에는 `draftDigest` 대조가 포함된다**(Codex PR#289 4R P1) — stage·revision·세대· +`resultOid` 만 보면 draft 투영 안의 나머지 필드가 롤백·손상돼도 통과한다. + +**재검증 증거는 저널 레코드 전체를 봉인한다**(Codex PR#289 4R P1 · `digestJournalRecord`) — +`draftDigest` 는 권위 draft 투영만 덮으므로 `sourceSnapshot`·`targetBranch`· +`targetHeadBeforeIntegration`·`resultTree` 가 바뀌어도 같은 값이 나온다. + +**6R·로컬 리뷰 반영분**(계획 정정 227~235) — ⓐ최초 레코드의 `lifecycle` 은 `open`(첫 CAS 가 완결을 +주장하는 종결 bench 를 만들 수 없다) ⓑ새 txn 시작은 **출발지·목적지 둘 다** `open` ⓒ종결 증거 +(`abandonedAt`·`abandonReason`)는 **admitted 저널 전수**에서 본다(`journal-terminal-evidence`) ⓓpre-CAS +도 결과 상속·draft 결속을 본다 ⓔ`composed` 이후의 포기는 ref 를 요구한다 ⓕ**종결 bench 에는 고아 +benign 창이 없다** ⓖ현재 txn ref 이름·OID 대조는 **승격 경로에도** 적용된다(면제 반환 앞) ⓗ완결 귀속 +검사는 **활성 게이트 밖**이며 판정은 「`finalized` 가 아니다」다(`completed-txn-journal-unfinalized`). + +**완결 귀속 txn 의 저널이 `finalized` 가 아니면 blocker 다**(CodeRabbit PR#289 · 정정 234) — 완결된 txn 의 +저널은 정상 상태에서 `finalized` 다. 「귀속돼 있으니 고아가 아니다」로 제외하면 매체 손상의 증거가 +조용히 통과한다. + +**7R 반영분**(계획 정정 236~240) — ⓐ`abandoned` 의 ref revision 도 `previousAuthorityStage` 로 결속한다 +(위 산술 절 · 면제 폐기) ⓑ포기 저널의 **상속 증거 형태**(`resultTree`·`resultOid`·`publishedAt` 이 그 +stage 에서 필수인지 금지인지)를 복구가 **독립적으로** 요구한다(`journal-abandon-evidence-invalid`) — +쓰기 경로의 증거 동결은 **착지한 레코드를 보는 복구에서 재사용할 수 없다** ⓒ완결 귀속 txn 의 저널 +**부재**도 blocker 다(저널은 감사 보존이라 부재 = 손상) ⓓ**결과 ref 요구는 완결 txn 에도** 건다 +(`current` 정의역에만 걸면 조기 반환보다 뒤라 평가되지 않는다) ⓔ완결 귀속 txn 은 §W-8 5연언 중 +**「현 소스 세대 대표」**를 만족해야 한다(`completed-txn-stale-generation`) — 판정은 **저널의 불변 +`sourceGeneration`** 과 권위 `sourceGeneration` 의 대조다(권위의 `currentIntegrationTxnGeneration` 은 +저널 `integrationGeneration` = **시도 카운터**라 그 값이 아니다) ⓕ완결 귀속의 저널·ref 검사는 +**열거가 `ok` 일 때만** 판정한다 — 실패를 빈 집합으로 축소해 「부재 = 손상」을 주장하면 읽지 못한 +파일에 대한 사실 주장이 된다(§3-T78 · 정정 241). + +**8R 반영분**(계획 정정 242~245) — ⓐ완결 txn 의 보존 ref 는 **이름·OID 전수 대조**다(존재 술어면 +OID 교체·여분 손상 ref 가 통과한다 · 같은 손상에 current txn 과 **같은 종별** `result-ref-mismatch`). +⚠ `integrated → archived` 는 `completedIntegrationTxnId` 를 소거하므로 보관 이후 그 ref 는 이 검사의 +**정의역 밖**이다(정직 표기) ⓑ권위 CAS 는 제출 draft 를 **한 번만 관측**한다 — 사전조건 직후 스냅숏을 +떠 identity·전이 검증·직렬화가 같은 값을 본다(getter·`Proxy` 가 층마다 다른 값을 주면 전이 계층이 +검증한 것과 착지한 것이 갈린다) ⓒ**미종결 통합의 소거는 lifecycle 을 전진시킬 수 없다**(§3-T82) — +정정 228 의 대칭이며, 그러지 않으면 전이 검증기가 「종결 bench 위의 활성 고아」를 직접 제조한다. + +**고아 활성 저널의 benign 창은 「권위에 미종결 통합이 없을 때」에만 열린다**(Codex PR#289 P1) — +전이 불변식이 「미종결 T1 위에 T2 시작」을 금지하므로 저널-선기록 프로토콜은 T2 저널을 **애초에 쓰지 +말았어야 한다.** 그 존재 자체가 이상 신호인데 benign 규칙이 그것을 삼키면 T1 승격까지 그대로 나간다. + +⚠ **면제 판정의 입력을 권위 파일 하나로 좁히지 않는다**(정정 199ⓐ 의 회귀 핀 · §3-T89): 「현재 txn 이니 +면제」는 T1 완료 → 권위가 T2 로 전진 → 파일 롤백으로 current 가 **다시 T1** 인 사슬에서 **롤백을 증명하는 +가장 높은 ref 를 바로 그 이유로 숨긴다.** 정의역 축소도 판정의 일부이고 그 입력이 롤백 가능하다는 사실을 +무시할 수 없다 — 면제는 **독립 매체 두 개(ref + 저널)의 교차검증**으로만 선다. + +⚠ **앵커는 `durability` 값으로 게이트하지 않는다**(PR3c 구현 중 확정). 발화가 필요한 표면은 +`file-only`(C3)이지만 그 값은 **롤백 대상인 권위 파일 안에** 있다 — 검사의 활성화 조건을 검사 대상과 같은 +매체에서 읽으면 독립성이 그 지점에서 조용히 끊긴다(정정 199 2R 교훈의 동형). 판정은 **항상** 돌고, +`file+dir` 표면에서는 정상 프로토콜상 발화 입력 자체가 만들어지지 않는다. - **ref-앵커 재조정(반증 반영 — 원안 폐기)**: 원안은 "저널 부재 ∧ `lastObservedTargetHead` 불일치"였으나 두 겹으로 발화하지 않는다 — ⓐ앵커를 **롤백 대상인 권위 레코드 안에** 두었으므로 권위 파일이 통째로 이전 세대로 되돌아가면 앵커도 함께 되돌아간다 ⓑFleet 은 baseRef 를 어떤 조건에서도 전진시키지 않으므로 (C11) target HEAD 는 애초에 움직이지 않아 "불일치"가 win32 크래시 주 경로에서 성립하지 않는다. -- **확정 판정식**: 부팅 시 `git for-each-ref refs/fleet/integrated//` 를 열거해, - 권위 레코드의 `currentIntegrationTxnId`·`completedIntegrationTxnId` **어디에도 귀속되지 않는 txnId 의 - 결과 ref 가 1건이라도 존재하면 `reconciliation-required`**(권위 롤백 탐지). 앵커가 권위 파일 **밖**에 - 있어야 하는 이유는 안에 두면 롤백 시 함께 되돌아가 발화하지 않기 때문이다. + ⓒ**그 대체로 도입한 「귀속되지 않는 ref = reconciliation」식도 폐기됐다**(정정 191) — 포기는 결과 ref 를 + **보존**하고 §3-T29 는 형제 시도 ref 의 영구 공존을 정상으로 요구하므로 그 식은 **영구 오탐**이었다. +- **열거는 두 질의 모두**(정정 157 · 실측): 접두 `refs/fleet/integrated//` **와** 정확 이름 + `refs/fleet/integrated/`. 접두 질의는 bare 부모를 **0건**으로 답하므로 접두만 쓰면 D/F 충돌 + bench 가 「발행된 ref 없음」과 구분되지 않아 최우선 분기가 **도달 불가**가 된다. - ⚠ **「독립 매체라 동시 롤백이 불가능하다」는 거짓이다**(git 소스 대조로 확정 · 계획 정정 133·152·156). git 은 기본 설정에서 ref 를 fsync 하지 않고(`FSYNC_COMPONENTS_DEFAULT` 에 REFERENCE 부재 · `core.fsync=all` 로도 loose ref 게시 rename 경로에는 디렉터리 fsync 가 없다) 권위 파일은 rename **전에** @@ -856,7 +1022,7 @@ export interface IntegrationTxnRecord { | 대상 | 포기가 하는 일 | |---|---| -| 저널 | `stage:'abandoned'` 확인-응답 기록 → 권위 CAS. ⚠ **「엔트리 제거」는 「활성 집합에서 제외」로 읽는다**(계획 정정 135) — 파일은 **삭제하지 않고 감사 보존**한다(1109행이 후행 개정이자 목적 명시라 권위다). 765행 복구표의 「청소 복구」도 **멱등 종결**이지 삭제가 아니며, 그 표 문면 개정은 PR3c 소관이라 그때까지 스펙은 이 축에서 부분 모순으로 남는다(은폐하지 않는다) | +| 저널 | `stage:'abandoned'` 확인-응답 기록 → 권위 CAS. ⚠ **「엔트리 제거」는 「활성 집합에서 제외」로 읽는다**(계획 정정 135) — 파일은 **삭제하지 않고 감사 보존**한다(1109행이 후행 개정이자 목적 명시라 권위다). 복구표의 「청소 복구」도 **멱등 종결**이지 삭제가 아니며, **그 표 문면은 PR3c 가 개정해 부분 모순이 해소됐다**(표 셀이 「청소 복구(**멱등 종결** · 삭제 아님)」로 확정) | | 권위 레코드 | `currentIntegrationTxnId → null`(revision CAS) · `lifecycle` **무변** · `sourceGeneration` **무변** | | 결과 ref | **삭제하지 않는다**(소비자가 이미 머지했을 수 있음) | | auto-keep 커밋 | **bench 브랜치에 보존**(사용자 작업 무손실 = 포기가 안전한 근거) | @@ -1165,7 +1331,7 @@ reconciliation 아니라 미인가 · `abandon` = 액션 목록 부재). | **I4** | **`broken ∧ busy` 는 도달 가능** — 이때 설계 357행 "삭제만 허용"은 성립하지 않는다 | **전 액션 거부 + 리스 해제 대기 안내** | | I6 | `partially-integrated ⟹ lifecycle==='open'` | reconciliation 승격 | | I8 | `archived ∧ integration-ready` 도달 가능(결과 ref 보존 · "결과 미적용" 명시) | 정상 | -| **I11** | **고아 저널을 새로 만들지 않는다** — **활성 저널**(stage ∈ {`prepared`,`composed`})이 있는 bench 레코드는 제거 불가. `published` 는 활성이 아니므로 **integration-ready bench 의 `delete-record` 는 허용** | `delete-record` 거부(`journal-pending`) | +| **I11** | **고아 저널을 새로 만들지 않는다** — **활성 저널**(stage ∈ {`prepared`,`composed`})이 있는 bench 레코드는 제거 불가. `published` 는 활성이 아니므로 **integration-ready bench 의 `delete-record` 는 허용**. ⚠ 활성 판정은 **stage 단독 순수 술어**이며 귀속 조건을 더하지 않는다(계획 정정 194 — 귀속은 활성 집합의 *정의*가 아니라 복구 판정의 *입력 특성*이다). **권위가 가리키지 않는 활성 stage 엔트리 = 고아 저널**이고 그 처분은 복구 판정이 **2분**한다: (A) 프로토콜상 정상 크래시 창인 `prepared` 고아 = `no-mutation`(재준비 적격) — 단 **ⓐ대응 ref 없음 ∧ ⓑ권위에 미종결 통합 없음 ∧ ⓒ`lifecycle === 'open'`** 을 모두 만족할 때만이다(계획 정정 212·232 — 종결 bench 나 pending txn 위에서는 그 저널이 애초에 쓰일 수 없다). 그 외(고아 `composed` · ref 를 동반한 고아 `prepared` · 종결 bench 의 고아) = `reconciliation-required`. 이 2분이 없으면 정정 154ⓐ 가 닫은 영구 고착이 `delete-record` 표면에서 재현된다 | `delete-record` 거부(`journal-pending`) | | **I12** | **`incompatible-version`(지원 범위 초과 schemaVersion) bench 는 `reconciliation-required` 로만 노출하고 `delete-record` 를 포함한 모든 파괴 액션을 거부**(사유 `newer-schema` · 안내 = "더 새 버전의 Fleet 으로 열 것") | 파괴 액션 거부 | - **`incompatible-version` 을 `invalid` 로 분류하지 않는 이유(반증 반영)**: `invalid` → broken → "삭제만 @@ -1195,7 +1361,7 @@ reconciliation 아니라 미인가 · `abandon` = 액션 목록 부재). | `src/shared/transport/channels.ts` | bench invoke 채널 6(scope `'both'`) · **push 0** | | `src/shared/transport/fixtures.ts` | `CHANNEL_FIXTURES` 6 추가 · **`PUSH_FIXTURES` 신설** | | `src/shared/transport/serialization.test.ts` | push 왕복 검증 확장 | -| `src/main/core/workbench/` **(신설)** | `ulid.ts` · `slug.ts` · `authority.ts` · `journal.ts` · `locks.ts` · `coord-area.ts` · `integration.ts` · `registry.ts` · `derive.ts` · `authorize.ts` · **`bench-workspace.ts`**(태스크 경로 seam 주입) | +| `src/main/core/workbench/` **(신설)** | `ulid.ts` · `slug.ts` · `authority.ts` · `journal.ts` · **`result-ref.ts`**(결과 ref 문법 · PR3c) · **`recovery.ts`**(복구 판정 순수 함수 · PR3c) · `locks.ts` · `coord-area.ts` · `integration.ts` · `registry.ts` · `derive.ts` · `authorize.ts` · **`bench-workspace.ts`**(태스크 경로 seam 주입) | | `src/main/core/durable/` **(신설)** | `DurableFs` 구현 + 등급 프로브 (기존 store 무변경) | | `src/main/core/workspace/git.ts` | `GitRepo` 표면 추가 + `createWorkspace` 에 `taskWorktreeDir` **옵셔널 seam**(미주입 = 현행 바이트 동일) | | `src/renderer/components/ProjectPanel.tsx` | **라이브 이벤트 핸들러 3분기 + `getRunActivity` 하이드레이션의 benchId 스코프화(R-2)** — 누락 시 CI 가 잡지 못하는 **조용한 회귀** | @@ -1259,9 +1425,9 @@ playwright(ubuntu, 컨테이너 없음)만 돈다. 해당 행(T55·N2·N3·N4) | **T16b** | **type-level(`@ts-expect-error`)** — `AuthorityCommit` 인자 없는 bench launcher 호출이 tsc 에러임을 고정(계약 4항의 1차 방어) | verify | | **T16c** | `no-restricted-syntax` bench-spawn 가드가 eslint flat config 에 `'error'` 로 존재함을 **config 객체 단언**으로 핀(`eslint-config-purity.test.ts` 동형) | verify | | **T18** | 내구 등급 — win32 `'file-only'` 가 레코드에 기록되고 조용히 스킵되지 않음(U4) | verify | -| **T18b** | **file-only 롤백 시뮬레이션** — 권위 파일을 이전 세대 바이트로 되돌린 뒤 재부팅 → **ref-앵커 재조정**이 reconciliation 을 강제하고 되돌아간 revision 위에 새 CAS 가 커밋되지 않음(C3 파급) | verify | +| **T18b** | **file-only 롤백 시뮬레이션** — 권위 파일을 이전 세대 바이트로 되돌린 뒤 재부팅 → **ref-앵커**(`refRevision > record.revision` · 계획 정정 191 이 귀속식을 대체)가 reconciliation 을 강제하고 되돌아간 revision 위에 새 CAS 가 커밋되지 않음(C3 파급). ⚠ **판정 계층은 PR3c(§3-T73)**, **「새 CAS 가 커밋되지 않는다」는 행동 연언은 시퀀서 소유라 PR5 T18 귀속**(정직 표기 — PR3c 안에서는 생산자 부재로 vacuous) | verify | | **T19** | **크로스 프로세스 stale-cache(계약 5항 · 재작성)** — **같은 표면의 두 엔진 인스턴스**(재시작 전후·컨테이너 교체·데스크톱 다중 창 — §W-2-b 로 두 표면 동시는 배제됐으므로 시나리오를 이렇게 고정). 두 인스턴스는 **`vi.resetModules()` + 동적 `import()` 2회로 모듈 인스턴스를 격리**하고 각자 별도 `DurableFs` 를 주입한다(모듈 상태 공유 금지가 테스트 셋업 계약). 원안의 단순 in-process 2인스턴스는 **같은 ESM 모듈을 공유**하므로 — 스펙 자신이 `readSeq`·commit WeakSet 을 "모듈 내부"로 규정하므로 이는 가정이 아니라 확정 — `Map` 모듈 캐시 구현이 **GREEN 통과**하고 실제 2프로세스 배포에서만 RED 가 된다. 인스턴스1 이 T1 무효화·G2 기록 후 리스 해제 → 인스턴스2 가 낡은 T1/G1 투영으로 리스 획득 → 공유 권위 G2 fresh read 로 `integrated` **거부**. **negative control 2중**: ⓐ전이 직전 `projection.currentIntegrationTxnId===T1 ∧ sourceGeneration===G1` ⓑ**모듈 격리 자기검사** — 인스턴스 A 의 모듈 스코프 카운터가 B 에서 초기값임을 단언(격리가 깨지면 이 행이 먼저 RED) | verify | -| **T20** | stale 전체 스냅숏 순차 기록이 최신 세대를 되돌리지 못함(revision-CAS) | verify | +| **T20** | stale 전체 스냅숏 순차 기록이 최신 세대를 되돌리지 못함(revision-CAS). ⚠ **두 해석의 귀속을 나눈다**(계획 정정 85 이월분 — 이제까지 빈 귀속이었다): ⓐ**CAS 경유** 해석분은 PR2b 의 revision-CAS 가 이미 막는다 ⓑ**raw 전체 스냅숏 쓰기** 해석분(`createJsonFileStore` LWW 클로버)은 store 에 막을 수단이 없고 탐지식이 §W-7 **ref-앵커**라 **PR3c 귀속**이다 — 판정은 `refRevision > record.revision`(§3-T73)이 답하고, 「되돌아간 revision 위에 새 CAS 가 커밋되지 않는다」는 **행동** 연언은 §3-T18b 와 같이 **PR5 T18 귀속** | verify | | **T21** | **마이그레이션 충돌(계약 6항)** — 두 표면이 같은 bench 에 상충 권위 필드를 만든 상태에서 자동 병합·LWW 없이 `reconciliation-required`. **"기존 로컬 레코드 없음" 전제의 근거 = `StoreState` 무변경(T53)** — 이 근거를 계약 문면에 명시해 6항 미충족 재지적을 차단한다 | verify | | **T21c** | **버전 스큐(I12)** — 지원 범위 초과 `schemaVersion` 레코드 → `incompatible-version` 분류 · `delete-record` 거부 · git 호출 0 · area 락 획득 시도 0(구 버전이 신 버전 권위를 삭제하면 RED) | verify | | **T22** | 실 fork 2 프로세스 + 파일 배리어 — 리스 배타성이 in-process Map 뮤텍스로는 통과 불가함을 증명 | verify | @@ -1274,9 +1440,9 @@ playwright(ubuntu, 컨테이너 없음)만 돈다. 해당 행(T55·N2·N3·N4) | **T29** | 다중 시도 **형제 그래프** — R1 만 머지 → `partially-integrated` · T2 ff 불가 감지 → 명령 노출 중단 → `stale-attempt` → 재준비 T3 후에만 완결, 완결은 **T3 귀속** | verify(실 git) | | **T30** | 다중 시도 **조상 그래프** — R1 적용 후 T2 준비 → 정상 ff → 완결이 정확히 T2 귀속 · 저널 순회 순서 무관 | verify(실 git) | | **T31** | 활동-무효화 원자성 — exec 게이트 직전 정지 후 R1 소비자 머지와 완료 관측 동시 실행 → ⓐ또는ⓑ **하나만** 성립 · 무효화 저장 실패 시 **CLI 0줄 실행** | verify | -| **T32** | WAL 복구 판정 순수 함수 — `prepared`/`composed` × ref 유무 6조합 + `REF_NAMESPACE_CONFLICT` 최우선 | verify | -| **T33** | **`published` 저널은 차단하지 않는다**(C6) — integration-ready bench 가 실행·통합 가능, **그리고 `delete-record`·`abandon` 도 허용**(활성 저널 집합 = `prepared`·`composed` 뿐) | verify | -| **T34** | **ref-앵커 재조정** — 권위 레코드의 어느 txnId 에도 귀속되지 않는 결과 ref 가 존재하면 reconciliation(win32 4번째 분기 · C3). 앵커를 권위 파일 안에 두면 롤백 시 함께 되돌아가 발화하지 않으므로 **git ref 열거가 판정식**임을 고정 | verify(실 git) | +| **T32** | WAL 복구 판정 순수 함수(**PR3c**) — `prepared`/`composed` × ref 유무 6조합 + `ref-namespace-conflict` **최우선**. ⚠ 표기는 **kebab 통일**(계획 정정 141)이고 표는 **first-match 가 아니라 계층**이다 — 앵커 게이트가 이 6조합보다 **먼저** 답한다(§3-T79) | verify | +| **T33** | **`published` 저널은 차단하지 않는다**(C6) — integration-ready bench 가 실행·통합 가능, **그리고 `delete-record`·`abandon` 도 허용**(활성 저널 집합 = `prepared`·`composed` 뿐 · **stage 단독 순수 술어**로 유지 · 계획 정정 194). **권위가 가리키지 않는 활성 stage 엔트리**(고아 저널)는 활성 집합의 정의를 바꾸는 대신 **복구 판정이 2분**한다(I11 참조) | verify | +| **T34** | **ref-앵커 재조정** — 판정식은 **`refRevision > record.revision`**(권위 부재 = `0`)이며 「어느 txnId 에도 귀속되지 않는 ref」식은 **폐기**됐다(계획 정정 191: 포기가 결과 ref 를 보존하고 §3-T29 가 형제 ref 공존을 정상으로 요구하므로 귀속식은 영구 오탐이었다). 앵커를 권위 파일 안에 두면 롤백 시 함께 되돌아가 발화하지 않으므로 **git ref 열거가 판정식**임을 고정하고, **정확 이름 질의**를 함께 실어 D/F 충돌이 「ref 없음」과 구분되게 한다 | verify(실 git) | | **T35** | 포기 — git 변이 0 · 결과 ref/keep 커밋 보존 · `lifecycle` 무변 · `reachable-from-base` 거부 | verify(실 git) | | **T36** | 생성 고아 — 디렉터리 선점으로 `worktree add -b` 실패 시 브랜치 잔존을 **결정론적으로 재현**하고 R1~R4 되감기 허용 · 크래시 경로는 R1 미충족으로 자동 삭제 차단 | verify(실 git) | | **T37** | 상태×액션 인가 표 **전수 테이블 테스트**(표 자체가 단일 권위). ⚠ 완료 조건의 "엔진 거부 집합 == **UI 액션 여집합**"은 §7 범위 분리(UI = #253)와 충돌하므로 **UI 여집합 일치는 #253 완료 조건으로 이관**한다 | verify | @@ -1292,6 +1458,28 @@ playwright(ubuntu, 컨테이너 없음)만 돈다. 해당 행(T55·N2·N3·N4) | **T70** | **저널 쓰기는 bench 리스 아래에서만(신설 · PR3b)** — 비민팅(복제) 리스면 파일시스템 **무접촉**(쓰기 0) · 리스 identity ↔ 레코드 3필드 대조 실패 시 거부 · tmp 이름의 `` 은 **리스에서만** 취한다(문자열 인자 부재 = 위조 불가) · rename **회차마다** `revalidate()` 를 다시 보므로 「1회차 실패 → 그 사이 탈취 → 2회차 성공」 구현이 RED(L-6 동형). PR3d 수확기의 배타원이 이 계약 위에 선다 | verify | | **T71** | **저널 레코드 불변 필드·조건부 결속(신설 · PR3b)** — 단계 전진에서 불변 12필드(`txnId`·`benchId`·`repoCommonGitDir`·`benchRoot`·`sourceBranch`·`sourceSnapshot`·`sourceGeneration`·`targetBranch`·`targetHeadBeforeIntegration`·`resultRef`·`startedAt`·`ownerEngineId`) 중 하나라도 바뀌면 거부(「전이는 합법인데 내용이 통째로 바뀐」 레코드 차단) · `stage ≥ composed` → `resultOid`·`resultTree` 필수 · `published`·`finalized` → `publishedAt` 필수이고 **`prepared`·`composed` 는 금지**(게시 전 게시 시각) · 결과 증거(`resultTree`·`resultOid`·`publishedAt`)는 **그 값을 요구하는 단계에서만 도입**되고 그 뒤 불변 · `abandoned` → `abandonedAt`·`abandonReason` 필수 ∧ `nextAuthorityStage` **부재**(포기 CAS 는 통합 필드를 소거한다) · 그 외 stage 는 `stage === nextAuthorityStage` | verify | | **T72** | **생성 저널 3채널 판정 규칙(신설 · PR4 T16)** — {①저널 엔트리 ②worktree 디렉터리 ③git 브랜치} **8조합 전수**를 {없음·부분·완전}으로 사상하는 **순수 술어** + 그 판정을 소비하는 생성 트랜잭션의 행동 단언. 생성은 통합의 `prepared` 규칙을 상속하지 않으므로 「없음」은 reconciliation 없이 종결 가능해야 한다. ⚠ **귀속이 PR3b → PR4 로 바뀌었다**(계획 정정 182): 규칙만 먼저 착지시키면 「판정 함수가 자기 입력의 생산자보다 먼저 서는」 배치가 되어 정정 159 가 방금 제거한 안티패턴을 되살린다. 대상 레코드도 통합 WAL 이 아니라 **생성 저널**이라 PR3b 모듈의 계약이 아니다 | verify | +| **T73** | **앵커 발화(신설 · PR3c)** — 권위 `N` → 결과 ref `-` 발행 → CAS 로 권위 `N+1` → **권위 파일만** 이전 세대로 롤백(`file-only`) → 재부팅 관측이 `refRevision(N+1) > record.revision(N)` 을 **참**으로 읽고 `reconciliation-required`. ⚠ 판정식은 **귀속이 아니라 revision 비교**다(계획 정정 191 이 「귀속 없는 ref = reconciliation」을 영구 오탐으로 폐기) | verify | +| **T74** | **음성 통제(신설 · PR3c)** — 권위가 `N+1` **이상**이면 같은 ref 집합에서 오탐 **0**. 앵커는 「권위 단독 롤백」만 답해야 하고 정상 진행·완결 후 상태를 차단하면 안 된다 | verify | +| **T75** | **ref 값의 산술 결속(신설 · PR3c)** — 결속은 **stage 전수**에 건다. `prepared` 저널의 expected 를 `R0` 라 하면 **`refRevision = R0 + 2` 고정**(= 그 ref 에 결속된 composed CAS 가 **기록할** revision)이고, 관측 저널 기준으로는 `refRevision = expected + 2 - stageIndex` 다 — `prepared` +2 · `composed` +1 · `published` +0(계획 정정 223). ⚠ **`finalized` 는 등식이 아니라 상한**(`refRevision ≤ expected` — 머지 관측 이후의 개입 CAS 가 `expected` 를 올린다 · 계획 정정 233). **`abandoned` 도 정의역 안**이며 정의역은 `previousAuthorityStage`(=P)가 준다 — 결속은 **밴드** `expected + 1 - idx(P) ≤ refRevision ≤ expected + 2 - idx(P)`(P 가 **직전 저널 stage** 라 권위는 post-CAS 이거나 pre-CAS 크래시 창이고 `expected` 가 두 값을 가진다 · 열린 창 `P ∈ {published, finalized}` 는 **하한을 뗀다**) · `P` **부재는 fail-closed**(계획 정정 236 이 「abandoned 는 정의역 밖」을 폐기 — 무검사 대역이 `refRevision ≤ record.revision` 전체였다). ⚠ 계획 정정 196 의 고정식 `expected + 1` 은 **`composed` 한 stage 로 정의역이 좁혀졌다** — 전 stage 에 균일 적용하면 정상 `prepared`(+2)·`published`(+0)·`finalized`(상한) 저널이 전부 RED 다. 정의역을 벗어난 값을 실은 ref(예: `composed` 저널에 `-`)는 저널과 **산술 결속 불일치**로 fail-closed 이며 「결과 없음」으로 축소되지 않는다. 문법은 `refs/fleet/integrated//-` · 10진 가변폭 · **선행 0 거부**(`01`·`1` 비단사 매핑 차단) · 안전 정수 초과는 파싱이 거부 | verify | +| **T76** | **보존된 전 결과 ref 에 동일 판정(신설 · PR3c)** — 포기·`stale-attempt` 로 보존된 형제 시도 ref(§3-T29 가 영구 공존을 정상으로 요구)는 revision 이 **현재 이하**라 앵커가 침묵한다. 같은 함수·같은 식으로 판정하며 예외 조항을 두지 않는다 | verify | +| **T77** | **권위 부재 + ref 잔존(신설 · PR3c)** — 권위 레코드가 없으면 비교 기준 revision 은 `0` 이고 모든 결과 ref(revision ≥ 1)가 그보다 크므로 `reconciliation-required`. 「레코드가 없으니 판정 없음」으로 조용히 통과시키지 않는다 | verify | +| **T78** | **열거 실패를 empty set 으로 축소하지 않음(신설 · PR3c)** — git 실패·D/F 충돌(`refs/fleet/integrated/` 자신이 ref) · 문법 위반 ref 는 **각각 다른 blocker 종별**이며 어느 것도 「발행된 ref 없음」과 같은 결론을 내지 않는다. `ref-namespace-conflict` 는 **최우선**이라 다른 사유에 가려지지 않는다 | verify | +| **T79** | **앵커 게이트가 승격 판정보다 먼저(신설 · PR3c)** — 저널이 승격 조건을 전부 만족해도 **설명되지 않는 더 높은 ref** 가 하나라도 있으면 승격이 아니라 reconciliation 이다(순서를 뒤집으면 롤백된 stage 위에서 승격한다). 저널은 게이트 **앞에서 읽되**(읽기·검증만) 권위 CAS·저널 변이는 게이트 뒤다(계획 정정 200) | verify | +| **T80** | **blocker 존재 중 행동 인가 전면 차단(신설 · PR3c)** — blocker 가 하나라도 있으면 판정은 **어떤 입력 조합에서도** `no-mutation`·`resume-composed-cas`·`normal-wait` 를 답하지 않는다(조합 전수 성질). ⚠ 이 행의 **행동** 단언(복구기가 실제로 인가를 막는지)은 소비자 부재로 PR5·PR7 귀속 | verify | +| **T81** | **앵커 검사의 결속 구간(신설 · PR3c)** — 판정은 「새 리스 획득 후 fresh read」와 **같은 임계 구역**에 결속하며 통과 결과를 장기 캐시로 재사용하지 않는다. PR3c 는 그 구조적 seam(면제가 의존한 입력 전체의 증거 다이제스트 + 재검증 동치 판정)만 착지시키고 **행동 단언은 PR5·PR7 귀속**(정직 표기) | verify | +| **T82** | **신·구 레코드 전이 불변식 계층(신설 · PR3c)** — 현행 `checkInvariants` 는 단일 레코드 전용이라 `published → prepared` 역행·단계 건너뛰기·미종결 txn 위의 새 시도가 **어느 층에서도** 차단되지 않았다. 계획 정정 142 가 저널에 세운 전이 강제를 권위 쪽에 세우고, **복구 판정의 면제 조건 8**(후속 전이 일치)이 이 계층을 재사용한다. ⓐ**최초 레코드(`prev` 부재)도 시작 단계를 본다** — `prepared` 외의 stage 로 태어나는 첫 CAS 는 거부다(WAL 1단계와 그 크래시 안전 순서를 건너뛴다). 「단일 레코드 불변식이 막는다」는 **거짓**이었다(Codex PR#289 P1 — `checkInvariants` 어디에도 그 규칙이 없다) ⓗ**세대 대표성은 이 층의 소관이 아니다**(Codex PR#289 7R P1 → 로컬 적대 리뷰가 위치 정정) — 「어느 세대의 결과인가」를 싣는 값은 **저널의 불변 `sourceGeneration`** 이라 권위 레코드 두 장만 보는 이 함수에서는 보이지 않는다. 착지 레코드의 감사는 §W-7 복구의 `completed-txn-stale-generation` 이, CAS 시점 강제는 완결 관측 생산자(PR5)가 맡는다. ⚠ 권위의 `currentIntegrationTxnGeneration` 을 대신 쓰면 안 된다 — 그것은 저널 `integrationGeneration`(「`prepared` 마다 +1」= **시도 카운터**)과 묶이는 값이라 활동 카운터와 등치하면 정상 흐름을 막는다 ⓕ**완결 귀속은 결과 증거를 전제한다**(Codex PR#289 5R P1) — txn identity 만 보면 `prepared` 상태의 T1 을 그대로 `integrated`+`completed: T1` 로 커밋해 **결과가 존재한 적 없는 bench 를 완결로 기록**한다 ⓖ**lifecycle 은 통합 축과 독립으로 단조다**(`open → integrated → archived` · Codex PR#289 5R P1) — 통합 축을 건드리지 않는 CAS 는 그 축의 검사를 통째로 건너뛰므로, archived 를 `archivedBranch` 만 떼고 `open` 으로 되돌리는 제출이 **어느 층에도 걸리지 않았다** ⓔ**`abandoned` 는 종결이라 숫자 stage 비교의 정의역 밖이다**(Codex PR#289 4R P1) — 파서는 그 값을 여전히 읽는데 `indexOf` 가 **-1** 이라 `abandoned → prepared` 가 역행·건너뛰기 **두 검사를 모두 통과**해 포기된 txn 을 부활시킨다. 숫자 비교 앞에서 잘라내되 **소거(청소 복구)는 계속 허용**한다 ⓒ**새 txn 시작 인가는 stage 가 아니라 `lifecycle` 에 건다**(Codex PR#289 3R P1) — `integrated` ∧ current stage `finalized` 인 레코드는 「미종결 아님」을 통과해 **종결된 bench 를 다시 연다**. §5 「lifecycle 회귀(재개) 없음 · `integrated` 후 추가 작업은 새 bench」와 정면 충돌이다 ⓓ**완결 귀속의 「도입」도 검사한다**(Codex PR#289 3R P1) — 교체 금지만 있으면 첫 도입이 무제한이라 current 가 T1 인데 `completedIntegrationTxnId: T2` 로 커밋해도 두 층이 다 통과하고 **부분 통합이 숨는다**. §W-8 의 「완결은 권위 시도만 · 관측 CAS 는 txn 동일」을 코드로 세운다(도달성·stage 전제는 외부 관측이라 여기서 보지 않는다 — 정직 표기) ⓑ`completedIntegrationTxnId` 소거 금지의 **예외는 보관 전이**다(`lifecycle → 'archived'`) — 불변식 ②가 그 필드를 `integrated` 에만 허용하므로 예외가 없으면 `integrated → archived` 가 **전부 `invariant-violation`** 이 된다. 예외는 「소거」가 아니라 **lifecycle 에 결속**한다(귀속 교체는 계속 거부) | verify | +| **T83** | **정상 crash window(신설 · PR3c)** — 권위 `N` · 유효 `composed` 저널(expected `N`) · matching ref `N+1` → **`resume-composed-cas`** 이며 전역 reconciliation **아님**. 픽스처 인터리브는 (A) 로 고정한다: composed 저널 acknowledged → 결과 ref create-only 발행 → **composed 권위 CAS**(그 직전 crash) | verify | +| **T84** | **저널 없는 `N+1` ref(신설 · PR3c)** → reconciliation. 대응 저널이 없으면 그 ref 를 설명할 증거가 없다 | verify | +| **T85** | **result OID 불일치(신설 · PR3c)** — ref 가 가리키는 OID ≠ `journal.resultOid` → reconciliation(`result-ref-mismatch`) | verify | +| **T86** | **expected revision 불일치(신설 · PR3c)** — `journal.expectedAuthorityRevision` 이 관측 권위 revision 과 다르면(`N-1`·`N+1` 양방향) 승격 **금지** | verify | +| **T87** | **두 단계 이상 앞섬(신설 · PR3c)** — 권위 `N-1` · 저널 expected `N` · ref `N+1` → reconciliation. 정상 프로토콜에서 ref 는 txn 당 한 번만 만들어지므로 두 단계 앞섬은 발생하지 않는다(계획 정정 204 가 4경로를 금지) | verify | +| **T88** | **복수 next-revision ref(신설 · PR3c)** — 같은 resulting revision 을 주장하는 promotable ref 가 둘 이상이면 reconciliation. `+1` 은 필요조건이지 충분조건이 아니다 | verify | +| **T89** | **롤백된 current 슬롯(신설 · PR3c · 회귀 핀)** — `current` 라는 이유만으로 면제되지 않는다. 면제 판정의 입력이 **롤백 가능한 권위 파일 하나**로 좁혀지면 「롤백을 증명하는 가장 높은 ref 가 바로 그 이유로 숨는다」(계획 정정 199ⓐ). 면제는 **독립 매체 두 개(ref + 저널)의 교차검증**으로만 성립한다 | verify | +| **T90** | **superseded/abandoned ref(신설 · PR3c)** — 현재 revision **이하** = 정상 보존(차단 아님) · 현재 **초과** = `current` 여부와 무관하게 reconciliation | verify | +| **T91** | **anchor-before-mutation(신설 · PR3c)** — 저널은 판정 **전에 읽지만**(읽기·검증만) 권위 CAS·저널 stage 변이는 게이트 전 **0건**. PR3c 의 판정 함수는 **순수**(무변이 관찰)이며 그 사실 자체가 이 행의 구조적 형태다 — 변이 순서의 **행동** 단언은 PR5 귀속 | verify | +| **T92** | **재검증 race(신설 · PR3c)** — 최초 분류와 락·리스 획득 사이에 입력이 바뀌면 fresh 재검증이 실패하고 promotion **0건**. PR3c 는 면제가 의존한 입력 전체를 증거 다이제스트로 봉인하고 「같은 증거인가」를 판정하는 층까지 착지시킨다(**행동 단언은 PR5 귀속**) | verify | +| **T93** | **정확한 promotion(신설 · PR3c)** — CAS 성공 후 `record.revision === parsedRefRevision` 이고 저널은 **그 뒤에만** `published` 로 전진한다. 권위가 이미 `N+1` 이며 composed 전이를 **정확히** 반영한 상태는 재실행이 아니라 **완료 확인**이다(계획 정정 204ⓑ) — 판정은 그 상태를 `normal-wait` 로 답한다. **CAS 행동 단언은 PR5 귀속** | verify | +| **T94** | **CAS 실패 시 ref 보존(신설 · PR3c)** — 승격 CAS 가 실패해도 결과 ref 는 보존되고 그 상태가 「정상 대기」로 숨겨지지 않는다(다음 관측에서 같은 blocker 로 다시 발화). **행동 단언은 PR5 귀속** | verify | | **T38** | **I4: `broken ∧ busy` → 전 액션 거부**(크로스 프로세스 리스 보유) | verify | | **T39** | **I11 분리** — `prepared`/`composed` 잔존 bench 만 `delete-record` 거부(`journal-pending`) · **integration-ready(`published`) bench 는 허용** · `broken ∧ 활성 저널`은 `abandon-and-discard` 3구간으로만 탈출(락 밖 승인 → 락 안 재검사 → 단일 CAS) | verify | | **T39b** | bench 런 진행 중 **레거시 ProjectPanel 의 running 잠금·자동 선택이 발생하지 않음**(라이브 이벤트 + 하이드레이션 두 경로 · R-2) | verify | diff --git a/src/main/core/workbench/authority-store.test.ts b/src/main/core/workbench/authority-store.test.ts index ac8f310..1980a32 100644 --- a/src/main/core/workbench/authority-store.test.ts +++ b/src/main/core/workbench/authority-store.test.ts @@ -639,12 +639,27 @@ describe('§3-T13 revision-CAS', () => { it('ⓑ 첫 draft 가 세운 옵셔널이 두 번째 커밋에 살아남지 않는다(LWW 병합이면 RED)', async () => { const { store, lease, benchId, fs, path } = await setup() + // 4필드를 전부 세우려면 `composed` 가 필요한데(`prepared` 는 `resultOid` 를 실을 수 없다 · + // 불변식 ③c), **최초 레코드는 `prepared` 로만 시작할 수 있다**(Codex PR#289 P1). 그래서 두 단계로 + // 올라간 뒤 4필드가 전부 선 상태를 만든다 — 지름길이 막힌 것이지 시나리오가 바뀐 것은 아니다. await store.withAuthority(lease, async (tx) => { const read = tx.readFresh() if (read.kind !== 'absent') throw new Error('absent 예상') return tx.compareAndSwap( read.read, - // `composed` 를 쓴다 — `prepared` 는 `resultOid` 를 실을 수 없다(불변식 ③c · Codex 2R P1-E). + draft(benchId, { + currentIntegrationTxnId: 'T1', + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 1, + }), + ) + }) + + await store.withAuthority(lease, async (tx) => { + const read = tx.readFresh() + if (read.kind !== 'found') throw new Error('found 예상') + return tx.compareAndSwap( + read.read, draft(benchId, { currentIntegrationTxnId: 'T1', currentIntegrationStage: 'composed', @@ -1782,11 +1797,48 @@ describe('store 옵션은 생성 시점 스냅샷이다(Codex 2R P1-C)', () => { * 그래서 그 규칙은 **WAL 이 첫 단계에 진입조차 못 하게** 만들었다. */ describe('통합 WAL 단계별 resultOid 규칙(Codex 2R P1-E)', () => { + const STAGE_PATH = ['prepared', 'composed', 'published', 'finalized'] as const + + /** + * 목표 stage 까지 **WAL 순서대로 올라간 뒤** 마지막 draft 를 커밋한다. + * + * ⚠ 이전 판은 **첫 CAS 로 곧바로 목표 stage 를 만들었다**(픽스처 지름길). Codex PR#289 P1 이후 + * 「최초 레코드는 `prepared` 로만 통합을 시작한다」가 전이 불변식으로 서면서 그 지름길은 표현 + * 불가능해졌고, **지름길이 성립했다는 사실 자체가 결함**이었다 — 첫 CAS 가 `composed` 로 태어나면 + * WAL 의 `prepared` 단계와 그 크래시 안전 순서를 통째로 건너뛴다. 이 describe 가 검증하는 것은 + * **resultOid 규칙**이지 진입 경로가 아니므로, 경로만 합법으로 바꿔 의도를 보존한다. + */ const casWith = async (over: Partial): Promise => { const fx = await setup() + const target = over.currentIntegrationStage + const climb = + target === undefined || target === 'prepared' + ? [] + : // ⚠ `abandoned` 도 **prepared 를 먼저 커밋한 뒤** 제출한다(CodeRabbit PR#289): 첫 CAS 로 + // 내면 「최초는 prepared 로만」이 먼저 답해서, 이 행이 겨냥한 「권위는 abandoned 를 갖지 + // 않는다」(정정 177)를 **한 번도 검증하지 못한다**(가림). + target === 'abandoned' + ? (['prepared'] as const) + : STAGE_PATH.slice(0, STAGE_PATH.indexOf(target)) + for (const stage of climb) { + const step = await fx.store.withAuthority(fx.lease, async (tx) => { + const read = tx.readFresh() + if (read.kind !== 'absent' && read.kind !== 'found') throw new Error(read.kind) + return tx.compareAndSwap( + read.read, + draft(fx.benchId, { + currentIntegrationTxnId: 'T1', + currentIntegrationStage: stage, + currentIntegrationTxnGeneration: 1, + ...(stage === 'prepared' ? {} : { currentIntegrationResultOid: 'oid1' }), + }), + ) + }) + if (step.kind !== 'committed') return step + } return fx.store.withAuthority(fx.lease, async (tx) => { const read = tx.readFresh() - if (read.kind !== 'absent') throw new Error('absent 예상') + if (read.kind !== 'absent' && read.kind !== 'found') throw new Error(read.kind) return tx.compareAndSwap(read.read, draft(fx.benchId, over)) }) } @@ -1840,12 +1892,95 @@ describe('통합 WAL 단계별 resultOid 규칙(Codex 2R P1-E)', () => { expect(r.kind).toBe('invariant-violation') }) - it('abandoned 는 resultOid 를 요구하지 않는다', async () => { + /** + * **CodeRabbit PR#289** — 「최초 레코드가 완결 귀속을 들고 태어나는」 축에 행이 없었다. 전이 계층은 + * `prev === undefined` 에서 lifecycle 만 보므로(정정 227) 그 조합을 막는 것은 **단일 레코드 불변식 + * ②**(`completedIntegrationTxnId` 존재 ⟺ `lifecycle==='integrated'`)다. 이 세션의 규율대로 + * **「다른 층이 막는다」를 주석이 아니라 행으로** 남긴다 — 규칙 이름을 문면까지 단언한다. + */ + it('최초 CAS 는 완결 상태를 직접 만들 수 없다(②와 정정 227 이 각각 답한다)', async () => { + // ⓐ `open` + 완결 귀속 → 불변식 ② 가 답한다. + const openWithCompleted = await casWith({ completedIntegrationTxnId: 'T1' }) + expect(openWithCompleted.kind).toBe('invariant-violation') + expect( + openWithCompleted.kind === 'invariant-violation' && openWithCompleted.violations.join(), + ).toContain('②') + + // ⓑ `integrated` + 완결 귀속 → 전이 계층(정정 227)이 답한다. + const integrated = await casWith({ + lifecycle: 'integrated', + completedIntegrationTxnId: 'T1', + }) + expect(integrated.kind).toBe('invariant-violation') + expect(integrated.kind === 'invariant-violation' && integrated.violations.join()).toContain( + '최초 레코드의 lifecycle 은 open', + ) + }) + + /** + * ⚠ **기대가 뒤집혔다**(Codex PR#289 P1 의 파급 · 은폐하지 않는다). 이전 판은 첫 CAS 로 + * `abandoned` 레코드를 만들어 「resultOid 를 요구하지 않는다」를 보였는데, 계획 정정 177 은 + * **권위 레코드가 `abandoned` 를 갖지 않는다**고 못박았다 — 포기는 단계를 남기는 게 아니라 통합 + * 4필드를 **소거**하는 것이다. 즉 이전 기대는 **어떤 생산자도 만들지 않는 상태**를 정상으로 + * 고정하고 있었고, 「최초는 prepared 로만」이 서면서 그 모순이 드러났다. + */ + it('abandoned 는 권위 레코드가 가질 수 있는 단계가 아니다(정정 177)', async () => { const r = await casWith({ currentIntegrationTxnId: 'T1', currentIntegrationStage: 'abandoned', currentIntegrationTxnGeneration: 1, }) - expect(r.kind).toBe('committed') + expect(r.kind).toBe('invariant-violation') + expect(r.kind === 'invariant-violation' && r.violations.join()).toContain( + 'abandoned 를 가질 수 없다', + ) + }) +}) + +/* ================================================================================================ + * draft 스냅숏 — **검증한 값과 착지한 값은 같은 값**이어야 한다 (Codex PR#289 8R P1) + * ============================================================================================= */ + +describe('CAS 는 제출 draft 를 한 번만 관측한다(전이 검증의 전제)', () => { + /** + * **Codex PR#289 8R P1** — `runCas` 는 `next` 를 ③사전조건·④b `checkTransitionInvariants`· + * `serialize` 에서 **각각 다시 읽는다**. 평범한 객체는 안전하지만 getter·`Proxy` 는 층마다 다른 값을 + * 줄 수 있고, 유일한 사후 관문인 왕복 검증은 `checkInvariants`(**단일 레코드**)만 태우므로 + * **WAL 단계를 건너뛴 레코드가 커밋된다** — 전이 계층이 본 것과 디스크에 착지한 것이 갈린다. + * + * 재현: 최초 CAS(권위 부재)에서 `currentIntegrationStage` 가 전이 검증 때는 `prepared`(정정 227 이 + * 허용하는 유일한 시작), 직렬화 때는 `published` 를 답한다. + */ + it('getter 가 층마다 다른 값을 줘도 검증한 값이 그대로 착지한다', async () => { + const fx = await setup() + let reads = 0 + const shifty = { + schemaVersion: 1, + identity: { commonGitDir: COMMON_GIT_DIR, benchRoot: BENCH_ROOT, benchId: fx.benchId }, + lifecycle: 'open', + sourceGeneration: 1, + currentIntegrationTxnId: 'T1', + currentIntegrationTxnGeneration: 1, + currentIntegrationResultOid: 'a'.repeat(40), + get currentIntegrationStage() { + reads += 1 + // 첫 관측만 합법(`prepared`), 그 뒤로는 단계를 건너뛴 값을 답한다. + return reads <= 1 ? 'prepared' : 'published' + }, + } as unknown as BenchAuthorityDraft + + const r = await fx.store.withAuthority(fx.lease, async (tx) => { + const read = tx.readFresh() + if (read.kind !== 'absent') throw new Error('absent 예상') + return tx.compareAndSwap(read.read, shifty) + }) + + // 커밋되든 거부되든 **디스크에 단계를 건너뛴 레코드가 남아서는 안 된다.** + if (r.kind === 'committed') { + const landed = JSON.parse(fx.fs.readFileUtf8(fx.path)) as BenchAuthorityRecord + expect(landed.currentIntegrationStage).toBe('prepared') + } else { + expect(r.kind).toBe('invariant-violation') + } }) }) diff --git a/src/main/core/workbench/authority-transition.test.ts b/src/main/core/workbench/authority-transition.test.ts new file mode 100644 index 0000000..33eb02a --- /dev/null +++ b/src/main/core/workbench/authority-transition.test.ts @@ -0,0 +1,610 @@ +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + type BenchAuthorityDraft, + type BenchAuthorityRecord, + checkTransitionInvariants, + createBenchAuthorityStore, +} from './authority' +import { createFakeDurableFs } from './__testing__/durable-fs-fake' +import { createFakeLockBackend } from './__testing__/lock-backend-fake' +import { createLockScope } from './locks' +import { newUlid } from './ulid' + +/** + * §3-T82 — **신·구 레코드 전이 불변식 계층**(계획 정정 195·204). + * + * 현행 `checkInvariants` 는 **단일 레코드 전용**이라(authority.ts) 권위 CAS 는 신 레코드만 검사한다. + * 그래서 `published → prepared` 역행이나 `abandoned` 이후 부활이 **어느 층에서도 차단되지 않는다** + * (계획 정정 142 가 저널 쪽에만 세운 전이 강제를 권위 쪽에 세우는 것이 이 계층이다). + * + * ⚠ 이 계층이 없으면 정정 204 의 불변식 ①③⑤(단계 커밋 전진 금지 · pending 중 새 시도 금지 · + * CAS 실패는 published 전진의 인가가 아님)가 **문면으로만** 존재한다. + */ + +const IDENTITY = { + commonGitDir: '/repo/.git', + benchRoot: '/repo/../.fleet-wb', + benchId: '01J8Z4T7K9QW3M5N7P9R1S3T5V', +} as const + +const T1 = '01J8Z4T7K9QW3M5N7P9R1S3T5W' +const T2 = '01J8Z4T7K9QW3M5N7P9R1S3T5X' + +const base = (over: Partial = {}): BenchAuthorityRecord => + ({ + schemaVersion: 1, + identity: IDENTITY, + revision: 5, + lifecycle: 'open', + sourceGeneration: 3, + writtenBy: { ownerToken: 'ow', at: 1, durability: 'process-durable' }, + ...over, + }) as BenchAuthorityRecord + +const draft = (over: Partial = {}): BenchAuthorityDraft => { + const { revision: _r, writtenBy: _w, ...rest } = base() + return { ...rest, ...over } as BenchAuthorityDraft +} + +describe('전이 불변식 — 통합 stage', () => { + it('부재 → prepared 는 새 트랜잭션의 정상 시작이다', () => { + const prev = base() + const next = draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }) + expect(checkTransitionInvariants(prev, next)).toEqual([]) + }) + + it('prepared → composed → published → finalized 는 전진이다', () => { + const pairs = [ + ['prepared', 'composed'], + ['composed', 'published'], + ['published', 'finalized'], + ] as const + for (const [from, to] of pairs) { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: from, + currentIntegrationTxnGeneration: 3, + ...(from === 'prepared' ? {} : { currentIntegrationResultOid: 'a'.repeat(40) }), + }) + const next = draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: to, + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + expect(checkTransitionInvariants(prev, next)).toEqual([]) + } + }) + + it('역행을 거부한다 — 이것이 이 계층의 존재 이유다', () => { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'published', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + // ⚠ 증거를 그대로 들고 역행시킨다 — 증거를 함께 떨어뜨리면 **동결 위반까지 같이 발화**해서 + // 이 행이 「역행을 잡았다」가 아니라 「둘 중 뭔가 잡았다」가 된다(축 분리). + const next = draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + expect(checkTransitionInvariants(prev, next)).toEqual([ + '전이: stage 역행 금지(published → prepared)', + ]) + }) + + it('단계 건너뛰기를 거부한다 — prepared → published 는 composed CAS 를 삼킨다', () => { + // 정정 203 이 verdict 이름을 바꾼 바로 그 위험의 기계 방어다: 이름이 허용하던 「composed 를 + // 건너뛰고 published 를 직접 기록」이 여기서 **컴파일은 되지만 커밋되지 않는다**. + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }) + const next = draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'published', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + expect(checkTransitionInvariants(prev, next)).toEqual([ + expect.stringContaining('단계 건너뛰기'), + ]) + }) + + it('통합 축을 건드리지 않는 CAS 는 그 축의 no-op 이다 — 자기 전이가 아니다', () => { + // gated-orphan 회수(`reclaimDraft`)·활동 시작/종료·lifecycle 변경은 통합 4필드를 **보존한 채** + // 커밋된다. 「stage 가 같다 = 자기 전이」로 읽으면 그 정상 경로가 전부 거부된다 — 실제로 기존 + // 무회귀 핀(`authority-node.test.ts` 의 commit-uncertain 회수 행)이 내 초안을 그렇게 잡았다. + const integration = { + currentIntegrationTxnId: T1, + currentIntegrationStage: 'composed', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + } as const + const prev = base({ + ...integration, + activeActivity: { + activityId: 'act', + kind: 'run', + generation: 3, + ownerToken: 'ow', + execGate: 'gated', + startedAt: 1, + }, + }) + expect(checkTransitionInvariants(prev, draft({ ...integration }))).toEqual([]) + }) + + it('권위 레코드는 `abandoned` 를 가질 수 없다 — 포기는 4필드 소거로 표현된다(정정 177)', () => { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'composed', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + const next = draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'abandoned', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + expect(checkTransitionInvariants(prev, next)).toEqual([expect.stringContaining('abandoned')]) + }) + + /** + * **Codex PR#289 4R P1** — 파서는 `abandoned` 권위 레코드를 **여전히 읽을 수 있는데**(레거시·손상) + * `AUTHORITY_STAGE_ORDER.indexOf('abandoned')` 가 **-1** 이라 `abandoned → prepared` 가 + * `fi=-1 · ti=0` 으로 역행·건너뛰기 두 검사를 **모두 통과**해 포기된 txn 을 부활시켰다. + */ + it('abandoned 권위 상태에서 같은 txn 을 되살릴 수 없다', () => { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'abandoned', + currentIntegrationTxnGeneration: 3, + }) + for (const to of ['prepared', 'composed', 'published', 'finalized'] as const) { + const next = draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: to, + currentIntegrationTxnGeneration: 3, + ...(to === 'prepared' ? {} : { currentIntegrationResultOid: 'a'.repeat(40) }), + }) + expect(checkTransitionInvariants(prev, next), to).toEqual([ + expect.stringContaining('abandoned 는 종결'), + ]) + } + // 소거(청소 복구)는 그대로 허용된다 — 막다른 길을 만들지 않는다. + expect(checkTransitionInvariants(prev, draft())).toEqual([]) + }) + + it('소거(포기)는 어느 단계에서나 허용된다', () => { + for (const from of ['prepared', 'composed', 'published'] as const) { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: from, + currentIntegrationTxnGeneration: 3, + ...(from === 'prepared' ? {} : { currentIntegrationResultOid: 'a'.repeat(40) }), + }) + expect(checkTransitionInvariants(prev, draft())).toEqual([]) + } + }) + + /** + * **Codex PR#289 8R P1** — 소거 arm 이 **목적지 lifecycle 에 아무 제약도 걸지 않았다.** 그래서 한 CAS 가 + * 미종결 통합을 지우면서 동시에 bench 를 종결(`archived`·`integrated`)시킬 수 있고, 단일 레코드 + * 검사와 lifecycle 단조 검사는 그것을 받아들인다. 그러면 보존된 그 txn 의 저널이 **종결 bench 위의 + * 활성 고아**가 되어 복구가 즉시 `reconciliation-required` 를 답한다. + * + * 6R 정정 232 가 복구 쪽에서 잡던 상태를 **전이 검증기 자신이 제조**할 수 있다는 것이 새 증거다. + * 대칭 규칙은 이미 있다 — 「새 통합 txn 은 open bench 에서만 시작한다」(정정 228). 그 역방향으로 + * 「미종결 통합의 소거는 lifecycle 을 전진시킬 수 없다」를 세운다(포기는 포기대로 먼저 착지시켜라). + */ + it('미종결 통합의 소거는 lifecycle 을 전진시킬 수 없다', () => { + for (const from of ['prepared', 'composed', 'published'] as const) { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: from, + currentIntegrationTxnGeneration: 3, + ...(from === 'prepared' ? {} : { currentIntegrationResultOid: 'a'.repeat(40) }), + }) + const terminalize = draft({ lifecycle: 'archived', archivedBranch: 'preserved' }) + expect(checkTransitionInvariants(prev, terminalize), from).toEqual([ + expect.stringContaining('미종결 통합의 소거'), + ]) + } + // 음성 통제 ⓐ — lifecycle 을 그대로 두는 소거(정상 포기)는 계속 허용된다. + const pending = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'composed', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + expect(checkTransitionInvariants(pending, draft())).toEqual([]) + // 음성 통제 ⓑ — **종결된**(`finalized`) txn 의 소거는 종결화와 함께여도 정상이다(완결·보관 경로). + const finalized = base({ + lifecycle: 'integrated', + completedIntegrationTxnId: T1, + currentIntegrationTxnId: T1, + currentIntegrationStage: 'finalized', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + expect( + checkTransitionInvariants( + finalized, + draft({ lifecycle: 'archived', archivedBranch: 'preserved' }), + ), + ).toEqual([]) + }) +}) + +describe('전이 불변식 — pending 중 새 시도 금지 (정정 204 불변식 ③)', () => { + it('미종결 txn 위에 다른 txnId 를 얹는 것을 거부한다', () => { + for (const from of ['prepared', 'composed', 'published'] as const) { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: from, + currentIntegrationTxnGeneration: 3, + ...(from === 'prepared' ? {} : { currentIntegrationResultOid: 'a'.repeat(40) }), + }) + const next = draft({ + currentIntegrationTxnId: T2, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }) + expect(checkTransitionInvariants(prev, next)).toEqual([expect.stringContaining('미종결 txn')]) + } + }) + + /** + * **Codex PR#289 3R P1** — stage 종결만 보면 **종결된 bench 가 다시 열린다.** `lifecycle==='integrated'` + * ∧ current stage `finalized` 인 레코드는 pending 검사를 통과하는데, 스펙 §5 는 「lifecycle 회귀(재개) + * 없음 · `integrated` 후 추가 작업은 **새 bench**」다. 시작 인가를 stage 가 아니라 lifecycle 에 건다. + */ + it('integrated·archived bench 에서는 새 txn 을 시작할 수 없다', () => { + for (const lifecycle of ['integrated', 'archived'] as const) { + const prev = base({ + lifecycle, + ...(lifecycle === 'integrated' + ? { completedIntegrationTxnId: T1 } + : { archivedBranch: 'preserved' as const }), + currentIntegrationTxnId: T1, + currentIntegrationStage: 'finalized', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + const next = draft({ + lifecycle, + ...(lifecycle === 'integrated' + ? { completedIntegrationTxnId: T1 } + : { archivedBranch: 'preserved' as const }), + currentIntegrationTxnId: T2, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }) + expect(checkTransitionInvariants(prev, next)).toEqual([ + expect.stringContaining('새 통합 txn 은 open bench 에서만 시작한다'), + ]) + } + }) + + /** + * **Codex PR#289 6R P1** — `prev` 만 보면 **한 CAS 가** T2 를 시작하면서 동시에 bench 를 + * `integrated` 로 넘길 수 있고, 그 뒤의 같은-T2 단계 전이는 이 게이트를 **아예 지나지 않는다**. + */ + it('새 txn 을 시작하면서 동시에 bench 를 종결시킬 수 없다', () => { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'finalized', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + const next = draft({ + lifecycle: 'integrated', + completedIntegrationTxnId: T1, + currentIntegrationTxnId: T2, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }) + expect(checkTransitionInvariants(prev, next)).toContain( + '전이: 새 통합 txn 은 open bench 에서만 시작한다(open → integrated)', + ) + }) + + it('finalized 뒤에는 새 txn 을 시작할 수 있다', () => { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'finalized', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + const next = draft({ + currentIntegrationTxnId: T2, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }) + expect(checkTransitionInvariants(prev, next)).toEqual([]) + }) +}) + +describe('전이 불변식 — 증거·세대 동결', () => { + it('같은 txn 안에서 resultOid 교체를 거부한다 — 한 번 나타나면 동결', () => { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'composed', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + const next = draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'published', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'b'.repeat(40), + }) + expect(checkTransitionInvariants(prev, next)).toEqual([ + '전이: currentIntegrationResultOid 는 한 번 나타나면 동결이다(증거 교체 금지)', + ]) + }) + + it('같은 txn 안에서 txnGeneration 변경을 거부한다', () => { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }) + const next = draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'composed', + currentIntegrationTxnGeneration: 2, + currentIntegrationResultOid: 'a'.repeat(40), + }) + expect(checkTransitionInvariants(prev, next)).toEqual([ + '전이: 같은 txn 안에서 currentIntegrationTxnGeneration 변경 금지', + ]) + }) + + it('sourceGeneration 감소를 거부한다 — 세대는 단조다', () => { + expect(checkTransitionInvariants(base(), draft({ sourceGeneration: 2 }))).toEqual([ + expect.stringContaining('sourceGeneration'), + ]) + expect(checkTransitionInvariants(base(), draft({ sourceGeneration: 4 }))).toEqual([]) + }) + + it('완결 귀속(`completedIntegrationTxnId`)의 교체·소거를 거부한다', () => { + const prev = base({ lifecycle: 'integrated', completedIntegrationTxnId: T1 }) + expect( + checkTransitionInvariants( + prev, + draft({ lifecycle: 'integrated', completedIntegrationTxnId: T2 }), + ), + ).toEqual([expect.stringContaining('완결 귀속')]) + // ⚠ `integrated → open` 은 이제 **두 사유**가 함께 발화한다(완결 귀속 소거 + lifecycle 회귀 · + // Codex PR#289 5R P1). 축이 다르므로 둘 다 남기고, 이 행은 **완결 귀속 축**만 단언한다. + expect(checkTransitionInvariants(prev, draft({ lifecycle: 'open' }))).toContain( + '전이: 완결 귀속(completedIntegrationTxnId)의 교체·소거 금지', + ) + }) + + /** + * **Codex PR#289 3R P1** — 초안은 **이미 귀속이 있을 때만** 돌아서 **첫 도입이 무제한**이었다. + * current 가 T1 인 레코드를 `completedIntegrationTxnId: T2` 로 커밋해도 단일 레코드·전이 검사가 + * 둘 다 통과했고, 무관한 txn 이 완결로 기록되면서 **부분 통합이 숨는다.** 스펙 §W-8 은 「완결은 + * **권위 시도만** · 관측 CAS 는 **txn 동일**」이다. + */ + it('완결 귀속의 도입은 직전 레코드의 current txn 이어야 한다', () => { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'published', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + const complete = (completedIntegrationTxnId: string) => + draft({ + lifecycle: 'integrated', + completedIntegrationTxnId, + currentIntegrationTxnId: T1, + currentIntegrationStage: 'published', + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }) + expect(checkTransitionInvariants(prev, complete(T2))).toEqual([ + expect.stringContaining('완결 귀속은 직전 레코드의 current txn'), + ]) + expect(checkTransitionInvariants(prev, complete(T1))).toEqual([]) + }) + + /** + * **Codex PR#289 5R P1** — txn 이 같다는 것만으로는 부족하다. `prepared` 상태의 T1 을 그대로 + * `integrated` + `completed: T1` 로 커밋해도 두 층이 통과해 **결과가 존재한 적 없는 bench 가 + * 완결로 기록**된다. §W-8 의 완결 정의(`resultOid` 의 base 도달성)는 결과 증거를 전제한다. + */ + it('결과 증거 없는 txn 은 완결로 기록할 수 없다', () => { + const prev = base({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }) + const next = draft({ + lifecycle: 'integrated', + completedIntegrationTxnId: T1, + currentIntegrationTxnId: T1, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }) + expect(checkTransitionInvariants(prev, next)).toEqual([ + expect.stringContaining('결과 증거 없는 txn'), + ]) + }) + + /** + * **Codex PR#289 5R P1** — 통합 축을 건드리지 않는 CAS 는 통합 검사를 통째로 건너뛰므로, + * archived 를 `archivedBranch` 만 떼고 `open` 으로 되돌리는 제출이 **어느 층에도 걸리지 않았다.** + */ + it('lifecycle 은 회귀하지 않는다(archived → open · integrated → open)', () => { + const archived = base({ lifecycle: 'archived', archivedBranch: 'preserved' }) + expect(checkTransitionInvariants(archived, draft({ lifecycle: 'open' }))).toEqual([ + expect.stringContaining('lifecycle 회귀 금지'), + ]) + expect(checkTransitionInvariants(archived, draft({ lifecycle: 'integrated' }))).toContain( + '전이: lifecycle 회귀 금지(archived → integrated)', + ) + // 전진은 허용된다 — open → integrated · open → archived · 같은 값 유지. + expect(checkTransitionInvariants(base(), draft({ lifecycle: 'open' }))).toEqual([]) + expect( + checkTransitionInvariants( + archived, + draft({ lifecycle: 'archived', archivedBranch: 'preserved' }), + ), + ).toEqual([]) + }) + + /** + * **Codex PR#289 P1** — 이 규칙의 초안은 보관 워크플로를 통째로 막았다. 단일 레코드 불변식 ②가 + * 「`completedIntegrationTxnId` 존재 ⟺ `lifecycle==='integrated'`」이므로 `integrated → archived` 는 + * **반드시 그 필드를 소거**해야 하는데, 「소거 금지」가 그 정상 전이를 `invariant-violation` 으로 답했다. + */ + it('보관 전이(integrated → archived)의 완결 귀속 소거는 허용한다', () => { + const prev = base({ lifecycle: 'integrated', completedIntegrationTxnId: T1 }) + expect( + checkTransitionInvariants( + prev, + draft({ lifecycle: 'archived', archivedBranch: 'preserved' }), + ), + ).toEqual([]) + // 보관이 아닌 소거는 여전히 거부다 — 예외는 lifecycle 에 결속한다. + expect(checkTransitionInvariants(prev, draft({ lifecycle: 'integrated' }))).toEqual([ + expect.stringContaining('완결 귀속'), + ]) + }) +}) + +describe('전이 불변식 — 최초 레코드', () => { + it('이전 레코드 부재(최초 생성)에서 통합 없는 레코드는 통과한다', () => { + expect(checkTransitionInvariants(undefined, draft())).toEqual([]) + }) + + /** + * **Codex PR#289 P1** — 초안은 「최초 레코드가 미종결 통합을 들고 태어나는 것은 단일 레코드 불변식 + * ③ 계열의 소관」이라고 적고 침묵했는데, **그 주장이 거짓이었다.** `checkInvariants` 의 ③·③b·③c·③d + * 어디에도 「최초 stage 는 `prepared`」가 없어서, 첫 CAS 가 `composed`·`published`·`finalized` 로 + * 곧바로 태어나 **WAL 의 `prepared` 단계와 그 크래시 안전 순서를 통째로 건너뛸 수 있었다**. + * + * 「다른 층이 막는다」는 검증 없는 안전 주장이 코드 주석으로 착지한 형태(PR#266 교훈)의 재발이다. + */ + /** + * **Codex PR#289 6R P1** — 이 early return 이 아래 완결 귀속 검사들을 **통째로 건너뛴다.** 그래서 + * `lifecycle: 'integrated'` + 임의의 `completedIntegrationTxnId` 를 든 **첫 CAS** 가 두 층을 다 + * 통과해 **트랜잭션도 결과도 없이 완결을 주장하는 종결 bench** 를 만들 수 있었다. + */ + it('최초 레코드의 lifecycle 은 open 이어야 한다', () => { + for (const lifecycle of ['integrated', 'archived'] as const) { + const next = draft({ + lifecycle, + ...(lifecycle === 'integrated' + ? { completedIntegrationTxnId: T2 } + : { archivedBranch: 'preserved' as const }), + }) + expect(checkTransitionInvariants(undefined, next), lifecycle).toEqual([ + expect.stringContaining('최초 레코드의 lifecycle 은 open'), + ]) + } + }) + + it('최초 레코드는 prepared 로만 통합을 시작할 수 있다', () => { + for (const stage of ['composed', 'published', 'finalized'] as const) { + expect( + checkTransitionInvariants( + undefined, + draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: stage, + currentIntegrationTxnGeneration: 3, + currentIntegrationResultOid: 'a'.repeat(40), + }), + ), + ).toEqual([expect.stringContaining('prepared')]) + } + expect( + checkTransitionInvariants( + undefined, + draft({ + currentIntegrationTxnId: T1, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + }), + ), + ).toEqual([]) + }) +}) + +/** + * **배선 단언**(§3-T82 의 프로덕션 관통 층). + * + * ⚠ 위 스위트는 전부 **순수 함수**만 부른다. 순수 테스트만 두면 `compareAndSwap` 안의 호출부 한 줄을 + * 지워도 전 게이트가 GREEN 이고, 그러면 「선언했으나 실재하지 않는 방어」가 된다 — PR0 이 `bootServer` + * 에서, PR1b 가 eslint 룰·raw 획득 스캔에서 각각 실측한 형태다. + */ +describe('전이 불변식 — CAS 관통 배선', () => { + const COMMON_GIT_DIR = '/repo/.git' + const BENCH_ROOT = '/workbenches' + const AUTHORITY_DIR = join('/repo/.git/fleet', 'authority') + + it('역행 draft 는 저장소 CAS 에서 거부된다 — 디스크 무변이', async () => { + const benchId = newUlid() + const txn = newUlid() + const path = join(AUTHORITY_DIR, `${benchId}.json`) + const seeded: BenchAuthorityRecord = { + schemaVersion: 1, + identity: { commonGitDir: COMMON_GIT_DIR, benchRoot: BENCH_ROOT, benchId }, + revision: 4, + lifecycle: 'open', + sourceGeneration: 2, + currentIntegrationTxnId: txn, + currentIntegrationStage: 'published', + currentIntegrationTxnGeneration: 2, + currentIntegrationResultOid: 'a'.repeat(40), + writtenBy: { ownerToken: 'seed', at: 1, durability: 'file-only' }, + } + const fs = createFakeDurableFs({ initial: { [path]: JSON.stringify(seeded) } }) + const scope = createLockScope({ + identity: { commonGitDir: COMMON_GIT_DIR, benchRoot: BENCH_ROOT }, + backend: createFakeLockBackend(), + }) + const acquired = await scope.tryAcquireBenchLease(benchId) + if (acquired.status !== 'acquired') throw new Error(`리스 획득 실패: ${acquired.status}`) + const store = createBenchAuthorityStore(fs, { + authorityDir: AUTHORITY_DIR, + durability: 'file-only', + now: () => 1_700_000_000_000, + sleep: () => Promise.resolve(), + }) + + const result = await store.withAuthority(acquired.lease, (tx) => { + const fresh = tx.readFresh() + if (fresh.kind !== 'found') throw new Error('found 예상') + const { revision: _r, writtenBy: _w, ...rest } = fresh.record + const backward: BenchAuthorityDraft = { ...rest, currentIntegrationStage: 'prepared' } + return Promise.resolve(tx.compareAndSwap(fresh.read, backward)) + }) + + expect(result.kind).toBe('invariant-violation') + expect(result.kind === 'invariant-violation' && result.violations).toEqual([ + '전이: stage 역행 금지(published → prepared)', + ]) + // 디스크 무변이 — revision 이 전진하지 않았다. + const after = JSON.parse(fs.readRaw(path) ?? '{}') as BenchAuthorityRecord + expect(after.revision).toBe(4) + expect(after.currentIntegrationStage).toBe('published') + }) +}) diff --git a/src/main/core/workbench/authority.ts b/src/main/core/workbench/authority.ts index 2988879..9b507dc 100644 --- a/src/main/core/workbench/authority.ts +++ b/src/main/core/workbench/authority.ts @@ -108,7 +108,12 @@ export interface BenchAuthorityRecord { * ⚠ `durability==='file-only'` 표면에서는 머신 크래시 시 디렉터리 엔트리 유실로 단조성이 보장되지 * 않는다(C3). **win32 전용이 아니다**(계획 정정 151) — dirfd fsync 를 지원하지 않는 마운트 위의 POSIX 도 * `probeDurability`(durable-fs.ts:184-201)가 같은 등급으로 강등한다. 그 표면의 **보조** 탐지가 §W-7 - * **ref-앵커**(PR3c 미착지)다. + * **ref-앵커**이며 `recovery.ts` 의 `classifyRecovery` 가 그것을 판정한다(PR3c 착지). + * + * ⚠ 그 판정은 이 필드(`writtenBy.durability`)를 **읽지 않는다**(계획 정정 205) — 검사의 활성화 조건을 + * 검사 대상과 같은 매체에서 읽으면 독립성이 그 지점에서 끊긴다. 판정은 항상 돌고 `file+dir` 표면에서는 + * 발화 입력이 만들어지지 않는다. **「되돌아간 revision 위에 새 CAS 가 커밋되지 않는다」는 행동 연언의 + * 생산자는 여전히 PR5(시퀀서)** 이므로 이 표면의 안전 논증은 아직 미완결이다. * * ⚠ **「git ref 는 독립 매체라 동시 롤백이 불가능하다」는 거짓이었다**(계획 정정 133·152·156 — git * **소스** 대조로 확정. 문서만 읽으면 정반대 결론이 나온다): ⓐ두 경로는 대개 같은 볼륨이다(정션·심링크· @@ -988,6 +993,223 @@ const checkInvariants = (r: BenchAuthorityRecord): string[] => { return v } +/** + * 권위 레코드가 **가질 수 있는** 통합 단계의 순서. `abandoned` 는 **없다** — 포기는 권위에 단계를 + * 남기지 않고 통합 4필드를 함께 소거하는 것으로 표현한다(계획 정정 177 · `reclaimDraft` 선례). + */ +const AUTHORITY_STAGE_ORDER: readonly IntegrationStage[] = [ + 'prepared', + 'composed', + 'published', + 'finalized', +] + +/** + * lifecycle 의 **단조 순서**(스펙 §5 「lifecycle 회귀(재개) 없음」). `open → integrated → archived` 이며 + * 뒤로 가는 전이는 없다(`open → archived` 처럼 건너뛰는 전진은 허용 — 통합 없이 보관할 수 있다). + */ +const LIFECYCLE_ORDER: readonly BenchLifecycle[] = ['open', 'integrated', 'archived'] + +// ⚠ 유니온 밖 값은 이 계층의 소관이 아니다 — 형태 오류는 왕복 검증이 답해야 하는데 전이 검사가 +// 먼저 발화하면 더 근본적인 진단을 가린다(기존 무회귀 핀이 실제로 잡았다). 판정은 파일 위쪽의 +// `isLifecycle`(LIFECYCLES 기반)을 **재사용**한다 — 사본을 만들지 않는다. + +/** 미종결 = 아직 `finalized` 에 도달하지 않은 진행 중 통합. 새 시도의 시작을 막는 기준이다. */ +const isPendingStage = (s: IntegrationStage | undefined): boolean => + s !== undefined && s !== 'finalized' + +/** + * **신·구 레코드 전이 불변식 계층**(#251 PR3c · 계획 정정 195·204 · §3-T82). + * + * `checkInvariants` 는 신 레코드를 **단독으로만** 본다. 그래서 `published → prepared` 역행이나 단계 + * 건너뛰기가 어느 층에서도 차단되지 않았다 — 계획 정정 142 가 저널 쪽에 세운 전이 강제를 권위 쪽에 + * 세우는 것이 이 함수다. + * + * ⚠ **최초 레코드(`prev === undefined`)에서도 시작 단계는 본다**(Codex PR#289 P1). 초안은 「그것은 단일 + * 레코드 불변식 ③ 계열의 소관」이라며 침묵했는데 **그 주장이 거짓이었다** — `checkInvariants` 어디에도 + * 「최초 stage 는 `prepared`」가 없어서 첫 CAS 가 `composed`·`published`·`finalized` 로 곧바로 태어나 + * **WAL 의 `prepared` 단계와 그 크래시 안전 순서를 건너뛸 수 있었다.** 검증 없는 안전 주장을 주석으로 + * 착지시킨 형태(PR#266 교훈)의 재발이라 그 자리에서 규칙으로 바꾼다. + * + * 정정 204 의 불변식 ①③⑤(단계 커밋 전진 금지 · pending 중 새 시도 금지 · CAS 실패는 published + * 전진의 인가가 아님)가 여기서 **문면이 아니라 코드**가 된다. + */ +export function checkTransitionInvariants( + prev: BenchAuthorityRecord | undefined, + next: BenchAuthorityDraft, +): string[] { + if (prev === undefined) { + const v0: string[] = [] + const to = next.currentIntegrationStage + // 통합을 아예 들고 있지 않은 최초 레코드는 정상(생성 직후)이다. + if (to !== undefined && to !== 'prepared') { + v0.push(`전이: 최초 레코드는 prepared 로만 통합을 시작할 수 있다(관측: ${to})`) + } + // ⚠ **최초 레코드는 `open` 이다**(Codex PR#289 6R P1). bench 는 열린 상태로 태어난다 — + // 이 early return 이 아래 완결 귀속 검사들을 통째로 건너뛰므로, `lifecycle: 'integrated'` + + // 임의의 `completedIntegrationTxnId` 를 든 **첫 CAS** 가 두 층을 다 통과해 **트랜잭션도 결과도 + // 없이 완결을 주장하는 종결 bench** 를 만들 수 있었다. 최초 쓰기에는 대조할 출처가 없으므로 + // 형태로 막는다. + if (isLifecycle(next.lifecycle) && next.lifecycle !== 'open') { + v0.push(`전이: 최초 레코드의 lifecycle 은 open 이어야 한다(관측: ${next.lifecycle})`) + } + return v0 + } + const v: string[] = [] + + const from = prev.currentIntegrationStage + const to = next.currentIntegrationStage + const sameTxn = + prev.currentIntegrationTxnId !== undefined && + prev.currentIntegrationTxnId === next.currentIntegrationTxnId + + // **통합 축을 건드리지 않는 CAS 는 이 축의 no-op 이다.** gated-orphan 회수(`reclaimDraft`)·활동 + // 시작/종료·lifecycle 변경은 통합 4필드를 **보존한 채** 커밋되므로, 「stage 가 같다」를 자기 전이로 + // 읽으면 정상 경로가 전부 거부된다(기존 무회귀 핀 `authority-node.test.ts` 가 내 초안을 그렇게 잡았다). + const integrationUnchanged = + prev.currentIntegrationTxnId === next.currentIntegrationTxnId && + prev.currentIntegrationStage === next.currentIntegrationStage && + prev.currentIntegrationTxnGeneration === next.currentIntegrationTxnGeneration && + prev.currentIntegrationResultOid === next.currentIntegrationResultOid + + if (integrationUnchanged) { + // 통합 축 검사를 통째로 건너뛴다. + } else if (to === 'abandoned') { + v.push('전이: 권위 레코드는 abandoned 를 가질 수 없다(포기 = 통합 4필드 소거 · 정정 177)') + } else if (to === undefined) { + // 소거 = 포기. 어느 단계에서나 허용한다(§W-7 「포기 의미론」). + // ⚠ **단, 같은 CAS 가 bench 를 종결시킬 수는 없다**(Codex PR#289 8R P1). 제약이 없으면 한 CAS 가 + // 미종결 통합을 지우면서 동시에 `archived`·`integrated` 로 넘어갈 수 있고, 단일 레코드 검사와 + // lifecycle 단조 검사는 그것을 받아들인다 — 그러면 보존된 그 txn 의 저널이 **종결 bench 위의 + // 활성 고아**가 되어 복구가 즉시 reconciliation 을 답한다(6R 정정 232 가 복구 쪽에서 잡던 상태를 + // **전이 검증기 자신이 제조**한다). 대칭 규칙은 이미 있다 — 「새 통합 txn 은 open bench 에서만 + // 시작한다」(정정 228). 포기는 포기대로 먼저 착지시키고 종결은 그 다음 CAS 다. + // ⚠ 정의역은 **미종결**(`isPendingStage`)에만 건다 — `finalized` txn 의 소거는 완결·보관 경로의 + // 정상 동반 변경이고, 그 저널은 활성 집합 밖이라 고아가 되지 않는다. + const advancesLifecycle = + isLifecycle(next.lifecycle) && + LIFECYCLE_ORDER.indexOf(next.lifecycle) > LIFECYCLE_ORDER.indexOf(prev.lifecycle) + if (isPendingStage(from) && advancesLifecycle) { + v.push( + `전이: 미종결 통합의 소거는 lifecycle 을 전진시킬 수 없다(${String(from)} · ${prev.lifecycle} → ${String(next.lifecycle)})`, + ) + } + } else if (!sameTxn) { + // 새 txn 의 시작. 직전 txn 이 미종결이면 거부한다(정정 204 불변식 ③) — 허용하면 pending CAS 를 + // 둔 채 다음 시도가 시작돼 결과 ref 가 권위보다 두 단계 앞서는 창이 열린다. + if (isPendingStage(from)) { + v.push( + `전이: 미종결 txn(${String(prev.currentIntegrationTxnId)} · ${String(from)}) 위에 새 txn 을 시작할 수 없다`, + ) + } + // ⚠ **stage 종결만으로는 부족하다**(Codex PR#289 3R P1). `lifecycle==='integrated'` ∧ current + // stage `finalized` 인 레코드는 위 pending 검사를 통과해 **종결된 bench 를 다시 열어버린다** — + // 스펙 §5 「lifecycle 회귀(재개) 없음 · `integrated` 후 추가 작업은 **새 bench**」와 정면 충돌이다. + // 시작 인가를 stage 가 아니라 **lifecycle** 에 건다. + // ⚠ **출발지와 목적지 둘 다** `open` 이어야 한다(Codex PR#289 6R P1). `prev` 만 보면 **한 CAS 가** + // T2 를 시작하면서 동시에 bench 를 `integrated`·`archived` 로 넘길 수 있고, 그 뒤의 같은-T2 + // 단계 전이는 이 게이트를 **아예 지나지 않아** 종결 bench 위에서 작업이 계속된다. + if (prev.lifecycle !== 'open' || next.lifecycle !== 'open') { + v.push( + `전이: 새 통합 txn 은 open bench 에서만 시작한다(${prev.lifecycle} → ${next.lifecycle})`, + ) + } + if (to !== 'prepared') { + v.push(`전이: 새 txn 은 prepared 로 시작해야 한다(관측: ${to})`) + } + } else { + // ⚠ **`abandoned` 는 종결이다**(Codex PR#289 4R P1). 파서는 그 값을 여전히 읽을 수 있는데 + // (레거시·손상 레코드) `AUTHORITY_STAGE_ORDER.indexOf('abandoned')` 가 **-1** 이라, 아래 숫자 + // 비교에서 `abandoned → prepared` 가 `fi=-1 · ti=0` 으로 **두 검사를 모두 통과**해 포기된 txn 을 + // 부활시킨다(저널 전이 그래프가 금지하는 바로 그것). 숫자 비교 **앞에서** 잘라낸다. + // ⚠ 소거(`to === undefined` · 청소 복구)는 위 분기가 이미 처리했으므로 여기 걸리지 않는다. + const fi = from === undefined ? -1 : AUTHORITY_STAGE_ORDER.indexOf(from) + const ti = AUTHORITY_STAGE_ORDER.indexOf(to) + // ⚠ **「자기 전이 금지」 규칙은 두지 않는다.** stage 가 같은데 여기 도달했다는 것은 통합 4필드 중 + // 무언가가 바뀌었다는 뜻인데, 바뀔 수 있는 나머지 둘(`TxnGeneration`·`ResultOid`)은 **아래 동결 + // 규칙이 이름을 짚어 거부**한다. 별도 규칙을 두면 같은 입력에 두 방어가 함께 발화해 **가림**이 + // 기본값이 되고(정정 189 가 실측한 형태), 독립 반증력은 0 이다(정정 183 의 「도달 불가 arm」 규율). + // ⚠ **`abandoned` 는 종결이라 숫자 비교의 정의역 밖이다**(Codex PR#289 4R P1). 파서는 그 값을 + // 여전히 읽을 수 있는데(레거시·손상 레코드) `indexOf('abandoned')` 가 **-1** 이라 + // `abandoned → prepared` 가 `fi=-1 · ti=0` 으로 **두 검사를 모두 통과**해 포기된 txn 을 부활시킨다. + // `else` 로 묶어 **한 입력에 한 사유만** 발화시킨다(가림 방지 · 정정 189). + // ⚠ 소거(`to === undefined` · 청소 복구)는 위 분기가 이미 처리했으므로 여기 오지 않는다. + if (from === 'abandoned') { + v.push('전이: abandoned 는 종결이다 — 같은 txn 을 되살릴 수 없다(정정 177)') + } else if (ti < fi) v.push(`전이: stage 역행 금지(${String(from)} → ${to})`) + else if (ti > fi + 1) v.push(`전이: 단계 건너뛰기 금지(${String(from)} → ${to})`) + + if ( + prev.currentIntegrationResultOid !== undefined && + next.currentIntegrationResultOid !== prev.currentIntegrationResultOid + ) { + v.push('전이: currentIntegrationResultOid 는 한 번 나타나면 동결이다(증거 교체 금지)') + } + if ( + prev.currentIntegrationTxnGeneration !== undefined && + next.currentIntegrationTxnGeneration !== prev.currentIntegrationTxnGeneration + ) { + v.push('전이: 같은 txn 안에서 currentIntegrationTxnGeneration 변경 금지') + } + } + + // **lifecycle 회귀 없음**(Codex PR#289 5R P1 · 스펙 §5). 통합 축을 건드리지 않는 CAS 는 위에서 + // 통째로 건너뛰므로, archived 레코드를 `archivedBranch` 만 떼고 `open` 으로 되돌리는 제출이 **어느 + // 층에도 걸리지 않았다** — 종결된 bench 가 되살아나 이후 작업이 가능해진다. lifecycle 은 통합 축과 + // **독립**이므로 그 검사도 독립으로 세운다. + const li = (l: BenchLifecycle): number => LIFECYCLE_ORDER.indexOf(l) + if (isLifecycle(next.lifecycle) && li(next.lifecycle) < li(prev.lifecycle)) { + v.push(`전이: lifecycle 회귀 금지(${prev.lifecycle} → ${next.lifecycle})`) + } + if (next.sourceGeneration < prev.sourceGeneration) { + v.push(`전이: sourceGeneration 은 단조다(${prev.sourceGeneration} → ${next.sourceGeneration})`) + } + // ⚠ **완결 귀속의 「도입」도 검사한다**(Codex PR#289 3R P1). 아래 규칙은 **이미 귀속이 있을 때**만 + // 돌아서, 첫 도입이 무제한이었다 — current 가 T1 인 레코드를 `completedIntegrationTxnId: T2` 로 + // 커밋해도 단일 레코드·전이 검사가 **둘 다 통과**했고, 무관한 txn 이 완결로 기록되면서 부분 통합이 + // 숨는다. 스펙 §W-8 은 「완결은 **권위 시도만**(`currentIntegrationTxnId` 의 도달성) · 관측 CAS 는 + // **txn 동일**」이므로 그 결속을 여기서 코드로 세운다. + // ⚠ 정직 표기: **도달성 전제는 여기서 보지 않는다** — `resultOid` 가 base 에서 도달 가능한지는 + // 외부 관측(§W-8)이고 이 순수 함수의 입력에 없다. + if ( + prev.completedIntegrationTxnId === undefined && + next.completedIntegrationTxnId !== undefined + ) { + if (next.completedIntegrationTxnId !== prev.currentIntegrationTxnId) { + v.push( + '전이: 완결 귀속은 직전 레코드의 current txn 이어야 한다(무관한 txn 을 완결로 기록 금지)', + ) + } else if (prev.currentIntegrationResultOid === undefined) { + // ⚠ **txn 이 같다는 것만으로는 부족하다**(Codex PR#289 5R P1). `prepared` 상태의 T1 을 그대로 + // `integrated` + `completed: T1` 로 커밋해도 두 층이 통과해 **결과가 존재한 적 없는 bench 를 + // 완결로 기록**한다. §W-8 의 완결 정의는 「`resultOid` 의 base 도달성」이므로 **결과 증거의 + // 존재**가 최소 전제다(불변식 ③d 가 그것을 `stage >= composed` 와 등가로 만든다). + v.push('전이: 결과 증거 없는 txn 을 완결로 기록할 수 없다(resultOid 부재)') + } + // ⚠ **세대 대표성(§W-8 완결 CAS 5연언의 나머지 한 항)은 이 층에서 판정할 수 없다**(Codex PR#289 + // 7R P1 → 로컬 적대 리뷰가 위치를 정정). 「어느 세대의 결과인가」를 싣는 값은 **저널의 불변 + // `sourceGeneration`** 인데 이 함수는 권위 레코드 두 장만 본다. 초안은 대신 + // `currentIntegrationTxnGeneration === sourceGeneration` 을 걸었는데, 그 필드는 저널 + // `integrationGeneration`(「`prepared` 마다 +1」= **시도 카운터**)과 묶이는 값이라 두 카운터를 + // 등치시켜 「활동 2회 뒤 첫 통합」 같은 **정상 흐름을 막았다**(실측 적발). + // 착지 레코드의 감사는 `recovery.ts` 의 `completed-txn-stale-generation` 이 맡고, CAS 시점의 + // 강제는 완결 관측 생산자(PR5)의 몫이다 — 여기에 이름만 남기면 「검사한다」로 읽힌다(정정 234 의 잣대). + } + if ( + prev.completedIntegrationTxnId !== undefined && + next.completedIntegrationTxnId !== prev.completedIntegrationTxnId + ) { + // ⚠ **보관은 예외다**(Codex PR#289 P1). 단일 레코드 불변식 ②가 「`completedIntegrationTxnId` 존재 + // ⟺ `lifecycle==='integrated'`」이므로 `integrated → archived` 는 **반드시** 그 필드를 소거한다. + // 초안은 그 정상 전이를 `invariant-violation` 으로 답해 **보관 워크플로를 통째로 막았다.** + // 예외를 「소거」가 아니라 **lifecycle 에 결속**시킨다 — 그래야 귀속 교체는 계속 거부된다. + const archiving = next.completedIntegrationTxnId === undefined && next.lifecycle === 'archived' + if (!archiving) v.push('전이: 완결 귀속(completedIntegrationTxnId)의 교체·소거 금지') + } + + return v +} + const sameIdentity = (a: BenchAuthorityIdentity, b: BenchAuthorityIdentity): boolean => a.commonGitDir === b.commonGitDir && a.benchRoot === b.benchRoot && a.benchId === b.benchId @@ -1249,7 +1471,13 @@ export function createBenchAuthorityStore( if (Object.hasOwn(next, 'writtenBy')) bad.push('draft 는 writtenBy 를 실을 수 없다(저장소만 배정)') if (bad.length > 0) return { kind: 'invariant-violation', violations: bad } - if (!sameIdentity(next.identity, lease.identity)) { + // ⚠ **여기서 draft 를 한 번만 관측한다**(Codex PR#289 8R P1). 초안은 `next` 를 사전조건·전이 + // 검증·직렬화에서 **각각 다시 읽었다** — 평범한 객체는 안전하지만 getter·`Proxy` 는 층마다 + // 다른 값을 줄 수 있고, 유일한 사후 관문인 왕복 검증은 `checkInvariants`(**단일 레코드**)만 + // 태우므로 **WAL 단계를 건너뛴 레코드가 커밋**됐다(전이 계층이 본 것과 착지한 것이 갈린다). + // 이 줄 이후로 `next` 를 **다시 읽지 않는다** — identity 검사·전이 검증·직렬화가 같은 값을 본다. + const submitted: BenchAuthorityDraft = { ...next } + if (!sameIdentity(submitted.identity, lease.identity)) { return { kind: 'lease-invalid', reason: 'identity-mismatch' } } @@ -1304,8 +1532,17 @@ export function createBenchAuthorityStore( return assertNever(fresh) } + // ④b **전이 불변식 계층**(#251 PR3c · §3-T82). ④ 가 방금 읽은 **디스크 레코드**와 대조한다 — + // 토큰이 실어온 값이 아니라 디스크여야 한다(④ 가 재독을 강제한 것과 같은 이유). 이 층이 없으면 + // `published → prepared` 역행·단계 건너뛰기가 **어느 층에서도** 차단되지 않는다. + const prevRecord = fresh.kind === 'found' ? fresh.record : undefined + const transitionViolations = checkTransitionInvariants(prevRecord, submitted) + if (transitionViolations.length > 0) { + return { kind: 'invariant-violation', violations: transitionViolations } + } + const revision = minted.observedRevision + 1 - const record = serialize(next, revision, { + const record = serialize(submitted, revision, { ownerToken: lease.ownerToken, at: opts.now(), durability: opts.durability, diff --git a/src/main/core/workbench/journal.ts b/src/main/core/workbench/journal.ts index f1cff18..60fd73b 100644 --- a/src/main/core/workbench/journal.ts +++ b/src/main/core/workbench/journal.ts @@ -231,7 +231,19 @@ export interface JournalStore { * 순수 술어 — 소비자(PR3c·PR5·PR7)보다 먼저 서는 부분 * ============================================================================================= */ -const STAGE_ORDER: readonly IntegrationStage[] = ['prepared', 'composed', 'published', 'finalized'] +/** + * WAL 단계의 **순서**(종결 `abandoned` 는 순서 밖이다). 소비자가 둘이 되면서 export 한다 — PR3c 의 + * 복구 판정이 「권위 stage ≤ 저널 stage ≤ 권위 stage + 1」 교차 결속을 이 순서로 판정하므로, 사본을 + * 만들면 두 곳이 조용히 갈린다. + */ +export const WAL_STAGE_ORDER: readonly IntegrationStage[] = [ + 'prepared', + 'composed', + 'published', + 'finalized', +] + +const STAGE_ORDER = WAL_STAGE_ORDER /** * 합법 전이 그래프(계획 정정 142). 타입·스펙·코드 어디에도 없어 **역행·부활이 어느 층에서도 차단되지 @@ -354,6 +366,18 @@ const canonicalJson = (value: unknown): string => { * 저널이 결속하는 `draftDigest` 의 **유일한 생산자**(계획 정정 167). 여기 두지 않으면 생산자(PR5)와 * 대조자(PR3c)가 각자 다른 정준화를 만들어 「불일치」가 상시 참이 된다. */ +/** + * 저널 레코드 **전체**의 정준 다이제스트(#251 PR3c · Codex PR#289 4R P1). + * + * 복구의 증거 봉인이 `draftDigest` 만 실으면 **권위 draft 투영 밖의 통합 입력**(`sourceSnapshot`· + * `targetBranch`·`targetHeadBeforeIntegration`·`resultTree` …)이 관측과 락 사이에 바뀌어도 같은 값이 + * 나온다 — 소비자가 **바뀐 통합 의도를 같은 증거로** 받아들인다. 정준화를 복구 쪽에서 새로 만들지 않고 + * 여기 두는 것은 `digestAuthorityDraft` 와 같은 이유다(생산자가 둘이면 정준화가 갈린다). + */ +export function digestJournalRecord(record: IntegrationTxnRecord): string { + return createHash('sha256').update(canonicalJson(record), 'utf8').digest('hex') +} + export function digestAuthorityDraft(draft: BenchAuthorityDraft): string { // ⚠ **CAS 가 실제로 기록할 투영을 해시한다**(Codex PR#269 3R P1). 호출자 draft 를 그대로 해시하면 // 구조적 서브타이핑으로 실려 온 **초과 키**가 값에 섞이는데, 정작 CAS 는 필드 명시 재조립을 기록한다 — diff --git a/src/main/core/workbench/recovery.test.ts b/src/main/core/workbench/recovery.test.ts new file mode 100644 index 0000000..90bd034 --- /dev/null +++ b/src/main/core/workbench/recovery.test.ts @@ -0,0 +1,2063 @@ +import { describe, expect, it } from 'vitest' + +import type { BenchAuthorityDraft, BenchAuthorityRecord } from './authority' +import { digestAuthorityDraft, type IntegrationTxnRecord } from './journal' +import { + classifyRecovery, + isSameRecoveryEvidence, + type RecoveryObservation, + type RecoveryVerdict, +} from './recovery' +import { formatResultRef } from './result-ref' + +/** + * §3-T73~T94 — **복구 판정 순수 함수**(#251 PR3c · 스펙 §W-7 「복구 판정」). + * + * 이 파일이 고정하는 것은 **계층**이다. 복구표는 first-match 표가 **아니고**(계획 정정 150ⓐ·197ⓑ), + * 앵커 게이트가 승격 판정보다 **먼저** 답한다 — 순서를 뒤집으면 롤백된 stage 위에서 승격한다. + * + * ⚠ **행동 단언은 여기 없다**(정직 표기 · 계획 정정 137 의 규율): 「복구기가 인가를 막는다」(T80)· + * 「변이가 게이트 뒤에 0건」(T91)·「CAS 후 revision 일치」(T93)·「CAS 실패 시 ref 보존」(T94)· + * 「락 아래 fresh 재검증」(T81·T92)의 **행동** 연언은 생산자(PR5 시퀀서 · PR7 부팅)가 없으므로 이 PR + * 에서 vacuous 다. 여기서 세우는 것은 그 행동이 **딛고 설** 판정 계층과 증거 봉인 seam 이다. + */ + +const BENCH = '01J8Z4T7K9QW3M5N7P9R1S3T5V' +const T1 = '01J8Z4T7K9QW3M5N7P9R1S3T5W' +const T2 = '01J8Z4T7K9QW3M5N7P9R1S3T5X' +const TPREV = '01J8Z4T7K9QW3M5N7P9R1S3T5Y' + +const IDENTITY = { + commonGitDir: '/repo/.git', + benchRoot: '/repo/../.fleet-wb', + benchId: BENCH, +} as const + +const OID = 'a'.repeat(40) +const OID2 = 'b'.repeat(40) +const TREE = 'c'.repeat(40) + +/** 관측된 권위 revision. 결과 ref 는 `N+1`(= 아래 `R0 + 2`)을 이름에 싣는다(계획 정정 223·233). */ +const N = 5 + +/** + * **프로토콜이 실제로 만들 수 있는 저널**을 만든다(Codex PR#289 5R P1 이후 · 계획 정정 223). + * + * `resultRef` 는 불변이고 ref 는 composed CAS **앞**에서 한 번만 발행되므로, `prepared` 저널이 관측한 + * revision 을 `R0` 라 하면 **ref = R0 + 2** 로 고정이고 `expected = R0 + stageIndex` 다. 이 산술을 + * 픽스처마다 손으로 맞추면 **결속상 존재할 수 없는 쌍**이 정상으로 고정된다(4R·5R 에서 실제로 그랬다). + * + * 기본 레코드(`revision: N` · current T1 `prepared`)를 만든 저널의 `R0` 는 **`N - 1`** 이다 — + * `prepared` CAS 가 `R0 → R0+1 = N` 으로 커밋했기 때문이다. + */ +const R0 = N - 1 +const STAGE_IDX = { prepared: 0, composed: 1, published: 2, finalized: 3 } as const + +const walBound = (stage: keyof typeof STAGE_IDX): Partial => ({ + stage, + expectedAuthorityRevision: R0 + STAGE_IDX[stage], + resultRef: formatResultRef(BENCH, R0 + 2, T1), + previousAuthorityStage: stage === 'prepared' ? undefined : WAL_ORDER[STAGE_IDX[stage] - 1], + nextAuthorityStage: stage, +}) + +const WAL_ORDER = ['prepared', 'composed', 'published', 'finalized'] as const + +const record = (over: Partial = {}): BenchAuthorityRecord => + ({ + schemaVersion: 1, + identity: IDENTITY, + revision: N, + lifecycle: 'open', + sourceGeneration: 3, + currentIntegrationTxnId: T1, + currentIntegrationStage: 'prepared', + currentIntegrationTxnGeneration: 3, + writtenBy: { ownerToken: 'ow', at: 1, durability: 'file-only' }, + ...over, + }) as BenchAuthorityRecord + +/** 후속 CAS 가 제출할 draft 의 **재구성** — 면제 조건 ⑧ 이 대조하는 바로 그 값이다. */ +const composedDraft = (r: BenchAuthorityRecord, txnId = T1, oid = OID): BenchAuthorityDraft => { + const { revision: _rev, writtenBy: _w, ...rest } = r + return { + ...rest, + currentIntegrationTxnId: txnId, + currentIntegrationStage: 'composed', + currentIntegrationTxnGeneration: r.currentIntegrationTxnGeneration, + currentIntegrationResultOid: oid, + } as BenchAuthorityDraft +} + +/** + * **post-CAS 픽스처의 draftDigest** — 권위가 이미 저널의 후속 단계를 커밋한 상태에서는 저널이 든 + * digest 가 **그 권위 레코드의 draft 투영**과 같아야 한다(Codex PR#289 4R P1). 이 헬퍼가 없으면 + * 픽스처가 「stage·revision 은 맞는데 draft 는 딴 것」이라는 **존재할 수 없는 쌍**이 된다. + */ +const draftDigestOf = (r: BenchAuthorityRecord): string => { + const { revision: _rev, writtenBy: _w, ...rest } = r + return digestAuthorityDraft(rest as BenchAuthorityDraft) +} + +/** + * **pre-CAS 픽스처의 draftDigest** — 저널이 낼 **다음 draft** 의 재구성(Codex PR#289 6R P1 이후 + * 전 stage 로 일반화됐다). `composedDraft` 는 그 특수 경우다. + */ +const nextDraftDigestOf = ( + r: BenchAuthorityRecord, + stage: 'prepared' | 'composed' | 'published' | 'finalized', + oid: string = OID, +): string => { + const { revision: _rev, writtenBy: _w, ...rest } = r + return digestAuthorityDraft({ + ...rest, + currentIntegrationTxnId: T1, + currentIntegrationStage: stage, + currentIntegrationTxnGeneration: r.currentIntegrationTxnGeneration, + currentIntegrationResultOid: oid, + } as BenchAuthorityDraft) +} + +/** **포기 CAS 가 제출할 draft** — 통합 4필드 소거(정정 177). pre-CAS 결속이 이것과 대조한다. */ +const abandonDraftDigestOf = (r: BenchAuthorityRecord): string => { + const { + revision: _rev, + writtenBy: _w, + currentIntegrationTxnId: _t, + currentIntegrationStage: _s, + currentIntegrationTxnGeneration: _g, + currentIntegrationResultOid: _o, + ...rest + } = r + return digestAuthorityDraft(rest as BenchAuthorityDraft) +} + +const journal = (over: Partial = {}): IntegrationTxnRecord => + ({ + schemaVersion: 1, + txnId: T1, + benchId: BENCH, + repoCommonGitDir: IDENTITY.commonGitDir, + benchRoot: IDENTITY.benchRoot, + sourceBranch: 'fleet/bench', + sourceSnapshot: 'd'.repeat(40), + sourceGeneration: 3, + targetBranch: 'main', + targetHeadBeforeIntegration: 'e'.repeat(40), + resultRef: formatResultRef(BENCH, N + 1, T1), + startedAt: 1, + ownerEngineId: 'eng', + stage: 'composed', + resultTree: TREE, + resultOid: OID, + expectedAuthorityRevision: N, + previousAuthorityStage: 'prepared', + nextAuthorityStage: 'composed', + integrationGeneration: 3, + draftDigest: digestAuthorityDraft(composedDraft(record())), + ...over, + }) as IntegrationTxnRecord + +const obs = (over: Partial = {}): RecoveryObservation => ({ + identity: IDENTITY, + authority: { kind: 'found', record: record() }, + prefixRefs: { kind: 'ok', refs: [] }, + exactRefs: { kind: 'ok', refs: [] }, + journal: { kind: 'ok', entries: [] }, + ...over, +}) + +const entries = (...records: IntegrationTxnRecord[]): RecoveryObservation['journal'] => ({ + kind: 'ok', + entries: records.map((r) => ({ txnId: r.txnId, read: { kind: 'found', record: r } })), +}) + +const refs = ( + ...pairs: readonly (readonly [string, string])[] +): RecoveryObservation['prefixRefs'] => ({ + kind: 'ok', + refs: pairs.map(([ref, oid]) => ({ ref, oid })), +}) + +const REF_N1 = formatResultRef(BENCH, N + 1, T1) + +const kinds = (v: RecoveryVerdict): readonly string[] => + v.kind === 'reconciliation-required' ? v.blockers.map((b) => b.kind) : [] + +const reasons = (v: RecoveryVerdict): readonly string[] => + v.kind === 'reconciliation-required' ? v.blockers.flatMap((b) => b.reasons) : [] + +/* ================================================================================================ + * T83 — 정상 crash window (인터리브 (A) · 계획 정정 202) + * ============================================================================================= */ + +describe('T83 — 정상 crash window 는 전역 reconciliation 이 아니다', () => { + /** + * 확정 인터리브(계획 3R): ①권위 `N` fresh read → ②전이 불변식 선검증 → ③composed 저널 + * (`expected=N`) acknowledged → ④결과 ref `-` create-only 발행 → ⑤**composed 권위 CAS + * 직전 crash** → ⑦`권위 N` + `저널 composed expected N` + `matching ref N+1` 관측. + */ + it('권위 N · composed 저널 expected N · matching ref N+1 → resume-composed-cas', () => { + const v = classifyRecovery( + obs({ prefixRefs: refs([REF_N1, OID]), journal: entries(journal()) }), + ) + expect(v.kind).toBe('resume-composed-cas') + if (v.kind !== 'resume-composed-cas') return + expect(v.resume.txnId).toBe(T1) + expect(v.resume.refName).toBe(REF_N1) + expect(v.resume.resultOid).toBe(OID) + expect(v.resume.expectedAuthorityRevision).toBe(N) + expect(v.resume.resultingRevision).toBe(N + 1) + }) + + it('verdict 이름은 단계성을 보존한다 — 폐기 어휘 promote-published 는 재유입되지 않는다', () => { + const v = classifyRecovery( + obs({ prefixRefs: refs([REF_N1, OID]), journal: entries(journal()) }), + ) + // 「published 권위를 곧바로 기록」하는 구현이 **이름상으로도** 성립하지 않아야 한다(정정 203). + expect(JSON.stringify(v)).not.toContain('promote-published') + expect(JSON.stringify(v)).not.toContain('published') + }) +}) + +/* ================================================================================================ + * T73·T74·T77 — 앵커 발화와 음성 통제 + * ============================================================================================= */ + +describe('T73 — 앵커 발화(권위 단독 롤백)', () => { + it('저널이 composed 를 지나 published 인데 권위가 N 이면 롤백이 증명된다 → reconciliation', () => { + // composed CAS 가 커밋(revision N+1)된 뒤 published 저널이 선기록됐고, 그다음 권위 파일만 + // 이전 세대로 되돌아간 상태. ref `N+1` 은 남아 있으므로 `refRevision > record.revision` 이 참이다. + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID]), + journal: entries( + journal({ + ...walBound('published'), + publishedAt: 9, + expectedAuthorityRevision: N + 1, + previousAuthorityStage: 'composed', + nextAuthorityStage: 'published', + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('anchor-rollback') + expect(reasons(v)).toContain('stage-not-composed') + }) +}) + +describe('T74 — 음성 통제(오탐 0)', () => { + it('권위가 이미 N+1 이고 composed 전이를 정확히 반영하면 정상 대기다(재실행 아님 · 정정 204ⓑ)', () => { + const advanced = record({ + revision: N + 1, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: advanced }, + prefixRefs: refs([REF_N1, OID]), + journal: entries(journal({ draftDigest: draftDigestOf(advanced) })), + }), + ) + expect(v.kind).toBe('normal-wait') + }) + + it('권위가 더 전진(published)해도 같은 ref 집합이 blocker 를 만들지 않는다', () => { + const advanced = record({ + revision: N + 2, + currentIntegrationStage: 'published', + currentIntegrationResultOid: OID, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: advanced }, + prefixRefs: refs([REF_N1, OID]), + journal: entries( + journal({ + ...walBound('published'), + publishedAt: 9, + expectedAuthorityRevision: N + 1, + previousAuthorityStage: 'composed', + nextAuthorityStage: 'published', + draftDigest: draftDigestOf(advanced), + }), + ), + }), + ) + expect(v.kind).toBe('normal-wait') + }) +}) + +describe('T77 — 권위 부재 + ref 잔존', () => { + it('권위 레코드가 없으면 비교 기준은 0 이고 모든 결과 ref 가 그보다 크다 → reconciliation', () => { + const v = classifyRecovery( + obs({ + authority: { kind: 'absent' }, + prefixRefs: refs([formatResultRef(BENCH, 2, TPREV), OID2]), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('anchor-rollback') + }) + + it('권위 부재 ∧ ref 도 부재면 판정할 것이 없다(조용한 통과가 아니라 무변이 적격)', () => { + expect(classifyRecovery(obs({ authority: { kind: 'absent' } })).kind).toBe('no-mutation') + }) + + it('권위를 읽지 못하면 그 자체가 blocker 다(부재로 축소하지 않는다)', () => { + const v = classifyRecovery(obs({ authority: { kind: 'unreadable', detail: 'EIO' } })) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('authority-unreadable') + }) +}) + +/* ================================================================================================ + * T76·T90 — 보존된 과거 ref + * ============================================================================================= */ + +describe('T76·T90 — 보존된 전 결과 ref 에 같은 식을 적용한다', () => { + it('현재 revision 이하의 형제 시도 ref 는 정상 보존이다(§3-T29 의 영구 공존)', () => { + const v = classifyRecovery( + obs({ + prefixRefs: refs( + [formatResultRef(BENCH, 2, TPREV), OID2], + [formatResultRef(BENCH, N, T2), OID2], + ), + journal: entries( + journal({ + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + draftDigest: draftDigestOf(record()), + }), + ), + }), + ) + expect(v.kind).toBe('no-mutation') + }) + + it('현재 revision 초과면 current 여부와 무관하게 reconciliation 이다', () => { + const v = classifyRecovery( + obs({ prefixRefs: refs([formatResultRef(BENCH, N + 4, TPREV), OID2]) }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('anchor-rollback') + }) +}) + +/* ================================================================================================ + * T78 — 열거 실패를 empty set 으로 축소하지 않는다 + * ============================================================================================= */ + +describe('T78 — 관측 실패는 「결과 없음」이 아니다', () => { + it('접두 열거 실패 → ref-enumeration-failed', () => { + const v = classifyRecovery(obs({ prefixRefs: { kind: 'failed', detail: 'exit 128' } })) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('ref-enumeration-failed') + }) + + it('정확 이름 열거 실패도 별개 관측이라 별개로 발화한다', () => { + const v = classifyRecovery(obs({ exactRefs: { kind: 'failed', detail: 'exit 128' } })) + expect(kinds(v)).toContain('ref-enumeration-failed') + }) + + it('D/F 충돌(bare 부모 ref) → ref-namespace-conflict 가 **최우선**이다', () => { + const v = classifyRecovery( + obs({ + exactRefs: refs([`refs/fleet/integrated/${BENCH}`, OID2]), + prefixRefs: { kind: 'failed', detail: 'exit 128' }, + authority: { kind: 'unreadable' }, + }), + ) + expect(v.kind).toBe('reconciliation-required') + // 최우선 = 다른 사유에 **가려지지 않는다**. 목록의 첫 항목이어야 한다. + expect(kinds(v)[0]).toBe('ref-namespace-conflict') + }) + + it('bare 부모가 **접두 열거**에서 나와도 최우선 충돌이다(관측 출처에 의존하지 않는다)', () => { + const v = classifyRecovery(obs({ prefixRefs: refs([`refs/fleet/integrated/${BENCH}`, OID2]) })) + expect(kinds(v)[0]).toBe('ref-namespace-conflict') + }) + + it('문법 위반 ref 는 무시가 아니라 blocker 다', () => { + const v = classifyRecovery( + obs({ prefixRefs: refs([`refs/fleet/integrated/${BENCH}/007-${T1}`, OID]) }), + ) + expect(kinds(v)).toContain('ref-syntax-invalid') + }) + + it('다른 bench 의 ref 가 열거에 섞이면 blocker 다(접두 경계 신뢰의 회귀 핀)', () => { + const v = classifyRecovery(obs({ prefixRefs: refs([formatResultRef(T2, N + 1, T1), OID]) })) + expect(kinds(v)).toContain('ref-foreign-bench') + }) + + it('저널 열거 실패·읽기 실패·버전 스큐는 각각 blocker 다', () => { + expect(kinds(classifyRecovery(obs({ journal: { kind: 'failed' } })))).toContain( + 'journal-enumeration-failed', + ) + const skew = classifyRecovery( + obs({ + journal: { + kind: 'ok', + entries: [ + { + txnId: T1, + read: { kind: 'incompatible-version', path: '/p', found: 2, supported: 1 }, + }, + ], + }, + }), + ) + expect(kinds(skew)).toContain('journal-unreadable') + }) + + it('저널 identity 불일치는 읽기 성공과 구분되는 blocker 다', () => { + const v = classifyRecovery( + obs({ journal: entries(journal({ repoCommonGitDir: '/other/.git' })) }), + ) + expect(kinds(v)).toContain('journal-identity-mismatch') + }) + + it('레코드 안의 txnId 가 열거된 이름과 다르면 blocker 다(저널 층 7종 검증의 판정 측 재확인)', () => { + const v = classifyRecovery( + obs({ + journal: { + kind: 'ok', + entries: [{ txnId: T2, read: { kind: 'found', record: journal() } }], + }, + }), + ) + expect(kinds(v)).toContain('journal-identity-mismatch') + }) + + it('권위 레코드의 identity 가 관측 대상 bench 와 다르면 blocker 다', () => { + const v = classifyRecovery( + obs({ + authority: { + kind: 'found', + record: record({ identity: { ...IDENTITY, benchId: T2 } }), + }, + }), + ) + expect(kinds(v)).toContain('authority-identity-mismatch') + }) +}) + +/* ================================================================================================ + * T79·T80 — 계층(앵커가 먼저) · blocker 지배 + * ============================================================================================= */ + +describe('T79 — 앵커 게이트가 승격 판정보다 먼저다', () => { + it('승격 조건을 전부 만족해도 설명되지 않는 더 높은 ref 가 있으면 승격하지 않는다', () => { + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID], [formatResultRef(BENCH, N + 3, T2), OID2]), + journal: entries(journal()), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('anchor-rollback') + }) + + it('T88 — 같은 next-revision 을 주장하는 ref 가 둘이면 승격은 없다', () => { + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID], [formatResultRef(BENCH, N + 1, T2), OID2]), + journal: entries(journal()), + }), + ) + expect(v.kind).toBe('reconciliation-required') + }) +}) + +describe('T80 — blocker 가 있으면 어떤 행동 인가도 나오지 않는다', () => { + it('blocker 를 만드는 입력 전수에서 no-mutation·resume·normal-wait 가 나오지 않는다', () => { + const blocking: readonly Partial[] = [ + { authority: { kind: 'unreadable' } }, + { prefixRefs: { kind: 'failed' } }, + { exactRefs: { kind: 'failed' } }, + { exactRefs: refs([`refs/fleet/integrated/${BENCH}`, OID2]) }, + { prefixRefs: refs([`refs/fleet/integrated/${BENCH}/x-${T1}`, OID]) }, + { prefixRefs: refs([formatResultRef(BENCH, N + 9, T2), OID2]) }, + { journal: { kind: 'failed' } }, + { journal: entries(journal({ benchId: T2 })) }, + ] + // 정상 조합(승격 가능)을 **겹쳐도** blocker 가 지배한다 — 「하나라도 있으면 전면 차단」. + for (const over of blocking) { + const v = classifyRecovery( + obs({ prefixRefs: refs([REF_N1, OID]), journal: entries(journal()), ...over }), + ) + expect(v.kind).toBe('reconciliation-required') + } + }) +}) + +/* ================================================================================================ + * T84~T87·T89 — 면제 조건의 개별 반증 + * ============================================================================================= */ + +describe('면제 10조건 — 하나라도 깨지면 승격이 아니다', () => { + it('T84 — 대응 저널이 없는 N+1 ref', () => { + const v = classifyRecovery(obs({ prefixRefs: refs([REF_N1, OID]) })) + expect(v.kind).toBe('reconciliation-required') + expect(reasons(v)).toContain('journal-missing') + }) + + it('T85 — ref 가 가리키는 OID 가 journal.resultOid 와 다르다', () => { + const v = classifyRecovery( + obs({ prefixRefs: refs([REF_N1, OID2]), journal: entries(journal()) }), + ) + expect(reasons(v)).toContain('oid-mismatch') + }) + + it('T86 — expected revision 이 관측 권위 revision 보다 크다(N+1)', () => { + const j = journal({ + expectedAuthorityRevision: N + 1, + resultRef: formatResultRef(BENCH, N + 2, T1), + }) + const v = classifyRecovery( + obs({ prefixRefs: refs([formatResultRef(BENCH, N + 2, T1), OID]), journal: entries(j) }), + ) + expect(reasons(v)).toContain('expected-revision-mismatch') + }) + + it('T87 — 두 단계 이상 앞섬(권위 N-1 · 저널 expected N · ref N+1)', () => { + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: record({ revision: N - 1 }) }, + prefixRefs: refs([REF_N1, OID]), + journal: entries(journal()), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(reasons(v)).toContain('expected-revision-mismatch') + }) + + it('T75 — `composed` 저널의 ref revision 이 expected+1 이 아니면 산술 결속 위반이다', () => { + // 발행자가 `expectedAuthorityRevision` 을 그대로 실은 off-by-one(정정 196 이 폐기한 정의). + // ⚠ 이 픽스처의 정의역은 `journal()` 기본값인 **`composed`** 다 — 전 stage 식은 + // `expected + 2 - stageIndex`(정정 223)이고 `published` 에서는 `` 가 **정상**이다. + const wrong = formatResultRef(BENCH, N, T1) + const v = classifyRecovery( + obs({ prefixRefs: refs([wrong, OID]), journal: entries(journal({ resultRef: wrong })) }), + ) + expect(v.kind).toBe('reconciliation-required') + // 판정 위치가 **저널 검증**으로 올라갔다(Codex PR#289 4R P1) — git ref 가 아직 없어도 잡힌다. + expect(kinds(v)).toContain('journal-result-ref-invalid') + }) + + it('T75 — 앵커 후보 쪽 산술 결속도 같은 식으로 본다(`composed` 저널에 expected+2 를 실은 이름)', () => { + // ⚠ `expected + 2` 는 `prepared`(stageIndex 0)에서는 **정상값**이다 — 위반이 되는 것은 이 픽스처의 + // 정의역인 `composed` 에서다(정정 223). + // ⚠ 위 케이스는 `refRevision ≤ revision` 이라 **복구표 경로**가 잡는다. 면제 조건 ⑥ 자신의 + // 반증력은 **후보 경로**(`refRevision > revision`)에서만 관측된다 — 뮤테이션 자기검사가 이 공백을 + // 드러냈다(⑥ 삭제 뮤턴트 생존). + const ahead = formatResultRef(BENCH, N + 2, T1) + const v = classifyRecovery( + obs({ prefixRefs: refs([ahead, OID]), journal: entries(journal({ resultRef: ahead })) }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-result-ref-invalid') + }) + + /** + * **Codex PR#289 7R P1** — `abandoned` 를 **산술 정의역에서 통째로 빼면** 그 자리에 구멍이 남는다. + * 근거였던 「포기 시점에 따라 expected 가 달라져 식이 하나로 고정되지 않는다」는 **식을 찾으라는 + * 신호**였지 면제의 근거가 아니었다(5R 정정 223 이 같은 형태를 이미 한 번 교정했다). + * + * 포기 저널은 `previousAuthorityStage`(=P)를 **반드시** 싣고(`journal.ts` `canAdvanceStage` + + * append 의 디스크 stage 대조), 포기 CAS 는 권위가 P 를 커밋한 뒤에 pending 이므로 + * `expected = R0 + idx(P) + 1` 이다. 절대식 `ref = R0 + 2` 를 대입하면 + * **`ref = expected + 1 - idx(P)`** — 닫힌 창(P ∈ {prepared, composed})은 등식, + * 열린 창(P ∈ {published, finalized})은 개입 CAS 가 `expected` 를 올리므로 **상한**이다. + * + * 이 구멍의 대역은 **`refRevision ≤ record.revision`** 이다 — 높은 쪽은 앵커가 잡지만 낡은 이름은 + * 어디서도 안 잡혀, 그 상태가 청소된 뒤 권위 롤백을 증언할 유일한 증인이 침묵한다. + */ + it('T75 — `abandoned` 저널의 ref revision 은 previousAuthorityStage 로 결속된다', () => { + const rec = record({ + revision: R0 + 2, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, + }) + const abandoned = (resultRef: string) => + journal({ + stage: 'abandoned', + expectedAuthorityRevision: R0 + 2, + previousAuthorityStage: 'composed', + nextAuthorityStage: undefined, + resultRef, + abandonedAt: 9, + abandonReason: 'user-abandon', + draftDigest: abandonDraftDigestOf(rec), + }) + // P = composed → 등식 `ref = expected + 1 - 1 = expected`. 낡은 이름은 앵커 대역 밖이라 여기서 잡아야 한다. + const stale = formatResultRef(BENCH, R0 - 1, T1) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + prefixRefs: refs([stale, OID]), + journal: entries(abandoned(stale)), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-result-ref-invalid') + + // 음성 통제 — 도출식이 요구하는 값이면 통과한다(과결속이 아니다). + const sound = formatResultRef(BENCH, R0 + 2, T1) + const ok = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + prefixRefs: refs([sound, OID]), + journal: entries(abandoned(sound)), + }), + ) + expect(kinds(ok)).not.toContain('journal-result-ref-invalid') + }) + + /** + * 열린 창(`published`·`finalized`)에서의 포기는 **등식이 아니라 상한**이다 — 그 창에서는 활동·회수 + * CAS 가 정상적으로 `expected` 를 올린다(정정 233 이 같은 이유로 `finalized` 를 상한으로 되돌렸다). + * 등식을 걸면 **정상 상태를 손상으로 오분류**한다. + */ + it('T75 — 열린 창에서의 포기는 상한이라 개입 CAS 를 오분류하지 않는다', () => { + // published 커밋 직후의 정상 revision 은 `R0+3` 인데, 그 뒤 개입 CAS 4회가 지나갔다. + const rec = record({ + revision: R0 + 7, + currentIntegrationStage: 'published', + currentIntegrationResultOid: OID, + }) + const sound = formatResultRef(BENCH, R0 + 2, T1) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + prefixRefs: refs([sound, OID]), + journal: entries( + journal({ + stage: 'abandoned', + expectedAuthorityRevision: R0 + 7, + previousAuthorityStage: 'published', + nextAuthorityStage: undefined, + resultRef: sound, + publishedAt: 9, + abandonedAt: 9, + abandonReason: 'user-abandon', + draftDigest: abandonDraftDigestOf(rec), + }), + ), + }), + ) + expect(kinds(v)).not.toContain('journal-result-ref-invalid') + }) + + /** + * 파서는 `previousAuthorityStage` 부재를 허용한다(`journal.ts` 의 optional 필드). 그 필드가 없으면 + * 포기 산술의 **정의역 자체를 알 수 없으므로** fail-closed 여야 한다 — 그러지 않으면 필드를 지우는 + * 것만으로 방금 세운 결속을 통째로 우회한다. + */ + /** + * **로컬 적대 리뷰 P1(7R 반영분에 대한)** — 포기 산술을 **등식**으로 두면 WAL 의 **정상 크래시 창**을 + * 손상으로 오분류한다. `previousAuthorityStage`(=P)는 권위 stage 가 아니라 **직전 저널 stage** 이고 + * (`journal.ts` 의 append 가 디스크 stage 와 일치를 강제한다), WAL 은 선기록이므로 저널이 P 일 때 + * 권위는 **P(post-CAS) 또는 P-1(pre-CAS 크래시 창)** 이다 — 후자는 §3-T83 이 정상으로 고정한 상태다. + * + * pre-CAS 에서 포기하면 `expected` 가 1 낮아 `ref = expected + 2 - idx(P)` 이므로, 등식은 정확히 + * 1 어긋난다. 저널은 삭제 금지·바이트 동일 재기록만 허용되므로 그 bench 는 **영구 고착**한다. + * ⇒ 결속은 등식이 아니라 **밴드**여야 한다: `expected + 1 - idx(P) ≤ ref ≤ expected + 2 - idx(P)`. + */ + it('T75 — 포기 산술은 밴드다(저널 선기록 뒤 CAS 전 포기를 오분류하지 않는다)', () => { + const rec = record({ + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + currentIntegrationResultOid: undefined, + }) + // 형제 txn — `composed` 저널을 선기록한 뒤 그 CAS 전에 포기했다(권위는 아직 `prepared` = R0+1). + const preCasAbandon = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + journal: entries( + journal({ + txnId: T2, + stage: 'abandoned', + previousAuthorityStage: 'composed', + nextAuthorityStage: undefined, + expectedAuthorityRevision: R0 + 1, + resultRef: formatResultRef(BENCH, R0 + 2, T2), + abandonedAt: 9, + abandonReason: 'user-abandon', + }), + ), + }), + ) + expect(kinds(preCasAbandon)).not.toContain('journal-result-ref-invalid') + }) + + it('T75 — previousAuthorityStage 없는 포기 저널은 fail-closed 다', () => { + // ⚠ 정의역을 **형제 txn**(current 아님)으로 잡는다 — current 로 잡으면 결속 축 + // (`journal-authority-binding`)이 먼저 답해 산술 층의 반증력이 가려진다. 종결 stage 는 + // 활성 집합 밖이라 고아 검사도 침묵하므로, 이 상태를 잡을 수 있는 층은 **저널 등재 산술뿐**이다. + const rec = record({ + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + currentIntegrationResultOid: undefined, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + journal: entries( + journal({ + txnId: T2, + stage: 'abandoned', + expectedAuthorityRevision: R0 + 2, + previousAuthorityStage: undefined, + nextAuthorityStage: undefined, + resultRef: formatResultRef(BENCH, R0 + 2, T2), + abandonedAt: 9, + abandonReason: 'user-abandon', + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-result-ref-invalid') + }) + + /** + * **Codex PR#289 7R P1** — 포기 저널의 검증이 종결 필드의 **존재**만 봤다. 결과·게시 증거가 + * `previousAuthorityStage` 와 정합하는지는 **쓰기 경로**(`journal.ts` 의 `EVIDENCE_FIELDS` 도입 + * 단계 제한)에만 있고, **복구는 착지한 레코드만 본다** — 그 방어가 돌지 않은 디스크 상태(롤백·손상)를 + * 복구가 그대로 받아 `idempotent-cleanup` 을 인가한다. + * + * 6R 이 「종결 증거는 모든 admitted 저널에서 본다」를 같은 이유로 세웠다. 이번엔 그 **반대 방향**이다: + * 종결 레코드가 **이전 stage 에서 상속돼야 할 증거 형태**를 갖췄는지 복구가 독립적으로 요구한다. + */ + it('포기 저널의 상속 증거 형태는 previousAuthorityStage 와 정합해야 한다', () => { + const rec = record({ + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + currentIntegrationResultOid: undefined, + }) + // 형제 txn(current 아님)으로 잡아 **저널 등재 층**만 남긴다. ref 는 절대식 `R0 + 2` 로 고정이고 + // `expected = R0 + idx(P) + 1` 이므로 stage 마다 expected 만 달라진다. + const sibling = ( + previousAuthorityStage: 'prepared' | 'composed' | 'published', + over: Partial, + ) => + classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + journal: entries( + journal({ + txnId: T2, + stage: 'abandoned', + previousAuthorityStage, + nextAuthorityStage: undefined, + expectedAuthorityRevision: R0 + WAL_ORDER.indexOf(previousAuthorityStage) + 1, + resultRef: formatResultRef(BENCH, R0 + 2, T2), + abandonedAt: 9, + abandonReason: 'user-abandon', + ...over, + }), + ), + }), + ) + const EV = 'journal-abandon-evidence-invalid' + + // ⓐ `composed` 에서 포기했는데 **게시 시각이 날조**됐다 — 게시된 적 없는 시도가 게시로 증언된다. + expect(kinds(sibling('composed', { publishedAt: 9 }))).toContain(EV) + // ⓑ `published` 에서 포기했는데 **게시 시각이 소거**됐다 — 이미 발행된 결과의 증거가 사라진다. + expect(kinds(sibling('published', { publishedAt: undefined }))).toContain(EV) + // ⓒ `prepared` 에서 포기했는데 **존재한 적 없는 결과**를 들고 있다. + expect( + kinds(sibling('prepared', { resultTree: TREE, resultOid: OID, publishedAt: undefined })), + ).toContain(EV) + // ⓓ `composed` 에서 포기했는데 **결과 증거가 없다**. + expect(kinds(sibling('composed', { resultTree: undefined, resultOid: undefined }))).toContain( + EV, + ) + + // 음성 통제 — 세 stage 의 **정상 형태**는 통과한다(과결속이 아니다). + expect( + kinds(sibling('prepared', { resultTree: undefined, resultOid: undefined })), + ).not.toContain(EV) + expect(kinds(sibling('composed', {}))).not.toContain(EV) + expect(kinds(sibling('published', { publishedAt: 9 }))).not.toContain(EV) + }) + + it('journal.resultRef 와 실제 ref 이름이 다르면 승격하지 않는다', () => { + const v = classifyRecovery( + obs({ + // 저널이 든 이름은 유효하지만(문법·bench·txn·산술 전부 통과) **관측된 ref 가 다르다**. + prefixRefs: refs([formatResultRef(BENCH, N + 2, T1), OID]), + journal: entries(journal()), + }), + ) + expect(reasons(v)).toContain('ref-name-mismatch') + }) + + it('stage 가 정확히 composed 가 아니면 승격하지 않는다', () => { + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID]), + journal: entries( + journal({ + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + draftDigest: draftDigestOf(record()), + }), + ), + }), + ) + expect(reasons(v)).toContain('stage-not-composed') + }) + + it('종결 증거(abandonedAt·abandonReason·publishedAt)가 실려 있으면 승격하지 않는다', () => { + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID]), + journal: entries(journal({ abandonedAt: 7, abandonReason: 'superseded' })), + }), + ) + // ⚠ 판정 위치가 **저널 검증**으로 올라갔다(Codex PR#289 6R P1) — 종결 증거는 앵커 후보가 아닌 + // 저널에서도 모순이므로 admitted 전수에서 본다. + expect(kinds(v)).toContain('journal-terminal-evidence') + }) + + it('draftDigest 가 현재 권위에서 재구성한 값과 다르면 승격하지 않는다', () => { + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID]), + journal: entries(journal({ draftDigest: 'f'.repeat(64) })), + }), + ) + expect(reasons(v)).toContain('digest-mismatch') + }) + + it('전이 결속 필드(previous·next·generation)가 어긋나면 승격하지 않는다', () => { + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID]), + journal: entries(journal({ integrationGeneration: 9 })), + }), + ) + expect(reasons(v)).toContain('stage-binding-mismatch') + }) + + it('T89 — 롤백된 current 슬롯: current 라는 이유만으로 면제되지 않는다', () => { + // T1 완료 → 권위가 T2 로 전진 → 파일 롤백으로 current 가 **다시 T1**. T2 의 composed 저널과 + // ref 가 남아 있는데, 「T1 이 current 다」를 근거로 T2 ref 를 숨기면 롤백을 증명하는 가장 높은 + // ref 가 바로 그 이유로 사라진다(계획 정정 199ⓐ). + const rolled = record({ currentIntegrationTxnId: T1, currentIntegrationStage: 'prepared' }) + const j2 = journal({ + txnId: T2, + resultRef: formatResultRef(BENCH, N + 1, T2), + draftDigest: digestAuthorityDraft(composedDraft(rolled, T2)), + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rolled }, + prefixRefs: refs([formatResultRef(BENCH, N + 1, T2), OID]), + journal: entries(j2), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(reasons(v)).toContain('transition-illegal') + }) +}) + +/* ================================================================================================ + * T32 — 복구표 6조합(현재 txn 정의역) + * ============================================================================================= */ + +describe('T32 — 복구표는 현재 txn 1건을 정의역으로 한다', () => { + const prepared = journal({ + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + draftDigest: draftDigestOf(record()), + }) + + it('prepared ∧ ref 부재 → no-mutation(포기·재준비 적격)', () => { + expect(classifyRecovery(obs({ journal: entries(prepared) })).kind).toBe('no-mutation') + }) + + /** + * **복구표의 「`prepared` ∧ ref 존재」 행은 전용 arm 없이 답한다**(Codex PR#289 5R P1 의 연쇄). + * + * stage 별 ref 산술이 서면서 그 상태는 **구조적으로 앵커 후보**가 됐다: `prepared` 저널이 현재 txn 을 + * 가리키려면 결속이 post-CAS 여야 하고(`revision = expected + 1`), 그 저널의 ref 는 `expected + 2` + * = `revision + 1` 이라 **항상 `> revision`** 이다. 이름이 다르면 그건 mismatch 라는 다른 축이다. + * 그래서 전용 `result-ref-unattributed` arm 은 **도달 불가**가 되어 제거했다(정정 183·189) — + * 표가 요구하는 **결과**(reconciliation)는 앵커가 그대로 보장한다. + */ + it('prepared ∧ ref 존재 → reconciliation(앵커가 답한다 · 전용 arm 없음)', () => { + const rec = record({ revision: N + 3 }) + const boundPrepared = journal({ + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + expectedAuthorityRevision: N + 2, + resultRef: formatResultRef(BENCH, N + 4, T1), + draftDigest: draftDigestOf(rec), + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + // 존재하는 ref 는 **그 저널이 이름 붙인 바로 그것**이어야 한다(아니면 mismatch 라는 다른 축). + prefixRefs: refs([formatResultRef(BENCH, N + 4, T1), OID]), + journal: entries(boundPrepared), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('anchor-rollback') + }) + + it('composed ∧ ref 부재 → no-mutation(ref 발행 전 크래시)', () => { + expect(classifyRecovery(obs({ journal: entries(journal()) })).kind).toBe('no-mutation') + }) + + it('composed ∧ ref 존재 ∧ OID 불일치 → result-ref-mismatch', () => { + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: record({ revision: N + 3 }) }, + prefixRefs: refs([REF_N1, OID2]), + // 결속된 pre-cas 쌍(권위 `prepared` · expected === revision)에서 ref 의 OID 만 어긋난다. + journal: entries( + journal({ + expectedAuthorityRevision: N + 3, + resultRef: formatResultRef(BENCH, N + 4, T1), + }), + ), + }), + ) + expect(kinds(v)).toContain('result-ref-mismatch') + }) + + it('OID 가 같아도 ref **이름**이 저널의 증언과 다르면 mismatch 다(축 분리)', () => { + // ⚠ 이름과 OID 를 **한 픽스처에서 함께** 어긋내면 어느 쪽이 잡았는지 알 수 없다 — 뮤테이션에서 + // 이름 대조를 지워도 OID 대조가 가려버렸다(생존). 여기서는 OID 를 **일치**시켜 축을 분리한다. + const rec = record({ revision: N + 3 }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + prefixRefs: refs([REF_N1, OID]), + journal: entries( + journal({ + expectedAuthorityRevision: N + 3, + resultRef: formatResultRef(BENCH, N + 4, T1), + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toEqual(['result-ref-mismatch']) + }) + + it('published 저널은 차단하지 않는다(C6 · 정상 대기)', () => { + const pub = journal({ + ...walBound('published'), + publishedAt: 9, + expectedAuthorityRevision: N + 1, + previousAuthorityStage: 'composed', + nextAuthorityStage: 'published', + }) + const rec = record({ + revision: N + 2, + currentIntegrationStage: 'published', + currentIntegrationResultOid: OID, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T1), OID]), + journal: entries(journal({ ...pub, draftDigest: draftDigestOf(rec) })), + }), + ) + expect(v.kind).toBe('normal-wait') + }) + + it('finalized·abandoned 잔존 → 청소 복구(멱등 종결 · 삭제 아님)', () => { + // ⚠ 권위 stage 는 저널보다 한 단계 뒤까지만 허용된다(WAL 교차 결속) — `finalized` 저널의 짝은 + // `published` 권위다. `abandoned` 는 종결 기록이라 그 결속의 정의역 밖이다(포기 CAS 가 pending). + // 결속된 **pre-CAS** 쌍 — 종결 CAS 가 아직 커밋되지 않은 상태(expected === 관측 revision). + // ⚠ `finalized` 는 ref 를 **요구**하고(5R P1) `abandoned` 는 요구하지 않는다(prepared 에서 + // 포기하면 ref 가 존재한 적이 없다). + const finalizedRec = record({ + revision: R0 + 3, + currentIntegrationStage: 'published', + currentIntegrationResultOid: OID, + }) + const finalized = classifyRecovery( + obs({ + authority: { kind: 'found', record: finalizedRec }, + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T1), OID]), + journal: entries( + journal({ + ...walBound('finalized'), + publishedAt: 9, + draftDigest: nextDraftDigestOf(finalizedRec, 'finalized'), + }), + ), + }), + ) + expect(finalized.kind).toBe('idempotent-cleanup') + + // ⚠ 산술이 **가능한 쌍**이어야 한다(Codex PR#289 7R P1 이후): `prepared` 에서의 포기는 + // `expected = R0 + 1 = N` 이고 ref 는 `expected + 1 = N + 1`(기본값)이다. 초안의 `N + 3` 은 + // 닫힌 창(활성 저널 = 실행 차단)에 개입 CAS 3회를 가정한 **존재할 수 없는 쌍**이었고, + // `abandoned` 산술 면제가 그것을 정상으로 고정해 온 것이 그 면제의 방증이다. + const abandonedRec = record() + const abandoned = classifyRecovery( + obs({ + authority: { kind: 'found', record: abandonedRec }, + journal: entries( + journal({ + stage: 'abandoned', + expectedAuthorityRevision: N, + previousAuthorityStage: 'prepared', + nextAuthorityStage: undefined, + // `prepared` 에서의 포기라 결과 증거가 **없다** — 그래야 ref 를 요구하지 않는다(6R P1). + resultTree: undefined, + resultOid: undefined, + abandonedAt: 9, + abandonReason: 'user-abandon', + draftDigest: abandonDraftDigestOf(abandonedRec), + }), + ), + }), + ) + expect(abandoned.kind).toBe('idempotent-cleanup') + }) + + it('권위가 이미 N+1 이어도 반영된 결과 증거가 다르면 완료 확인이 아니다(정정 204ⓒ)', () => { + // CAS 가 성공했는지 **불확실**한데 결속까지 어긋난 상태 — 자동 전진 금지. + const v = classifyRecovery( + obs({ + authority: { + kind: 'found', + record: record({ + revision: N + 1, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID2, + }), + }, + prefixRefs: refs([REF_N1, OID]), + journal: entries(journal()), + }), + ) + expect(v.kind).toBe('reconciliation-required') + // ⑥ 의 결속 판정이 **복구표보다 먼저** 답한다 — 「post-cas 인데 권위가 든 결과가 저널의 증언과 + // 다르다」는 stage 순서가 아니라 결속의 문제다(Codex PR#289 3R P1). + expect(kinds(v)).toContain('journal-authority-binding') + }) + + /** + * **Codex PR#289 3R P1** — 같은 stage 끼리도 결속을 본다. 이전 판은 stage **인덱스만** 비교해서 + * 「둘 다 composed 인데 결과·revision·세대가 어긋난」 손상 쌍이 그대로 복구표에 도달했고, ref 가 + * 없으면 `no-mutation`(= 포기·재준비 적격)이 나왔다. + */ + it('같은 stage 여도 결속이 어긋나면 ref 가 없어도 reconciliation 이다', () => { + const v = classifyRecovery( + obs({ + authority: { + kind: 'found', + record: record({ + revision: N + 1, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID2, + }), + }, + journal: entries(journal()), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-authority-binding') + }) + + /** + * **Codex PR#289 4R P1** — post-CAS 결속이 stage·revision·세대·resultOid 만 보면 **draft 투영 안의 + * 나머지 필드**(`lifecycle`·`sourceGeneration`·`activeActivity` …)가 롤백·손상돼도 통과한다. + * 저널은 그 전체를 `draftDigest` 로 이미 증언하고 있으므로 **추가 입력 없이** 대조할 수 있다. + */ + it('post-CAS 인데 권위 draft 가 저널의 draftDigest 와 다르면 결속 위반이다', () => { + const advanced = record({ + revision: N + 1, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, + }) + // 저널이 증언한 draft 는 `sourceGeneration: 3` 인데 권위는 4 로 전진했다 — stage·revision·세대· + // resultOid 는 전부 맞지만 **의도가 다른 draft** 다. + const rolled = record({ + revision: N + 1, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, + sourceGeneration: 4, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rolled }, + prefixRefs: refs([REF_N1, OID]), + journal: entries(journal({ draftDigest: draftDigestOf(advanced) })), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-authority-binding') + }) + + /** + * **Codex PR#289 4R P1** — 저널이 든 `resultRef` 는 **불변 필드**라, git ref 가 아직 없어도 + * 잘못된 이름이 살아남아 나중에 그대로 발행된다. 문법 소유가 이 PR 로 온 이상 여기서 잡아야 한다. + */ + it('저널이 든 resultRef 가 다른 bench·txn 을 가리키면 blocker 다(git ref 유무와 무관)', () => { + for (const bad of [ + formatResultRef(T2, N + 1, T1), + formatResultRef(BENCH, N + 1, T2), + 'refs/heads/x', + ]) { + const v = classifyRecovery(obs({ journal: entries(journal({ resultRef: bad })) })) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-result-ref-invalid') + } + }) + + /** + * **Codex PR#289 4R P1** — `published` 저널의 ref 는 revision 이 권위 이하라 앵커 후보가 아니다. + * 그 분기가 OID 를 대조하지 않으면, 교체·손상된 ref 위에서 시퀀서가 게시를 계속한다. + */ + /** + * **Codex PR#289 5R P1** — ref 발행은 composed CAS **앞**이므로 post-CAS 상태에서 ref 가 **사라진 + * 것**은 프로토콜상 불가능하다. 그것을 `no-mutation`(포기·재준비 적격)으로 답하면 **ref 롤백·손상이 + * 그대로 숨는다.** ⚠ pre-CAS composed(= 발행 전 크래시)는 그대로 `no-mutation` 이어야 한다. + */ + it('post-CAS 인데 결과 ref 가 사라졌으면 무변이 적격이 아니다', () => { + const rec = record({ + revision: N + 1, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, + }) + const gone = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + journal: entries(journal({ draftDigest: draftDigestOf(rec) })), + }), + ) + expect(gone.kind).toBe('reconciliation-required') + expect(kinds(gone)).toContain('result-ref-missing') + + // 대조군 — pre-CAS(권위가 아직 `prepared`)는 ref 가 없는 것이 **정상 크래시 창**이다. + expect(classifyRecovery(obs({ journal: entries(journal()) })).kind).toBe('no-mutation') + }) + + it('published 저널이어도 ref 가 교체됐으면 정상 대기가 아니다', () => { + const rec = record({ + revision: N + 2, + currentIntegrationStage: 'published', + currentIntegrationResultOid: OID, + }) + const pub = journal({ + ...walBound('published'), + publishedAt: 9, + expectedAuthorityRevision: N + 1, + previousAuthorityStage: 'composed', + nextAuthorityStage: 'published', + draftDigest: draftDigestOf(rec), + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + prefixRefs: refs([REF_N1, OID2]), // 같은 이름 · **다른 커밋** + journal: entries(pub), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('result-ref-mismatch') + }) + + it('고아 published 저널은 차단하지 않는다(활성 집합 = prepared·composed 뿐 · §3-T33)', () => { + const orphanPublished = journal({ + txnId: T2, + ...walBound('published'), + publishedAt: 9, + resultRef: formatResultRef(BENCH, N + 1, T2), + expectedAuthorityRevision: N + 1, + previousAuthorityStage: 'composed', + nextAuthorityStage: 'published', + }) + const v = classifyRecovery(obs({ journal: entries(journal(), orphanPublished) })) + expect(v.kind).toBe('no-mutation') + }) + + it('권위가 가리키는 txn 의 저널이 없으면 blocker 다(WAL 선기록 위반)', () => { + const v = classifyRecovery(obs({ journal: entries(journal({ txnId: T2 })) })) + expect(kinds(v)).toContain('current-txn-journal-missing') + }) +}) + +/* ================================================================================================ + * I11 — 고아 활성 저널의 2분 (계획 정정 194) + * ============================================================================================= */ + +describe('I11 — 고아 활성 저널은 활성 집합 정의가 아니라 복구 판정이 2분한다', () => { + const noCurrent = { + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + } as const + + it('prepared 고아 ∧ ref 없음 = WAL 선기록의 정상 크래시 창 → no-mutation', () => { + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: record(noCurrent) }, + journal: entries( + journal({ + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + draftDigest: draftDigestOf(record()), + }), + ), + }), + ) + expect(v.kind).toBe('no-mutation') + }) + + it('composed 고아 → reconciliation(영구 고착이 아니라 판정으로 탈출)', () => { + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: record({ revision: N + 3, ...noCurrent }) }, + journal: entries(journal()), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('orphan-active-journal') + }) + + it('ref 를 동반한 prepared 고아 → reconciliation', () => { + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: record({ revision: N + 3, ...noCurrent }) }, + prefixRefs: refs([REF_N1, OID]), + journal: entries( + journal({ + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + draftDigest: draftDigestOf(record()), + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + }) +}) + +/* ================================================================================================ + * Codex PR#289 P1 — 관측 계약의 구멍 3건 + * ============================================================================================= */ + +describe('P1 — 저널 identity 는 권위 유무와 무관하게 검증한다', () => { + it('권위 부재 + 다른 레포에서 복사된 저널은 benign 이 아니다', () => { + // benchId 가 같아도(영역 통째 복사·클론) `repoCommonGitDir`·`benchRoot` 는 다르다. 권위가 없다는 + // 이유로 그 대조를 건너뛰면 **남의 레포 저널이 재준비 적격으로 통과**한다 — 권위가 없을수록 더 + // 엄격해야 한다(fail-closed). + const v = classifyRecovery( + obs({ + authority: { kind: 'absent' }, + journal: entries( + journal({ + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + repoCommonGitDir: '/other/.git', + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-identity-mismatch') + }) + + it('권위 부재 + benchRoot 불일치도 같은 종별이다', () => { + const v = classifyRecovery( + obs({ + authority: { kind: 'absent' }, + journal: entries(journal({ benchRoot: '/elsewhere/.fleet-wb' })), + }), + ) + expect(kinds(v)).toContain('journal-identity-mismatch') + }) +}) + +describe('P1 — 권위와 저널의 교차 순서 결속(WAL 선기록)', () => { + it('권위가 저널보다 앞선 상태는 불가능하다 → reconciliation', () => { + // WAL 은 저널 선기록 → 권위 CAS 이므로 `authority stage ≤ journal stage` 다. 뒤집힌 관측은 + // **저널 단독 롤백·손상**의 증거이고, 저널 stage 만 보고 분기하면 그 상태가 `no-mutation` 으로 + // 통과해 손상 위에서 포기·재준비가 인가된다. + const v = classifyRecovery( + obs({ + authority: { + kind: 'found', + record: record({ + currentIntegrationStage: 'published', + currentIntegrationResultOid: OID, + }), + }, + journal: entries( + journal({ + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + draftDigest: draftDigestOf(record()), + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-behind-authority') + }) + + it('저널이 권위보다 두 단계 앞선 상태도 불가능하다(정정 204 불변식 ①)', () => { + // 「stage S 의 권위 커밋이 확인되기 전 stage S+1 저널 기록 금지」 → `journal ≤ authority + 1`. + // ref 를 두지 않아 **앵커가 침묵하는 구간**에서 이 결속만으로 잡히는지 본다. + const v = classifyRecovery( + obs({ + journal: entries( + journal({ + ...walBound('published'), + publishedAt: 9, + expectedAuthorityRevision: N + 1, + previousAuthorityStage: 'composed', + nextAuthorityStage: 'published', + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-too-far-ahead') + }) + + it('정상 창(권위 prepared · 저널 composed)은 여전히 통과한다', () => { + const v = classifyRecovery( + obs({ prefixRefs: refs([REF_N1, OID]), journal: entries(journal()) }), + ) + expect(v.kind).toBe('resume-composed-cas') + }) +}) + +describe('P1 — 미종결 txn 위의 고아 prepared 저널', () => { + it('다른 txn 이 pending 이면 prepared 고아는 benign 이 아니다', () => { + // 전이 불변식이 「미종결 T1 위에 T2 시작」을 금지하므로 저널-선기록 프로토콜은 T2 를 **애초에 + // 쓰지 말았어야 한다**. 그 존재 자체가 이상 신호인데, benign 규칙이 그것을 삼키고 T1 승격까지 + // 그대로 내주고 있었다. + const orphanT2 = journal({ + txnId: T2, + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + resultRef: formatResultRef(BENCH, N + 1, T2), + }) + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID]), + journal: entries(journal(), orphanT2), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('orphan-active-journal') + }) + + /** + * **CodeRabbit PR#289** — 완결 귀속 txn 의 저널은 정상 상태에서 `finalized` 다. 그것이 활성 stage 로 + * 남아 있으면 매체 손상·순서 위반의 증거인데, 초안은 「귀속돼 있으니 고아가 아니다」로 **제외**해 + * blocker 없이 통과시켰다. 다른 경로(`current-txn-journal-missing`)가 같은 계열의 매체 불일치를 + * 발화하는 것과 **비대칭**이었다. + */ + it('완결 귀속 txn 의 저널이 finalized 가 아니면 blocker 다(활성 게이트 밖 · 정정 234)', () => { + const v = classifyRecovery( + obs({ + authority: { + kind: 'found', + record: record({ + lifecycle: 'integrated', + completedIntegrationTxnId: T2, + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + }), + }, + journal: entries( + journal({ + txnId: T2, + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + resultRef: formatResultRef(BENCH, N + 1, T2), + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('completed-txn-journal-unfinalized') + }) + + /** + * **Codex PR#289 7R P1** — 위 검사는 저널이 **존재할 때만** 돌았다. 열거가 비면 + * `journals.get(completedTxnId)` 가 `undefined` 라 검사를 통째로 건너뛰고, 뒤이은 + * `current === undefined` 반환이 그 상태를 **`no-mutation`**(= 무변이 적격)으로 답한다. + * + * 저널은 **감사 보존**이고(§W-7 「복구 중 삭제 금지」) 완결 txn 은 `finalized` 저널을 반드시 + * 남기므로, 그 부재는 삭제·롤백이라는 **손상**이다. 「검사의 위치가 곧 정의역」(정정 229)의 + * 같은 형태 — 이번에는 **존재 전제**가 정의역을 좁혔다. + */ + it('완결 귀속 txn 의 저널 부재도 blocker 다(존재할 때만 보면 삭제가 숨는다)', () => { + const completedRecord = record({ + lifecycle: 'integrated', + completedIntegrationTxnId: T2, + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + }) + // 저널 열거가 **정상 성공**했는데 내용이 비어 있다(열거 실패는 별개 종별이다). + const v = classifyRecovery( + obs({ authority: { kind: 'found', record: completedRecord }, journal: entries() }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('completed-txn-journal-unfinalized') + }) + + /** + * **Codex PR#289 7R P1**(로컬 적대 리뷰가 **위치를 정정**) — §W-8 은 완결 CAS 를 「txn 동일 ∧ + * valid/current ∧ **현 소스 세대 대표** ∧ 활성/시작-중 편집 활동 없음 ∧ 전이 가능」으로 규정하는데 + * 세대 항이 비어 있었다. 열린 창에서의 실행(C6·§3-T33)이 `sourceGeneration` 을 올린 뒤 완결하면 + * **통합된 적 없는 세대의 작업을 든 bench 가 `integrated`** 로 기록돼 부분 통합이 숨는다. + * + * ⚠ **`currentIntegrationTxnGeneration` 은 그 값이 아니다** — 그것은 저널의 `integrationGeneration` + * (「`prepared` 마다 +1」= 시도 카운터)과 묶이는 값이라(`recovery.ts` 의 stage 결속·면제 조건), + * `sourceGeneration`(활동 시작마다 +1)과 등치하면 **활동 2회 뒤 첫 통합** 같은 정상 흐름이 막힌다. + * 「어느 세대의 결과인가」를 싣는 값은 저널의 **불변** `sourceGeneration`(T71 불변 12필드)이고, + * 그것은 권위 레코드 두 장만 보는 전이 불변식 층에서는 **보이지 않는다** — 그래서 이 층이다. + */ + it('완결 귀속 txn 은 현 소스 세대를 대표해야 한다', () => { + const completed = (sourceGeneration: number) => + classifyRecovery( + obs({ + authority: { + kind: 'found', + record: record({ + revision: R0 + 4, + sourceGeneration, + lifecycle: 'integrated', + completedIntegrationTxnId: T2, + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + }), + }, + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T2), OID]), + journal: entries( + journal({ + txnId: T2, + ...walBound('finalized'), + // 저널의 `sourceGeneration` 은 기본값 3 — 이 시도가 통합한 소스 세대다(불변 필드). + resultRef: formatResultRef(BENCH, R0 + 2, T2), + publishedAt: 9, + }), + ), + }), + ) + // 권위 세대가 4 로 전진했는데 완결된 시도는 세대 3 을 통합했다 — 세대 4 작업은 통합된 적이 없다. + expect(kinds(completed(4))).toContain('completed-txn-stale-generation') + // 음성 통제 — 대표하면 정상이다. + expect(kinds(completed(3))).not.toContain('completed-txn-stale-generation') + }) + + /** + * **Codex PR#289 8R P1** — 7R 이 넣은 완결 ref 요구가 **존재 술어(∃)** 였다. 같은 txnId 를 든 ref 가 + * 하나라도 있으면 통과하므로 ⓐ이름은 기대값인데 **OID 가 교체**된 ref ⓑ**다른 이름**의 ref + * ⓒ정상 ref **옆의 여분 손상 ref** 가 전부 `no-mutation` 으로 끝난다 — **불변 완결 결과의 교체가 숨는다.** + * + * current txn 은 같은 손상을 이미 `result-ref-mismatch` 로 잡는다(정정 235 의 전수 루프) — 비대칭이 + * 곧 결함이었다. 완결 txn 은 앵커가 구조적으로 답하지 않는다(완결 CAS 가 revision 을 올려 보존 ref 는 + * 항상 `revision` 이하)므로 이 층 말고는 그것을 볼 곳이 없다. + */ + it('완결 귀속 txn 의 보존 ref 도 이름·OID 로 전수 결속한다', () => { + const completedRecord = record({ + revision: R0 + 4, + lifecycle: 'integrated', + completedIntegrationTxnId: T2, + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + }) + const finalizedJournal = journal({ + txnId: T2, + ...walBound('finalized'), + resultRef: formatResultRef(BENCH, R0 + 2, T2), + publishedAt: 9, + }) + const withRefs = (refPairs: readonly (readonly [string, string])[]) => + classifyRecovery( + obs({ + authority: { kind: 'found', record: completedRecord }, + prefixRefs: { kind: 'ok', refs: refPairs.map(([ref, oid]) => ({ ref, oid })) }, + journal: entries(finalizedJournal), + }), + ) + const sound = formatResultRef(BENCH, R0 + 2, T2) + + // ⓐ 이름은 기대값인데 OID 가 교체됐다 — 불변 결과의 대체. + expect(kinds(withRefs([[sound, OID2]]))).toContain('result-ref-mismatch') + // ⓑ 이름이 저널이 증언한 값과 다르다. + expect(kinds(withRefs([[formatResultRef(BENCH, R0 + 1, T2), OID]]))).toContain( + 'result-ref-mismatch', + ) + // ⓒ 정상 ref 가 **있어도** 여분 손상 ref 를 잡는다(존재 술어가 아니라 전수여야 하는 이유). + expect( + kinds( + withRefs([ + [sound, OID], + [formatResultRef(BENCH, R0 + 1, T2), OID2], + ]), + ), + ).toContain('result-ref-mismatch') + // 음성 통제 — 정확히 일치하는 ref 하나면 정상이다. + expect(kinds(withRefs([[sound, OID]]))).toEqual([]) + }) + + /** + * **로컬 적대 리뷰 P2(7R 반영분에 대한)** — 완결 귀속 검사 두 개가 **관측 실패를 손상 사실로 승격** + * 한다. 이 모듈의 원칙은 「실패를 빈 집합으로 축소하지 않는다」(§3-T78)인데, 열거가 `failed` 면 + * `journals`·`parsedRefs` 가 비고 그 공집합이 「저널이 `finalized` 가 아니다」·「불변 ref 가 사라졌다」 + * 라는 **적극적 주장**으로 바뀐다. 읽지 못한 파일에 대한 사실 주장이고, 정정 189 가 금지한 **가림** + * (한 입력에 두 사유 발화)이며, 사용자의 조치가 「IO 복구」에서 「매체 손상 대응」으로 갈린다. + */ + it('완결 귀속 검사는 열거 실패를 손상으로 승격하지 않는다', () => { + const completedRecord = record({ + revision: R0 + 4, + lifecycle: 'integrated', + completedIntegrationTxnId: T2, + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + }) + const journalFailed = classifyRecovery( + obs({ + authority: { kind: 'found', record: completedRecord }, + journal: { kind: 'failed', detail: 'EIO' }, + }), + ) + expect(kinds(journalFailed)).toContain('journal-enumeration-failed') + expect(kinds(journalFailed)).not.toContain('completed-txn-journal-unfinalized') + + const refsFailed = classifyRecovery( + obs({ + authority: { kind: 'found', record: completedRecord }, + prefixRefs: { kind: 'failed', detail: 'EIO' }, + journal: entries( + journal({ + txnId: T2, + ...walBound('finalized'), + resultRef: formatResultRef(BENCH, R0 + 2, T2), + publishedAt: 9, + }), + ), + }), + ) + expect(kinds(refsFailed)).toContain('ref-enumeration-failed') + expect(kinds(refsFailed)).not.toContain('result-ref-missing') + }) + + /** + * **Codex PR#289 7R P1** — 「ref 발행 이후의 상태는 ref 를 요구한다」(5R 정정 224)가 **`current` + * 에만** 걸려 있었다. 완결 txn 이 더 이상 current 가 아니면 그 검사는 `current === undefined` + * 조기 반환보다 뒤에 있어 **한 번도 평가되지 않는다** — 게시는 필연적으로 finalization 에 + * 선행하므로(§W-8) 그 불변 ref 의 소실은 손상인데 `no-mutation` 이 나온다. + * + * 6R 정정 229 의 「검사의 위치가 곧 정의역」이 세 번째로 같은 형태를 낸 것이다. + */ + it('완결 귀속 txn 의 결과 ref 소실도 blocker 다(요구가 current 에만 걸려 있었다)', () => { + const completedRecord = record({ + // 완결 CAS(`finalized` 저널의 expected `R0+3` 를 맞춘 CAS)가 기록한 revision = `R0+4`. + // 낮게 두면 보존된 결과 ref(`R0+2`)가 앵커 후보가 돼 **다른 축**이 먼저 답한다. + revision: R0 + 4, + lifecycle: 'integrated', + completedIntegrationTxnId: T2, + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + }) + const finalizedJournal = journal({ + txnId: T2, + ...walBound('finalized'), + resultRef: formatResultRef(BENCH, R0 + 2, T2), + publishedAt: 9, + }) + const withRef = classifyRecovery( + obs({ + authority: { kind: 'found', record: completedRecord }, + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T2), OID]), + journal: entries(finalizedJournal), + }), + ) + // 음성 통제 — ref 가 보존돼 있으면 정상이다(검사가 완결 상태 자체를 차단하지 않는다). + expect(kinds(withRef)).toEqual([]) + expect(withRef.kind).toBe('no-mutation') + + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: completedRecord }, + journal: entries(finalizedJournal), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('result-ref-missing') + }) + + /** + * **Codex PR#289 6R P1** — `integrated`·`archived` 권위는 current txn 이 없어 `authorityPending` + * 이 거짓이지만, 그 위에서 새 txn 을 **시작할 수 없으므로**(전이 불변식) 그 저널은 애초에 쓰일 수 + * 없었다. stage 의 pending 여부만 보면 그 사실을 놓친다. + */ + it('종결 bench(integrated·archived)에는 benign 창이 없다', () => { + for (const lifecycle of ['integrated', 'archived'] as const) { + const rec = record({ + lifecycle, + ...(lifecycle === 'integrated' + ? { completedIntegrationTxnId: T2 } + : { archivedBranch: 'preserved' as const }), + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + journal: entries( + journal({ ...walBound('prepared'), resultTree: undefined, resultOid: undefined }), + ), + }), + ) + expect(v.kind, lifecycle).toBe('reconciliation-required') + expect(kinds(v)).toContain('orphan-active-journal') + } + }) + + /** + * **Codex PR#289 6R P1** — `previousAuthorityStage` 가 `composed` 이상인 포기는 **이미 ref 가 + * 발행된 뒤**라 그 부재는 손상이고, 청소 CAS 가 남은 통합 상태를 지우면서 **사라진 불변 결과를 + * 숨긴다.** 면제는 「`prepared` 에서의 포기」에만 준다. + */ + it('composed 이후의 포기는 결과 ref 를 요구한다', () => { + // ⚠ `composed` 에서의 포기는 `expected = R0 + 2 = N + 1` 이고 ref 는 `expected`(기본값 `N+1`)다 + // — 7R P1 로 포기 산술이 서면서 `N + 3` 쌍은 표현 불가가 됐다(닫힌 창엔 개입 CAS 가 없다). + const rec = record({ + revision: N + 1, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + journal: entries( + journal({ + stage: 'abandoned', + expectedAuthorityRevision: N + 1, + previousAuthorityStage: 'composed', + nextAuthorityStage: undefined, + abandonedAt: 9, + abandonReason: 'user-abandon', + draftDigest: abandonDraftDigestOf(rec), + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('result-ref-missing') + }) + + /** + * **Codex PR#289 6R P1** — pre-CAS 가지가 **세대만** 보면 「결과 A 를 든 composed 권위 + 결과 B 를 + * 든 published 저널」이 순서·산술·ref 대조를 전부 통과해 `normal-wait` 이 나온다 — 같은 txn 이 + * **불변 결과를 갈아치운** 것이다. + */ + it('pre-CAS 에서 불변 결과를 갈아치우면 결속 위반이다', () => { + const rec = record({ + revision: R0 + 2, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, // 권위가 든 결과 = A + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T1), OID2]), + journal: entries( + journal({ + ...walBound('published'), + publishedAt: 9, + resultOid: OID2, // 저널이 든 결과 = B + // ⚠ **축을 분리한다** — digest 는 B 로 정확히 재구성해 둔다. 그러지 않으면 draft 결속이 + // 함께 깨져 「둘 중 뭔가 잡았다」가 되고, 뮤테이션에서 두 검사가 서로를 가린다(실측). + draftDigest: nextDraftDigestOf(rec, 'published', OID2), + }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-authority-binding') + }) + + /** + * **Codex PR#289 6R P1(축 분리)** — 결과는 상속되는데 **draft 전체**가 다른 경우. 위 행과 반대로 + * 이쪽은 `resultInherited` 를 통과시키고 `draftBound` 만 깨뜨린다. + */ + it('pre-CAS 에서 draft 결속이 깨지면 결과가 같아도 위반이다', () => { + const rec = record({ + revision: R0 + 2, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T1), OID]), + journal: entries( + journal({ ...walBound('published'), publishedAt: 9, draftDigest: 'f'.repeat(64) }), + ), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-authority-binding') + }) + + it('미종결 txn 이 없으면(권위에 통합 없음) prepared 고아는 그대로 benign 이다', () => { + const v = classifyRecovery( + obs({ + authority: { + kind: 'found', + record: record({ + currentIntegrationTxnId: undefined, + currentIntegrationStage: undefined, + currentIntegrationTxnGeneration: undefined, + }), + }, + journal: entries( + journal({ + ...walBound('prepared'), + resultTree: undefined, + resultOid: undefined, + draftDigest: draftDigestOf(record()), + }), + ), + }), + ) + expect(v.kind).toBe('no-mutation') + }) +}) + +/* ================================================================================================ + * T81·T92 — 증거 봉인 seam (재검증의 판정 계층) + * ============================================================================================= */ + +describe('T81·T92 — 면제 증거는 봉인되고 재검증은 동치로 판정한다', () => { + const promotable = (over: Partial = {}): RecoveryObservation => + obs({ prefixRefs: refs([REF_N1, OID]), journal: entries(journal()), ...over }) + + const evidenceOf = (o: RecoveryObservation) => { + const v = classifyRecovery(o) + if (v.kind !== 'resume-composed-cas') throw new Error(`승격이 아니다: ${v.kind}`) + return v.resume + } + + it('같은 관측은 같은 증거를 낸다(결정론)', () => { + expect(isSameRecoveryEvidence(evidenceOf(promotable()), evidenceOf(promotable()))).toBe(true) + }) + + it('권위 revision 이 그 사이에 바뀌면 같은 증거가 아니다', () => { + // ⑦ 재검증에서 권위가 전진했다면 최초 분류의 면제는 더 이상 유효하지 않다. + const after = promotable({ + authority: { + kind: 'found', + record: record({ + revision: N + 1, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, + }), + }, + }) + const v = classifyRecovery(after) + expect(v.kind).toBe('normal-wait') + }) + + it('ref 집합이 바뀌면 같은 증거가 아니다', () => { + const before = evidenceOf(promotable()) + const withSibling = promotable({ + prefixRefs: refs([REF_N1, OID], [formatResultRef(BENCH, 2, TPREV), OID2]), + }) + expect(isSameRecoveryEvidence(before, evidenceOf(withSibling))).toBe(false) + }) + + /** + * **Codex PR#289 4R P1** — `draftDigest` 는 **권위 draft 투영**만 덮는다. 저널의 통합 입력 + * (`sourceSnapshot`·`targetBranch`·`targetHeadBeforeIntegration`·`resultTree`)은 그 투영 밖이라, + * 그것만 실으면 **바뀐 통합 의도가 같은 증거로** 재검증을 통과한다. + */ + it('저널의 통합 입력이 바뀌면 같은 증거가 아니다', () => { + const before = evidenceOf(promotable()) + for (const changed of [ + { sourceSnapshot: 'f'.repeat(40) }, + { targetBranch: 'release' }, + { targetHeadBeforeIntegration: 'f'.repeat(40) }, + { resultTree: 'f'.repeat(40) }, + ]) { + const after = evidenceOf(promotable({ journal: entries(journal(changed)) })) + expect(isSameRecoveryEvidence(before, after), JSON.stringify(changed)).toBe(false) + } + }) + + it('증거 다이제스트는 순수 함수의 산출물이라 관측 밖 값에 의존하지 않는다', () => { + const a = evidenceOf(promotable()) + const b = evidenceOf( + promotable({ + authority: { + kind: 'found', + record: record({ writtenBy: { ownerToken: 'other', at: 99, durability: 'file+dir' } }), + }, + }), + ) + // `writtenBy` 는 CAS 가 배정하는 필드라 draft 투영에 없다 — 면제 판정을 흔들지 않아야 한다. + expect(isSameRecoveryEvidence(a, b)).toBe(true) + }) +}) + +/* ================================================================================================ + * T91 — 순수성(무변이 관찰) + * ============================================================================================= */ + +describe('T91 — 판정은 순수하다(입력 무변이)', () => { + it('관측 객체를 변형하지 않는다', () => { + const o = obs({ prefixRefs: refs([REF_N1, OID]), journal: entries(journal()) }) + const snapshot = JSON.stringify(o) + classifyRecovery(o) + expect(JSON.stringify(o)).toBe(snapshot) + }) +}) + +/* ================================================================================================ + * 로컬 적대 리뷰(Codex 한도 소진 대체) — 정정 233~235 + * ============================================================================================= */ + +describe('정정 233 — 열린 창의 개입 CAS 는 정상이다(설계급 P1 회귀)', () => { + /** + * `revision` 은 **모든 CAS** 가 +1 하는 전역 카운터다. `published` 는 활성이 아니어서 그 창에서 + * 실행·보관이 **계약상 허용**되고(C6 · §3-T33 · I8), 그 CAS 들이 revision·`sourceGeneration`· + * `activeActivity` 를 바꾼다. tight 등식·전체 draft digest 는 그 정상 상태를 손상으로 오분류했다. + */ + const publishedJournal = journal({ ...walBound('published'), publishedAt: 9 }) + + it('published 뒤 활동 CAS 3회가 끼어들어도 정상 대기다', () => { + const drifted = record({ + revision: R0 + 2 + 3, // publish CAS(R0+2) 이후 gated·running·종료 3회 + currentIntegrationStage: 'published', + currentIntegrationResultOid: OID, + sourceGeneration: 4, + activeActivity: { + activityId: 'act', + kind: 'run', + generation: 4, + ownerToken: 'ow', + execGate: 'running', + startedAt: 1, + }, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: drifted }, + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T1), OID]), + journal: entries(publishedJournal), + }), + ) + expect(v.kind).toBe('normal-wait') + }) + + it('보관된 integration-ready bench 도 정상 대기다(I8)', () => { + const archived = record({ + revision: R0 + 4, + lifecycle: 'archived', + archivedBranch: 'preserved', + currentIntegrationStage: 'published', + currentIntegrationResultOid: OID, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: archived }, + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T1), OID]), + journal: entries(publishedJournal), + }), + ) + expect(v.kind).toBe('normal-wait') + }) + + it('finalized 저널의 ref revision 은 등식이 아니라 상한이다(정정 233 · 1-B)', () => { + // 완결 관측은 외부 소비자 머지 **이후**라 그 사이 개입 CAS 가 expected 를 올린다. + const rec = record({ + revision: R0 + 8, + currentIntegrationStage: 'finalized', + currentIntegrationResultOid: OID, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: rec }, + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T1), OID]), + journal: entries( + journal({ + ...walBound('finalized'), + expectedAuthorityRevision: R0 + 7, // 드리프트 + publishedAt: 9, + }), + ), + }), + ) + expect(v.kind).toBe('idempotent-cleanup') + }) + + it('⚠ 닫힌 창은 여전히 tight 다 — composed 에서 드리프트는 손상이다(음성 통제)', () => { + // `prepared`·`composed` 는 **활성 저널**이라 실행이 차단되고, 복구 판정은 모든 권위 CAS 보다 + // 먼저 돈다(정정 197ⓐ) → 개입 CAS 가 불가하므로 완화하지 않는다. + const drifted = record({ + revision: N + 2, + currentIntegrationStage: 'composed', + currentIntegrationResultOid: OID, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: drifted }, + prefixRefs: refs([REF_N1, OID]), + journal: entries(journal()), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('journal-authority-binding') + }) +}) + +describe('정정 235 — 승격 경로도 현재 txn ref 대조를 받는다', () => { + it('면제가 성립해도 같은 txn 의 여분 ref 는 blocker 다', () => { + // 4R 은 이 대조를 「표 앞」으로 올렸는데 그 「앞」이 **면제 반환 뒤**였다 — 승격 경로에서만 침묵했다. + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID], [formatResultRef(BENCH, 3, T1), OID2]), + journal: entries(journal()), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('result-ref-mismatch') + }) +}) + +describe('반증력 복원 — 측정으로 확정된 공백(로컬 적대 리뷰 L4)', () => { + const preparedOrphan = (txnId: string) => + journal({ + ...walBound('prepared'), + txnId, + resultTree: undefined, + resultOid: undefined, + resultRef: formatResultRef(BENCH, R0 + 2, txnId), + draftDigest: draftDigestOf(record()), + }) + + it('A — 권위 부재 + 유효 prepared 저널은 benign 이 아니다', () => { + const v = classifyRecovery( + obs({ authority: { kind: 'absent' }, journal: entries(preparedOrphan(T2)) }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toContain('orphan-active-journal') + }) + + it('B — 권위가 finalized 면 다른 txn 의 prepared 고아는 benign 이다', () => { + // finalized 저널의 **post-CAS** 짝 = revision 이 expected(R0+3) 보다 1 앞선 상태. + const done = record({ + revision: R0 + 4, + currentIntegrationStage: 'finalized', + currentIntegrationResultOid: OID, + currentIntegrationTxnId: T1, + }) + const v = classifyRecovery( + obs({ + authority: { kind: 'found', record: done }, + journal: entries( + journal({ ...walBound('finalized'), publishedAt: 9, draftDigest: draftDigestOf(done) }), + preparedOrphan(T2), + ), + prefixRefs: refs([formatResultRef(BENCH, R0 + 2, T1), OID]), + }), + ) + expect(v.kind).toBe('idempotent-cleanup') + }) + + it('C — 종결 증거는 abandonedAt 만으로도 blocker 다(축 분리)', () => { + const v = classifyRecovery(obs({ journal: entries(journal({ abandonedAt: 7 })) })) + expect(kinds(v)).toContain('journal-terminal-evidence') + }) + + it('D — 종결 증거는 abandonReason 만으로도 blocker 다(축 분리)', () => { + const v = classifyRecovery(obs({ journal: entries(journal({ abandonReason: 'superseded' })) })) + expect(kinds(v)).toContain('journal-terminal-evidence') + }) + + it('F — 권위 부재 시 면제 실패 사유는 authority-unavailable 이다', () => { + const v = classifyRecovery( + obs({ authority: { kind: 'absent' }, prefixRefs: refs([REF_N1, OID]) }), + ) + expect(reasons(v)).toContain('authority-unavailable') + }) + + it('G — 세대 동치만 어긋나도 결속 위반이다(축 분리)', () => { + // draftDigest 를 **저널의 세대(2)로 정확히 재구성**해 draft 축을 통과시킨다 → 세대 축만 남는다. + const rec = record() + const { revision: _r, writtenBy: _w, ...rest } = rec + const digestWithGen2 = digestAuthorityDraft({ + ...rest, + currentIntegrationTxnId: T1, + currentIntegrationStage: 'composed', + currentIntegrationTxnGeneration: 2, + currentIntegrationResultOid: OID, + } as BenchAuthorityDraft) + const v = classifyRecovery( + obs({ + journal: entries(journal({ integrationGeneration: 2, draftDigest: digestWithGen2 })), + }), + ) + expect(v.kind).toBe('reconciliation-required') + expect(kinds(v)).toEqual(['journal-authority-binding']) + }) + + it('L — 정확 이름 질의가 자식 ref 를 답해도 충돌이 아니다', () => { + // 실측: `for-each-ref refs/…/` 는 자식도 함께 답한다. 이름이 **정확히 같은** 항목만 본다. + const v = classifyRecovery( + obs({ + prefixRefs: refs([REF_N1, OID]), + exactRefs: refs([REF_N1, OID]), + journal: entries(journal()), + }), + ) + expect(v.kind).toBe('resume-composed-cas') + }) + + it('M — 저널 열거 실패는 current-txn-journal-missing 을 덧붙이지 않는다', () => { + const v = classifyRecovery(obs({ journal: { kind: 'failed', detail: 'EIO' } })) + expect(kinds(v)).toEqual(['journal-enumeration-failed']) + }) + + it('N — blocker 는 진단 앵커(subject)를 싣는다', () => { + const v = classifyRecovery(obs({ prefixRefs: { kind: 'failed', detail: 'exit 128' } })) + if (v.kind !== 'reconciliation-required') throw new Error(v.kind) + expect(v.blockers[0]?.subject).toBe('exit 128') + }) +}) diff --git a/src/main/core/workbench/recovery.ts b/src/main/core/workbench/recovery.ts new file mode 100644 index 0000000..c97d10c --- /dev/null +++ b/src/main/core/workbench/recovery.ts @@ -0,0 +1,833 @@ +/** + * 통합 WAL **복구 판정**(#251 PR3c · 스펙 §W-7 「복구 판정」) — 순수 함수 · 무변이 관찰 · git 변이 0. + * + * ## 이것은 first-match 표가 아니라 **계층**이다 + * + * ```text + * ① 권위 fresh read ⑤ anchor gate ← 여기까지 변이 0 + * ② 결과 ref 전수 열거·문법·identity·OID ⑥ 귀속·전이 불변식 재검증 + * ③ 저널 전수 열거·schemaVersion·identity ⑦ repo 락 + 리스 아래 fresh read·재열거 ← 소비자(PR5·PR7) + * ④ ref↔저널 설명 관계 **순수 분류** ⑧ 조건 유지 시에만 resume-composed-cas CAS ← 소비자 + * ``` + * + * 저널은 앵커 **앞에서 읽되 읽기·검증만** 한다(계획 정정 200) — 안전 목표는 「앵커보다 먼저 저널을 + * **변이**하지 않는다」이지 「읽지 않는다」가 아니었다. 안 읽으면 **정상 크래시와 롤백을 구분할 정보가 + * ⑤ 에 없다.** + * + * ## 앵커 = `refRevision > record.revision` + * + * 정렬 키가 권위 `revision` 인 이유(계획 정정 190): CAS 마다 정확히 `+1` 이라 단조가 **이미 기계 + * 집행**된다. ULID 사전순은 같은 ms 20,000건 중 **50.1% 역전**이라 정렬 키로 부적격이었고, + * `integrationGeneration` 은 `prepared` 마다만 +1 이라 통합 시도 **사이**의 롤백에 침묵한다. + * + * 「권위 레코드의 어느 txnId 에도 귀속되지 않는 ref = reconciliation」식은 **폐기**됐다(정정 191): + * 포기는 결과 ref 를 **보존**하고 §3-T29 는 형제 시도 ref 의 영구 공존을 정상으로 요구하므로 그 식은 + * **영구 오탐**이었다. revision 비교로 바꾸면 보존된 과거 ref 의 revision 은 항상 현재 이하라 그 모순이 + * 구조적으로 사라진다. + * + * ⚠ **`durability` 값으로 게이트하지 않는다**(구현 중 확정). 발화가 필요한 표면은 `file-only`(C3)이지만 + * 그 값은 **롤백 대상인 권위 파일 안에** 있다 — 검사의 활성화 조건을 검사 대상과 같은 매체에서 읽으면 + * 독립성이 그 지점에서 조용히 끊긴다(계획 정정 199 2R 교훈의 동형). 판정은 **항상** 돌고, `file+dir` + * 표면에서는 정상 프로토콜상 발화 입력 자체가 만들어지지 않는다. + * + * ## 이 PR 에서 **하지 않는** 것 (정직 표기 · 계획 정정 137 의 규율) + * + * - **행동 단언 0.** 「복구기가 인가를 막는다」(§3-T80)·「게이트 전 변이 0건」(T91)·「CAS 후 revision + * 일치」(T93)·「CAS 실패 시 ref 보존」(T94)·「락 아래 fresh 재검증」(T81·T92)의 **행동** 연언은 + * 생산자(PR5 시퀀서 · PR7 부팅)가 없어 이 PR 에서 **vacuous** 하다. 여기 있는 것은 그 행동이 딛고 설 + * 판정 계층과 **증거 봉인 seam**(`ResumeEvidence.evidenceDigest`)뿐이다. + * - **열거를 하지 않는다.** ref 열거는 `GitRepo.listRefs`(PR3a), 저널 디렉터리 순회는 PR3d 소관이고 + * 이 모듈은 **주어진 관측**만 본다. 「열거가 있다」로 읽지 말 것. + */ + +import { createHash } from 'node:crypto' + +import { + type BenchAuthorityDraft, + type BenchAuthorityIdentity, + type BenchAuthorityRecord, + checkTransitionInvariants, +} from './authority' +import { + digestAuthorityDraft, + digestJournalRecord, + isActiveJournalStage, + type IntegrationTxnRecord, + type JournalReadResult, + WAL_STAGE_ORDER, +} from './journal' +import { parseResultRef, RESULT_REF_ROOT } from './result-ref' + +/* ================================================================================================ + * 관측 입력 — **실패도 값**이다(§3-T78) + * ============================================================================================= */ + +export type AuthorityObservation = + | { readonly kind: 'found'; readonly record: BenchAuthorityRecord } + /** 레코드 부재 = 비교 기준 revision `0`. 「판정 없음」이 아니다(§3-T77). */ + | { readonly kind: 'absent' } + | { readonly kind: 'unreadable'; readonly detail?: string } + +export interface ObservedRef { + readonly ref: string + readonly oid: string +} + +export type RefObservation = + | { readonly kind: 'ok'; readonly refs: readonly ObservedRef[] } + /** git 실패를 **빈 집합으로 축소하지 않는다** — 「발행된 ref 없음」과 정반대 판정을 낸다. */ + | { readonly kind: 'failed'; readonly detail?: string } + +export interface ObservedJournalEntry { + readonly txnId: string + /** PR3b 의 읽기 결과를 **그대로** 싣는다 — 버전 스큐·손상·IO 실패가 각각 다른 사실로 남는다. */ + readonly read: JournalReadResult +} + +export type JournalObservation = + | { readonly kind: 'ok'; readonly entries: readonly ObservedJournalEntry[] } + | { readonly kind: 'failed'; readonly detail?: string } + +export interface RecoveryObservation { + /** + * **관측 대상의 identity 를 호출자가 독립으로 싣는다**(Codex PR#289 P1). 초안은 `benchId` 만 받고 + * 레포 identity 는 **권위 레코드에서** 가져다 저널과 대조했는데, 그러면 권위가 부재·손상일 때 대조가 + * 통째로 사라져 **다른 레포에서 복사된 저널**(같은 benchId · 영역 통째 복사)이 재준비 적격으로 + * 통과한다. 권위가 없을수록 더 엄격해야 하고, 대조 기준은 롤백 가능한 매체 밖에 있어야 한다. + */ + readonly identity: BenchAuthorityIdentity + readonly authority: AuthorityObservation + /** 접두 `refs/fleet/integrated//` 열거. */ + readonly prefixRefs: RefObservation + /** + * **정확 이름** `refs/fleet/integrated/` 질의(계획 정정 157 · 실측). 접두 질의는 bare 부모를 + * **0건**으로 답하므로 접두만 쓰면 D/F 충돌 bench 가 「발행된 ref 없음」과 구분되지 않아 최우선 + * 분기가 **도달 불가**가 된다. ⚠ 이 질의는 자식 ref 도 함께 답하므로 **이름이 정확히 같은 항목만** 본다. + */ + readonly exactRefs: RefObservation + readonly journal: JournalObservation +} + +/* ================================================================================================ + * 판정 출력 — 어휘는 **kebab 통일**(계획 정정 141) + * ============================================================================================= */ + +export type RecoveryBlockerKind = + /** 최우선 fail-closed — 다른 사유에 가려지지 않도록 목록의 **맨 앞**에 온다. */ + | 'ref-namespace-conflict' + | 'ref-enumeration-failed' + | 'ref-syntax-invalid' + | 'ref-foreign-bench' + | 'authority-unreadable' + | 'authority-identity-mismatch' + | 'journal-enumeration-failed' + | 'journal-unreadable' + | 'journal-identity-mismatch' + /** 권위가 저널보다 앞섰다 — WAL 선기록 위반(저널 단독 롤백·손상). */ + | 'journal-behind-authority' + /** 저널이 권위보다 두 단계 이상 앞섰다 — 정정 204 불변식 ① 위반. */ + | 'journal-too-far-ahead' + /** stage 는 맞는데 revision·결과 증거·세대·draft 결속이 어긋났다(같은 단계 손상 쌍). */ + | 'journal-authority-binding' + /** 저널이 든 `resultRef` 이름 자체가 문법·bench·txn·revision 결속을 깬다(git ref 유무와 무관). */ + | 'journal-result-ref-invalid' + /** 활성·진행 stage 인데 종결 증거(`abandonedAt`·`abandonReason`)를 들고 있다(모순 레코드). */ + | 'journal-terminal-evidence' + /** + * 포기 저널이 **`previousAuthorityStage` 에서 상속돼야 할 증거 형태**를 갖추지 못했다 — + * 날조된 `publishedAt`, 소거된 결과 증거 등. 위 종별의 **반대 방향**이라 이름을 나눈다 + * (쓰기 경로의 증거 동결은 착지한 레코드를 보는 복구에서 재사용할 수 없다). + */ + | 'journal-abandon-evidence-invalid' + /** 설명되지 않는 `refRevision > record.revision` — 앵커 발화. */ + | 'anchor-rollback' + | 'result-ref-mismatch' + /** ref 발행 이후의 상태(post-CAS composed·published·finalized)인데 그 ref 가 없다. */ + | 'result-ref-missing' + // ⚠ `expected-revision-mismatch` 는 blocker 종별에서 **삭제**했다(로컬 적대 리뷰 P3 · 정정 234) — + // 생산자가 0 이었고, 진단은 `anchor-rollback.reasons` 가 진다. 같은 파일이 `arithmetic-binding` 을 + // 지운 잣대를 여기에도 적용한다. + | 'current-txn-journal-missing' + /** 완결 귀속 txn 의 저널이 `finalized` 가 아니다(활성·published·abandoned 전부 손상 증거). */ + | 'completed-txn-journal-unfinalized' + /** + * 완결 귀속 txn 이 **현 소스 세대를 대표하지 않는다**(§W-8 완결 CAS 5연언 중 세대 항) — 그 시도가 + * 통합한 세대 **이후의 작업**이 통합된 적 없는데 bench 가 `integrated` 로 기록됐다(부분 통합 은폐). + */ + | 'completed-txn-stale-generation' + | 'orphan-active-journal' + +/** 앵커 후보가 **면제**에 실패한 조건(계획 정정 199ⓒ). 전수를 싣는다 — 첫 사유만 남기면 진단이 좁아진다. */ +export type ExemptionFailure = + | 'authority-unavailable' + | 'journal-missing' + | 'stage-not-composed' + | 'terminal-evidence-present' + | 'ref-name-mismatch' + | 'oid-mismatch' + // ⚠ `arithmetic-binding` 은 **폐기**했다(Codex PR#289 4R P1 의 연쇄) — ③ 저널 검증이 `composed` + // 저널의 `resultRef` 이름에 `= expected + 1` 을 강제하고 ④ 가 관측 ref 를 그 이름에 묶으므로 + // 이 자리의 산술 재검사는 **생산자가 없다**. 어휘만 남기면 「검사한다」로 읽힌다. + | 'expected-revision-mismatch' + | 'stage-binding-mismatch' + | 'transition-illegal' + | 'digest-mismatch' + +export interface RecoveryBlocker { + readonly kind: RecoveryBlockerKind + /** 앵커 후보의 면제 실패 사유 전수. 다른 종별에서는 빈 배열이다. */ + readonly reasons: readonly ExemptionFailure[] + /** 진단 앵커(ref 이름 · txnId · 관측 단서). **판정 동치성에 쓰지 않는다.** */ + readonly subject: string +} + +/** + * ⑦⑧ 이 재검증할 **봉인된 증거**. 소비자는 락·리스 아래에서 다시 관측해 `classifyRecovery` 를 돌리고, + * 여전히 `resume-composed-cas` 이며 `isSameRecoveryEvidence` 가 참일 때만 CAS 한다(§3-T81·T92). + */ +export interface ResumeEvidence { + readonly txnId: string + readonly refName: string + readonly resultOid: string + readonly expectedAuthorityRevision: number + readonly resultingRevision: number + /** 관측 전체(권위 revision · ref 집합 · 저널 결속)의 봉인. 하나라도 바뀌면 값이 바뀐다. */ + readonly evidenceDigest: string +} + +export type RecoveryVerdict = + /** 포기·재준비 적격. 디스크에 되돌릴 것도 이어갈 것도 없다. */ + | { readonly kind: 'no-mutation' } + /** 차단 아님(C6) — 정상 진행 중이거나 이미 반영된 상태다. */ + | { readonly kind: 'normal-wait' } + /** `finalized`·`abandoned` 잔존의 **멱등 종결**(삭제 아님 · §W-7 「복구 중 삭제 금지」). */ + | { readonly kind: 'idempotent-cleanup' } + /** + * 저널이 **이미 선기록한 `composed` 권위 CAS** 를 재개한다. + * + * ⚠ 이름이 계약이다(계획 정정 203). 폐기된 `promote-published` 는 「composed CAS 를 **건너뛰고** + * 곧바로 published 권위를 기록」하는 구현도 이름상 허용해 결속 필드를 통째로 무력화했다. + */ + | { readonly kind: 'resume-composed-cas'; readonly resume: ResumeEvidence } + | { readonly kind: 'reconciliation-required'; readonly blockers: readonly RecoveryBlocker[] } + +/* ================================================================================================ + * 내부 표현 + * ============================================================================================= */ + +interface ParsedRef { + readonly refName: string + readonly oid: string + readonly resultingRevision: number + readonly txnId: string +} + +/** + * 결과·게시 증거의 **도입 단계**(`journal.ts` 의 `EVIDENCE_FIELDS` introducer 와 같은 표). + * + * 포기 저널은 `previousAuthorityStage` 까지의 증거를 **상속**하므로, 그 stage 가 도입 단계 이상이면 + * 그 필드는 **필수**이고 미만이면 **금지**다. 쓰기 경로가 같은 규칙을 강제하지만 복구는 착지한 + * 레코드만 보므로(Codex PR#289 7R P1) 여기서 독립적으로 요구한다. + */ +const ABANDON_EVIDENCE = [ + ['resultTree', 'composed'], + ['resultOid', 'composed'], + ['publishedAt', 'published'], +] as const + +const blocker = ( + kind: RecoveryBlockerKind, + subject: string, + reasons: readonly ExemptionFailure[] = [], +): RecoveryBlocker => ({ kind, reasons, subject }) + +/** + * 전수화 강제 — **이 모듈 전용**이다(형제 `authority.ts` 와 같은 근거). 공용 `assertNever` 는 + * `Unhandled ContentBlock variant: …` 를 던져 복구 판정에서 발화하면 진단이 **엉뚱한 층**을 가리킨다. + * eslint `no-restricted-syntax` selector 가 `assertNever` **이름**을 요구하므로 이름은 유지한다. + */ +function assertNever(x: never): never { + throw new Error(`복구 판정: 미처리 판별 종별 ${JSON.stringify(x)}`) +} + +/** 권위 레코드에서 CAS 가 배정한 두 필드를 떼어낸 **draft 투영**. `draftDigest` 대조의 정의역이다. */ +const toDraft = (record: BenchAuthorityRecord): BenchAuthorityDraft => { + const { revision: _revision, writtenBy: _writtenBy, ...rest } = record + return rest as BenchAuthorityDraft +} + +/** + * 후속 CAS 가 제출할 draft 의 **재구성**(면제 조건 ⑧ 을 **전 stage 로 일반화** · Codex PR#289 6R P1). + * 현재 권위 레코드 위에 저널이 선기록한 의도를 얹는다 — `nextAuthorityStage` 부재는 **포기**이므로 + * 통합 4필드를 소거한다(계획 정정 177). + */ +const reconstructNextDraft = ( + record: BenchAuthorityRecord, + journal: IntegrationTxnRecord, +): BenchAuthorityDraft => { + const base = toDraft(record) + if (journal.nextAuthorityStage === undefined) { + const { + currentIntegrationTxnId: _t, + currentIntegrationStage: _s, + currentIntegrationTxnGeneration: _g, + currentIntegrationResultOid: _o, + ...cleared + } = base + return cleared as BenchAuthorityDraft + } + return { + ...base, + currentIntegrationTxnId: journal.txnId, + currentIntegrationStage: journal.nextAuthorityStage, + currentIntegrationTxnGeneration: journal.integrationGeneration, + ...(journal.resultOid === undefined ? {} : { currentIntegrationResultOid: journal.resultOid }), + } as BenchAuthorityDraft +} + +/** + * 면제 조건 ①~⑨ 의 **전수 평가**(⑩ 은 아래 `classifyRecovery` 가 「통과 후보가 정확히 하나」로 본다). + * + * ⚠ **면제 판정의 입력을 권위 파일 하나로 좁히지 않는다**(계획 정정 199ⓐ · §3-T89). 「현재 txn 이니 + * 면제」는 T1 완료 → 권위가 T2 로 전진 → 파일 롤백으로 current 가 **다시 T1** 인 사슬에서 **롤백을 + * 증명하는 가장 높은 ref 를 바로 그 이유로 숨긴다.** 면제는 독립 매체 두 개(ref + 저널)의 교차검증으로만 + * 서고, 「그 txn 이 현재 txn 인가」는 별도 조건이 아니라 **전이 불변식 계층**(§3-T82)이 답한다. + */ +const evaluateExemption = ( + ref: ParsedRef, + record: BenchAuthorityRecord | undefined, + journals: ReadonlyMap, +): readonly ExemptionFailure[] => { + if (record === undefined) return ['authority-unavailable'] + const j = journals.get(ref.txnId) + if (j === undefined) return ['journal-missing'] // ① 대응 저널 존재 · ② 런타임 검증 통과 + + const failures: ExemptionFailure[] = [] + if (j.stage !== 'composed') failures.push('stage-not-composed') // ③ + // ⑨ 종결 증거 — **여기서 보지 않는다**(로컬 적대 리뷰 P3 · 정정 234). + // ⓐ`abandonedAt`·`abandonReason` 은 ③ 저널 검증이 **admitted 전수**에 본다(6R 정정 229). + // ⓑ`publishedAt` 은 `prepared`·`composed` 에서 **형태 층이 이미 거부**한다 — + // `journal.ts` 의 「stage=… 인데 publishedAt 이 있다(게시 전 게시 시각)」 규칙(T71 · Codex + // PR#269 6R). 그래서 `stage === 'composed'` 전제 위에서 그 값을 든 관측은 **표현 불가**이고, + // 다른 stage 후보에서는 ③ 의 `stage-not-composed` 와 **항상 동반**으로만 실렸다. + // 측정 결과 이 줄을 지워도 어떤 판정도 바뀌지 않았다 → `arithmetic-binding` 과 같은 처분. + if (j.resultRef !== ref.refName) failures.push('ref-name-mismatch') // ④ + if (j.resultOid !== ref.oid) failures.push('oid-mismatch') // ⑤ + // ⑥ 산술 결속(`refRevision === expected + 1`)은 **여기 없다** — ③ 저널 검증이 `composed` 저널의 + // `resultRef` **이름 자체**에 그 등식을 이미 강제하고, ④ 가 관측 ref 이름을 그것과 동일하게 + // 묶으므로 이 자리의 재검사는 **완전히 가려진다**(뮤테이션으로 확인 · 정정 189·183 의 규율). + // 강제자가 사라지면 이름 검증 쪽이 RED 가 된다. + if (j.expectedAuthorityRevision !== record.revision) failures.push('expected-revision-mismatch') // ⑦ + + // ⑧ 결속 4필드가 **현재 권위에서 재구성되는 후속 전이**와 일치하는가. + if ( + j.previousAuthorityStage !== record.currentIntegrationStage || + j.nextAuthorityStage !== 'composed' || + j.integrationGeneration !== record.currentIntegrationTxnGeneration + ) { + failures.push('stage-binding-mismatch') + } + const draft = reconstructNextDraft(record, j) + if (checkTransitionInvariants(record, draft).length > 0) failures.push('transition-illegal') + if (digestAuthorityDraft(draft) !== j.draftDigest) failures.push('digest-mismatch') + + return failures +} + +/** 관측 전체의 봉인. 순서 의존을 없애려고 ref 집합은 **정렬해** 싣는다. */ +const sealEvidence = ( + benchId: string, + refs: readonly ParsedRef[], + ref: ParsedRef, + journal: IntegrationTxnRecord, +): string => { + const parts = [ + 'fleet-recovery-evidence/1', + benchId, + // ⚠ `observedRevision` 은 **싣지 않는다**(로컬 적대 리뷰 P3 · 정정 234): 면제가 성립한 관측은 + // 면제 조건 ⑦ 이 `expected === record.revision` 을 강제하고 그 `expected` 는 아래 + // `digestJournalRecord` 가 이미 덮는다 — 측정에서도 제거가 어떤 테스트도 깨지 않았다. + ref.refName, + ref.oid, + ref.txnId, + String(journal.expectedAuthorityRevision), + // ⚠ `draftDigest` 는 **권위 draft 투영**만 덮는다(Codex PR#289 4R P1). 저널의 통합 입력 + // (`sourceSnapshot`·`targetBranch`·`targetHeadBeforeIntegration`·`resultTree` …)은 그 투영 밖이라, + // 그것만 실으면 **바뀐 통합 의도가 같은 증거로** 재검증을 통과한다. 레코드 **전체**를 봉인한다. + digestJournalRecord(journal), + ...refs.map((r) => `${r.refName} ${r.oid}`).sort(), + ] + // 구분자는 **필드 경계**다 — 붙여 이으면 `["ab","c"]` 와 `["a","bc"]` 가 같은 다이제스트를 낸다. + // ⚠ 정직 표기(측정): 이 구분자를 제거해도 **테스트가 잡지 못한다.** 봉인에 실리는 값이 전부 고정 + // 길이·고정 문법(ULID 26 · OID 40 hex · sha256 64 hex · `refs/…` 접두)이라 유효 입력으로는 경계 + // 충돌을 **구성할 수 없다** — 즉 이 방어는 미래에 가변 길이 필드가 추가될 때를 위한 것이고 현재 + // 독립 반증력은 0 이다. 어휘·검사를 삭제하지 않는 이유는 그 추가가 **조용히** 일어나기 때문이다. + // ref 이름·OID·다이제스트 어디에도 나타날 수 없는 C0 제어문자를 쓰되 **이스케이프 표기로 적는다** — + // raw 제어문자는 소스에서 보이지 않고, 이 PR 이 실제로 그 오염을 두 번 만들었다(U+0000 · U+0001). + return createHash('sha256').update(parts.join('\u0001'), 'utf8').digest('hex') +} + +/** ⑦ 재검증의 판정 계층 — 「같은 증거인가」는 봉인값의 동치다. */ +export function isSameRecoveryEvidence(a: ResumeEvidence, b: ResumeEvidence): boolean { + return a.evidenceDigest === b.evidenceDigest +} + +/* ================================================================================================ + * 판정 + * ============================================================================================= */ + +export function classifyRecovery(obs: RecoveryObservation): RecoveryVerdict { + /** 최우선 종별 전용 버킷 — 다른 사유와 같은 배열에 섞으면 순서가 우연에 맡겨진다. */ + const conflicts: RecoveryBlocker[] = [] + const blockers: RecoveryBlocker[] = [] + + // ① 권위 fresh read. **부재는 revision `0`** 이고 「판정 없음」이 아니다. + const record = obs.authority.kind === 'found' ? obs.authority.record : undefined + const observedRevision = record?.revision ?? 0 + if (obs.authority.kind === 'unreadable') { + blockers.push(blocker('authority-unreadable', obs.authority.detail ?? 'authority')) + } + if ( + record !== undefined && + (record.identity.benchId !== obs.identity.benchId || + record.identity.commonGitDir !== obs.identity.commonGitDir || + record.identity.benchRoot !== obs.identity.benchRoot) + ) { + blockers.push(blocker('authority-identity-mismatch', record.identity.benchId)) + } + + // ② 결과 ref 전수 열거·문법·identity. 정확 이름 질의는 **이름이 정확히 같은 항목만** 본다. + const bare = `${RESULT_REF_ROOT}/${obs.identity.benchId}` + if (obs.exactRefs.kind === 'failed') { + blockers.push(blocker('ref-enumeration-failed', obs.exactRefs.detail ?? 'exact')) + } else if (obs.exactRefs.refs.some((r) => r.ref === bare)) { + conflicts.push(blocker('ref-namespace-conflict', bare)) + } + + const parsedRefs: ParsedRef[] = [] + if (obs.prefixRefs.kind === 'failed') { + blockers.push(blocker('ref-enumeration-failed', obs.prefixRefs.detail ?? 'prefix')) + } else { + for (const entry of obs.prefixRefs.refs) { + const parsed = parseResultRef(entry.ref) + switch (parsed.kind) { + case 'ok': + if (parsed.benchId !== obs.identity.benchId) { + blockers.push(blocker('ref-foreign-bench', entry.ref)) + break + } + parsedRefs.push({ + refName: entry.ref, + oid: entry.oid, + resultingRevision: parsed.resultingRevision, + txnId: parsed.txnId, + }) + break + case 'namespace-conflict': + conflicts.push(blocker('ref-namespace-conflict', entry.ref)) + break + case 'invalid': + // 문법 위반을 **무시하지 않는다** — 「해석할 수 없는 이름」은 「없음」이 아니다(§3-T78). + blockers.push(blocker('ref-syntax-invalid', entry.ref)) + break + default: + assertNever(parsed) + } + } + } + + // ③ 저널 전수 열거·schemaVersion·identity. 읽기 실패·버전 스큐는 **각각 다른 사실**로 남는다. + const journals = new Map() + if (obs.journal.kind === 'failed') { + blockers.push(blocker('journal-enumeration-failed', obs.journal.detail ?? 'journal')) + } else { + for (const entry of obs.journal.entries) { + if (entry.read.kind !== 'found') { + blockers.push(blocker('journal-unreadable', `${entry.txnId}:${entry.read.kind}`)) + continue + } + const j = entry.read.record + // ⚠ 대조 기준은 **관측자가 실어온 identity** 다 — 권위 레코드가 아니다(Codex PR#289 P1). + // 권위에서 가져오면 권위 부재·손상 시 대조가 통째로 사라진다. + const identityOk = + j.txnId === entry.txnId && + j.benchId === obs.identity.benchId && + j.repoCommonGitDir === obs.identity.commonGitDir && + j.benchRoot === obs.identity.benchRoot + if (!identityOk) { + blockers.push(blocker('journal-identity-mismatch', entry.txnId)) + continue + } + // **저널이 든 `resultRef` 이름 자체를 검증한다**(Codex PR#289 4R P1). PR3b 는 「비어 있지 않은 + // 문자열」까지만 봤고 문법 소유가 이 PR 로 넘어왔는데, 초안은 **관측된 git ref** 만 파싱하고 + // 저널이 든 이름은 한 번도 보지 않았다 — 그 값은 **불변 필드**라, 아직 ref 가 없는 `prepared` + // 저널의 잘못된 이름이 `no-mutation` 을 타고 살아남아 나중에 **그대로 발행**된다. + const named = parseResultRef(j.resultRef) + const nameBound = + named.kind === 'ok' && named.benchId === obs.identity.benchId && named.txnId === j.txnId + // **revision 산술은 stage 마다 다르지만 stage 전수에 고정된다**(Codex PR#289 5R P1 이 4R 의 + // 내 부분 수용을 좁혔다 — 「composed 에서만」이 아니라 **stage 별 정확한 식**이 있다). + // + // `resultRef` 는 불변 필드이고 ref 는 **composed CAS 앞**에서 딱 한 번 발행된다. 단계가 전진할 + // 때마다 `expectedAuthorityRevision` 만 `+1` 되므로, 기준 revision 을 `R0`(prepared 저널의 + // expected)라 하면 **ref = R0 + 2** 로 고정이고 `expected = R0 + stageIndex` 다: + // + // prepared(0) → ref = expected + 2 · composed(1) → +1 · published(2) → +0 · finalized(3) → -1 + // + // ⚠ `abandoned` 도 **산술 정의역 안**이다(7R P1 이 초안의 면제를 폐기했다 — 아래 `abandonBound`). + // **종결 증거는 모든 admitted 저널에서 본다**(Codex PR#289 6R P1). 초안은 이 검사를 **앵커 + // 면제 평가 안에만** 뒀는데, post-CAS `composed` 저널의 ref 는 revision 이 권위와 같아 후보가 + // 되지 않으므로 그 경로에 **영영 도달하지 않는다** — `abandonedAt`·`abandonReason` 을 든 채 + // `normal-wait` 이 나왔다. 파서는 그 필드를 abandoned 밖에서도 허용하므로 여기서 형태로 막는다. + const terminalEvidence = + j.stage !== 'abandoned' && (j.abandonedAt !== undefined || j.abandonReason !== undefined) + // ⚠ **`finalized` 는 등식이 아니라 상한이다**(로컬 적대 리뷰 P1 · 정정 233). `prepared`· + // `composed`·`published` 저널은 **창이 닫힌 동안**(활성 저널 = 실행 차단) 기록되므로 단계마다 + // revision 이 정확히 +1 이고 등식이 성립한다. 그런데 `finalized` 저널은 **외부 소비자 머지 관측 + // 이후**에 기록되고 그 사이의 활동·회수·보관 CAS 가 `expected` 를 올리므로, 불변 필드인 + // `resultRef` 의 revision 은 `expected` **이하**로만 보장된다. + // ⚠ **`abandoned` 는 stage 순서 밖이지만 산술 밖은 아니다**(Codex PR#289 7R P1). 초안은 + // `stageIndex < 0` 로 단락시켜 **모든 revision 을 통과**시켰는데, 그 면제의 근거였던 「포기 + // 시점에 따라 expected 가 달라져 식이 하나로 고정되지 않는다」는 5R 정정 223 이 이미 답한 + // 형태다 — **「stage 마다 다르다」는 면제의 근거가 아니라 식을 찾으라는 신호다.** + // 정의역은 `previousAuthorityStage`(=P)가 준다. + // ⚠ **등식이 아니라 밴드다**(로컬 적대 리뷰 P1 — 초안의 등식이 정상 상태를 오분류했다). `P` 는 + // 권위 stage 가 아니라 **직전 저널 stage** 이고(append 가 디스크 stage 와 일치를 강제한다), + // WAL 은 선기록이므로 저널이 P 일 때 권위는 **P(post-CAS)** 이거나 **P-1(pre-CAS 크래시 창 · + // §3-T83 이 정상으로 고정한 상태)** 이다. 그래서 `expected` 는 `R0 + idx(P) + 1` 또는 + // `R0 + idx(P)` 이고, 절대식 `ref = R0 + 2` 를 대입하면 + // **`expected + 1 - idx(P) ≤ ref ≤ expected + 2 - idx(P)`** 다. + // ⚠ 열린 창(P ∈ {published, finalized})은 개입 CAS 가 `expected` 를 올리므로 **하한을 뗀다** + // (정정 233 과 같은 근거). ⚠ **정직 표기**: 그 arm 은 상한만 남으므로 형제(비-current) 포기 + // 저널이 `P = finalized` 를 주장하면 임의로 낮은 revision 이 통과한다 — `expected` 를 대조할 + // 권위 축이 그 저널에는 없기 때문이다(면제 이전보다 좁지만 닫히지는 않았다). + // ⚠ `previousAuthorityStage` 부재는 **fail-closed** 다: 파서가 그 필드를 optional 로 허용하므로, + // 부재를 통과시키면 필드를 지우는 것만으로 이 결속을 통째로 우회한다. + // 막힌 대역: 이 검사가 없으면 `refRevision ≤ record.revision` 전체가 무검사다(높은 쪽만 앵커가 + // 잡는다) — 그 상태가 청소되고 나면 권위 롤백을 증언할 유일한 증인이 침묵한다. + const stageIndex = WAL_STAGE_ORDER.indexOf(j.stage) + const abandonedFrom = + j.stage === 'abandoned' && j.previousAuthorityStage !== undefined + ? WAL_STAGE_ORDER.indexOf(j.previousAuthorityStage) + : -1 + const abandonFloor = j.expectedAuthorityRevision + 1 - abandonedFrom + const abandonCeil = abandonFloor + 1 + const revisionBound = + named.kind === 'ok' && + (j.stage === 'abandoned' + ? abandonedFrom >= 0 && + named.resultingRevision <= abandonCeil && + (abandonedFrom >= WAL_STAGE_ORDER.indexOf('published') || + named.resultingRevision >= abandonFloor) + : j.stage === 'finalized' + ? named.resultingRevision <= j.expectedAuthorityRevision + : named.resultingRevision === j.expectedAuthorityRevision + 2 - stageIndex) + // **상속 증거의 형태도 복구가 독립적으로 요구한다**(Codex PR#289 7R P1). 초안은 포기 저널에서 + // 종결 필드의 **존재**만 봤고, 결과·게시 증거가 `previousAuthorityStage` 와 정합하는지는 + // **쓰기 경로**(`journal.ts` 의 증거 동결)에만 있었다 — 그런데 **복구는 착지한 레코드만 본다**. + // 그 방어가 돌지 않은 디스크 상태(롤백·손상)에서 `composed` 포기가 날조된 `publishedAt` 을, + // `published` 포기가 소거된 게시 시각을 들고도 `idempotent-cleanup` 을 받았다. + // ⚠ 6R 의 「종결 증거는 모든 admitted 저널에서」와 **반대 방향**이라 종별을 나눈다. + const abandonEvidenceInvalid = + j.stage === 'abandoned' && + abandonedFrom >= 0 && + ABANDON_EVIDENCE.some( + ([field, introducer]) => + abandonedFrom >= WAL_STAGE_ORDER.indexOf(introducer) !== (j[field] !== undefined), + ) + if (!nameBound || !revisionBound) { + blockers.push(blocker('journal-result-ref-invalid', entry.txnId)) + continue + } + if (terminalEvidence) { + blockers.push(blocker('journal-terminal-evidence', entry.txnId)) + continue + } + if (abandonEvidenceInvalid) { + blockers.push(blocker('journal-abandon-evidence-invalid', entry.txnId)) + continue + } + journals.set(j.txnId, j) + } + } + + // ④⑤ ref↔저널 설명 관계 분류 → anchor gate. **현재 이하의 ref 는 정상 보존**이다(§3-T76·T90). + const candidates = parsedRefs.filter((r) => r.resultingRevision > observedRevision) + const evaluated = candidates.map((ref) => ({ + ref, + reasons: evaluateExemption(ref, record, journals), + })) + const promotable = evaluated.filter((c) => c.reasons.length === 0) + // **정확히 하나**(조건 ⑩)일 때만 면제한다. `0` 과 `≥2` 는 같은 처분 — 후보 전부가 blocker 다. + // + // ⚠ **정직 표기(뮤테이션 자기검사 실측)**: 이 `=== 1` 을 `>= 1` 로 바꿔도 **테스트가 잡지 못한다.** + // 조건 ⑧(전이 불변식)이 「composed 는 새 txn 을 시작할 수 없다」를 강제하므로 통과 후보는 **현재 txn + // 하나**로 이미 좁혀져 `≥2` 가 도달 불가이기 때문이다. 그래도 남겨 두는 이유는 조건 ⑩ 이 승인된 계약 + // 조항(계획 정정 199ⓒ)이고, ⑧ 이 약해지는 미래 편집에서 **이 줄이 마지막 방어**가 되기 때문이다 — + // 대신 「이 PR 에서 독립 반증력 0」임을 숨기지 않는다. 복수 주장(§3-T88)의 **관측 가능한** 계약은 + // 이 줄이 아니라 「면제되지 않은 모든 후보가 blocker」라는 아래 루프가 진다. + const exempt = promotable.length === 1 ? promotable[0]?.ref : undefined + for (const c of evaluated) { + if (c.ref !== exempt) blockers.push(blocker('anchor-rollback', c.ref.refName, c.reasons)) + } + + // ⑥ 귀속. 정의역은 **현재 txn 1건**이고, 권위가 가리키지 않는 활성 엔트리는 고아 저널이다. + const currentTxnId = record?.currentIntegrationTxnId + const current = currentTxnId === undefined ? undefined : journals.get(currentTxnId) + // WAL 은 **선기록**이다 — 권위가 가리키는 txn 에 저널이 없다는 것은 매체 하나가 사라졌다는 뜻이다. + // ⚠ 열거 자체가 실패했을 때는 발화하지 않는다(그 사실은 이미 `journal-enumeration-failed` 다). + if (obs.journal.kind === 'ok' && currentTxnId !== undefined && current === undefined) { + blockers.push(blocker('current-txn-journal-missing', currentTxnId)) + } + // **권위 stage ≤ 저널 stage ≤ 권위 stage + 1**(Codex PR#289 P1). 앞의 부등식은 WAL 선기록에서, + // 뒤의 부등식은 정정 204 불변식 ①(「stage S 의 권위 커밋이 확인되기 전 S+1 저널 기록 금지」)에서 + // 나온다. 초안은 **저널 stage 만 보고 분기**해서, 권위가 앞선 관측(= 저널 단독 롤백·손상)이 + // `no-mutation` 으로 통과했다 — 손상 위에서 포기·재준비가 인가되는 경로였다. + // ⚠ 저널 `abandoned` 는 종결 기록이라 순서 결속의 정의역 밖이다(포기 CAS 가 pending 일 뿐이다). + const authorityStage = record?.currentIntegrationStage + if (current !== undefined && current.stage !== 'abandoned' && authorityStage !== undefined) { + const jIdx = WAL_STAGE_ORDER.indexOf(current.stage) + const aIdx = WAL_STAGE_ORDER.indexOf(authorityStage) + if (aIdx > jIdx) blockers.push(blocker('journal-behind-authority', current.txnId)) + else if (jIdx > aIdx + 1) blockers.push(blocker('journal-too-far-ahead', current.txnId)) + } + + // **stage 가 같아도 결속은 따로 본다**(Codex PR#289 3R P1). 위 검사는 **순서(인덱스)만** 비교하므로 + // 「둘 다 `composed` 인데 `resultOid`·expected revision·세대가 어긋난」 손상 쌍이 그대로 통과해, + // ref 가 없을 때 복구표가 `no-mutation`(= 포기·재준비 적격)을 답했다. 결속은 **두 배치 중 하나**를 + // 만족해야 한다 — ⓐ**CAS 전**(권위가 아직 `previousAuthorityStage` · `expected === revision`) + // ⓑ**CAS 후**(권위가 `nextAuthorityStage` · `revision === expected + 1`). 그 외는 전부 손상이다. + let binding: 'pre-cas' | 'post-cas' | undefined + if (record !== undefined && current !== undefined) { + // ⚠ **열린 창에서는 전역 revision·전체 draft 로 결속하지 않는다**(로컬 적대 리뷰 P1 · 정정 233). + // + // `revision` 은 **모든 CAS** 가 +1 하는 전역 카운터이고(`authority.ts` 의 유일 배정 지점), 통합 + // 4필드를 보존하는 CAS(활동 시작/종료·gated-orphan 회수·lifecycle 변경)는 전이 검사의 **no-op** + // 으로 통과한다. 스펙은 그 개입을 **요구**한다 — C6(「`published` 저널은 소비자 완결까지 무기한 + // 열려 있는 정상 상태 … 문자대로 구현하면 모든 integration-ready bench 가 즉시 차단」) · + // §3-T33(그 창에서 **실행** 가능) · I8(`archived ∧ integration-ready` 도 정상). + // + // ⓐ**닫힌 창**(`prepared`·`composed`)에서는 tight 하게 결속한다 — 그 stage 는 **활성 저널**이라 + // 실행이 차단되고, 복구 판정은 **모든 권위 CAS 보다 먼저** 돈다(정정 197ⓐ). 그래서 저널 선기록과 + // 그 CAS 사이에 개입 CAS 가 들어올 수 없고, `resultRef` 가 prepare 시점에 revision 을 실을 수 + // 있다는 정정 196 의 전제도 이 사실 위에 선다. + // ⓑ**열린 창**(`published`·`finalized`)의 **post-CAS** 는 방향으로만 결속한다 — 그 CAS 가 커밋된 + // 뒤로는 개입이 정상이므로 `revision === expected + 1` 은 정상 상태를 손상으로 오분류한다. + // ⓒ그 창의 **draft 전체 digest 대조는 폐기**한다(정직 표기): 4R 정정 218 이 그것으로 잡으려던 + // 「`lifecycle`·`sourceGeneration`·`activeActivity` 롤백」은 무기한 창에서 **정상 변화와 구분 + // 불가**다. 그 자리에는 통합 축 결속(txn·stage·세대·`resultOid`)과 앵커만 남는다. + const openWindow = current.stage === 'published' || current.stage === 'finalized' + const preCas = + record.currentIntegrationStage === current.previousAuthorityStage && + current.expectedAuthorityRevision === record.revision + const postCas = + record.currentIntegrationStage === current.nextAuthorityStage && + (openWindow + ? record.revision >= current.expectedAuthorityRevision + 1 + : record.revision === current.expectedAuthorityRevision + 1) + // CAS 가 이미 커밋됐다면 권위는 저널이 증언한 **그 결과**를 들고 있어야 하고, **draft 전체**가 + // 저널이 선기록한 의도와 같아야 한다(Codex PR#289 4R P1). stage·revision·세대·resultOid 만 보면 + // `lifecycle`·`sourceGeneration`·`activeActivity` 처럼 **draft 투영 안의 나머지 필드**가 롤백·손상 + // 돼도 통과해, 결과 ref 까지 맞는 상태에서 `normal-wait` 이 나온다. `draftDigest` 는 저널이 이미 + // 들고 있는 값이므로 **추가 입력 없이** 그 구멍을 닫는다. + // **pre-CAS 도 결과·draft 결속을 본다**(Codex PR#289 6R P1). 초안은 `!postCas` 가지에서 **세대만** + // 봐서, 「결과 A 를 든 composed 권위 + 결과 B 를 든 published 저널·같은 revision ref」가 순서·산술· + // ref 대조를 전부 통과해 `normal-wait` 이 나왔다 — **같은 txn 이 불변 결과를 갈아치운** 것이다. + // ⓐ**결과 상속**: 권위가 이미 든 결과가 있으면 저널의 증언과 같아야 한다(둘 다 있을 때) + // ⓑ**draft 결속**: post-CAS 는 권위 draft 투영 자체를, pre-CAS 는 **저널이 낼 다음 draft 의 + // 재구성**을 `draftDigest` 와 대조한다(면제 조건 ⑧ 을 전 stage 로 일반화한 것이다). + const draftBound = + openWindow && postCas + ? true // ⓒ — 폐기 근거는 위 주석. 통합 축은 아래 `resultInherited`·세대·stage 가 계속 진다. + : postCas + ? digestAuthorityDraft(toDraft(record)) === current.draftDigest + : digestAuthorityDraft(reconstructNextDraft(record, current)) === current.draftDigest + const resultInherited = + record.currentIntegrationResultOid === undefined || + current.resultOid === undefined || + record.currentIntegrationResultOid === current.resultOid + const evidenceOk = + record.currentIntegrationTxnGeneration === current.integrationGeneration && + resultInherited && + draftBound + if ((!preCas && !postCas) || !evidenceOk) { + blockers.push(blocker('journal-authority-binding', current.txnId)) + } else { + binding = postCas ? 'post-cas' : 'pre-cas' + } + } + + // 미종결 txn 이 있으면 **새 txn 의 저널 자체가 쓰이지 말았어야 한다**(전이 불변식이 그 시작을 + // 금지한다). 그래서 benign 창은 「권위에 pending 통합이 없을 때」에만 열린다(Codex PR#289 P1). + // **현재 txn 의 ref 는 stage·경로와 무관하게 저널의 증언과 같아야 한다**(Codex PR#289 4R P1 + + // 로컬 적대 리뷰 P2 · 정정 235). 4R 은 이 대조를 「표 앞」으로 올렸는데 그 「앞」이 **면제 반환 뒤** + // 였다 — 승격(`resume-composed-cas`) 경로에서만 같은 txn 의 여분·손상 ref 가 무검사로 통과했고, + // 그 상태가 `sealEvidence` 봉인에 실려 **손상이 정당한 증거로 고정**됐다. blocker 수집 구간으로 + // 올려 「한 곳에서만 판정한다」를 실제로 만족시킨다(T80 blocker 지배와도 통일). + // ⚠ OID 대조는 **저널이 결과를 증언할 때만** 성립한다 — `prepared` 는 `resultOid` 가 아직 없다. + if (current !== undefined) { + for (const r of parsedRefs.filter((x) => x.txnId === current.txnId)) { + if ( + r.refName !== current.resultRef || + (current.resultOid !== undefined && r.oid !== current.resultOid) + ) { + blockers.push(blocker('result-ref-mismatch', r.refName)) + } + } + } + + const authorityPending = authorityStage !== undefined && authorityStage !== 'finalized' + + // ⚠ **완결 귀속 검사는 활성 게이트 밖이다**(로컬 적대 리뷰 P3 · 정정 234). 스펙 §W-7 은 「완결된 + // txn 의 저널은 정상 상태에서 `finalized` 다」인데, 초안은 이 검사를 **활성 stage 루프 안**에 둬서 + // 그 txn 의 저널이 `published`·`abandoned` 로 남은 경우를 놓쳤다 — 6R 정정 229 가 지적한 「검사의 + // 위치가 곧 정의역」의 같은 형태다. 종별도 그 사실에 맞춰 개명한다. + // ⚠ **열거 실패는 손상 사실이 아니다**(로컬 적대 리뷰 P2). `journals`·`parsedRefs` 는 열거가 `failed` + // 여도 비어 있으므로, 가드 없이 「부재 = 손상」을 주장하면 **읽지 못한 파일에 대한 사실 주장**이 + // 된다 — 이 모듈의 「실패를 빈 집합으로 축소하지 않는다」(§3-T78)와 정면 충돌이고, 한 입력에 두 + // 사유를 발화해 진단을 가린다(정정 189). 실패는 이미 자기 종별로 답했다. + const completedTxnId = record?.completedIntegrationTxnId + if (record !== undefined && completedTxnId !== undefined && obs.journal.kind === 'ok') { + // ⚠ **부재도 위반이다**(Codex PR#289 7R P1). 초안은 `!== undefined` 를 전제로 걸어서, 저널 열거가 + // 비면 검사를 통째로 건너뛰고 뒤이은 `current === undefined` 반환이 그 상태를 `no-mutation` + // (무변이 적격)으로 답했다 — **삭제·롤백된 저널이 양성으로 숨는다.** 저널은 **감사 보존**이고 + // (§W-7 「복구 중 삭제 금지」 · spec:1005·1330 — 포기조차 파일을 남긴다) 완결 txn 은 `finalized` + // 저널을 반드시 남기므로, 부재는 정상 상태가 아니라 손상이다. + const completedJournal = journals.get(completedTxnId) + if (completedJournal === undefined || completedJournal.stage !== 'finalized') { + blockers.push(blocker('completed-txn-journal-unfinalized', completedTxnId)) + } else if (completedJournal.sourceGeneration !== record.sourceGeneration) { + // **완결은 현 소스 세대를 대표해야 한다**(Codex PR#289 7R P1 · §W-8 완결 CAS 5연언). 열린 창에서의 + // 실행(C6 · §3-T33 이 정상으로 요구)이 `sourceGeneration` 을 올린 뒤 완결하면, 그 세대의 작업은 + // 통합된 적 없는데 bench 가 `integrated` 로 기록돼 **부분 통합이 숨는다**. + // ⚠ **판정에 쓰는 값은 저널의 불변 `sourceGeneration`** 이다(T71 불변 12필드 — 그 시도가 통합한 + // 소스 세대). 권위의 `currentIntegrationTxnGeneration` 은 저널 `integrationGeneration` + // (「`prepared` 마다 +1」= **시도 카운터**)과 묶이는 값이라 등치하면 「활동 2회 뒤 첫 통합」 같은 + // 정상 흐름을 막는다 — 로컬 적대 리뷰가 초안의 그 오류를 실측으로 적발했다. + // ⚠ 그래서 이 검사는 권위 레코드 두 장만 보는 **전이 불변식 층에서는 불가능**하다(저널 불가시). + // CAS 시점의 강제는 완결 관측 생산자(PR5)의 몫이고, 이 층은 **착지한 레코드의 감사**다. + blockers.push(blocker('completed-txn-stale-generation', completedTxnId)) + } else if (obs.prefixRefs.kind === 'ok' && completedTxnId !== currentTxnId) { + // ⚠ **ref 요구도 완결 txn 에 걸어야 한다**(Codex PR#289 7R P1). 5R 정정 224 의 요구는 아래 + // `current` 정의역에만 있어서, 완결 txn 이 더 이상 current 가 아니면 `current === undefined` + // 조기 반환보다 뒤라 **한 번도 평가되지 않았다**. 게시는 필연적으로 finalization 에 + // 선행하므로(§W-8) 그 불변 ref 의 소실은 손상이고 `no-mutation` 으로 답할 수 없다. + // ⚠ **존재가 아니라 전수 결속이다**(Codex PR#289 8R P1). 초안은 `some(txnId 일치)` 라 ⓐ이름은 + // 맞고 **OID 가 교체**된 ref ⓑ**다른 이름**의 ref ⓒ정상 ref **옆의 여분 손상 ref** 를 전부 + // 통과시켰다 — 완결 txn 은 앵커가 구조적으로 답하지 않으므로(완결 CAS 가 revision 을 올려 + // 보존 ref 는 항상 `revision` 이하) 이 층 말고는 그 교체를 볼 곳이 없다. 그래서 위 `current` + // 경로의 **전수 대조를 같은 형태로** 적용한다(같은 손상에 같은 종별). + // ⚠ 저널 `resultOid` 는 `stage === 'finalized'` 가 확정된 이 분기에서 **존재가 보장**된다 + // (`journal.ts` 형태 층이 `composed` 이상에 필수로 요구) — `!== undefined` 가드는 반증력 0 이다. + // ⚠ `completedTxnId === currentTxnId` 면 위 루프가 이미 같은 판정을 냈다(중복 발화 회피). + const completedRefs = parsedRefs.filter((r) => r.txnId === completedTxnId) + if (completedRefs.length === 0) { + blockers.push(blocker('result-ref-missing', completedJournal.resultRef)) + } + for (const r of completedRefs) { + if (r.refName !== completedJournal.resultRef || r.oid !== completedJournal.resultOid) { + blockers.push(blocker('result-ref-mismatch', r.refName)) + } + } + } + } + + for (const j of journals.values()) { + if (!isActiveJournalStage(j.stage)) continue + if (j.txnId === currentTxnId || j.txnId === completedTxnId) continue + // (A) 프로토콜상 **정상 크래시 창은 하나뿐**이다 — 저널 선기록 후 `prepared` CAS 전. 그 창의 + // 엔트리는 결과 ref 를 가질 수 없으므로, ref 를 동반한 고아·`composed` 고아는 전부 reconciliation 이다 + // (계획 정정 194 의 2분 — 활성 집합의 **정의**는 stage 단독으로 유지한다). + // ⚠ **종결 bench 에는 benign 창이 없다**(Codex PR#289 6R P1): `integrated`·`archived` 권위는 + // current txn 이 없어 `authorityPending` 이 거짓이지만, stage 의 pending 여부만 보면 그 사실을 놓친다. + // ⚠ **근거 정정**(8R 로컬 검증): 초안은 「그 저널이 **애초에 쓰일 수 없었다**」고 적었는데 그것은 + // 거짓이다 — 그 저널은 bench 가 `open` 이던 시절에 **합법으로** 쓰였을 수 있고(정상 크래시 창), + // 전이 층이 막는 것은 그 위에서 **새 txn 을 시작**하는 것뿐이다. 참인 근거는 「종결 bench 위에서는 + // 그 저널의 재개·재준비가 인가되지 않으므로 처분을 사람에게 넘긴다」이다. 판정은 그대로다 — + // 바뀐 것은 **검증되지 않은 안전 주장**을 문서로 남기지 않는다는 규율이다(정정 133·152·156 계열). + const benign = + j.stage === 'prepared' && + !authorityPending && + record?.lifecycle === 'open' && + !parsedRefs.some((r) => r.txnId === j.txnId) + if (!benign) blockers.push(blocker('orphan-active-journal', j.txnId)) + } + + if (conflicts.length > 0 || blockers.length > 0) { + return { kind: 'reconciliation-required', blockers: [...conflicts, ...blockers] } + } + + if (exempt !== undefined) { + const j = journals.get(exempt.txnId) + // 면제가 성립했다는 것은 저널이 있다는 뜻이다(조건 ①). 타입 폭을 좁히는 것 이상은 하지 않는다. + if (j !== undefined) { + return { + kind: 'resume-composed-cas', + resume: { + txnId: exempt.txnId, + refName: exempt.refName, + resultOid: exempt.oid, + expectedAuthorityRevision: j.expectedAuthorityRevision, + resultingRevision: exempt.resultingRevision, + evidenceDigest: sealEvidence(obs.identity.benchId, parsedRefs, exempt, j), + }, + } + } + } + + // 현재 txn 이 없으면(또는 권위 자체가 없으면) 복구표의 정의역이 비어 있다 = 무변이 적격. + if (record === undefined || current === undefined) return { kind: 'no-mutation' } + + const ownRefs = parsedRefs.filter((r) => r.txnId === current.txnId) + + // **ref 발행 이후의 상태는 ref 를 요구한다**(Codex PR#289 5R P1). 발행은 composed CAS **앞**이므로 + // ⓐpost-CAS composed ⓑ`published` ⓒ`finalized` 에서 ref 가 **사라진 것**은 프로토콜상 불가능하고, + // 그것을 `no-mutation`(= 포기·재준비 적격)으로 답하면 **ref 롤백·손상이 그대로 숨는다**. + // ⚠ `abandoned` 는 제외한다 — `prepared` 에서 포기하면 ref 는 애초에 존재한 적이 없다. + // ⚠ `abandoned` 를 **통째로** 면제하면 안 된다(Codex PR#289 6R P1): `previousAuthorityStage` 가 + // `composed` 이상인 포기는 **이미 ref 가 발행된 뒤**라 그 부재는 손상이고, 청소 CAS 가 남은 통합 + // 상태를 지우면서 **사라진 불변 결과를 숨긴다**. 면제는 「`prepared` 에서의 포기」에만 준다. + // ⚠ 조건은 **하나**다(로컬 적대 리뷰 P2 · 정정 234): 증거 동결(`journal.ts` `EVIDENCE_FIELDS`)상 + // `previousAuthorityStage` 가 `composed` 이상이면 `resultOid` 존재가 **함의**되므로, 두 조건을 + // `||` 로 두면 둘을 분리 반증하는 **합법 픽스처가 존재하지 않는다**(측정: 어느 쪽을 지워도 초록). + const abandonedAfterCompose = current.stage === 'abandoned' && current.resultOid !== undefined + const refRequired = + current.stage === 'published' || + current.stage === 'finalized' || + abandonedAfterCompose || + (current.stage === 'composed' && binding === 'post-cas') + if (refRequired && ownRefs.length === 0) { + return { + kind: 'reconciliation-required', + blockers: [blocker('result-ref-missing', current.resultRef)], + } + } + + switch (current.stage) { + case 'prepared': + // (A) 에서 결과 ref 는 **composed 저널 뒤**에만 발행된다 — `prepared` 상태의 ref 는 설명되지 않는다. + // + // ⚠ 그 상태를 위한 **전용 arm 은 두지 않는다**(도달 불가 · Codex PR#289 5R 의 연쇄): `prepared` + // 저널이 현재 txn 을 가리키려면 결속이 post-CAS 여야 하고(`revision = expected + 1`), 그 저널의 + // ref 는 `expected + 2 = revision + 1` 이라 **항상 앵커 후보**다. 이름이 다르면 그건 위의 + // mismatch 축이 답한다. 복구표가 요구하는 **결과**(reconciliation)는 앵커가 그대로 보장한다. + return { kind: 'no-mutation' } + case 'composed': + // ref 발행 전(pre-CAS) 크래시 = 되돌릴 것이 없다. 그 외는 **완료 확인**이다(정정 204ⓑ). + // + // ⚠ **이름·OID·산술 대조가 여기 없는 것은 누락이 아니라 배치다**(Codex PR#289 4R P1): + // ⓐ이름·OID 는 **stage 와 무관하게** 위에서 대조했다(`published` 분기가 그것을 빠뜨렸던 것이 + // 4R 지적이다) ⓑ산술은 **③ 저널 검증**이 `resultRef` 이름 자체에 stage 별 식으로 강제하고 + // ⓐ 가 관측 ref 를 그 이름에 묶으므로 재검사는 가려진다. + return ownRefs.length === 0 ? { kind: 'no-mutation' } : { kind: 'normal-wait' } + case 'published': + // C6 — `published` 는 활성이 아니고 차단하지 않는다. 다음 단계 전진은 시퀀서의 정상 경로다. + return { kind: 'normal-wait' } + case 'finalized': + case 'abandoned': + // `finalized`·`abandoned` 잔존 = **멱등 종결**이지 삭제가 아니다(§W-7 「복구 중 삭제 금지」). + return { kind: 'idempotent-cleanup' } + default: + return assertNever(current.stage) + } +} diff --git a/src/main/core/workbench/result-ref.test.ts b/src/main/core/workbench/result-ref.test.ts new file mode 100644 index 0000000..48e936f --- /dev/null +++ b/src/main/core/workbench/result-ref.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' + +import { RESULT_REF_ROOT, formatResultRef, parseResultRef, resultRefPrefix } from './result-ref' + +/** + * §3-T75 계열 — 결과 ref 문법(계획 정정 192·196·223). + * + * 문법 = `refs/fleet/integrated//-` · + * **10진 가변폭 · 선행 0 금지 · 파싱 실패 fail-closed**. + * + * ⚠ 이 파일이 고정하는 것은 **문법**이다 — 산술 결속의 **정의역**(stage 별 식 `expected + 2 - stageIndex` · + * `finalized` 는 상한 · `abandoned` 는 정의역 밖 · 정정 223·233)은 여기가 아니라 `recovery.ts` 가 강제하고 + * `recovery.test.ts` 가 핀으로 박는다. 아래 회귀 핀의 `+1` 은 **`composed` 저널 기준 상대식**이며(절대식은 + * `prepared` 저널의 expected `R0` 기준 `R0 + 2`), 두 값을 같다고 둔 초안을 막는다 — 그러면 ref 발행에 + * 결속된 **바로 그 CAS** 가 롤백돼도 `N > N` 이 거짓이라 앵커가 영원히 침묵한다(정정 196 P1). + */ + +const BENCH = '01J8Z4T7K9QW3M5N7P9R1S3T5V' +const TXN = '01J8Z4T7K9QW3M5N7P9R1S3T5W' + +describe('결과 ref 문법 — 조립', () => { + it('`//-` 로 조립한다', () => { + expect(formatResultRef(BENCH, 42, TXN)).toBe(`${RESULT_REF_ROOT}/${BENCH}/42-${TXN}`) + }) + + it('열거 접두는 **경로 성분 경계**로 끝난다 — 이웃 benchId 를 삼키지 않는다', () => { + // `git.ts:198` 실측: 접두는 경로 성분 경계로 매칭되므로 `…/BX` 는 `…/BXY/T9` 를 답하지 않는다. + // 그 성질에 의존하려면 접두 자신이 성분 경계여야 한다. + expect(resultRefPrefix(BENCH)).toBe(`${RESULT_REF_ROOT}/${BENCH}/`) + }) + + it('revision 이 안전 정수 ∧ >= 1 이 아니면 조립을 거부한다', () => { + for (const bad of [0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => formatResultRef(BENCH, bad, TXN)).toThrow(/resultingRevision/) + } + }) + + it('benchId·txnId 가 ULID 문법이 아니면 조립을 거부한다 — 정규화하지 않고 거부', () => { + // PR1a `ulid.ts` 의 「정규화 금지 · 거부」 규율. 소문자를 대문자로 접으면 win32 case-fold 에서 + // 두 이름이 같은 ref 를 가리키는 비단사 매핑이 된다. + expect(() => formatResultRef(BENCH.toLowerCase(), 1, TXN)).toThrow(/benchId/) + expect(() => formatResultRef(BENCH, 1, TXN.toLowerCase())).toThrow(/txnId/) + }) +}) + +describe('결과 ref 문법 — 파싱', () => { + it('왕복한다', () => { + const parsed = parseResultRef(formatResultRef(BENCH, 7, TXN)) + expect(parsed).toEqual({ kind: 'ok', benchId: BENCH, resultingRevision: 7, txnId: TXN }) + }) + + it('선행 0 을 거부한다 — `01` 과 `1` 이 같은 revision 을 가리키면 비단사다', () => { + const r = parseResultRef(`${RESULT_REF_ROOT}/${BENCH}/07-${TXN}`) + expect(r).toEqual({ kind: 'invalid', reason: 'revision-syntax' }) + }) + + it('`0`·음수·소수·비10진을 거부한다', () => { + for (const rev of ['0', '-1', '1.5', '0x10', '1e3', '+1', ' 1', '1 ']) { + expect(parseResultRef(`${RESULT_REF_ROOT}/${BENCH}/${rev}-${TXN}`)).toEqual({ + kind: 'invalid', + reason: 'revision-syntax', + }) + } + }) + + it('안전 정수 범위를 넘는 revision 을 거부한다 — 가변폭이라 자릿수 상한이 없다', () => { + // 고정폭을 폐기한 대가(정정 192): 이름은 임의 길이 숫자를 실을 수 있으므로 **파싱이** 상한을 본다. + const huge = '9'.repeat(30) + expect(parseResultRef(`${RESULT_REF_ROOT}/${BENCH}/${huge}-${TXN}`)).toEqual({ + kind: 'invalid', + reason: 'revision-range', + }) + }) + + it('ULID 가 아닌 benchId·txnId 를 거부한다', () => { + expect(parseResultRef(`${RESULT_REF_ROOT}/nope/1-${TXN}`)).toEqual({ + kind: 'invalid', + reason: 'benchid-syntax', + }) + expect(parseResultRef(`${RESULT_REF_ROOT}/${BENCH}/1-nope`)).toEqual({ + kind: 'invalid', + reason: 'txnid-syntax', + }) + }) + + it('bare 부모 ref 를 **별도 종별**로 답한다 — D/F 충돌 최우선 판정의 입력', () => { + // spec §W-7:828 「`refs/fleet/integrated/` 가 ref 로 존재 = REF_NAMESPACE_CONFLICT 최우선」. + // 이것을 「문법 오류」로 뭉개면 최우선 종별이 일반 blocker 에 섞인다. + expect(parseResultRef(`${RESULT_REF_ROOT}/${BENCH}`)).toEqual({ + kind: 'namespace-conflict', + benchId: BENCH, + }) + }) + + it('ULID 가 아닌 단일 성분은 namespace-conflict 가 **아니라** 문법 오류다', () => { + // 미겨냥 축(자기검사): 「깊이 1 = namespace-conflict」로만 구현하면 우리 네임스페이스와 무관한 + // 쓰레기 이름이 **최우선 fail-closed 종별**을 참칭한다. 최우선 종별은 참칭당하면 안 된다. + expect(parseResultRef(`${RESULT_REF_ROOT}/nope`)).toEqual({ + kind: 'invalid', + reason: 'benchid-syntax', + }) + }) + + it('루트 자신을 거부한다', () => { + expect(parseResultRef(RESULT_REF_ROOT)).toEqual({ kind: 'invalid', reason: 'benchid-syntax' }) + }) + + it('루트 밖·깊이 초과를 거부한다', () => { + expect(parseResultRef(`refs/heads/${BENCH}/1-${TXN}`)).toEqual({ + kind: 'invalid', + reason: 'not-in-root', + }) + expect(parseResultRef(`${RESULT_REF_ROOT}/${BENCH}/1-${TXN}/extra`)).toEqual({ + kind: 'invalid', + reason: 'depth', + }) + }) + + it('구분자가 없거나 여러 개인 이름을 거부한다', () => { + expect(parseResultRef(`${RESULT_REF_ROOT}/${BENCH}/1${TXN}`)).toEqual({ + kind: 'invalid', + reason: 'revision-syntax', + }) + // txnId 자체는 ULID 라 `-` 를 포함할 수 없다 → 첫 `-` 하나로만 자르는 것이 계약이다. + expect(parseResultRef(`${RESULT_REF_ROOT}/${BENCH}/1-2-${TXN}`)).toEqual({ + kind: 'invalid', + reason: 'txnid-syntax', + }) + }) +}) + +describe('산술 결속(문법 층) — `composed` 저널 기준 refRevision = expected + 1 (정정 196·223)', () => { + it('`composed` 저널의 `expectedAuthorityRevision` 그대로가 아니라 **+1** 이 ref 에 실린다', () => { + // 이 행이 이 PR 의 P1 회귀 핀이다. `journal.ts:115` 의 expected 는 「CAS 가 **맞출**」 값(현재 N)이고 + // `authority.ts:1307` 은 `observedRevision + 1` 을 기록한다. 두 값을 같다고 두면 ref 발행에 결속된 + // 바로 그 CAS 의 롤백을 앵커가 못 잡는다. + // ⚠ 이 핀의 정의역은 **`composed` 저널 하나**다(정정 223) — stage 전수 식 `expected + 2 - stageIndex` + // 와 `finalized` 상한(정정 233)은 `recovery.ts` 의 `revisionBound` 가 강제한다. + const expectedAuthorityRevision = 41 + const ref = formatResultRef(BENCH, expectedAuthorityRevision + 1, TXN) + const parsed = parseResultRef(ref) + expect(parsed).toMatchObject({ kind: 'ok', resultingRevision: 42 }) + }) +}) diff --git a/src/main/core/workbench/result-ref.ts b/src/main/core/workbench/result-ref.ts new file mode 100644 index 0000000..8a7bf5a --- /dev/null +++ b/src/main/core/workbench/result-ref.ts @@ -0,0 +1,124 @@ +/** + * 통합 결과 ref 의 문법(§W-7 C1 · 계획 정정 192·196). **문법 소유는 이 PR(PR3c)이다** — PR3b 저널은 + * `resultRef` 를 「비어 있지 않은 문자열」까지만 봤고(정정 174), 여기서 파싱·조립을 확정한다. + * + * ```text + * refs/fleet/integrated//- + * ``` + * + * **`resultingRevision` = 「이 ref 에 결속된 composed 권위 CAS 가 *기록할* revision」**이다(정정 196 · + * Codex 체크포인트 1R P1 → 정정 223 이 정의역을 stage 전수로 넓혔다). 이름은 **불변 필드**라 `prepared` + * 저널을 쓸 때 이미 정해지므로 **발행자가 실어야 할 값은 `prepared` 시점 `expectedAuthorityRevision`(`R0`) + * 의 `+2`** 다 — `resultingRevision = R0 + 2` 가 절대식이고, 관측한 저널 기준 상대식 + * `expected + 2 - stageIndex` 는 `expected = R0 + stageIndex` 에서 파생된다(`composed` 에서만 `+1` 로 + * 보인다 · 강제자는 `recovery.ts` 의 `revisionBound`). ⚠ 발행자가 `R0 + 1`(= prepared CAS 가 기록한 값)에 + * 머물면 그것이 정정 196 이 적발한 **off-by-one** 이다: + * 저널의 `expectedAuthorityRevision` 은 「이어질 CAS 가 **맞출**(비교할) revision」(= 현재값 `N`)이고 + * `authority.ts` 의 CAS 는 `observedRevision + 1` 을 **기록**한다. 두 값을 같다고 두면 ref 발행에 결속된 + * **바로 그 CAS** 가 롤백돼도 `N > N` 이 거짓이라 앵커가 침묵한다 — 정정 169 가 막으려던 revision + * 재사용의 가장 짧은 형태다. + * + * **고정폭을 쓰지 않는다**(정정 192 가 정정 166 을 폐기). 이름을 문자열 정렬하지 않고 파싱해 숫자로 + * 비교하므로 고정폭은 이득이 0 이면서 자릿수 상한 초과 시 문법 붕괴라는 실패 모드만 만든다. 대신 + * **선행 0 을 거부**한다 — `01` 과 `1` 이 같은 revision 을 가리키면 비단사 매핑이다(PR1a `ulid.ts` 의 + * 「정규화 금지 · 거부」와 같은 규율). + */ + +import { ULID_RE } from './ulid' + +/** ref 네임스페이스 루트. 접두 열거의 기준이기도 하다. */ +export const RESULT_REF_ROOT = 'refs/fleet/integrated' + +/** 10진 · 선행 0 금지 · `0` 금지(revision 은 1부터 · `checkInvariants` ⑧). */ +const REVISION_RE = /^[1-9][0-9]*$/ + +export type ParsedResultRef = + | { + readonly kind: 'ok' + readonly benchId: string + readonly resultingRevision: number + readonly txnId: string + } + /** + * `refs/fleet/integrated/` 자신이 ref 로 존재하는 상태(D/F 충돌). spec §W-7:828 이 이것을 + * **최우선** fail-closed 로 규정하므로 일반 문법 오류와 **섞지 않는다** — 섞으면 최우선 종별이 + * blocker 집합에서 다른 사유에 가려진다. + */ + | { readonly kind: 'namespace-conflict'; readonly benchId: string } + | { readonly kind: 'invalid'; readonly reason: ParseFailureReason } + +export type ParseFailureReason = + 'not-in-root' | 'depth' | 'benchid-syntax' | 'revision-syntax' | 'revision-range' | 'txnid-syntax' + +const assertUlid = (value: string, label: 'benchId' | 'txnId'): void => { + if (!ULID_RE.test(value)) { + throw new Error( + `${label} 가 ULID 문법이 아니다(정규화하지 않고 거부한다): ${JSON.stringify(value)}`, + ) + } +} + +/** + * 조립. **거부는 throw** 다 — 호출자는 전부 이 모듈이 소유한 값(권위 revision · 검증된 ULID)을 넘기므로 + * 실패는 프로그래밍 오류이지 데이터 오류가 아니다(파싱 쪽과 대칭이 아닌 것이 의도다). + */ +export function formatResultRef(benchId: string, resultingRevision: number, txnId: string): string { + assertUlid(benchId, 'benchId') + assertUlid(txnId, 'txnId') + if (!Number.isSafeInteger(resultingRevision) || resultingRevision < 1) { + throw new Error( + `resultingRevision 은 안전 정수 ∧ >= 1 이어야 한다: ${String(resultingRevision)}`, + ) + } + return `${RESULT_REF_ROOT}/${benchId}/${String(resultingRevision)}-${txnId}` +} + +/** + * 열거 접두. **경로 성분 경계로 끝난다** — `git.ts:198` 실측이 「접두는 경로 성분 경계로 매칭된다」이므로 + * 그 성질에 기대려면 접두 자신이 경계여야 한다(`…/BX` 가 `…/BXY/T9` 를 삼키지 않는 근거). + */ +export function resultRefPrefix(benchId: string): string { + assertUlid(benchId, 'benchId') + return `${RESULT_REF_ROOT}/${benchId}/` +} + +/** 파싱. **fail-closed** — 어떤 실패도 「결과 없음」으로 축소하지 않는다(정정 191 · §3-T78). */ +export function parseResultRef(refName: string): ParsedResultRef { + if (refName !== RESULT_REF_ROOT && !refName.startsWith(`${RESULT_REF_ROOT}/`)) { + return { kind: 'invalid', reason: 'not-in-root' } + } + const rest = refName.slice(RESULT_REF_ROOT.length + 1) + const parts = rest.split('/') + + // bare 부모 = `…/` 로 깊이 1. benchId 문법이 성립할 때만 namespace-conflict 로 답한다 — + // 문법조차 아닌 이름은 우리 네임스페이스의 D/F 충돌 증거가 아니다. + if (parts.length === 1) { + const [benchId] = parts + if (benchId !== undefined && ULID_RE.test(benchId)) + return { kind: 'namespace-conflict', benchId } + return { kind: 'invalid', reason: 'benchid-syntax' } + } + if (parts.length !== 2) return { kind: 'invalid', reason: 'depth' } + + const [benchId, leaf] = parts + if (benchId === undefined || !ULID_RE.test(benchId)) { + return { kind: 'invalid', reason: 'benchid-syntax' } + } + if (leaf === undefined) return { kind: 'invalid', reason: 'revision-syntax' } + + // **첫 `-` 하나로만** 자른다. txnId 는 ULID 라 `-` 를 포함할 수 없으므로, 뒤에 `-` 가 더 있으면 + // 그것은 txnId 문법 위반으로 드러난다(구분자 개수를 따로 세지 않는다). + const sep = leaf.indexOf('-') + if (sep < 0) return { kind: 'invalid', reason: 'revision-syntax' } + const revText = leaf.slice(0, sep) + const txnId = leaf.slice(sep + 1) + + if (!REVISION_RE.test(revText)) return { kind: 'invalid', reason: 'revision-syntax' } + const resultingRevision = Number(revText) + // 가변폭의 대가 — 이름은 임의 길이 숫자를 실을 수 있으므로 **파싱이** 상한을 본다. + if (!Number.isSafeInteger(resultingRevision)) return { kind: 'invalid', reason: 'revision-range' } + + if (!ULID_RE.test(txnId)) return { kind: 'invalid', reason: 'txnid-syntax' } + + return { kind: 'ok', benchId, resultingRevision, txnId } +}