Index
Index 는 소싱 → 실사 → 투자심의 → 포트폴리오 관리 → 엑싯 → LP 보고로 이어지는 펀드 운용 lifecycle 의 backend 다.
보통의 투자 관리 도구는 숫자를 보관한다. Index 는 한 층을 더 둔다 — 그 숫자가 왜 그 값인지를 함께 보관한다. IRR 이 얼마인지는 스프레드시트도 안다. 6개월 뒤 “이 가정은 누가, 어떤 근거로, 어떤 표면 값을 조정해서 넣었나” 를 답할 수 있는지가 다르다. Index 는 그 판단을 일급 데이터로 적재하고, 판단이 바뀌면 지배받는 숫자가 결정론적으로 다시 계산된다.
3-layer 모델
| Layer | 무엇 | 대표 도구 |
|---|---|---|
| L1 · 관계형 계산 층 | deal 의 숫자가 사는 곳. 재무 모델 · 펀드↔딜 N:M · 수식 DSL · 위상정렬 평가기. 셀 재계산이 결정론적이다 | compute_outputs · query_irr · compute_full_model |
| L2 · 판단 층 | 관계형 값을 지배하는 판단 을 typed artifact 로 적재. 가정 · calibration · 비교군 · 기준율 + 근거 citation + append-only 이벤트 | get_judgment_provenance · list_calibrations · confirm_artifact |
| L3 · skill 과 스키마 계약 | 투자 도메인 skill 의 정본 + 고정 해시로 못 박은 typed-fact 계약 | list_skills · /index/schemas |
판단 층이 하는 일
- 5종의 판단 — 가정(한 필드를 지배하는 입력) · calibration(표면 값 → 조정 값) · 비교군 · 기준율(확률의 baseline) · 딜 처분 판단(왜 진행/보류/종결했나 + 남는 교훈).
- 링크는 컬럼이 아니라 citation — 판단과 숫자, 판단과 판단 사이의 관계가
calibrated_from·anchored_to·contradicts·supersedes같은 명시적 관계로 저장된다. - 조정을 숨기지 않는다 — 표면 값과 조정 값이 다를 때 하나를 조용히 고르지 않고, 충돌 자체를 calibration 레코드로 드러낸다.
- lifecycle — 제안(propose) → 확정(confirm), 소유자 단위 행 수준 격리, 이벤트는 append-only.
상품 성격 분기
주식형(equity)과 크레딧(credit)은 판단 렌즈가 다르다. 단기 브릿지 대출에 연율화 IRR 을 붙이면 의미 없는 숫자가 나온다. 그래서 재무 모델이 상품 성격을 들고 있고, 크레딧 딜에는 명목 유효연 · 기대 배수 · 부도확률 · 기대신용손실 4지표로 답한다 — 연율화 IRR 은 아예 표시하지 않는다. 주식형 딜의 계산 경로와 결과는 이 분기의 영향을 받지 않는다.
접속
| 표면 | 주소 |
|---|---|
| MCP | https://<workspace>.axelabs.ai/index/mcp |
| 스키마 조회 | https://<workspace>.axelabs.ai/index/schemas |
| 웹 | https://index.axelabs.ai |
MCP 와 스키마 조회는 인증이 필요하다. 웹은 로그인 없이 열리지만 보이는 것은 집계 수치(group-by COUNT)와 스키마 계약뿐이고, 딜별 IRR·calibration 상세는 조직 SSO 로그인 후에 열린다.
딜 단위 기밀 제한: 딜에 기밀 소유자가 지정되면 (a) 직접 조회는 그 사람만 가능하고 — 다른 사람에게는 “없음” 으로 응답해 존재 자체를 숨긴다 — (b) 비교군·유사딜·섹터·처분 등 횡단 조회에서는 소유자에게도 제외된다. 기밀 딜이 다른 딜의 판단 근거로 새어 나가지 않게 하기 위해서다.
우측 패널의 협업 세션
firm-read 콘솔 우측 “세션” 섹션에 Blueprint 협업 세션 중 service=index 로 분류된 세션이 컴팩트 한 줄 목록(상태 도트: 진행 = 채운 초록, 완료 = 빈 원)으로 뜬다 (D-bp-collab-3, @axe/ui 0.38.0 레일 v2). 머리행 [+] 는 그 자리에서 새 세션을 만들고(원장은 Blueprint, 귀속은 로그인한 본인 + 법인 AXEV), 프리셋 아이콘은 /collab/sessions 목록 페이지로 간다. 행을 누르면 Index 안의 세션 상세(/collab/sessions/:id)에서 프롬프트와 게재물을 읽는다 — Blueprint 로 이탈하지 않는다. 세션 페이지는 firm 도메인 로그인 전용이다(무세션 = 로그인으로 303, 비-firm 도메인 = 403).
우측 패널의 프롬프트 카드
같은 패널 슬롯 ② 에 로컬 AI 에이전트에 붙여넣을 복사 블록이 뜬다 (ContextPanel@1, @axe/ui 0.38.6). Index 의 카드는 대상 없음(no-subject) 전용이다 — 첫 줄이 axe index guide 실행하고, 출력된 지침대로 처리해줘. 이고, 그 뒤에 작업 머리말과 규율문이 붙는다. 나가는 것은 서비스 이름 하나뿐이라 참조 토큰도 고지문도 없다(재조회가 없으면 고지할 대상이 없다). 붙여넣은 에이전트는 axe index guide 를 실행해 그 출력이 허용한 조회 명령만 쓴다.
MCP 도구 카탈로그 (56)
딜 · IRR 조회
| Tool | 하는 일 |
|---|---|
list_deals | deal 요약 목록 (단계·섹터 필터) |
get_deal | deal 전체 스냅샷 — 대상 기업 + 펀드 투자 + 시나리오 + 재무 모델 메타 |
query_irr | deal IRR + 포지션별 독립 IRR. 크레딧 딜은 4지표 블록으로 응답 |
fund_performance | 펀드별 수익성 — 펀드 IRR · 기대 배수 · 납입/약정 · 소진율 + 포지션별 |
compute_outputs | 시나리오별 엑싯 매트릭스 집계 (확률 × 회수액) |
get_exit_matrix | leaf 단위 분해 (시나리오 × 시점 × 경로 × 확률 × 회수액) |
cross_deal_benchmark | driver 별 딜 횡단 비교 |
validate_financial_model | 7종 sanity check (순환 참조 · 시나리오 커버리지 · 확률 합 · 펀드 불변식 등) |
compute_full_model | 파생 수식을 기간 순차 평가 (스프레드시트 셀 등가) |
compute_3fs | 손익·재무상태·현금흐름 typed line item 재구성 |
PIPELINE 폴더 연동 (B-index-pipeline-folder-sync)
OneDrive 1_Project/PIPELINE 딜 폴더와 deal 레지스트리의 연동 표면. deal.pipeline_folder 에 폴더명(leaf)을 박제하고 — heuristic 매칭이 아니라 저장된 필드가 SoT — 폴더 스냅샷과 대사한다. 한 폴더에 딜 여럿(예: 소싱 메모 2사 동거 폴더) 허용(1:N). propose_deal_closure 가 요구하는 full 재무모델 없이 초기 파이프라인 딜을 라이트 intake 한다.
| Tool | 하는 일 |
|---|---|
register_pipeline_deal | 라이트 intake — deal_code·legal_name(+ brand/sector/stage[기본 Screening]/pipeline_folder) 만으로 target_company + deal 등재. 재무모델 없음. firm 멤버 전용 |
set_deal_stage | deal.stage 전환 (도메인 검증 + deal_stage_history append, note 선택). firm 멤버 전용 |
set_pipeline_folder | deal ↔ 폴더명 매핑 박제/해제(null). firm 멤버 전용 |
reconcile_pipeline | 대사 — 폴더명 스냅샷 입력. 폴더 자리 3분류(D-index-79): 평범한 폴더 = 심의 중 · 100_Archive/ = 멈춤(이유 불명) · 100_Deal_Passed/ 류 = 처분 선언(숫자·Deal 접두사 제거 후 종료 stage 4종 매칭 — Lost/Writedown/Exited 폴더도 동일 규칙). 출력 = matched(딜별 1행, stage_mismatch — 선언 폴더는 ‘선언 ≠ 현재’ 로 판정) / gap_folder_only / gap_deal_only(심의중 무폴더만). 기본 read-only, apply:true 면 선언 처분을 deal.stage 에 반영하되 자동 반영은 심의 중 딜의 Passed/Lost 선언만 — 그 외(집행·기존 처분 딜, Exited/Writedown 선언)는 refused 로 반환, 사람이 set_deal_stage 로 전이 |
투자 심의 관문 (D-index-74)
딜이 IR 접수부터 최종 투심까지 지나는 8관문(s1 IR 접수 · s2 AI 투심보고서 · s3 1차 투심 · ir IR 세션 · s4 자료 추가 · s5 2차 투심 · s6 실사 · s7 최종 투심)의 판정과 검토 코멘트를 담는다. 투표 집계는 사람이 오프라인에서 하고 결론만 들어오므로 “3:1 가결” 같은 숫자는 메모가 유일한 흔적이다.
기록은 append-only — 한 관문에 다시 찍으면 UPDATE 가 아니라 새 행이 쌓이고, 그 관문의 현재값은 시각이 아니라 최대 id 로 정한다(동시 삽입·시계 역행에도 전순서가 유일하다). 그래서 “8/8 통과였다가 8/19 보류” 가 순서대로 보존된다.
심의 축은 lifecycle 축과 독립이다 — 관문 기록은 어떤 경로로도 deal.stage 를 바꾸지 않고, set_deal_stage 는 관문을 바꾸지 않는다. 관문 축 안의 결론은 gate_outcome 4종(all_passed 8관문 전부 통과 · rejected 하나라도 부결 · in_progress · not_started)으로 따로 답한다. 세 도구 모두 firm 멤버 전용이고, 기밀 딜은 쓰기 도구에서 존재 자체가 숨겨지고 전체 보드에서는 제외된다.
| Tool | 하는 일 |
|---|---|
set_ic_gate | 관문 1개에 판정 append — go(통과) · nogo(부결) · hold(보류) · pending(미정) + 선택 메모. 딜 행을 잠그고 단일 트랜잭션으로 돈다 |
add_ic_note | 검토 코멘트 추가 (1~8000자). 관문이 “무엇을 결정했나” 라면 이쪽은 “왜 그렇게 봤나” — 정정도 삭제가 아니라 새 코멘트 |
get_ic_board | deal 지정 시 그 딜의 8관문 현재값(미기록 관문은 pending 으로 채워 항상 8칸) + 코멘트 + 선택적 관문별 전체 이력. 생략 시 기밀 딜을 제외한 전체 딜 보드. 읽기 전용 |
판단(artifact)
| Tool | 하는 일 |
|---|---|
query_artifacts | 소유 artifact 목록 (앵커·종류·지표·상태 필터). 상태는 제안·확정·반려에 대체됨과 끊어짐이 더해져 다섯이고(proposed · confirmed · rejected · superseded · orphaned), 대체분 행에는 어떤 이전 버전 fact 를 밀어냈는지가 함께 붙는다. 확정이 은퇴시킨 행 — 낡은 확정본과 검토를 건너뛴 중간 제안 — 은 대체됨 필터로, 교체본이 그 자리를 더는 담지 않아 내려간 행은 끊어짐 필터로 모아 본다 |
get_artifact | 단건 전체 뷰 (payload + citation + 이벤트 이력) |
confirm_artifact | 제안된 fact 확정. 같은 트랜잭션에서 같은 자리(소유자·종류·자료방·원본 파일·위치)의 살아 있는 다른 행을 전부 대체됨으로 은퇴시켜 한 자리에 확정본이 하나만 남게 한다 (행과 이력은 남는다). 은퇴 대상은 그 자리 질의로만 정하므로, payload 가 지목한 대상이 그 자리 밖이면 FOREIGN_SUPERSEDES_TARGET 로 거절하고 아무것도 쓰지 않는다. 확정하면서 넘기는 편집분이 자리 자체(자료방·원본 파일·위치)를 바꾸거나 더하거나 지우려 하면 IDENTITY_IMMUTABLE 로, payload 의 지목 블록(supersedes)을 더하거나 바꾸거나 지우려 하면 SUPERSEDES_IMMUTABLE 로 거절한다 — 둘 다 잠금을 잡기 전·쓰기 전이라 아무 흔적도 남지 않는다. 같은 자리에 이 행의 원본 버전보다 나중에 들어온 원본 버전의 미검토 제안이 있으면 STALE_PROPOSAL 로 거절하면서 그 id 들을 data.newer[] 로 알린다 (기준은 행이 만들어진 시각이 아니라 그 버전이 이 자리에 처음 도착한 시각이다). 이미 대체된 행이나 교체본이 버린 자리의 행(끊어짐)을 다시 확정하면 SUPERSEDED_ARTIFACT 로 거절되고, 이 셋의 의도적 진행은 모두 override_superseded 인자 하나로 명시한다 |
reject_artifact | 제안된 fact 반려 (행은 남고 상태만 바뀐다). ingest 로 들어온 fact 라면 확정·적재와 같은 잠금(원본 파일 → 자리, 같은 순서)을 잡고 그 안에서 payload 를 다시 읽는다 — 잠금을 넘겨받았을 때 그 행이 이미 은퇴해 있으면(대체됨·끊어짐) SUPERSEDED_ARTIFACT 로 거절하고 아무것도 쓰지 않는다. 살아 있는 행에 남은 반려는 최종 판정이다 |
reconcile_artifacts | 출처가 다른 값의 충돌 노출 (지표별 그룹, 상대 격차 임계 초과 표시) |
get_judgment_provenance | 한 필드를 지배하는 가정·calibration + 그것을 해소한 근거 |
list_calibrations | deal 의 모든 calibration (조정 종류 + 표면→조정 + 링크) |
query_dispositions | 단계·섹터·태그 횡단 처분 판단 — “왜 안 했나” 와 남은 교훈 |
get_deal_judgment | 단일 deal 의 처분 판단 (thesis + 근거 + 교훈 + 태그 + supersede 체인) |
emit_deal_judgment | 처분 판단 적재 (+ 선택적으로 deal 단계 전이). 같은 딜에 대해 idempotent |
verify_teardown | 종결 처리 자체 검증 — 단계 · 처분 판단 · 증거 보관 무결성을 GREEN/RED 로 반환 (읽기 전용) |
query_sector | 섹터 부분일치로 처분 판단 횡단 조회 |
find_similar_deals | 태그 교집합 + 섹터 일치 점수로 유사 딜 랭킹 (구조 신호 기반) |
verify_citations | 근거 내구성 감사 — 오래 버티는 근거 대 깨지기 쉬운 근거 집계 |
적재 · 제안
| Tool | 하는 일 |
|---|---|
propose_artifacts_from_ingest | 자료 ingest 결과를 제안 상태 fact 로 적재 (출처 스탬프 + 드리프트 게이트). sidecar_paths 로 사이드카 목록을 받으면 정확히 그 파일만 읽고, 생략하면 자료방 전체를 훑는다 (수동 복구 경로 — 제안 전용이라 사이드카의 supersedes 선언은 무시되고 그 건수만 total_supersedes_ignored 로 보고된다). 자동 경로는 목록에 더해 경로별로 그 사이드카 바이트의 sha256(sidecar_sha256)과 그 바이트를 낳은 원본 버전의 sha256(source_sha256)을 함께 싣고, index 는 파일을 한 번만 열어 자기가 읽은 바이트를 직접 해시해 둘 다 대조한다 — 원본 해시는 지금 디스크에 있는 원본 파일을 직접 해시한 값과도 대조한다(어긋나면 field=source_bytes) — 하나라도 어긋나면 트랜잭션을 열기 전에 SIDECAR_DIGEST_MISMATCH 로 그 호출 전체를 거절한다. 두 해시는 한 쌍이라 함께여야 인가한다 — 한 경로에 한쪽만 실린 요청은 트랜잭션을 열기 전에 INVALID_PARAMS 로 거절되고(반쪽을 제안 전용으로 조용히 강등하지 않는다), 둘 다 없는 경로만 예전처럼 제안 전용이다. resubmit_rejected(선택 불리언, 기본 false) 는 반려된 바이트의 재심을 사람이 명시하는 유일한 인자다 — ingest 클라이언트는 보내지 않는다. 교체를 선언한 사이드카를 그 버전으로 처음 들인 실행은 그 원본 파일의 살아 있는 행 중 이번에 나오지 않은 자리의 것을 — 확정본이든 아직 검토를 기다리는 제안이든 — 끊어짐으로 내리고(한 자리도 내놓지 않은 교체본이면 전부), 파일별 orphaned 와 total_orphaned 로 보고한다. 단 훑기를 보류하는 두 경우가 있다(total_sweep_withheld 로 보고, 제안은 그대로·은퇴 0): xlsx 사이드카의 어느 sheet 든 추출 상한에 걸렸을 때, 그리고 pdf 사이드카 전부(도표 위치가 페이지 내 서수라 앞 도표 하나가 빠지면 뒤가 재번호된다) |
propose_deal_closure | 대상 기업 + deal + 펀드 투자 + 재무 모델을 한 트랜잭션으로 적재. replace 로 기존 딜 전체 교체. expected_state 로 낙관적 동시성(CAS) — 아래 참조 |
import_xlsx | 수정된 xlsx 가정 시트 → 값 차이 비교 후 반영 |
draft_seed_from_artifacts | deal artifact 로부터 seed 초안 자동 작성 (판단은 자동 생성하지 않는다) |
get_seed_template | 주석 달린 seed 템플릿 반환 — 원격 에이전트가 파일 접근 없이 채워 제출 |
get_seed_schema | propose_deal_closure 입력의 완전한 JSON Schema 반환 — 에러 메시지로 계약을 역추적할 필요가 없다 |
ingest 가 끝나면 그 자리에서 제안이 만들어진다
자료를 변환하는 ingest skill 은 구조화 사이드카(xlsx 셀 추출 · pdf 도표 추출 결과)가 하나라도 잡히면 변환 직후 propose_artifacts_from_ingest 를 이어서 호출한다 — 그 실행에서 새로 쓴 사이드카뿐 아니라, 내용이 그대로라 변환을 건너뛴 자료의 사이드카도 이전 ingest 가 자료방 밖 원장에 적어 둔 해시와 대조해 통과하면 같은 목록에 실린다. 사이드카가 하나도 잡히지 않으면 호출 자체를 하지 않는다. 내용 해시 앵커가 막 찍힌 시점이라 근거가 가장 온전한 자리다. 적재된 fact 는 제안 상태로만 들어가고 확정은 사람이 confirm_artifact 로 한다 — 자동 호출이 늘리는 것은 제안이지 확정이 아니다. 호출은 멱등이라 같은 자료를 다시 변환해도 중복이 쌓이지 않는다. 적재 대상은 목록으로 명시해서 넘긴다 — 자료방을 통째로 훑지 않으므로 트리에 남아 있는 과거본·연습용·사람이 손으로 놓아둔 추출 JSON 이 제안 행으로 둔갑하지 않는다. 목록은 서버가 다시 검증한다: 자료방 기준 상대경로일 것, 사이드카 이름 규칙에 맞을 것, 실제로 존재할 것, 경로를 정규화해도 자료방 안에 있을 것(상위 경로 참조나 심링크 탈출은 거절). 하나라도 어긋나면 인자 오류로 거절되고 아무것도 적재되지 않는다. 목록을 생략하면 예전처럼 자료방 전체를 훑으며, 이 경로는 수동 복구용으로 남겨 둔 것이다.
목록 없이 훑는 길은 제안만 한다. 자료방을 통째로 훑어 나온 사이드카는 아무도 바이트를 인가하지 않은 것들이다 — 낡은 것, .history/ 에서 되살아난 것, 픽스처, 사람이 손으로 놓아둔 것이 섞여 있다. 그런데도 예전에는 그중 하나가 실은 supersedes 선언 한 줄이 그 원본의 교체 훑기를 돌렸고, 자리를 하나도 내놓지 않은 사이드카 한 장이 검토하는 사람 없이 그 원본의 확정본을 전부 끊어짐으로 내릴 수 있었다. 지금 이 경로는 제안 전용이다: 사이드카에 실린 supersedes 는 무시되고, 대체도 끊어짐 훑기도 훑기 기록도 돌지 않는다. 무시한 것은 조용히 넘어가지 않고 세어서 보고한다 — MCP 결과와 CLI 요약의 total_supersedes_ignored 다. 교체를 실제로 정리해야 하면 순서는 하나뿐이다: ① 원본을 ingest --force 로 다시 ingest 한다 — 경로별 바이트 해시와 원본 버전 해시를 인가하는 믿을 수 있는 생산자 경로라 대체와 훑기가 제대로 돈다. ② 그것이 불가능할 때만 전체 훑기로 fact 를 다시 들이고, 교체 정리는 뒤로 미룬다.
목록은 경로가 아니라 바이트를 인가한다. 경로만 넘기면 ingest 가 해시한 시점과 index 가 그 파일을 여는 시점 사이가 비어 있어서, 그 사이에 자료방에 쓸 수 있는 쪽이 바이트를 바꿔치면 인가받은 이름 아래로 다른 내용이 들어갔다. 그래서 자동 경로는 경로마다 사이드카 바이트의 sha256 과 그 바이트를 낳은 원본 버전의 sha256 을 함께 실어 보내고, index 는 트랜잭션을 열기 전에 목록 전체를 한 번 훑으며 파일을 딱 한 번 읽어 자기가 읽은 그 바이트를 직접 해시해 앞의 값과 대조하고, 그 바이트에서 읽어 낸 근거 앵커의 원본 해시를 뒤의 값과 대조한다. 여기에 더해 원본 파일 자체를 호출 시점의 디스크 상태 그대로 해시해 실제 해시 · 호출자가 실어 온 원본 해시 · 사이드카에 박힌 근거 앵커 셋이 모두 같을 때만 통과시킨다. 앞의 두 값끼리 견주는 것만으로는 부족하기 때문이다 — 앵커와 호출자의 값은 같은 추출에서 함께 나온 한 쌍이라 서로는 언제나 맞고, 추출이 끝난 뒤 원본이 편집된 것은 그 대조로는 알 수 없다. 그대로 두면 낡은 사이드카가 계속 제안되고, 그 교체 훑기가 지금 원본과 다른 근거로 확정본을 끊어짐으로 내렸다. 어긋나면 SIDECAR_DIGEST_MISMATCH(호출자 잘못, -32602)의 field 가 source_bytes 로 온다 — 기존 sidecar_sha256·source_sha256 값은 그대로이고 새 오류 코드는 늘지 않았다. 어느 대조든 하나라도 어긋나면 그 호출을 통째로 거절한다 — 트랜잭션을 열기 전이라 같은 호출에 실린 멀쩡한 사이드카조차 한 줄도 쓰이지 않고, 교체 훑기도 돌지 않는다. 다만 이 훑기는 싼 조기 거절이지 최종 권위가 아니다 — 원본 대조의 권위는 아래의 트랜잭션 안 재대조에 있다. 원본이 없거나 읽히지 않거나 자료방 밖으로 풀리는 경우도 같은 거절이다 — 없는 것을 동의로 읽지 않는다(그때 actual 에는 해시 대신 <source unreadable> 이 남아 어떤 주장과도 같아질 수 없다). 원본을 찾는 규칙은 좁다: 사이드카가 적어 둔 원본 경로에서 파일 이름만 취해 사이드카 자신의(이미 자료방 안임을 확인한) 디렉터리에 붙이고, 정규화한 뒤 자료방 아래인지 다시 확인한다 — 상위 경로 참조나 그 이름 자리에 놓인 심링크로 자료방 밖 파일을 해시하게 만들 수 없다. 대조를 통과한 바이트는 그대로 들고 있다가 파싱까지 같은 바이트로 하므로, 파일을 다시 여는 자리 자체가 없다. 이 원본 대조는 해시를 실어 온 호출에만 걸린다 — ingest 클라이언트는 두 해시 지도를 항상 함께 보내므로 자동 경로는 언제나 여기를 지나고, 사람이 명시로 부르는 운영자·CLI 호출은 예전처럼 digest 없이 경로만 넘길 수 있다(그 경로의 신뢰는 부른 사람에게 있다). 두 해시는 경로마다 함께여야 하고, 반쪽으로는 아무것도 인가되지 않는다. 둘은 대안이 아니라 서로 다른 것을 인가한다 — 사이드카 해시는 supersedes 선언과 이번에 내놓은 자리 집합(끊어짐 훑기가 그 여집합을 내린다)을 실은 바이트를 붙들고, 원본 해시는 그 바이트를 읽어 낸 원본 버전에 파일을 붙들어 매 트랜잭션 안의 재대조가 잡고 있을 값을 준다. 사이드카 해시만 있으면 아무도 다시 읽지 않은 원본을 근거로 낡은 사이드카가 확정본을 끊어짐으로 내리고, 원본 해시만 있으면 그 파괴적 선언 자체를 아무도 보증하지 않는다. 그래서 한 경로에 한쪽만 실린 요청은 트랜잭션을 열기 전에 호출자 잘못(INVALID_PARAMS)으로 거절한다 — 반쪽을 조용히 제안 전용으로 강등하지 않는다. ingest 클라이언트는 자기가 목록에 올린 모든 경로에 두 지도를 다 싣기 때문에 반쪽 봉투는 잘못 만들어진 요청이고, 요청한 것의 절반만 조용히 제안하면 그 사실이 가려진다. 두 해시가 다 없는 경로는 예전 그대로 제안 전용이다 — 그 사이드카의 supersedes 는 무시되고 그 건수만 total_supersedes_ignored 로 보고된다. ingest 쪽도 보내기 직전에 같은 규칙으로 원본을 다시 해시해, 스캔 이후 바뀐 원본의 사이드카 그 한 건만 경고와 함께 봉투에서 빼고 나머지는 그대로 싣는다 — 서버는 한 건에 호출 전체를 거절하므로, 빠진 원본은 다시 ingest 해서 사이드카를 재생성하면 된다.
원본 대조는 트랜잭션 안에서 한 번 더 하고, 그쪽이 권위다. 트랜잭션을 열기 전에 잰 해시는 잰 그 순간의 값이다 — 그 훑기와 커밋 사이에 원본이 갈리면, 인가만 받아 둔 낡은 실행이 그대로 제안하고 그 교체 훑기가 지금 디스크에 없는 버전을 근거로 확정본을 끊어짐으로 내린다. 그래서 index 는 사이드카마다 자기 트랜잭션 안에서 원본을 두 번 다시 해시해 호출자가 인가한 값과 대조한다. 한 번은 그 원본 파일의 잠금을 잡은 직후·무엇을 제안하거나 훑기 전에 — 낡은 실행이 아무 일도 하지 못하게 하는 싼 거절이다. 다시 한 번은 커밋 바로 앞 마지막 문장으로 — 제안 INSERT 하나하나, 끊어짐 훑기가 남긴 은퇴 이벤트, 원본별 훑기 기록까지 이 트랜잭션이 쓸 것을 전부 쓴 뒤다. 첫 번째만으로는 창이 좁아질 뿐 닫히지 않았기 때문이다: 그 대조와 커밋 사이의 작업량에는 상한이 없어서, 그 사이에 갈아끼워진 워크북이 낡은 제안을 그대로 커밋시키고 이 실행이 읽어 본 적조차 없는 버전을 근거로 확정본을 끊어짐으로 내렸다. 제안과 훑기가 딛고 서는 판정은 이 두 번째 대조다 — 어긋나면 이미 쓴 것까지 통째로 되돌아가므로, 거절이 없애는 것은 애초에 참인 적이 없던 값뿐이다. 어긋나거나 원본이 없거나 읽히지 않으면 그 사이드카의 트랜잭션이 롤백되고 같은 SIDECAR_DIGEST_MISMATCH(field 가 source_bytes)로 호출이 거기서 멈춘다 — 뒤에 남은 사이드카는 아예 처리되지 않고, 같은 호출에서 이미 커밋된 앞의 사이드카는 그대로 남는다. 원자성의 단위를 사이드카 하나로 잡은 데는 이유가 있다: 사이드카 하나가 곧 원본 하나 · 잠금 하나 · 훑기 하나이고, 바꿔치기당한 워크북과 무관한 다른 워크북의 정직한 값을 되돌릴 근거는 그 사건 어디에도 없으며, 되돌리려면 한 호출의 모든 원본 파일을 서로의 트랜잭션 내내 붙들고 있어야 한다 — 이 코드가 일부러 거부하는 역방향 잠금 순서다. 다시 ingest 하면 커밋되지 못한 사이드카만 정확히 다시 판정된다.
남는 창은 남는다고 적는다. 마지막 재대조와 그 다음 한 문장인 커밋 사이에도 원본은 갈릴 수 있다 — 이제 그 사이에 이 서비스 쪽 I/O 는 한 줄도 끼지 않지만, 폭이 0 은 아니다. 그 창을 받아들이는 이유는 셋이다. 첫째, 닫을 수 있는 잠금이 없다 — 여기서 겨루는 상대는 폴더에 새 워크북을 떨어뜨리는 사람(또는 동기화 클라이언트)이라 이 서비스가 잡는 어떤 OS 잠금도 그를 구속하지 못한다. 트랜잭션 내내 원본 파일 자체를 붙드는 길은 검토했고 택하지 않았다 — ingest 클라이언트가 잡는 잠금은 자료방 밖 자기 원장에만 걸리는 것이라, 원본 파일 잠금은 새 패턴을 하나 늘리면서도 정작 겨루는 상대를 묶지 못한다. 둘째, 그 사이에 틀린 내용이 쓰일 수는 없다 — 커밋되는 값은 전부 바이트째로 인가를 대조한 사이드카에서 나오고, 원본 해시는 그 추출이 지금 파일을 여전히 설명하는지만 가른다. 둘째, 다음 ingest 가 정리한다 — 훑기의 멱등 키는 원본별로 마지막에 적용한 버전 기록이고, 갈아끼운 원본은 다른 해시를 들고 오므로 그 실행은 건너뛰어지지 않고 자기 값을 제안하며 자기 몫을 훑는다.
존재만으로는 신뢰하지 않는다. 그리고 인가하는 값은 자료방 밖에 둔다. ingest 는 사이드카를 쓰고 나면 그 바이트의 sha256 을 자료방 밖 원장에 곧바로 적는다 — 사이드카를 실제로 만들어 낸 변환기 자신이 쓰는 기록이고, 자리는 운영자 로컬 상태 디렉터리(INGEST_STATE_DIR, 없으면 XDG_STATE_HOME/axe-ingest, 그것도 없으면 ~/.local/state/axe-ingest)의 sidecar_ledger.json 이다. 원장의 한 항목은 sha256 하나가 아니라 사이드카 바이트의 sha256 · 그 바이트를 낳은 원본 버전의 sha256 · 원본 경로 셋이다. 변환을 건너뛴(내용이 그대로인) 자료를 다시 돌릴 때는 현재 파일을 다시 해시해 그 값이 원장의 사이드카 sha 와 같고 동시에 원장이 적어 둔 원본 버전 sha 가 지금 원본 파일의 실제 해시와 같을 때만 재제안한다 — 그리고 그렇게 실린 두 값을 index 가 자기가 읽은 바이트로 다시 대조한다. 사이드카 옆에 적어 두는 핀만으로는 부족하기 때문이다 — 그 핀은 사이드카와 같은 나무에 살아서, 자료방에 파일을 놓을 수 있는 쪽이 사이드카와 핀을 함께 고쳐 쓰면 그대로 통과했다. 자료방 안의 핀은 값싼 자기-정합성 확인으로 남지만 권위는 아니다. 원장에 없거나 값이 다르면 그 사이드카는 막는 쪽으로 목록에서 빠지고, 경고에 그 파일 이름과 복구 경로가 함께 남는다. 원본 버전이 어긋나는 경우 — 옛 사이드카 바이트를 그대로 되살려 놓았고 그 바이트가 원장의 사이드카 sha 와 여전히 일치하는 경우 — 도 같은 취급이고, 경고는 그 사이드카가 이전 원본 버전의 산출물이라고 말한다. 복구는 두 가지 — 원본을 --force 로 다시 변환해 사이드카와 원장 기록을 함께 재생성하거나, 사람이 명시로 자료방 전체를 훑는 적재를 돌린다. 어느 쪽도 자동으로는 열리지 않는다. 원장 항목 자체가 깨진 경우도 같다 — JSON 은 읽히는데 이 자료방 자리에 객체가 아닌 값이 들어 있으면 그 원장으로는 인가가 0건이다. 읽을 수 없는 기록으로 무언가를 인가하지 않는다(막는 쪽). 그때도 실행이 멈추지는 않고, 무엇이 깨졌는지와 원본을 다시 ingest 하면 그 자리가 다시 쓰인다는 것을 경고 한 줄이 알린다.
원장은 원본별로 합쳐지지 않고 갈아끼워진다. 한 원본을 변환할 때마다 그 원본 몫의 항목 집합이 이번 실행이 실제로 만든 것으로 통째로 교체된다 — 그래서 추출기가 더는 내놓지 않는 사이드카(셀이 다른 자리로 옮겨 갔거나 산출물 자체가 사라진 경우)는 파일이 트리에 남아 있어도 그 순간 인가를 잃는다. 아무것도 만들지 않은 변환도 마찬가지로 기록한다 — “이번엔 하나도 안 나왔다” 역시 갈아끼워야 할 사실이라, 그 원본의 인가는 전부 비워진다. 원장 형식은 2판이고, 원본 결속이 없던 1판 항목(sha 문자열 하나뿐인 기록)은 검증할 수 없으므로 인가하지 않는다 — 그 원본을 다시 ingest 하면 2판으로 재기록된다. 갈아끼울 자리에 객체가 아닌 값이 들어 있으면 빈 것으로 보고 이번 변환의 산출물로 그대로 덮어쓴다(자가 복구) — 경고 한 줄이 남을 뿐 그 실행은 계속된다. 예전에는 그 자리에서 예외가 터져 그 원본의 변환 결과가 통째로 버려지고 원장은 깨진 채 남아, 이후 모든 실행이 같은 자리에서 같은 방식으로 무너졌다. 원장 쓰기 실패는 어떤 것이든 경고 한 줄로 끝나는 비-치명 계약이다 — 손상된 원장이 변환을 중단시키는 일은 없다. 그 경고가 실제로 남도록 원장은 메타 파일이 쓰이기 전에 기록된다 — 거부·항목 손상·쓰기 실패로 붙은 경고 줄이 그 실행의 메타 warnings 에 함께 실린다. 순서가 반대였을 때는 경고가 이미 쓰인 메타에 못 실려 프로세스와 함께 사라졌다.
원장 쓰기는 한 줄로 선다. 원장은 파일 하나를 통째로 다시 쓰는 구조라, 서로 다른 원본을 동시에 처리하는 실행 둘이 같은 스냅샷을 읽고 차례로 덮어쓰면 늦게 쓴 쪽이 먼저 쓴 쪽의 항목 — 곧 그쪽 자동 제안의 인가 — 을 통째로 지웠다. 그래서 읽기→합치기→쓰기 한 번을 원장 옆의 sidecar_ledger.json.lock 에 건 배타 잠금 으로 감싸고, 쓰기 자체는 실행마다 이름이 다른 임시 파일에 쓴 뒤 원자적으로 갈아끼운다(공유 임시 파일 하나를 쓰던 시절에는 두 실행이 같은 파일에 끼어들어 반쯤 쓰인 JSON 이 교체될 수 있었다). 잠금을 원장 자신이 아니라 옆 파일에 거는 이유는 원장이 교체 순간 inode 째 바뀌기 때문이다. 그 결과 동시에 도는 두 변환은 서로의 항목을 지우지 않고 원본별로 합쳐진다.
원장은 사이드카만이 아니라 원본의 적재 전이도 든다. 같은 잠금 안에서 같은 읽기→합치기→쓰기 한 번으로, 원장은 자료방마다 원본 경로마다(자료방 루트 기준 상대경로) 두 값을 더 적는다 — 마지막으로 적재한 원본 바이트의 sha256 과 그 앞 버전의 sha256(없으면 비어 있다). 자리는 사이드카 항목이 사는 datarooms 옆의 sources 지도이고, 원장 판 번호는 그대로다 — 읽는 쪽이 “그 지도가 없다” 를 그대로 다루므로 형식이 갈릴 일이 없고, sources 가 없는 원장은 그저 아무것도 증명하지 못한다. 전이를 적는 규칙은 셋뿐이다: 마지막 적재본이 지금 바이트와 같으면(같은 버전을 --force 로 다시 변환한 경우) 이미 이고 있던 앞 버전을 그대로 이고 가고, 다르면 그 마지막 적재본이 앞 버전이 되며, 기록이 아예 없으면 앞 버전도 없다. 사이드카 항목과 한 번에 쓰는 이유는 둘이 갈라지면 안 되기 때문이다 — “이 바이트를 낸 원본 버전” 과 “그 버전이 무엇을 밀어냈나” 는 같은 실행이 본 같은 사실이다.
교체 선언은 원장이 증명한 전이에만 찍는다. 사이드카에 실리는 supersedes 는 무해한 이력 메모가 아니다 — 그 값이 비어 있지 않으면 index 는 그것을 교체 선언으로 읽고 적재 시점의 끊어짐 훑기를 무장시켜, 교체본이 더는 담지 않는 자리의 확정본을 사람 확인 없이 내린다. 예전 근거는 meta.json 의 current 와 previous_sources 마지막 항목이었고 둘 다 자료방 안에 산다 — meta 한 줄을 고치면 바뀐 적 없는 원본이 교체된 것처럼 보였고, 그렇게 찍힌 사이드카는 뒤따르는 해시 대조를 전부 정직하게 통과했다(그 대조들은 사이드카 바이트와 지금 원본을 붙들 뿐 계보를 인증하지 않는다). 이제 근거는 원장의 sources 기록 하나다: 마지막 적재본이 지금 바이트와 다를 때만 그 값이 이전 버전으로 찍히고, 같으면(--force 재생성) 원장이 이고 있던 앞 버전이 첫 교체와 같은 선언을 다시 찍는다 — 교체는 실행의 성질이 아니라 원본 버전의 성질이라, 문서화된 복구 경로가 선언을 지우지 않는다. meta 의 계보는 계속 쓰이고 사람도 계속 읽는다 — 이전 clone 을 .history/ 로 옮기는 판정도, replaced_at·history_md 같은 읽을 수 있는 이력도 여전히 거기서 나온다. 다만 이제 그 값이 supersedes 에 sha 를 넣지는 못한다(부속 정보조차 원장이 증명한 sha 와 맞는 항목에서만 가져온다). meta 를 고쳐서는 바뀐 적 없는 원본을 교체된 것처럼 보이게 할 수도, 훑기를 무장시킬 수도 없다.
증명할 수 없으면 선언하지 않는다. 원장 자리가 거부됐거나(바로 아래), 원장 파일을 신뢰할 수 없거나 아예 없으면 supersedes 는 나가지 않는다. 이 원본의 sources 기록이 없을 때는 한 번 더 본다 — 이 지도를 쓰지 않던 시절의 ingest 가 마지막으로 적재한 자료방이라면, 그 자료방의 사이드카 기록들이 자기가 나온 원본 경로와 그 바이트를 낳은 원본 버전의 sha256 을 들고 있고, 한 원본의 항목 집합은 매 변환마다 통째로 교체되므로 거기 남은 값이 곧 마지막 적재본이다. 그래서 그 원본을 가리키는 기록들이 하나의 유효한 sha256 으로 만장일치일 때만 그 값을 앞 버전으로 역산해 첫 교체를 선언하고, 같은 실행이 그 값을 previous_sha256 으로 적어 --force 재생성까지 이어 간다. 엇갈리거나 하나도 없으면(첫 적재) 역산도 선언도 없다 — meta.json 은 여기서도 근거가 아니다. 모르면 훑기도 없다. 값은 정직하게 적어 둔다: 그 실행이 마침 진짜 교체를 보고 있었더라도 그 교체는 선언 없이 지나가고, 원장은 그다음부터 지금 바이트에서 다시 세므로 놓친 그 전이는 나중에도 선언되지 않는다. 복구는 위의 두 갈래 그대로다 — ① 그 원본을 다시 ingest 해 전이를 원장에 세우면 그 뒤의 교체부터 다시 증명되고, ② 운영자가 명시로 부르는 자료방 전체 훑기는 이 판정에 걸리지 않지만 원래부터 제안 전용이라 교체 정리를 대신하지는 않는다.
원장이 설 자리도 검사한다. 원장이 권위인 근거는 단 하나 — 자료방에 파일을 놓을 수 있는 쪽이 원장에는 쓸 수 없다는 것이다. 그 전제가 깨진 자리에서는 사이드카와 자료방 안의 핀과 원장을 한 손으로 맞춰 쓸 수 있고, 그러면 서버는 위조자가 고른 바이트를 정직하게 검증해 통과시킨다. 그래서 이 조건은 문서에 적어 둔 권고가 아니라 ingest 가 매 실행 확인하는 조건이다: 원장 디렉터리와 자료방을 심링크까지 풀어 실경로로 맞춰 본 뒤 — 둘이 같은 디렉터리이거나, 원장이 자료방 안에 있거나, 자료방이 원장 디렉터리 안에 있으면 거부한다. 권한 판정은 잎 하나가 아니라 원장 디렉터리와 파일시스템 루트까지 존재하는 조상 전부에 건다 (ssh 의 StrictModes 와 같은 결). 소유자는 지금 실행 중인 사용자여야 하고 — 조상은 root 소유도 받아들인다 — group/other 쓰기가 열려 있으면 거부한다. 예외는 하나, 그 조상이 sticky 비트를 달고 있을 때뿐이다(/tmp 가 그렇다 — 남이 새로 만들 수는 있어도 남의 것을 rename 하거나 지우지는 못한다). 잎에는 그 예외가 없다. 잎이 0700 이어도 sticky 아닌 조상에 쓸 수 있는 쪽은 검사와 사용 사이에 잎을 통째로 rename 해 자기 디렉터리로 갈아끼울 수 있었기 때문이다. 아직 없는 잎은 가장 가까운 존재하는 조상으로 같은 판정을 받고, 통과하면 그때 0700 으로 만들어진다. 그리고 판정을 통과한 디렉터리는 한 번만 열어 그 디스크립터로 고정한다 — 연 뒤 fstat 으로 검사했던 그 inode(장치 번호·inode 번호)가 맞는지 다시 확인하고, 원장 읽기·잠금·원자적 교체를 전부 그 디스크립터 기준으로 한다. 검사가 끝난 뒤 디렉터리를 rename 하거나 심링크로 갈아끼워도 인가가 적히는 자리는 더는 움직이지 않는다. 원장 파일 자체도 같은 의심을 받는다 — 그 디스크립터 아래에서 원장을 열 때 심링크는 따라가지 않고, 열린 것이 일반 파일이며 지금 실행 중인 사용자 소유이고 group/other 쓰기가 닫혀 있는지 fstat 으로 확인한다(디렉터리가 통과해도, 남이 지나다닐 수는 있는 디렉터리 안에 미리 놓인 0666 원장이나 FIFO 는 그 자리를 차지할 수 있기 때문이다). 어긋나면 그 파일에 적힌 어떤 항목도 인가하지 않고 경고 한 줄을 남기며(적재 실행에서는 그 실행의 메타 warnings 에 실린다), 다음 기록은 그 파일을 이어 쓰는 대신 새 0600 일반 파일로 통째로 갈아끼운다 — 그때 다른 자료방의 항목도 함께 버려지고, 다시 ingest 하면 재기록된다. 남는 위험은 이미 이 프로세스의 uid 를 쥔 공격자 하나뿐이고 그건 범위 밖이다 — 그 uid 는 원장을 직접 고쳐 쓸 수 있어 어떤 검사로도 막히지 않는다. 거부는 세 곳에서 한꺼번에 닫힌다 — 그 원장에 적힌 어떤 항목도 인가하지 않고(한 줄도 목록에 실리지 않는다), 그 자리에 원장을 새로 쓰지도 않으며, 자동 제안 단계 자체를 건너뛴다. 이 실행이 방금 쓴 사이드카도 예외가 아니다: 원장과 자료방이 겹치는 판에서는 “방금 썼다” 는 사실조차 그 판을 쥔 쪽이 연출할 수 있기 때문이다. 건너뛸 때는 두 경로를 모두 이름으로 밝히고 문제가 된 그 자리(잎이든 조상이든)를 지목한 오류 한 줄과 복구 방법 — 원장을 자료방 밖(기본 상태 디렉터리)으로 옮겨 다시 ingest 하거나, 운영자가 자료방 전체를 훑는 적재를 직접 부르거나 — 이 함께 남는다. 사람이 명시로 부르는 그 적재 경로는 이 판정과 무관하다 — 거기서 인가하는 것은 원장이 아니라 부른 사람이기 때문이다.
호출 경로는 CLI 우선이다. 로컬 index 바이너리가 있으면 그쪽으로 직접 호출하고, 없으면 AXE CLI 의 axe index call 로 넘어간다. 둘 다 없으면 경고 한 줄을 남기고 변환만 끝내며, 사이드카는 그대로 남아 index 가 있는 곳에서 나중에 적재할 수 있다. 제안 호출이 실패해도 변환 자체는 성공으로 끝나므로(투자 심의 첫 단계를 막지 않는다), 적재 여부는 경고 로그 또는 query_artifacts 로 확인한다.
| 환경 변수 | 하는 일 |
|---|---|
INDEX_BIN | 로컬 index 바이너리 경로. 있으면 이 경로로 직접 호출한다 |
INDEX_ACTOR | INDEX_BIN 경로에서 넘기는 신원 — 제안 행의 소유자가 된다 |
AXE_CLI | INDEX_BIN 이 없을 때 쓰는 AXE CLI 경로. 이 경로에서는 로그인 토큰의 신원이 소유자가 되므로, INDEX_ACTOR 와 다르면 제안이 다른 소유자 밑에 쌓여 query_artifacts 에 보이지 않는다 |
INDEX_PROPOSE | 0 이면 자동 제안을 건너뛴다 (연습용·스크래치 자료를 실제 index 에 넣고 싶지 않을 때) |
INGEST_STATE_DIR | 사이드카 원장(sidecar_ledger.json)과 그 쓰기를 직렬화하는 잠금 파일(sidecar_ledger.json.lock)이 사는 자료방 밖 상태 디렉터리. 생략하면 XDG_STATE_HOME/axe-ingest, 그것도 없으면 ~/.local/state/axe-ingest. 이 위치는 검사된다 — 자료방과 겹치거나, 이 디렉터리 또는 루트까지의 존재하는 조상 중 하나가 남이 쓸 수 있는 자리(소유자가 다르거나 group/other 쓰기가 열려 있고 sticky 도 아닌 조상)이면 자동 제안 자체가 서지 않고, 오류가 문제의 경로를 이름으로 지목한다. 판정을 통과한 디렉터리는 한 번만 열어 디스크립터로 고정하고 원장 읽기·잠금·교체를 그 디스크립터로만 하므로, 검사 뒤 rename·심링크 갈아끼우기는 인가가 적히는 자리를 옮기지 못한다 (바로 아래) |
자리에는 자료방이 한 칸 더 있다. 자리를 이루는 원본 파일은 경로가 아니라 파일 이름 하나라, 소유자가 같으면 서로 다른 딜의 model.xlsx 둘이 한 문자열로 접혔다 — 한쪽 딜의 교체본이 다른 쪽 딜의 확정본을 대체하거나 끊어짐으로 내려 버렸다. 그래서 추출된 fact 는 자기가 나온 자료방을 함께 들고 다닌다: 딜 앵커(모든 행이 예전부터 갖고 있다)와 자료방 루트의 정규화된 절대경로(이번 버전부터 새 행에 찍힌다) 두 조각이다. 앵커가 같고 루트까지 같을 때만 한 자리다 — 대조는 엄격하고 대칭이라, 루트를 가진 행은 같은 루트를 가진 행하고만, 루트가 없는 옛 행은 루트가 없는 행하고만 맞고 어느 쪽도 경계를 넘지 않는다. 그 결과 한 소유자 밑에서도 딜 A 의 model.xlsx 와 딜 B 의 model.xlsx 는 서로 다른 두 fact 이고, A 의 교체본은 A 의 행만 대체하고 A 의 행만 훑는다 — B 의 확정본은 그대로다. 자료방은 멱등 판정, 형제 은퇴 질의, 끊어짐 훑기, 원본별 훑기 기록에 모두 함께 걸린다. 잠금 키는 그중 딜 앵커로 잡는다 — 루트가 없는 옛 행에도 걸려야 실제로 직렬화되기 때문이고, 그래서 딜 B 의 확정이 딜 A 의 적재를 기다리는 일도 없다. 이 차원이 생기기 전에 쓰인 행은 루트가 없어 루트가 없는 행하고만 맞는다 — 다른 딜의 교체본이 그 행에 도달하는 길이 없는 대신, 자기 앵커를 낳은 자료방의 재적재조차 그 행에 닿지 못한다(바로 아래). 자료방을 새 경로로 옮기면 신원이 새로 시작된다 — 새 경로에서 나온 fact 는 옛 fact 를 대체하지 않고 검토 대기 제안으로 새로 들어오고, 옛 확정본은 사람이 내릴 때까지 그대로 남는다. 루트를 가로질러 맞추는 쪽이 바로 이 차원이 막으려는 충돌이라, 눈에 보이는 중복 쪽을 택했다. 자료방도 확정으로는 옮길 수 없다 — 편집분이 이 값을 바꾸려 하면 원본 파일·위치와 똑같이 IDENTITY_IMMUTABLE 로 거절된다.
한 자료방 안에서도 원본 파일은 이름이 아니라 자리다. 자료방 차원이 막는 것은 딜과 딜 사이뿐이라, 같은 자료방 안에서는 여전히 접혔다 — 훑기가 재귀라 finance/model.xlsx 와 scenarios/model.xlsx 는 둘 다 정당한 입력인데 model.xlsx 한 문자열로 만났고, 한쪽 교체본이 다른 쪽의 확정본을 대체하거나 끊어짐으로 내렸다. 이번 버전부터 새 행의 원본 파일은 자료방 루트 기준 상대경로(finance/model.xlsx, 정규화: forward slash, 선행 ./ 없음)다. 그 값은 사이드카 자신의 위치에서 나온다 — 사이드카는 원본과 같은 디렉터리에 쓰이고 그 경로는 이미 자료방 안임이 검증돼 있으므로, 그 디렉터리를 정규화해 정규화한 루트로 잘라 낸 뒤 원본 파일 이름을 붙인다. 근거(citation) 앵커의 rel_path 를 쓰지 않은 것은 의도적이다 — 그 값은 자료방이 아니라 가장 가까운 워크스페이스 마커 기준이라, 마커 파일이 자료방 안에 있으면 두 하위 폴더가 똑같이 model.xlsx 로 접혀 지금 없애려는 그 충돌을 그대로 재현한다. 그것은 근거를 오래 버티게 하는 앵커이지 신원이 아니다. 원본 파일에 매달린 것은 전부 이 상대경로로 옮겨 간다 — 멱등 판정, 두 겹의 잠금, 확정의 형제 은퇴, 끊어짐 훑기, 원본별 훑기 기록. 이미 있던 행을 고쳐 쓰는 일은 마이그레이션이 아니라 운영자 명령이다. 한때는 마이그레이션이 근거의 rel_path 로 고쳐 쓰게 돼 있었는데 배포 전에 걷어 냈다 — 신원의 근거는 자료방 루트 아래의 실제 파일인데 DB 안에서 도는 마이그레이션에는 자료방이 없고, 대신 쓰려던 그 rel_path 는 자료방이 아니라 워크스페이스 기준이라 dataroom/ 접두어를 달거나 마커가 자료방 안에 있으면 이름 하나로 접힌다. 그것으로 고쳐 쓴 행은 어떤 적재도 닿지 못하는 신원에 떨어져, 없애려던 그 옆에 제안을 한 걸음 더 멀리 밀어냈을 것이다. 대신 사람이 부르는 index source-relpath-backfill <자료방> [--owner …] [--apply] 가 파일시스템을 읽는다(--apply 를 붙이기 전까지는 dry-run 이라 세어서 보여 주기만 한다). 이름 댄 자료방의 정규화된 루트를 훑어, 그 루트를 가진 행 가운데 원본 파일이 아직 이름 하나인 것만 본다: 그 이름의 파일이 루트 아래 정확히 하나면 원본 파일을 자료방 기준 상대경로로 고쳐 쓰고 원본별 훑기 기록도 같은 키로 함께 옮기며(목적지 키가 이미 있으면 옮기지 않고 보고한다), 적재하는 쪽과 같은 잠금을 같은 순서로(원본 파일 → 자리) 잡은 채 판정하고, 한 건 한 건을 편집 이력으로 남긴다 — 상태를 건드리지 않으므로 확정본은 확정인 채다. 그 이름의 파일이 없거나 둘 이상이면 넘겨짚지 않고 건너뛰며 그 건수를 보고한다(이름 중복 판정은 아래 index dataroom-stamp 와 같은 해석을 쓴다). 파일이 루트 바로 아래에 있으면 그 이름이 이미 상대경로다. 옮겨 갈 신원을 이미 루트를 가진 살아 있는 행이 쥐고 있으면 건너뛰고 가로막은 행의 id 를 댄다 — 한 자리에 살아 있는 행을 둘 남기지 않는다. 루트가 없는 옛 행은 손대지 않는다 — 그쪽은 아래 index dataroom-stamp 몫이다. 그때까지의 귀결은 그대로 적어 둔다: 루트를 가졌는데 원본 파일이 아직 이름 하나인 행은, 운영자가 이 명령을 돌리기 전까지 상대경로로 다시 적재하는 실행이 대체하지 않고 옆에 제안을 세운다.
옛 행을 자료방에 되붙이는 길은 명령 하나뿐이다. 예전 대조는 “이 행이 어느 자료방에서 왔는지 모른다” 를 “그러니 전부에 걸어도 된다” 로 읽어, 이 차원이 막으려던 딜 간 오염을 옛 행 쪽으로 되열어 놓았다 — 같은 앵커를 내는 딜 폴더가 둘일 때 한쪽 옛 행을 확정하면 그 앵커를 공유하는 모든 루트의 형제가 은퇴했고, 한 딜의 교체본 훑기가 남의 딜 옛 행을 끊어짐으로 내렸다. 지금은 넘지 않는다. 대신 값을 치른다: 옛 행은 그 행이 나온 바로 그 자료방을 다시 적재해도 대체되지 않고, 그 실행은 루트를 가진 제안을 옆에 새로 세운다. 옛 행은 추론으로 줄지 않고 사람이 지시할 때만 줄어든다. 그 지시가 운영자 명령 index dataroom-stamp <자료방> [--owner …] [--root <루트>] [--apply] [--prefer-rooted | --prefer-legacy] 다 — 이름 댄 자료방의 딜 앵커를 가진 옛 행 중 원본 파일이 그 루트 아래에 아직 있는 행에만 루트와 그 루트 기준 상대경로를 함께 각인하고(둘이 한 쌍으로 상대경로 신원을 이룬다 — 루트만 찍으면 어떤 적재도 닿지 못하는 자리에 행이 놓인다), 각인 한 건 한 건을 편집 이력으로 남긴다(상태를 건드리지 않으므로 확정본은 확정인 채다). 한 이름이 그 루트 아래 두 파일을 가리키면 넘겨짚지 않고 건너뛰며 그 건수를 따로 보고한다. 각인은 이제 후보 하나하나를 적재하는 쪽과 같은 잠금을 같은 순서로(원본 파일 → 자리, 둘 다 딜 앵커로) 잡은 채 판정한다 — 각인이 옮겨 갈 자리의 적재나 확정과 엇갈려 끼어들 수 없다. 그리고 옮겨 갈 그 신원을 이미 루트를 가진 살아 있는 행이 쥐고 있으면 기본은 거절이다: 각인해 버리면 한 자리에 살아 있는 행이 — 흔히 확정본이 — 둘 남고, 그것이 이 서비스가 지키는 “한 자리에 현재값 하나” 가 깨지는 자리다. dry-run 이 충돌 건수와 가로막은 행의 id 를 대고, --apply 는 사람이 어느 쪽이 살아남을지 대기 전까지 아무것도 쓰지 않고 멈춘다. 대는 방법은 둘뿐이다 — --prefer-rooted 는 루트를 가진 행을 남기고 옛 행을 그것에 대체됨으로 내린다(각인하지 않는다), --prefer-legacy 는 옛 행을 각인하고 루트를 가진 살아 있는 행을 그것에 대체됨으로 내린다. 어느 쪽이든 그 자리에 살아 있는 행은 정확히 하나만 남고, 내려간 쪽은 여느 은퇴와 똑같이 덧붙이기만 하는 대체 이벤트로 남아 행도 근거도 이력도 지워지지 않는다. --apply 를 붙이기 전까지는 dry-run 이라 세어서 보여 주기만 한다 — 찾은 옛 행 · 원본이 루트에 있어 맞은 행 · 원본이 없어 건너뛴 행 · 이름이 중복돼 건너뛴 행 · 충돌한 행 · 각인한 행. 한 앵커를 둘 이상의 루트가 주장하면 아무것도 쓰지 않고 거절한다(어느 루트인지는 --root 로 사람이 댄다) — 여기서 넘겨짚는 것이 곧 이 엄격한 대조가 막으려는 그 오염이기 때문이다. 이 경로는 MCP 도구가 아니라 CLI 전용이고, propose_artifacts_from_ingest 와 confirm_artifact 의 설명이 여기를 가리킨다.
원본이 같은 경로에서 교체되면 이전 fact 가 은퇴한다. 멱등 판정이 버전을 보되, 대조 상대는 그 자리(소유자·종류·자료방·원본 파일·위치)의 살아 있는 행 — 확정본, 또는 아직 검토를 기다리는 제안 — 이다. 같은 원본 버전의 살아 있는 행이 있으면 건너뛰고, 없으면 새 fact 를 제안하면서 그 자리의 현재값(확정본이 있으면 그것, 없으면 검토 대기 중 최신 행)을 지목한다. 그래서 원본을 예전 버전의 바이트로 되돌리면 은퇴한 옛 행과 해시가 같더라도 건너뛰지 않는다 — 은퇴한 행과의 일치는 이력이지 멱등이 아니므로, 현재값을 밀어내는 새 제안이 된다. 버전은 fact 에 찍힌 원본 해시로 판별하고, 그 스탬프가 없던 시절의 행은 근거(citation)에 남은 해시로 대신 읽는다 — 그래서 옛 행도 이관 없이 그대로 이어진다.
버전이 다르다는 판정은 양쪽 해시가 모두 있고 서로 다를 때뿐이다. 한쪽에만 해시가 있거나 양쪽 다 없으면, ingest 가 교체를 알려 오지 않는 한 예전처럼 건너뛴다(중복을 만들지 않는다). 반려는 그 자리가 아니라 바이트에 대한 판정이고, 그 바이트에 남은 판정 중 마지막 것이 선다. 같은 원본 버전을 다시 제안하려 하면 index 는 그 자리를 가로질러 그 바이트에 남은 검토자 판정(확정·반려)을 모아 가장 나중 것만 보고, 그것이 반려일 때만 건너뛴다 — 그 자리에 더 오래된 확정본이 현재값으로 남아 있든 아니든 마찬가지다. 검토자가 이미 아니오를 한 값을 매 실행 다시 줄 세우지 않기 위해서다. 순서는 그 판정 이벤트가 일어난 시각으로 재고, 그 시각이 없으면 행이 만들어진 시각으로 대신한다 — 같은 조회가 이미 쓰는 그 시계다. 반려된 바이트는 자동으로는 다시 제안되지 않는다. 중간 버전으로 교체됐다가 되돌아오든, 사이드카가 이전 버전 해시를 다르게 지목하든 결과는 같다 — 원본이 흔들린 것은 검토자의 재심 의사가 아니기 때문이다. 재심은 resubmit_rejected=true 를 사람이 명시할 때만 열리고(선택 인자, 기본 false), ingest 클라이언트는 이 인자를 아예 보내지 않는다. 재심이 열리면 새 제안 행이 하나 생기고, 반려된 행은 자기 판정을 그대로 유지한다 — 그래서 한 버전에 판정이 둘 남는다. “아무 반려나 하나” 로 막던 시절에는 옛 거절이 사람의 나중 승인을 이겼다: 재심본이 확정되고 그 뒤 더 새 버전에 대체되면 그 바이트를 든 살아 있는 행이 없어지는데, 원본을 그 승인된 바이트로 되돌린 적재가 옛 반려 행에 걸려 그 값이 영영 제안되지 못했다. 지금은 나중 승인이 이긴다. 반대로 아무도 다시 보지 않은 반려는 그대로 영원히 막고, 그 문을 여는 것은 여전히 사람이 명시하는 resubmit_rejected 하나뿐이다.
새로 만들어진 제안은 자기가 밀어내는 이전 버전 fact 를 지목한 채로 들어온다(지목 대상을 찾지 못하면 그 주장은 저장되지 않고 버려진다). 실제 은퇴는 사람이 확정할 때 일어난다 — confirm_artifact 가 같은 트랜잭션에서 그 자리의 살아 있는 다른 행을 전부 대체 이벤트로 내린다. 지목된 이전 버전만이 아니라 검토를 건너뛴 중간 제안까지 함께 은퇴하므로, 확정본 위에 제안이 둘 쌓인 상태에서 가장 나중 것을 확정해도 한 자리에 확정된 값이 둘 남지 않는다. 반려된 행은 검토자의 판정을 그대로 유지하고, 원본 파일이 없는 fact(처분 판단 등)는 이 자리 개념 자체가 없어 쓸려 나가지 않는다. 무엇을 은퇴시킬지는 지목이 아니라 자리가 정한다. 은퇴 대상은 그 자리 질의(같은 소유자·종류·자료방·원본 파일·위치) 하나로만 도출되고, payload 에 적힌 지목은 그 집합에 대조되는 주장일 뿐 권한이 아니다 — payload 는 확정하면서 고칠 수 있으므로, 지목을 그대로 따르면 심어 둔 id 하나로 무관한 artifact 를 내릴 수 있다. 지목이 그 자리 밖의 행을 가리키면 FOREIGN_SUPERSEDES_TARGET 로 거절하고 트랜잭션을 통째로 되돌린다 — 확정 이벤트조차 남지 않는다. 자리는 확정으로 옮길 수 없다. 확정하면서 넘기는 편집분이 자료방이나 원본 파일이나 위치를 바꾸거나 더하거나 지우면 IDENTITY_IMMUTABLE 로 거절된다 — 호출자 잘못으로 분류되고, 잠금을 잡기도 전·아무것도 쓰기도 전에 되돌아가므로 흔적이 남지 않는다. 종류는 payload 가 아니라 열이라 편집분이 닿지도 못하고, 값·라벨 같은 나머지 필드는 그대로 고칠 수 있다. 지목도 확정으로 심을 수 없다. payload 의 지목 블록(supersedes)은 제안이 쓰고 확정이 검증하는 값이라 확정하면서 더하거나 바꾸거나 지우면 SUPERSEDES_IMMUTABLE 로 거절된다 — 검증이 읽는 것은 저장된 블록인데 행에 남는 것은 편집된 payload 라서, 편집으로 새 블록을 심으면 아무도 검증하지 않은 이전 버전 주장이 그대로 저장된다. 저장된 것과 검증된 것이 한 호출 안에서 갈라지는 자리이므로, 이 거절도 잠금 전·쓰기 전이다. 자리 밖 지목을 잡는 FOREIGN_SUPERSEDES_TARGET 는 이 거절이 생기기 전에 쓰인 행에 대한 방어로 남아 있다. 그래서 자리에서 도출되는 셋 — 전이를 직렬화하는 잠금 키, 은퇴 대상 집합, 지목 검증 — 이 저장된 자리에 영구히 묶인다. 편집이 자리를 옮길 수 있었다면 확정 하나가 어떤 행을 남의 자리에 세워 두고 다음 확정이 그 남의 행을 형제로 쓸어 내릴 수 있었고, 그때 잡은 잠금은 옛 자리 것이라 새 자리에 대해서는 아무것도 직렬화하지 못했다. 행과 이력은 지우지 않는다.
반대 방향은 막혀 있다. 이미 대체된 행을 나중에 확정하려 하면 SUPERSEDED_ARTIFACT 로 거절되고 아무것도 쓰이지 않는다 — 되살렸다가는 한 자리에 확정본이 둘 남기 때문이다. 옛 값으로 되돌리는 것이 의도라면 override_superseded=true 를 명시해야 하고, 그러면 그 행이 확정되면서 그때의 현재값이 대신 은퇴한다. 확정 전까지는 이전 값이 정당하게 현재값으로 남는다 — 무엇을 현재로 볼지는 검토 큐가 정한다. 대체에서 눈에 보이는 신호는 셋이다: 적재 결과의 대체 제안 건수, 확정 결과의 은퇴된 fact 목록, 그리고 query_artifacts 의 대체됨 상태 필터.
낡은 제안이 최신 제안을 묻어서도 안 된다. 확정은 그 자리의 살아 있는 다른 행을 전부 은퇴시키므로, 검토를 기다리는 더 나중 제안이 있는데 낡은 행을 확정하면 최신본이 검토 없이 내려가고 낡은 값이 현재값이 된다 — 그러고 나서 최신본을 확정하려 하면 그때는 이미 SUPERSEDED_ARTIFACT 다. 그래서 같은 자리에 이 행보다 엄격히 나중인 미검토 제안이 있으면 STALE_PROPOSAL 로 거절하고 그 id 들을 data.newer[] 에 실어 (가장 새 버전부터) 함께 돌려준다 — 아래에서 위로 훑는 검토는 되돌리려는 의사보다 큐를 잘못 읽은 쪽이 훨씬 많기 때문이다. “더 나중” 은 행이 아니라 원본 버전으로 잰다 — 순서의 기준은 그 버전이 이 자리에 처음 도착한 시각(그 원본 해시를 실은 가장 이른 행)이지, 눈앞의 행이 언제 쓰였는지가 아니다. 평범한 적재에서는 둘이 같은 답을 주고(한 버전에 한 행, 도착 순서대로), 갈라지는 길은 옛 바이트를 다시 줄 세우는 resubmit_rejected 하나뿐이다: v2 가 반려된 뒤 v3 가 미검토로 들어왔고 사람이 v2 를 재심으로 되올리면 그 행은 그 자리에서 가장 나중에 만들어진 행이지만 여전히 옛 버전이다. 행 시각으로 재던 시절에는 이 행이 보호를 그대로 통과해 미검토 v3 를 형제로 은퇴시키고 낡은 버전을 현재값으로 만들었다. 이제 되올린 v2 는 여전히 v2 라 거절이 v3 를 지목한다. 버전 신원이 어디에도 없는 옛 행(해시 스탬프도 근거 앵커도 없는 시절)은 견줄 버전이 없어 자기 생성 시각으로 줄 선다. 도착 시각이 같으면 순서가 없으므로 최신으로 치지 않는다(같은 트랜잭션에서 난 형제 제안이 서로를 막지 않는다). 여기서도 트랜잭션이 통째로 되돌아가 아무것도 쓰이지 않는다.
“한 자리에 살아 있는 제안 하나” 규칙은 두지 않았다. 더 새 버전이 제안된다고 해서 옛 미검토 제안을 자동으로 은퇴시키지는 않는다 — 은퇴한 행은 반려할 수 없으므로(이미 이력이 된 행에 판정을 새로 남기는 것은 거절된다) 검토자가 그 바이트에 아니오를 남길 길이 사라지는데, 바로 그 판정이 “반려된 바이트는 원본이 흔들렸다는 이유만으로 다시 제안되지 않는다” 규칙의 근거다. 적재 때 은퇴시켜 그 근거를 잃는 대신, 불변식은 확정 시점의 거절로 지킨다. 그래서 큐에는 한 자리에 미검토 제안이 여럿 보일 수 있고, 그중 그대로 확정할 수 있는 것은 가장 나중에 들어온 원본 버전의 제안뿐이다 — 나머지를 확정하려면 아래의 문을 명시해야 한다.
override_superseded=true 는 이제 세 경우를 덮는 하나의 문이다: 이미 대체된 행을 되살리는 것, 최신 제안을 지나 낡은 제안을 확정하는 것, 그리고 끊어진 fact 를 다시 확정하는 것. 셋 다 “검토 순서가 꼬였다” 가 아니라 “알고 되돌린다” 를 사람이 명시하는 자리다.
교체본이 버린 자리는 끊어짐으로 내려간다. 대체는 이번 실행이 낸 자리(원본 파일·위치)별로만 맞춰지므로, 교체본이 아예 지웠거나 다른 셀로 옮긴 값은 새 fact 가 생기지 않아 이전 확정본이 그대로 현재값으로 남았다 — 원본 어디에도 없는 숫자를 확정된 사실로 계속 내주는 것이다. 그래서 교체를 선언한(사이드카가 이전 버전 해시를 실어 온) 사이드카를 그 버전으로 처음 들인 실행은, 같은 자료방 안에서 같은 소유자·종류·원본 파일의 살아 있는 행 가운데 이번에 나온 위치 집합에 없는 것을 orphaned 이벤트로 표시한다. 그 선언은 실행이 아니라 원본 버전의 성질이다 — 문서화된 복구 경로인 --force 재생성은 이전 버전을 이미 옮겨 둔 뒤라 이번 실행이 아무것도 보관하지 않지만, 기록된 계보에서 그 이전 버전을 되읽어 첫 교체와 같은 선언을 다시 찍는다. 그러지 않던 시절에는 재생성이 선언을 지워 훑기가 영영 돌지 못했고, 교체본이 뺀 자리의 옛 확정본이 그대로 남았다. 교체한 적 없는 원본은 --force 여도 선언을 지어내지 않는다. 이벤트에는 어느 버전이 밀어냈는지(replaced_by_source_sha256)와 사유가 남고 행·근거·이력은 그대로 보존된다 — 지우는 것이 아니라 파생 상태가 확정에서 끊어짐으로 바뀔 뿐이다. 확정 때가 아니라 적재 때 표시한다: 확정은 원본에 대한 주장인데 그 원본은 이미 바뀐 뒤라, 사람이 다른 fact 를 검토하러 올 때까지 미뤄 두면 검토가 늦는 만큼 거짓 주장이 그대로 서 있다. 대상은 그 자리에 살아 있는 행 전부다 — 확정본뿐 아니라 아직 검토를 기다리는 제안까지, 같은 트랜잭션에서 같은 버전 키로 함께 끊어짐이 된다(반려·대체된 행은 이미 이력이라 건드리지 않는다). 미검토 제안을 남겨 두면 교체본이 더는 담지 않는 자리를 나중에 확정할 수 있어, 같은 문제가 검토 시점으로 미뤄질 뿐이기 때문이다. 내려간 뒤에 그 제안을 확정하려 하면 확정본과 똑같이 SUPERSEDED_ARTIFACT 로 거절되고, 알고 되돌리는 문도 똑같이 override_superseded 하나다.
여집합이 진실이 아닐 때는 훑지 않는다. 훑기의 근거는 “이번 실행이 낸 위치 집합에 없다 = 원본에서 사라졌다” 인데, 그 등식이 깨지는 자리가 둘 있다. 첫째, xlsx 추출은 sheet 당 셀 상한(5,000)과 행 상한(1,000)에서 멈추고 사이드카에 그 사실을 적는다(cells_overflow·rows_truncated_at) — 상한 너머의 셀은 원본에 그대로 있어도 위치 집합에 없으므로, 어느 sheet 든 그 표식이 있으면 그 사이드카는 제안만 하고 훑지 않으며 훑기 기록도 남기지 않는다(같은 바이트를 온전히 읽은 뒤의 실행이 훑는다). 둘째, pdf 도표의 위치는 p<페이지>#<서수> — 그 페이지 안에서 몇 번째로 잡혔는가 — 라서 앞 도표 하나가 빠지거나 잡히지 않으면 뒤 도표가 전부 재번호되고, 그대로 두면 바뀌지 않은 도표의 확정본이 끊어졌다. 그래서 pdf 사이드카는 여집합 훑기를 돌리지 않는다(fact 별 supersedes 계보와 확정 시 이전 버전 은퇴는 그대로다; 도표 신원이 내용 기반이 되면 다시 연다). 두 경우 모두 파일별·합계 total_sweep_withheld 로 보고해, 훑기가 안 돈 것이 조용히 지나가지 않게 한다.
한 자리도 내놓지 않은 교체본은 건너뛰는 것이 아니라 전부를 내린다. 사이드카가 있다는 것은 추출이 실제로 돌았다는 뜻이고, 거기서 나온 “셀 0개” 는 추출 결과이지 실패가 아니다 — 읽히지 않거나 파싱되지 않는 사이드카는 그 파일의 트랜잭션이 열리기도 전에 실패해 이 훑기에 닿지조차 못한다. 그러므로 빈 교체본도 교체본으로 세고, 그 원본 파일에 살아 있는 행은 하나도 빠짐없이 끊어짐으로 내려간다 — 원본이 내용을 통째로 잃은 것이야말로 확정본이 남아 있어서는 안 되는 경우다. 예전에는 “이번 실행이 제안을 하나라도 냈나” 로 교체 여부를 대신 읽었고, 그 대리 판정은 빈 교체본을 “아무 일도 없었다” 로 읽어 옛 확정본을 그대로 세워 두었다.
다시 돌리는 것은 실행이 아니라 버전으로 가른다. index 는 (소유자·종류·자료방·원본 파일)마다 훑기를 마지막으로 적용한 원본 버전 하나를 따로 들고 있고, 들어온 버전이 그 값과 같을 때만 훑기를 건너뛴다. 이 기록은 훑기가 끝날 때마다 갱신되며, 한 행도 내리지 않은 훑기도 기록을 남긴다. 그래서 원본이 중간 버전들을 거쳐 예전 바이트로 되돌아와도 그때 다시 맞춰 본다 — 예전에는 “그 버전을 지목한 끊어짐 표시가 하나라도 있나” 로 갈랐는데, 그 명제는 한 번 참이면 영원히 참이라 되돌아온 바이트의 훑기가 통째로 건너뛰어졌고, 중간 버전이 만든 확정본이 현재 원본 어디에도 없는데 그대로 남았다. 내릴 것이 없던 훑기가 아무 흔적도 남기지 않아 같은 바이트가 매번 다시 판정되던 구멍도 같은 기록이 함께 막는다. 검토자가 override_superseded 로 되살려 둔 fact 가 도로 내려가지 않는 근거는 행 자체에 있다 — 되살림은 그 행을 내린 원본 버전에 대한 답이라, 그 버전을 지목한 끊어짐 표시가 있고 그 뒤에 확정이 따라온 적이 있으면 훑기 후보에서 빠진다. 묻는 대상은 그 행의 이력 전체이지 가장 최근 표시 하나가 아니다 — v2 를 되살린 뒤 v3 가 그 자리를 다시 빼서 그것도 되살렸고 원본이 v2 의 바이트로 돌아오면, v2 는 이미 답한 버전이라 다시 내려가지 않는다. 최근 표시만 보던 시절에는 위에 얹힌 v3 때문에 사람이 이미 답한 v2 에 대고 행을 또 내렸다. 면제는 거기까지다 — 더 새 버전이 그 자리를 다시 빼면 사람이 보지 못한 새 증거이므로 다른 살아 있는 행과 똑같이 다시 끊어짐이 되고, 검토자는 그 새 증거에 대해 같은 문을 한 번 더 쓴다. 그 자리를 다시 담는 버전으로 원본이 정확히 되돌아가는 경우는 되살린 행이 이미 같은 바이트를 들고 있어 새 행이 생기지 않는 멱등 재실행이고, 되살린 행이 그대로 그 자리의 현재값으로 이어진다. 영구 면제가 뚫린 곳이 바로 여기다 — 정확한 되돌림이 멱등 경로를 타는 바람에, 그 행은 이후 어떤 버전이 자리를 다시 빼도 영영 내려가지 않았다. 버전 신원이 없는 옛 사이드카는 견줄 버전이 없어 종전대로 끊어짐 이력이 있으면 후보에서 제외를 유지한다. 이 기록을 들이면서 과거분을 채워 넣지는 않는다 — 배포 후 첫 실행은 기록이 비어 있어 현재 버전 기준으로 한 번 더 맞춰 보고, 그 훑기가 내릴 수 있는 것은 지금 살아 있으면서 현재 버전에 없는 자리뿐이다. 해시 앵커가 없던 시절의 사이드카는 견줄 버전이 없어 매 실행 다시 평가되지만, 이미 내려간 행은 더는 살아 있지 않아 새로 내려갈 것이 없다.
끊어짐에서 눈에 보이는 신호는 넷이다: 적재 결과의 파일별 orphaned 와 total_orphaned, 훑기를 보류했을 때의 total_sweep_withheld, query_artifacts 의 끊어짐 상태 필터, 그리고 그 행을 다시 확정하려 할 때의 거절.
한 자리의 전이는 한 줄로 선다. 제안과 확정과 반려가 같은 키로 그 자리의 advisory lock 을 잡고, 확정과 반려는 lifecycle 상태를 읽기 전에 잡는다 — 그러지 않으면 형제 제안 둘을 동시에 확정할 때 둘 다 상대가 쓰기 전의 세계를 읽고 각자 성공을 돌려주어, 어느 쪽 결과와도 맞지 않는 최종 상태가 남는다. 잠금을 뒤에 넘겨받은 확정은 먼저 끝난 쪽의 확정본을 보고 자신이 대체되었음을 발견해 SUPERSEDED_ARTIFACT 로 거절된다 (여기서도 의도적 롤백이면 override_superseded=true). 원본 파일이 없는 fact 는 이 자리 개념 자체가 없으므로 잠금도 잡지 않는다 — 직렬화할 것이 없고, 그런 fact 끼리 서로를 기다리게 하지도 않는다.
반려도 같은 줄에 선다. 반려는 오래도록 잠금 밖에서 상태를 읽고 판정을 적었다 — 그래서 형제 은퇴 sweep 이나 교체본 훑기와 겹치면 그 행이 대체됨·끊어짐으로 굳는데 반려 이벤트만 따로 남았고, 반려된 바이트를 다시 제안하지 않는다는 규칙이 실제 은퇴 사실과 어긋났다. 이제 ingest 로 들어온 fact 를 반려할 때는 확정과 같은 키를 같은 순서로(원본 파일 → 자리) 잡고, 그 안에서 payload 를 다시 읽은 뒤에 상태를 판정한다. 잠금을 넘겨받은 시점에 그 행이 이미 은퇴해 있으면 판정을 적는 대신 SUPERSEDED_ARTIFACT 로 거절하고 트랜잭션을 통째로 되돌린다 — 아무것도 쓰이지 않는다. 그래서 어느 순서로 들어와도 남는 결론은 하나다: 반려는 살아 있는 행에만 내려앉고, 내려앉으면 최종이다. 확정의 형제 은퇴는 반려된 행을 건너뛰고, 교체본의 끊어짐 훑기가 보는 후보는 마지막 이벤트가 확정이나 제안인 행뿐이라, 검토자가 아니오를 한 행은 나중 적재가 다시 줄 세우지 않는다. 원본 파일이 없는 fact(처분 판단 등)의 반려는 직렬화할 자리가 없어 예전 그대로다.
자리 위에 원본 파일 한 겹이 더 있다. 끊어짐 훑기는 한 자리가 아니라 파일 하나 전체를 판정한다 — 이번 버전이 내놓은 자리들을 제안하고, 내놓지 않은 자리의 확정본을 자기가 잡지도 않은 자리에서 내린다. 자리 잠금만으로는 훑기가 “무엇이 확정인가” 를 읽은 뒤 내리기 전에 다른 확정이 끼어들 수 있고, 그러면 교체본에 없는 자리의 확정본이 그대로 살아남는다. 그래서 자리 잠금 위에 (소유자·종류·딜 앵커·원본 파일) 잠금을 한 겹 더 두고, 사이드카 한 장을 적재하는 쪽(제안 + 훑기)과 원본 파일을 가진 fact 를 확정·반려하는 쪽이 둘 다 자리 잠금보다 먼저 잡는다. 순서는 언제나 파일 → 자리 한 방향이라 서로를 기다리는 고리가 생기지 않는다(훑기가 이미 쥔 자리 잠금을 두고 확정이 파일 잠금을 기다리는 역방향이 없다). 그 결과 훑기와 동시에 들어온 확정은 두 갈래로만 끝난다 — 훑기보다 먼저 들어와 확정되었다가 그 훑기에 끊어짐으로 내려가거나, 나중에 들어와 이미 끊어진 행을 보고 SUPERSEDED_ARTIFACT 로 거절된다(알고 되돌리는 것이면 override_superseded=true). 어느 쪽이든 남는 결론은 하나다 — 교체본이 담지 않는 자리에는 살아 있는 행이 남지 않는다.
expected_state — 파괴적 replace 의 낙관적 동시성(CAS)
replace: true 는 딜의 fund_investment + financial_model(과 그 children)을 DELETE 후
seed 내용만으로 RE-INSERT 하는 전체 문서 교체다. 그래서 “무엇을 지우는지” 를 먼저 보고
승인하는 절차(ic push replace 의 preflight)가 있는데, 승인과 전송 사이에 다른 세션이 같은
딜을 쓰면 승인한 것과 다른 상태 위에 덮어쓴다. 클라이언트가 전송 직전 재조회로 창을 좁힐
수는 있어도 재조회↔전송 사이는 구조적으로 남는다 — 그 창은 서버가 파괴적 쓰기와 같은
트랜잭션 안에서 닫아야만 닫힌다.
expected_state 에 그 딜의 현재 financial_model.id(= get_deal 이 돌려주는 값)를
실으면, 서버가 DELETE 직전에 그 행을 잠그고(FOR UPDATE) 대조한다:
| 상황 | 결과 |
|---|---|
| 일치 | 종전대로 replace 수행 |
| 불일치(남이 먼저 씀) | EXPECTED_STATE_MISMATCH (JSON-RPC -32602) — 아무것도 지우지 않는다. data.actual_state 에 서버의 현재 토큰을 함께 반환 |
| 그 딜에 모델이 없음(미적재/삭제) | 같은 에러, actual_state: null |
| UUID 가 아닌 값 | INVALID_PARAMS — 즉시 거절(끝없는 CAS 거절로 위장되지 않게) |
| 생략 | 무검사 (종전 동작) |
토큰이 financial_model.id 인 이유: replace 마다 새로 생성되므로 남이 쓰면 반드시 변하고,
content_hash 와 달리 legacy 행에서도 NULL 이 아니다.
동시에 같은 상태를 못박은 replace 둘이 오면 정확히 하나만 성공하고 나머지는 CAS 거절을 받는다(패자는 상대가 커밋한 뒤 “행 없음” 을 보고 떨어진다).
LP 산출물
| Tool | 하는 일 |
|---|---|
export_lp_html | 자체 완결 HTML 대시보드 (IRR + 손익 + 엑싯 매트릭스 + 가정) |
export_lp_model | 4시트 xlsx (가정 · 손익 · 엑싯 매트릭스 · IRR 요약), 결정론적 출력 |
export_lp_bundle | xlsx + HTML + README 를 단일 zip |
투자자 레지스트리
연락처와 식별 정보만 담는다. 금액·투자 규모·수익률은 담지 않는다 — 금액의 정본은 회계 쪽에 있고, 여기서는 citation 으로 연결만 한다.
| Tool | 하는 일 |
|---|---|
upsert_investor | 투자자(개인·법인) 레지스트리 생성/갱신 — 대상은 artifact_id → (email AND name) → name 순으로 정하고, 전달한 키만 갱신한다 (아래 참조) |
query_investors | 목록·검색 (앵커·종류·이름/이메일 필터) |
get_investor | 단건 조회 (연락처 + 회계 citation + 받은 IR 메일 이력) |
record_ir_dispatch | IR 메일 발송 직후 수신 이력 기록. 캠페인 단위로 idempotent 라 재시도가 안전하다 |
LP 출자약정 (D-index-75)
조합 inbound 출자약정의 SoT. 납입(paid-in)은 담지 않는다 — 납입액·납입일의 정본은 회계(frame) 분개이고, 법인 주주명부(cap table)도 frame cap_table_at 이 정본이다. 네 도구 모두 firm 멤버 전용.
| Tool | 하는 일 |
|---|---|
upsert_lp | LP 신원 등록/갱신 (lp_code 키, dormant 였던 lp_master 부활) |
query_lps | LP 목록 (kind 필터) |
upsert_lp_commitment | 출자약정 upsert — (fund × LP × 차수) idempotent. 증액은 새 차수 행 |
query_lp_commitments | 약정 목록 + 펀드별 롤업 — Σ약정 vs fund_registry 결성총액 괴리(divergence) 보고 |
Skill
| Tool | 하는 일 |
|---|---|
list_skills | 투자 도메인 skill 카탈로그 |
get_skill_resource | skill 리소스 내용 + 해시 |
증거 보관 · 사후 점검
종결한 딜의 원본 파일을 내용 주소(content-addressed)로 흡수해 둔다. 외부 원본을 지워도 복원 가능한 사본이 남는다.
| Tool | 하는 일 |
|---|---|
register_evidence_blob | 파일 1개를 내용 주소 blob 으로 저장 (해시 중복 제거, 크기 상한 있음) |
list_deal_evidence | deal 의 보관 파일 목록 (해시 · 종류 · 파일명 · 크기 · MIME) |
get_evidence_blob | 저장된 blob 내용 반환. 해시가 곧 주소다 |
schedule_postmortem | 사후 점검 · 재검토 · 후속 조치 일정 저장 (기한 + 발동 조건 + 할 일) |
list_postmortems | deal 의 사후 점검 일정 목록 |
update_postmortem | 상태 · 할 일 · 메모 갱신 |
테넌트 경계 (D-index-81)
index 는 한 데이터베이스에 여러 고객을 담는다. 판정은 응용 코드가 아니라 행 수준 보안이 한다.
표는 두 갈래다. 사람이 소유하는 표(문서·인용·감사 원장 등)는 테넌트와 소유자를 함께 본다. 조직 공용 원장(딜·펀드·재무 모델·조합원 등)은 소유자 축이 없으므로 테넌트만 본다. 두 갈래 모두 세션 변수로 테넌트를 받고, 고객마다 별도의 연결 묶음이 그 값을 세션 범위로 건다. 그래서 요청당 결정할 것은 “어느 묶음을 고르는가” 하나다.
참조 무결성은 행 수준 보안을 타지 않는다. 자식 행이 다른 고객의 부모를 가리킬 수 있으면, 그 부모를 지우는 정당한 삭제가 남의 행을 연쇄로 지운다. 그래서 부모·자식이 모두 축을 가진 관계는 전부 테넌트를 포함한 복합 참조로 묶었다. 이 판을 만들면서 세 관계가 빠져 있었고, 실제로 한 고객이 자기 행을 지우자 다른 고객의 행이 사라지는 것을 재현한 뒤 막았다. 목록을 손으로 관리하면 같은 누락이 반복되므로, 검사도 목록 대조가 아니라 참조 그래프 전수 대조로 바꿨다.
사람이 타이핑하는 식별자(사업자등록번호·딜 코드·조합원 코드 등)는 고객별로 유일하다. 전역 유일이면 두 번째 고객이 같은 회사를 등록하지 못하고, 동시에 그 값을 아는 사람이 다른 고객의 데이터 존재를 알아낼 수 있다.
운영에서 조심할 것. 접속 계정은 행 수준 보안을 우회하지 못한다. 세션 변수 없이 조회하면 오류가 아니라 빈 결과가 돌아온다. 규모를 재거나 데이터가 없다고 판단하기 전에 세션 변수를 걸었는지 먼저 본다. 쓰기가 막히면 정책 위반으로 실패하며, 메시지에 뜨는 것은 컬럼이 아니라 표와 정책 이름이다.
근거는 파일 경로가 아니라 내용 해시
citation 은 원본 파일이 옮겨지거나 이름이 바뀌거나 지워져도 살아남아야 한다. 그래서 모든 citation 이 인용한 바이트의 sha256 을 1차 앵커로 들고 다니고, 위치 기반 앵커는 보조로만 쓴다. verify_citations 가 근거의 내구성을 감사해 “이 판단은 근거가 사라지면 재현 불가” 인 것들을 골라낸다.