axmap-cli 1.2.5 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,675 +1,14 @@
1
- # 합의제 MR — 저장소 안에서 정족수를 센다
1
+ # axMap 거버넌스 도구
2
2
 
3
- 이 층이 답하는 질문은 **하나뿐**이다.
3
+ `src/governance.mjs`가 정책·투표·기여 이력으로 판정하고, `governance/gate.mjs`가 git에서 재료를 모아 게이트를 실행한다. `governance/vote.mjs`는 git에 투표를 기록한다.
4
4
 
5
- > **"이 변경은 정족수를 채웠는가?"**
6
- > 정족수(定足數) — **통과에 필요한 최소 찬성 수.**
7
-
8
- MR(**Merge Request** — 내 브랜치의 변경을 다른 브랜치로 합쳐 달라는 요청)에 붙는
9
- 리뷰 코멘트·담당자 배정·라벨은 GitLab 이 이미 잘 한다. 우리는 **숫자 하나**만 센다.
10
-
11
- ---
12
-
13
- ## 🔴 먼저 — 이것은 강제 장치가 아니다
14
-
15
- > **이 층은 강제 장치가 아니라 부인 불가능성(non-repudiation) 장치다.**
16
- > 부인 불가능성 — **"나는 그런 적 없다" 고 말할 수 없게 만드는 성질.**
17
- >
18
- > 규칙을 우회한 머지를 **막지는 못한다.**
19
- > 우회했다는 사실이 **지워지지 않는 기록으로 남게** 한다.
20
-
21
- 이 문장을 첫 화면에 두는 이유는, 숨기면 팀이 **보호받고 있다고 믿기** 때문이다.
22
- 믿는 순간 아무도 확인하지 않고, 확인하지 않는 보호는 없는 보호보다 나쁘다.
23
-
24
- ### 못 막는 것 셋 — 정확히 무엇인지
25
-
26
- **1. `.gitlab-ci.yml` 에서 governance 잡을 지우는 MR**
27
-
28
- CI(**Continuous Integration** — 코드를 올릴 때마다 자동으로 검사를 돌리는 것)의
29
- 파이프라인(**한 번 올릴 때 도는 검사 묶음**)은, MR 에서는 **소스 브랜치**
30
- (**합쳐 달라고 내미는 쪽 브랜치**)의 `.gitlab-ci.yml` 로 돈다. 즉 잡(job —
31
- 파이프라인이 돌리는 작업 하나)을 지운 MR 은 **그 잡이 없는 파이프라인**을 돌린다.
32
- 전부 초록이 되고, 초록이니 머지 버튼이 열린다.
33
-
34
- **어떤 CI 설계로도 이것을 사전에 막을 수 없다.** GitLab 이 소스 브랜치의 설정으로
35
- 파이프라인을 만들기 때문이고, 그건 우리가 바꿀 수 있는 것이 아니다.
36
-
37
- 대신 **사후 감사**로 잡기로 했다. `gate audit` 이 잠가 둔 브랜치의 머지 이력을 처음부터
38
- 재생하면서 "이 머지는 그때 정족수를 채웠는가" 를 다시 계산하고, 채우지 못한 머지를
39
- 커밋 해시와 함께 지목한다. 막지는 못하지만 **지워지지 않는다.**
40
-
41
- > ⚠️ **`gate audit` 은 아직 안 만들었다.** 2026-08-26 기준으로 이 층에서 유일하게
42
- > 비어 있는 자리다 (아래 "지금 있는 것"). 그때까지 이 문단은 **설계이지 사실이 아니다.**
43
-
44
- **2. 표 위조**
45
-
46
- 막지 못한다. 파일을 손으로 만들면 그만이다.
47
-
48
- 다만 게이트(**판정을 실행하는 프로그램**)는 표에 적힌 `voter` 의 email 과, **그 표
49
- 파일을 만든 커밋의 committer**(**커밋을 실제로 기록한 git 신원**)를 대조한다.
50
- 어긋나면 세지 않는다. 그래서 남의 표를 위조하려면 **git 신원 자체를 바꿔야 하고**,
51
- 바꾼 사실이 `axmap/votes` 브랜치의 이력에 영구히 남는다.
52
-
53
- GPG 서명(**커밋에 암호학적 서명을 붙여 작성자를 증명하는 것**)은 **선택으로 열어
54
- 둔다.** 필수로 걸면 키를 못 만든 사람이 투표를 못 하고, 그 순간 팀은 이 층을 끈다.
55
-
56
- **3. 투표권자가 전멸한 저장소**
57
-
58
- 승계(→ 아래)로도 못 채우면 **막힌 채로 둔다.** 탈출구를 만들지 않았다.
59
- 오픈소스에서 그 상태의 정답은 fork(**저장소를 통째로 복제해 새 주인이 이어가는 것**)다.
60
- 탈출구를 하나 두면 그것이 곧 평시의 우회로가 된다.
61
-
62
- ### 코드로 만들 수 없는 전제 조건 셋
63
-
64
- 아래 셋은 **저장소 밖(GitLab 설정)에 있다.** 사람이 켜야 하고, 안 켜면 이 층은 장식이다.
65
-
66
- > 🔴 **"지금" 칸은 2026-08-26 에 GitLab API**(**다른 프로그램이 GitLab 에 물어보는
67
- > 창구**)**로 직접 읽은 값**이다. **이 표 밖의 GitLab 설정은 아무도 안 봤다** —
68
- > 안 본 것을 본 것처럼 적으면 다음 사람이 그 줄을 믿고 확인을 건너뛴다.
69
-
70
- | 조건 | 지금 (2026-08-26 실측) | 없으면 무슨 일이 | 왜 코드로 못 만드나 |
71
- |---|---|---|---|
72
- | **`Pipelines must succeed`** — *"MR 을 머지하려면 CI 검사가 전부 초록이어야 한다"* 는 설정 (`Settings → Merge requests → Merge checks`) | ❌ **꺼져 있다** | 판정이 빨개도 머지 버튼이 눌린다. 이 층 전체가 **읽어도 되고 안 읽어도 되는 참고 자료**가 된다 | 저장소 안의 파일이 아니라 GitLab 프로젝트 설정이다 |
73
- | **보호 브랜치**(**아무나 직접 밀어 넣지 못하게 잠가 둔 브랜치**) **직접 push 금지** | ⚠️ **`main` 하나뿐**이다 (push `No one` · merge `Maintainers`). `front/dev` · `back/dev` · `front/main` · `back/main` 은 **보호돼 있지 않다** | MR 을 아예 안 거치고 밀어 넣으면 판정이 돌 자리가 없다 | 같은 이유. 서버가 거절해야 한다 |
74
- | **Owner 최소 2명** — Owner(**프로젝트 설정 자체를 바꿀 수 있는 최고 권한**) | **확인 안 했다.** 위 두 줄과 달리 이건 읽어보지 않았다 | 한 사람이 위 두 설정을 **혼자 조용히 끄고 되돌릴 수 있다.** 그 사람이 팀을 떠나면 되돌릴 사람도 없다 | 권한은 사람의 목록이고, 목록은 가진 사람이 고친다 |
75
-
76
- 🔴 **보호 브랜치가 `main` 뿐이라는 사실의 결과 — 지금 이 층은 파트 브랜치를 못 지킨다.**
77
- 누구나 MR 없이 `front/dev` 같은 파트 브랜치에 **직접 push 할 수 있다.** 게이트도
78
- `verify:mr-target`(**MR 이 한 칸씩만 올라가는지 보는 잡** — [docs/CI.md](../../docs/CI.md))도
79
- **MR 이 있어야 도는 잡**이라, 직접 push 에는 아예 걸릴 자리가 없다. 표 한 장 없이
80
- 코드가 들어가고, 그 사실이 어디에도 안 남는다. 나머지 결과 하나(봇 토큰을 Protected
81
- 로 만들면 그 브랜치들에서 값이 비어 태그가 안 붙는 것)는
82
- [docs/HANDOVER.md](../../docs/HANDOVER.md) 4.1 절에 있다.
83
-
84
- 🔴 **그리고 지금은 러너**(**CI 잡을 실제로 실행하는 프로그램**)**가 한 대도 없다.**
85
- 인스턴스·그룹·프로젝트 전부 0개다(같은 날 실측). 러너가 없으면 파이프라인은 실패가
86
- 아니라 `pending`(**받아갈 실행기가 없어 멈춰 있는 상태**) 이고, `governance` 잡은
87
- **돌지도 않는다.** 위 첫 줄이 꺼져 있으니 그 상태로도 머지 버튼은 열린다.
88
- **지금 이 층은 아무것도 막지 못한다** — 손으로 부르면 판정은 정확하지만(아래 실측 기록),
89
- 아무도 부르지 않으면 아무 일도 일어나지 않는다.
90
-
91
- 루트 [`CLAUDE.md`](../../CLAUDE.md) 3절이 같은 말을 한다 —
92
- **권한은 사람의 목록이고 파이프라인은 코드의 조건이다.** 이 층은 조건 쪽에 서 있지만,
93
- 그 조건을 켜는 스위치는 목록 쪽에 있다.
94
-
95
- ---
96
-
97
- ## 표는 저장소 안의 파일이다
98
-
99
- **GitLab 의 Approve 버튼은 판정에 넣지 않는다.**
100
-
101
- 이 저장소는 **오픈소스 이식이 목표**다. 판정을 GitLab API(**다른 프로그램이 GitLab 에
102
- 물어보는 창구**)에 걸면, 이 저장소를 다른 곳으로 clone(**저장소를 통째로 내려받는 것**)한
103
- 사람에게는 **이 층이 존재하지 않는다.**
104
- 파일이면 `git clone` 이 곧 이관이다. 근거는 [DECISIONS.md](../docs/DECISIONS.md) 의 D18.
105
-
106
- > 판단 당시에는 원격(**코드를 올려두는 서버 쪽 저장소**)이 셋(`origin`·`personal`·`aws`)
107
- > 이었고, 2026-08-26 에 개인 저장소를 접어 **지금은 `origin` 하나**다. 근거는 그대로
108
- > 유효하다 — 이유는 원격의 개수가 아니라 *"clone 에 따라오는가"* 이기 때문이다.
109
-
110
- ### 자리
111
-
112
- ```
113
- 브랜치: axmap/votes (코드 브랜치와 섞이지 않는다. 장부 브랜치와 같은 방식)
114
- 경로 : votes/<소스브랜치>/<voter>-<sha8>.json
115
- ```
116
-
117
- 이 브랜치는 **2026-08-26 에 원격(`origin`)에 실제로 생겼다.** 고아 브랜치
118
- (**아무 이력에도 붙지 않은 브랜치**)라 코드 이력과 섞이지 않는다.
119
-
120
- 장부 브랜치 — **이 저장소가 claim(선점) 기록을 코드와 섞지 않고 따로 두는 브랜치**
121
- (`axmap/claims`). 표도 같은 방식이다: 판정에 쓰는 데이터는 코드 리뷰를 오염시키지 않는
122
- 자리에 둔다.
123
- `sha` — **커밋 하나를 가리키는 40자짜리 지문.** 파일 이름에는 앞 8자만 쓴다.
124
-
125
- **한 사람이 한 커밋에 정확히 파일 하나.** 중복이 파일시스템 수준에서 불가능하다.
126
- `tools/bus.mjs`(에이전트 쪽지함)에서 가져온 것은 이 **모양**뿐이다 — 코드가 아니다.
127
-
128
- ### 표 한 장
129
-
130
- ```json
131
- {
132
- "voter": "rleaderjoon",
133
- "email": "rleaderjoon@gmail.com",
134
- "branch": "feat/S15P21E201-144-login",
135
- "sha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
136
- "vote": "approve",
137
- "at": "2026-08-25T05:00:00Z",
138
-
139
- "note": "정족수와 투표권자 명단을 안 건드리는 것을 확인했다.",
140
-
141
- "agent": {
142
- "id": "claude-opus-5",
143
- "shown": {
144
- "files": 3, "insertions": 12, "deletions": 0,
145
- "paths": ["governance/policy.json", "governance/GOVERNANCE.md", "src/governance.mjs"],
146
- "gate": "미달 1/2",
147
- "axmap": { "commit": "6d9fe68050baed93b06b7c563b15ac638defd65e", "behind": 0 }
148
- },
149
- "summary": "policy.json 에 self_vote 한 줄 추가. 정족수·명단 불변."
150
- }
151
- }
152
- ```
153
-
154
- | 칸 | |
155
- |---|---|
156
- | `voter` | **정책에 적힌 id 여야 한다** — `git config user.name` 이 아니다. `vote.mjs` 가 email 로 명단을 찾아 채운다. 승계로 들어온 사람은 정책에 id 가 없으므로 이 칸을 대조하지 않는다 |
157
- | `email` | **유일 키다.** 사람을 가리키는 것은 id 가 아니라 email 이다 |
158
- | `sha` | 이 표가 승인한 커밋. 40자 전체를 적는다 |
159
- | `vote` | `approve` 또는 `reject`. 그 밖의 값은 판정 불가다 |
160
- | `committerEmail` | 파일에 적지 않는다. **게이트가 git 에서 읽어 채운다** — 표에 적힌 신원과 대조하기 위한 칸이라, 표를 쓰는 사람이 적으면 대조가 아니라 자기 신고가 된다 |
161
-
162
- `reject` 는 **정족수 계산에 들어가지 않는다.** 이 층은 "찬성이 몇인가" 만 세고,
163
- 거부권은 다른 제도다. 다만 출력에는 반드시 보인다 — 사람이 읽어야 하기 때문이다.
164
-
165
- #### 🔴 이유 칸 — 칸마다 주인이 다르다
166
-
167
- `note` 와 `agent` 는 **누가 채우는가가 서로 다르고, 그게 설계의 전부다.**
168
- 한 사람이 다 채울 수 있으면 그건 근거가 아니라 자기 신고다.
169
-
170
- | 칸 | 채우는 주체 | 어떻게 |
171
- |---|---|---|
172
- | `note` | **사람** | `--note "..."` |
173
- | `agent.summary` | **AI** | `--agent-summary "..."` |
174
- | `agent.id` | 부르는 쪽 | `--agent <이름>`. 안 주면 `"none"`(= 사람이 혼자 던졌다) |
175
- | `agent.shown` | 🔴 **`vote.mjs` 가 직접 잰다** | **플래그가 없다. 아무도 못 적는다** |
176
-
177
- `agent.shown` 에 플래그를 두지 않은 이유는 `committerEmail` 을 표에 못 적게 한
178
- 이유와 **완전히 같다** — 재는 사람과 재어지는 사람이 같으면 측정이 아니다.
179
- `--agent` 를 안 줘도 이 칸은 채워진다. 사람이 혼자 던진 표에도 *"그때 무엇이
180
- 보였나"* 는 남아야 하기 때문이다.
181
-
182
- | `shown` 안 | |
183
- |---|---|
184
- | `files` · `insertions` · `deletions` · `paths` | `git diff --numstat <타깃>...<소스>` 를 그대로 잰 값. 세 점(`...`)은 **갈림점부터의 차이**라 타깃에 새 커밋이 들어와도 안 흔들린다. 못 재면 **`null`** 이다 — 0 은 "안 바뀌었다" 는 뜻이라 못 잰 것과 완전히 다른 말이다 |
185
- | `gate` | **던지기 직전**의 판정 요약(`"미달 1/2"`). 이 표 자신은 아직 없다 — 즉 *"내가 던지기 전에 1표였다"* 는 뜻이다. 못 돌리면 `"잴 수 없음"` |
186
- | `axmap` | 이 저장소가 쓰는 axMap **사본**의 출처 커밋과, 원본이 몇 커밋 앞서 있는지. 🔴 **못 재면 `behind: null` 이고 표는 그대로 던져진다** — axMap 이 죽어도 팀이 멈추면 안 된다. `null` 은 *"안 뒤처졌다"* 가 아니라 *"못 쟀다"* 로 읽는다 |
187
-
188
- `shown` 은 **판정에 안 쓰인다.** 게이트는 이 칸을 쳐다보지 않는다 — 사람이
189
- 나중에 *"왜 이걸 통과시켰나"* 를 되물을 수 있게 하는 기록이다. 그래서 모양이
190
- 늘어도 옛 게이트가 표를 깨진 것으로 보지 않는다.
191
-
192
- ### 표를 던지는 법
193
-
194
- ```bash
195
- node axmap/governance/vote.mjs --branch <소스브랜치> --note "왜 찬성/반대하는가" \
196
- [--sha <커밋>] [--vote approve|reject] \
197
- [--agent <AI 이름>] [--agent-summary "AI 가 요약한 한 줄"]
198
-
199
- # 판정을 손으로 미리 돌려본다 (CI 밖에서도 된다)
200
- node axmap/governance/gate.mjs --source <소스브랜치> --target <타깃브랜치>
201
- ```
202
-
203
- `voter` 칸은 **정책 명단이 정한다.** `vote.mjs` 가 내 email 로 명단을 찾아 그 줄의
204
- `id` 를 쓰고, `git config user.name`(**이 PC 의 git 에 설정된 내 이름**)과 다르면
205
- 바꾼다는 사실을 화면에 알린다. 왜 그렇게 됐는지는 이 문서 아래쪽 **"실측 기록"** 의
206
- 두 번째 항목에 있다 — **이 문서가 "사람을 가리키는 것은 email 이다" 라고 적어 둔 규칙을,
207
- 표를 쓰는 코드가 안 지키고 있었다.**
208
-
209
- ---
210
-
211
- ## 지키는 성질 — G1~G5
212
-
213
- | | | 안 지키면 |
214
- |---|---|---|
215
- | **G1** | **자기 표는 세지 않는다** — MR 소스 브랜치 커밋의 author 인 사람의 표는 무효 (예외 하나 — 바로 아래) | 혼자 쓰고 혼자 승인한다 |
216
- | **G2** | **정책은 언제나 타깃 브랜치**(**합쳐 받는 쪽 브랜치**)**에서 읽는다** | 정족수를 1 로 낮추는 MR 이 **자기 자신의 낮춘 규칙으로** 통과한다 |
217
- | **G3** | **표는 커밋(sha)에 묶인다** — 헤드가 바뀌면 효력을 잃는다 | 승인받은 뒤 내용을 갈아끼운다 |
218
- | **G4** | **한 사람은 한 표** — id 유일, email 유일 | 명단에 자기를 두 줄 적으면 두 표다 |
219
- | **G5** | **못 세면 통과가 아니다** — 판정 불가는 언제나 빨강 | 고장이 통과로 읽힌다 |
220
-
221
- **G3 의 "효력을 잃는다" 는 "사라진다" 가 아니다.** 만료된 claim 과 같은 취급이다
222
- ([SPEC.md](../docs/SPEC.md) §5) — 레코드는 남고 효력만 잃는다. 그래서 출력에
223
- `안 센 표` 로 **보여준다.** 지워버리면 "왜 갑자기 표가 없지" 를 아무도 못 푼다.
224
-
225
- **G2 만 순수 판정 밖에 있다.** 어느 브랜치에서 정책을 읽어오는가는 git 을 만지는
226
- 일이라 게이트의 책임이다. `src/governance.mjs` 는 **읽어온 정책이 성립하는지**만 본다.
227
-
228
- ### 🔴 G1 의 예외 하나 — 던질 수 있는 사람이 정족수보다 적을 때
229
-
230
- 오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록 커밋한 사람이 늘어
231
- **던질 수 있는 사람이 0 으로 수렴한다.** 실측 (팀 저장소 S15P21E201, 2026-09-07):
232
-
233
- | 브랜치 | 투표권자 | 그중 소스 브랜치의 author | 던질 수 있는 사람 | 정족수 |
234
- |---|---|---|---|---|
235
- | `front/main` ← `front/dev` | 4명 | **4명 전원** | **0명** | 2 |
236
- | `back/main` ← `back/dev` | 6명 | **6명 전원** | **0명** | 2 |
237
-
238
- 이건 **"아직 표가 모자라다"(미달)가 아니다.** 표를 더 모아도 안 풀린다 — 던질 수
239
- 있는 사람 자체가 없기 때문이다. 그 상태에 미달(종료 코드 2)을 내면 부르는 쪽에게
240
- *"사람에게 부탁하면 된다"* 고 거짓말을 하는 것이고, 사람은 시스템 밖에서
241
- 탈출한다 (`hotfix/` 를 코드 안에 둔 것과 같은 이유다).
242
-
243
- 그래서 **그 판정 하나에 한해** G1 을 푼다. 조건 셋을 **모두** 만족할 때만이다.
244
-
245
- | | |
246
- |---|---|
247
- | 1 | 이번 판정의 **정족수를 숫자로 알고 있다** (`judge` 가 `countVotes` 에 넘긴다) |
248
- | 2 | **G1 이 실제로 명단에서 누군가를 뺐다** — 안 뺐으면 G1 이 만든 교착이 아니다 |
249
- | 3 | 그러고 **남은 사람이 정족수보다 적다** |
250
-
251
- 2번이 없으면 *"명단 자체가 정족수보다 작다"*(승계로도 못 채운 **막힌 저장소**)까지
252
- 이 문으로 샌다. 그건 G1 을 풀어도 아무도 못 던지므로, 판정문에 거짓 사유만
253
- 한 줄 남는다.
254
-
255
- **바뀌지 않는 것 — 이게 절반이다.**
256
-
257
- - **정족수 숫자.** `majorityThreshold` 는 그대로다. 문턱을 낮추면 *"최소 두 사람이
258
- 본다"* 는 팀의 뜻이 조용히 사라진다. 여기서 푸는 것은 **누가 던질 수 있는가** 하나다
259
- - **G3 · G4 · G5**, 브랜치 대조, committer(**표를 기록한 git 신원**) 대조,
260
- `vote_note`, 미래 시각 — 전부 그대로 돈다
261
- - **fail-closed.** 소스 브랜치의 author 목록을 못 받으면 여전히 판정 불가다
262
- (`authors-missing`). **"저자를 모를 때" 는 해제 조건이 아니다** — 거기로 새면
263
- G1 은 켜고 끄는 스위치가 되고, 그건 성질이 아니라 설정이다
264
-
265
- 🔴 **해제는 조용히 발동하지 않는다.** 승계와 같은 규칙이다 — 판정문이 찍는다.
266
-
267
- ```
268
- 🔴 G1(자기 표 배제)이 해제된 판정입니다 — 유효 투표권자 0명 < 정족수 2.
269
- 저자 본인의 표를 셌습니다.
270
- ```
271
-
272
- 해제 덕분에 세어진 사람의 이름까지 남는다. 기계가 읽을 표식은 `tally` 에 있다 —
273
- `g1Waived` · `eligibleCount` · `threshold` · `selfVoted`. `gate --json` 이 그대로 낸다.
274
-
275
- 🔴 **표를 쓰는 쪽도 같은 판단을 한다.** `governance/vote.mjs` 는 자기 표를 만나면
276
- 게이트를 `--json` 으로 한 번 돌려 그 판정의 `tally.g1Waived` 를 읽는다. 해제가
277
- 걸리면 **`--force` 없이 그냥 써 주고** 왜 걸렸는지(유효 투표권자 N명 < 정족수 M)를
278
- 같이 찍는다. 안 걸리면 지금까지대로 경고하고 `--force` 를 요구한다.
279
-
280
- > **왜 이 줄이 여기 있나** — 1.2.0 에서 이 예외를 세는 쪽에만 넣었더니, 쓰는 쪽이
281
- > 그것을 모른 채 *"이 표는 자기 표라 세지지 않습니다"* 라며 막았다. **세어질 표를
282
- > 막은 것이다.** 사람은 던져 보지도 않고 교착이라고 믿었다 (실측: 팀 저장소
283
- > S15P21E201 의 `front/dev → front/main`, 2026-09-07 — `--force` 로 밀어 넣은
284
- > 자기 표 둘이 판정문에서 "해제 덕분에 센 표" 로 세어져 종료 코드 0 이 났다).
285
- >
286
- > 그래서 쓰는 쪽은 조건을 **다시 세지 않는다.** 판정이 내놓은 표식을 읽을 뿐이다.
287
- > 같은 규칙을 두 곳에 적으면 한쪽만 고치는 날 같은 어긋남이 또 난다 —
288
- > `vote_note` 를 두 곳에서 같은 함수 하나로 보는 것과 같은 이유다.
289
- >
290
- > **fail-closed 는 쓰는 쪽에도 그대로다.** 게이트를 못 돌렸거나 판정이 `count`
291
- > 단계까지 못 갔으면 해제하지 않는다. 즉 **모르면 여전히 `--force` 가 필요하다.**
292
-
293
- **`self_vote` 정책(아래)과는 다른 층이다.** `self_vote` 는 *"누구까지를 자기로
294
- 보는가"* 를 **사람이 미리 정하는 것**이고, 이 예외는 *"그렇게 정했더니 아무도 못
295
- 던지게 됐을 때"* **코드가 그 판정에서만 푸는 것**이다. `self_vote: "off"` 면
296
- 배제할 사람이 없으므로 이 예외는 아예 안 걸린다.
297
-
298
- ---
299
-
300
- ## 종료 코드
301
-
302
- 종료 코드 — **프로그램이 끝나면서 내는 숫자. 0이면 성공, 그 외는 실패.**
303
- [SPEC.md](../docs/SPEC.md) §8 의 관례를 잇는다.
304
-
305
- | 코드 | 뜻 | 사람이 할 일 |
306
- |---|---|---|
307
- | **0** | 정족수 충족 | 머지 |
308
- | **2** | 정족수 미달 | **정상적인 "아직 아니다".** 사람에게 투표를 요청한다 |
309
- | **1** | 판정 불가 — 정책 못 읽음, 타깃 브랜치 모름, JSON 깨짐 | **환경을 고친다.** 투표로는 안 풀린다 |
310
- | **4** | 정책 자체가 깨짐 — 계층 위반, email 중복, 문턱이 투표권자 수보다 큼 | 정책을 고친다 (그 MR 은 개정 문턱을 지난다) |
311
-
312
- > 🔴 **2 와 1 을 가르는 것이 이 층의 핵심이다.**
313
- > **2 는 "사람이 아직 안 눌렀다", 1 은 "내가 판정을 못 했다".**
314
- > 둘을 뭉치면 AI 에이전트가 고장을 **기다리면 되는 일**로 읽고 영원히 기다린다.
315
- > 이 저장소가 선점 프로토콜에서 `2`(겹침)와 `1`(환경)을 가른 것과 같은 이유다.
316
-
317
- ---
318
-
319
- ## 정책 — `policy.json`
320
-
321
- ### 자리 — 팀 저장소의 루트다. `axmap/` 안이 아니다
322
-
323
- ```
324
- governance/policy.json ← 팀 저장소 루트. 여기가 정본이다
325
- axmap/governance/policy.json ← 2026-08-26 까지 있던 옛 자리. 지금은 없다
326
- ```
327
-
328
- **도구는 나가고 데이터는 남는다.** axMap 은 곧 자기 저장소로 떨어져 나가고, 그때
329
- 팀 저장소에서 `axmap/` 폴더는 통째로 사라진다. 그런데 *"누가 투표권자인가"* 는
330
- 도구가 정하는 것이 아니라 **팀이 정하는 것**이다. 정책을 도구 폴더 안에 두면
331
- 도구가 나가는 날 팀의 합의 규칙이 같이 딸려 나가고, 그 순간 모든 MR 의 `governance`
332
- 잡이 정책을 못 찾아 종료 코드 1 로 죽는다 — `Pipelines must succeed`(**MR 을 머지하려면
333
- CI 검사가 전부 초록이어야 한다는 설정**)가 켜져 있으면 **아무것도 머지되지 않는다.**
334
- 장부 브랜치(`axmap/claims`)와 표 브랜치(`axmap/votes`)를 팀 저장소에 남기는 것과
335
- 같은 이유다.
336
-
337
- ### 자리를 옮기려면
338
-
339
- axMap 은 이제 범용 도구라 남의 저장소는 정책을 다른 자리에 둘 수 있다.
340
- `gate.mjs` 와 `vote.mjs` 는 **이 순서**로 자리를 정한다.
341
-
342
- | 순서 | | |
343
- |---|---|---|
344
- | 1 | `--policy <경로>` | 플래그가 가장 세다 (`--remote` 가 `AXMAP_REMOTE` 를 이기는 것과 같은 관례) |
345
- | 2 | `AXMAP_POLICY_PATH` | 환경변수. CI 에서 한 번 정해 두기 좋다 |
346
- | 3 | `governance/policy.json` | 기본값. `src/governance.mjs` 의 `DEFAULT_POLICY_PATH` |
347
-
348
- 경로는 **저장소 루트 기준**이다 — 게이트를 어느 폴더에서 돌리든 같은 파일을 읽는다.
349
-
350
- > 🔴 **못 찾으면 다른 자리를 대신 뒤지지 않는다.** 옛 자리로 되돌아가는 폴백은
351
- > 일부러 넣지 않았다. 두 자리를 다 보면 어느 것이 진짜 정책인지 아무도 모르게 되고,
352
- > 이 저장소는 그것을 *"장부가 둘"* 로 한 번 아프게 배웠다. 못 찾으면 **종료 코드 1 로
353
- > 멈추고 어디를 봤는지·어떻게 알려주는지를 말한다.** 그 성질은 `test/gate.test.mjs`
354
- > 의 음성 테스트(**막아야 하는 것이 실제로 막히는지 보는 테스트**)로 고정돼 있다.
355
-
356
- ### 지금 들어 있는 내용
357
-
358
- **지금 저장소에 실제로 들어 있는 파일이다.** 아래는 베낀 것이 아니라 그 내용이다 —
359
- 어긋나면 [`governance/policy.json`](../../governance/policy.json) 쪽이 기준이다.
360
-
361
- ```json
362
- {
363
- "version": 1,
364
-
365
- "voters": [
366
- { "id": "rleaderjoon", "email": "rleaderjoon@gmail.com" },
367
- { "id": "masdf13", "email": "masdf13@naver.com" }
368
- ],
369
-
370
- "amendment": {
371
- "paths": ["governance", ".gitlab-ci.yml", "ci"],
372
- "threshold": "majority"
373
- },
374
-
375
- "default": { "threshold": "majority" },
376
-
377
- "rules": [],
378
-
379
- "succession": { "window_days": 90, "top": 3 }
380
- }
381
- ```
382
-
383
- `amendment.paths` 에 셋이 들어 있는 이유는 전부 다르다.
384
-
385
- | 경로 | 왜 개정 문턱인가 |
386
- |---|---|
387
- | `governance` | 폴더를 통째로 덮으므로 **정책 파일 자신이 덮인다.** 안 덮이면 `validatePolicy` 가 종료 코드 4 를 낸다 — 문턱을 낮추는 변경이 기본 문턱으로 통과하는 것을 막는 규칙이다 |
388
- | `.gitlab-ci.yml` | **게이트 잡을 지우는 변경**이 기본 문턱으로 통과하면 안 된다 (지운 MR 자체를 막지는 못한다 — 위 "못 막는 것 셋") |
389
- | `ci` | 🔴 `ci/axmap/` 은 **규칙을 실제로 집행하는 코드**다 (벤더링 — **다른 저장소의 코드를 복사해 와서 그 사본을 쓰는 것**). 사본을 고치는 것은 규칙을 고치는 것과 같다. `ci/verify-vendor.mjs`(사본이 원본과 같은지 확인하는 검사)도 같은 이유로 덮인다 |
390
-
391
- ### 문턱 — `"majority"` (과반)
392
-
393
- `threshold` 자리에는 **1 이상의 정수**나 문자열 **`"majority"`** 를 적는다.
394
- 지금 정책은 기본과 개정 **둘 다 `"majority"`** 다. 계산식은 하나뿐이다.
395
-
396
- > **문턱 = floor((정책 명단 − 이 MR 의 author) / 2) + 1**
397
-
398
- 계산은 `src/governance.mjs` 의 `majorityThreshold()` 가 하고, **코드가 정본이다.**
399
-
400
- | | |
401
- |---|---|
402
- | **분모에서 작성자를 뺀다** | 자기 표는 어차피 안 세는데(G1) 분모에 남겨 두면 **투표권자가 둘일 때 과반이 2 라서 영원히 못 채운다** — 한 명은 작성자라 셀 수 없기 때문이다. 명단이 둘인 지금 팀에서는 첫날부터 막힌다 |
403
- | **승계로 들어온 사람은 분모에 안 넣는다** | 넣으면 *모자라서 부른 사람*이 자기가 넘어야 할 문턱을 같이 올린다. 승계는 문턱을 **채우는 쪽**만 돕는다 |
404
- | **최소 1** | 명단이 전부 작성자여도 0 으로 안 내려간다. **문턱 0 은 문턱이 아니라 이 층이 없는 것**이다. 그때는 승계로 들어온 남이 한 표를 줘야 통과한다 |
405
- | **작성자를 모르면 판정 불가(1)** | 그럴듯한 숫자로 때우지 않는다. 분모가 틀린 문턱은 아무것도 지키지 않는다 (G5) |
406
- | **계층 비교에서는 가장 높은 값** | 아래 계층 검사에서 `"majority"` 는 어떤 고정 숫자보다도 크게 친다. 명단이 커지면 실제로 그럴 수 있고, 검사는 fail-closed 여야 한다 |
407
-
408
- 지금 명단은 둘(`rleaderjoon` · `masdf13`)이므로, 둘 중 한 명이 쓴 MR 의 문턱은
409
- **floor(1 / 2) + 1 = 1 표** — *"상대 한 명의 찬성"* 이다. 세 번째 사람이 명단에
410
- 들어오면 아무도 아무것도 안 고쳐도 문턱이 2 로 오른다. **고정 숫자 대신 과반을 고른
411
- 이유가 그것이다** (버린 대안은 [DECISIONS.md](../docs/DECISIONS.md) 의 D20).
412
-
413
- 출력에도 과반이라는 사실과 분모가 함께 찍힌다. 숫자만 보이면 왜 이 문턱인지 아무도
414
- 모르고, 명단이 하나 늘어난 날 그 숫자가 조용히 달라진다.
415
-
416
- ### 자기 표 배제의 범위 — `self_vote` (선택 필드)
417
-
418
- G1(자기 표는 안 센다)이 **누구까지를 "자기" 로 보는가**를 정하는 값이다.
419
- 안 적으면 `"authors"` — 이 필드가 없던 시절과 **완전히 같은 동작**이다.
420
-
421
- | 값 | 배제되는 사람 |
422
- |---|---|
423
- | `"authors"` (기본) | 소스 브랜치의 **모든** 커밋 author |
424
- | `"tip"` | 소스 브랜치 **맨 위 커밋(tip)의 author 한 명**. 그 커밋이 표가 묶이는 sha 자신이다 (G3) |
425
- | `"off"` | 아무도 배제하지 않는다 |
426
-
427
- **왜 생겼나 — 실측.** 오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록 커밋한
428
- 사람이 늘어 **던질 수 있는 사람이 0 으로 수렴한다.** 팀 저장소의 `common/dev` 는
429
- 투표권자 4명 중 3명이 커밋해서 던질 수 있는 사람이 1명인데 필요한 표는 2표였다.
430
- 이건 *"아직 표가 모자라다"*(미달, 2)가 아니라 **영구히 잠긴 것**이고, 표를 더
431
- 모아도 풀리지 않는다.
432
-
433
- **그래도 기본값을 `"off"` 로 두지 않는다.** G1 이 실제로 막는 것은 *"혼자 올리고
434
- 혼자 통과"* 하나이고, `"tip"` 은 그것을 여전히 막는다 — 머지될 커밋을 만든 사람은
435
- 못 던진다. `"off"` 는 그 마지막 한 겹까지 없앤다.
436
-
437
- | | |
438
- |---|---|
439
- | **분자와 분모가 같은 값을 본다** | 문턱(`majorityThreshold`)과 표(`countVotes`)가 **같은 함수 하나**(`excludedAuthors`)로 배제 집합을 만든다. 어긋나면 아무 에러 없이 판정만 틀린다 |
440
- | **이상한 값은 판정 불가(1)** | 조용히 기본값으로 떨어뜨리지 않는다. 오타 하나가 배제 범위를 바꾸면 그건 미달이 아니라 **틀린 답**이다 (G5) |
441
- | **`"tip"` 인데 맨 위 커밋의 author 를 못 읽으면 판정 불가(1)** | 누구를 뺄지 모르면 세지 않는다. 게이트가 `git log -1 --format=%aE <소스커밋>` 으로 채운다 |
442
- | **`"off"` 일 때만 author 목록이 없어도 된다** | `"authors"` · `"tip"` 에서는 author 를 못 모으면 여전히 `authors-missing` 으로 판정 불가다 |
443
-
444
- ### 표에 이유를 요구한다 — `vote_note` (선택 필드)
445
-
446
- ```json
447
- "vote_note": { "min_chars": 20, "since": "2026-09-01" }
448
- ```
449
-
450
- 이유(`note`)를 안 적었거나 너무 짧은 표를 **안 세게** 만드는 값이다.
451
- 안 적으면 **아무것도 요구하지 않는다** — 이 필드가 없던 시절과 완전히 같은 동작이다.
452
-
453
- | 칸 | |
454
- |---|---|
455
- | `min_chars` | 몇 글자 이상 적어야 하는가. 앞뒤 공백을 뗀 뒤에 센다 (공백으로 길이를 못 채운다). 안 적으면 `1`(= 뭐든 한 글자) |
456
- | `since` | **시행일.** 이 시각 **이후**에 던져진 표(`at` 기준)에만 요구한다. 안 적으면 시행일 제한 없음 |
457
-
458
- **왜 생겼나 — 실측.** `--note` 라는 칸은 원래도 있었다. 그런데 (1) 필수가 아니었고,
459
- (2) **세는 코드가 그 칸을 쳐다보지도 않았다.** 그래서 실제로 머지된 표에
460
- `"note": "내용 확인함"` 네 글자짜리가 남아 있다. **그건 이유가 아니라 서명이다** —
461
- 무엇을 봤고 왜 괜찮다고 판단했는지가 하나도 안 남는다.
462
-
463
- | | |
464
- |---|---|
465
- | 🔴 **강제는 두 곳에서 한다** | 쓰는 쪽(`governance/vote.mjs`)은 `--note` 없이 **쓰기를 거부**하고, 세는 쪽(`src/governance.mjs` 의 `countVotes`)은 이유 없는 표를 **무효**로 센다 |
466
- | **왜 두 곳인가** | 쓰는 쪽만 막으면 **우회된다.** `git pull` 을 안 한 사람의 옛 `vote.mjs` 는 이 규칙을 모른 채 이유 없는 표를 그냥 쓴다. **진짜 문은 세는 쪽**이다 — CI 에서 돌기 때문이다. 쓰는 쪽의 거부는 던지기 전에 알려주는 **친절함**이다 |
467
- | **규칙은 함수 하나다** | 두 곳이 같은 `voteNoteProblem()` 을 부른다. 갈리면 던질 때는 통과하고 셀 때는 무효인 표가 생기고, 던진 사람은 던졌다고 믿는다 (**투표에서 최악은 조용한 누락이다**) |
468
- | 🔴 **`reject` 에도 요구한다** | 반대야말로 이유가 필요하다. 이유 없는 반대는 판정문에 *"누군가 막고 있다"* 만 남기고 **무엇을 고쳐야 하는지는 안 남긴다** |
469
- | 🔴 **소급하지 않는다** | `since` 이전에 던져진 표는 이유가 없어도 **그대로 유효하다.** 규칙을 고치는 사람이 남의 지난 표를 지울 수 있으면 그건 규칙이 아니다 |
470
- | **`--force` 로 못 넘긴다** | `--force` 는 자기 표(G1) 경고 전용이다 |
471
- | **이상한 값은 판정 불가(1)** | `self_vote` 와 같은 등급이다. 이 값이 이상하면 **어떤 표가 유효한지 자체를 모른다** (G5) |
472
-
473
- 판정문에는 왜 안 셌는지가 `이유(note)를 안 적은 표` · `이유(note)가 정책의 최소
474
- 길이보다 짧음` 으로 보인다. **안 보이면 아무도 못 고친다.**
475
-
476
- > 🔴 **켜는 것은 별도의 MR 이다.** axMap 은 이 필드를 **읽을 줄 알게** 됐을 뿐,
477
- > 어느 저장소의 정책에도 자동으로 들어가지 않는다. 실제로 켜려면 팀 저장소의
478
- > `governance/policy.json` 에 `vote_note` 줄을 넣어야 하고, 그 파일은
479
- > `amendment.paths` 안이라 **개정 문턱을 지나는 MR** 로만 바뀐다.
480
- > 그때 `since` 는 **그 MR 이 머지되는 날 이후**로 잡는다 — 그래야 이미 던져진
481
- > 표가 소급해서 무효가 되지 않는다.
482
-
483
- #### 그 이유는 어디에 남는가 — 머지 커밋
484
-
485
- 표에 적은 이유는 **머지 커밋 본문**으로 들어간다. 승격 봇이 GitLab 에 머지를
486
- 요청할 때 함께 보낸다.
487
-
488
- ```
489
- Merge branch 'front/dev' into 'front/main'
490
-
491
- 정족수 2/2 · 판정 커밋 a1b2c3d4e5f6
492
-
493
- Reviewed-by: 찬성 — yeaseung-lee <yeaseung.lee96@example.com> · ERD 칼럼 이름을 대조했습니다
494
- Reviewed-by: 찬성 — jinmiri <wlsalfl321@example.com>
495
- Rejected-by: 반대 — bob <bob@example.com> · 마이그레이션이 빠졌습니다
496
- ```
497
-
498
- 맨 아래 세 줄을 **트레일러**(trailer — 커밋 메시지 끝에 `이름: 값` 꼴로 붙이는 줄.
499
- `Signed-off-by:` 가 대표적이다)라고 한다. git 에는 그 줄들만 뽑아내는 기능이 있어서,
500
- 나중에 "이 커밋에 반대가 있었나" 를 **사람이 눈으로 훑지 않고 기계가 찾을 수 있다.**
501
-
502
- ```bash
503
- git log --format=%B -1 <커밋> | git interpret-trailers --parse
504
- ```
505
-
506
- 🔴 **왜 MR 댓글이 아닌가.** 댓글은 셋이 나쁘다 — clone 에 안 따라오고(다음 사람이
507
- `git log` 로 못 읽는다), GitLab 을 떠나면 사라지고, MR 이 닫히면 아무도 안 본다.
508
- 장부(`axmap/claims`)와 쪽지(`axmap/bus`)를 서버가 아니라 저장소 안에 둔 것과
509
- 같은 판단이다.
510
-
511
- 🔴 **반대표도 싣는다.** 반대는 정족수 계산에 안 들어가므로(거부권은 다른 제도다)
512
- **반대가 있어도 머지될 수 있다.** 그때 그 반대가 아무 데도 안 남으면 나중에
513
- 문제가 터졌을 때 "아무도 몰랐다" 로 기록된다. 알았던 사람이 있었다는 것이 남아야 한다.
514
-
515
- ##### 🔴 콜론 앞자리는 영문이어야 한다 — 2026-08-28 실측
516
-
517
- 한글이 먼저 오게 쓰고 싶지만 **그 자리는 사람에게 보여주는 칸이 아니라 기계가
518
- 찾는 열쇠 칸**이다. 한글이나 괄호가 섞이면 git 은 그 줄을 트레일러가 아니라
519
- 평범한 본문으로 보고 **조용히 지나친다. 오류도 안 난다.**
520
-
521
- | 넣어 본 것 | git 이 뽑아내나 |
522
- |---|---|
523
- | `Reviewed-by: bob <b@x.com>` | ✅ |
524
- | `Reviewed-by: bob <b@x.com> (찬성) — 이유` | ✅ |
525
- | `찬성(Reviewed-by): bob <b@x.com>` | ❌ 무시 |
526
- | `Reviewed-by(찬성): bob <b@x.com>` | ❌ 무시 |
527
- | `찬성: bob <b@x.com>` | ❌ 무시 |
528
-
529
- 그래서 열쇠는 영문으로 두고 **한글을 값의 맨 앞**에 놓았다. 눈으로 읽을 때
530
- 먼저 보이는 것은 여전히 `찬성` · `반대` 다.
531
-
532
- 라벨을 한글로 바꾸는 것은 자연스러운 개선처럼 보이므로 **테스트로 막아 뒀다**
533
- (`test/promote.test.mjs` 의 "진짜 git" 절). 언젠가 git 이 이걸 지원하면 그 테스트가
534
- 빨개지고, 그때 다시 정하면 된다 — 근거가 바뀌면 결정도 다시 본다.
535
-
536
- 이유가 비면 꼬리(`· …`)를 안 붙인다. `vote_note` 를 안 켠 저장소에서는 이유가
537
- 없는 것이 정상이고, 빈 꼬리는 "이유 칸이 깨졌다" 처럼 보인다. 표가 하나도 없으면
538
- 아무것도 안 보내 GitLab 기본 메시지를 그대로 쓴다.
539
-
540
- > 사람이 손으로 누른 머지에는 안 붙는다. 봇이 부르는 API 경로에만 실린다.
541
- > 첫 줄은 GitLab 기본형 그대로 둔다 — 봇 머지와 사람 머지가 이력에서 다르게
542
- > 보이면, 훑는 사람이 **다르게 보이는 것 자체를 신호로 오해한다.**
543
-
544
-
545
- ### 계층 — `개정 ≥ 기본 ≥ 파트별 규칙`
546
-
547
- 어기면 exit 4 다. 이유는 하나다.
548
-
549
- > **기본값이 어떤 규칙보다 낮으면, 새 폴더를 만드는 것이 기존 폴더를 고치는 것보다
550
- > 싸진다.** 규칙이 안 붙은 새 경로는 기본값으로 판정되기 때문이다.
551
- > 그 순간 사람들은 고치지 않고 옆에 새로 만들기 시작한다.
552
-
553
- `amendment.paths` 는 **반드시 `policy.json` 자신을 덮어야 한다.** 안 덮으면 exit 4 다.
554
- 안 덮으면 문턱을 1 로 낮추는 변경이 **기본 문턱**으로 통과하고, 이 층은 그날 끝난다.
555
-
556
- 규칙이 **여럿 걸리면 최댓값**을 쓴다(fail-closed — **판단이 갈리면 안전한 쪽으로
557
- 닫는다**). 최솟값이면 규칙을 하나 더 얹는 것이 문턱을 낮추는 수단이 된다.
558
-
559
- 경로 매칭은 새로 만들지 않았다. `src/protocol.mjs` 의 `coversPath` 를 그대로 쓴다 —
560
- "규칙 경로가 이 파일을 덮는가" 는 pre-commit 훅(**커밋 직전에 자동으로 도는 검사**)이
561
- "claim 이 이 파일을 덮는가" 를 묻는 것과 같은 질문이다. **한 저장소에 경로 규칙이
562
- 둘이면 어느 쪽이 맞는지 아무도 모르게 된다.**
563
-
564
- ### 승계 — `{ window_days: 90, top: 3 }`
565
-
566
- 살아 있는 투표권자가 필요 표보다 적으면, 최근 90일 안에 커밋한 author 상위 3명이
567
- **임시 투표권**을 갖는다.
568
-
569
- - 승계로 들어온 사람도 **자기 표는 못 센다** (G1). 예외는 하나 — 던질 수 있는
570
- 사람이 정족수보다 적어 G1 이 해제된 판정에서는 승계로 들어온 사람의 자기 표도
571
- 센다 (위 "G1 의 예외 하나")
572
- - 정책에 적힌 사람은 **살아 있지 않아도 투표권을 잃지 않는다.** 생사는 *승계를 켤지*만
573
- 정한다. 두 달 자리를 비운 사람이 돌아와 던진 표가 안 세지면 그건 fail-closed 가
574
- 아니라 그냥 틀린 판정이다
575
- - 🔴 **승계 발동 사실은 반드시 출력에 드러난다.** 조용히 발동하는 승계는 승계가 아니라
576
- 우회로다. 그래서 그 줄을 지우면 테스트가 빨개지게 고정해 두었다
577
- - 승계로도 못 채우면 **막힌 채로 둔다** (위 "못 막는 것 셋" 의 3번)
578
-
579
- ---
580
-
581
- ## 지금 있는 것
582
-
583
- **전부 있다.** 예전 이 자리에 있던 *"아직 없다"* 목록은 2026-08-26 에 다 채워졌다.
584
-
585
- | | | |
586
- |---|---|---|
587
- | ✅ | `governance/GOVERNANCE.md` | 이 문서 |
588
- | ✅ | `docs/DECISIONS.md` 의 D18 · D20 | 왜 파일에 기록하나(D18) · 왜 과반인가(D20). 버린 대안이 함께 있다 |
589
- | ✅ | `src/governance.mjs` | **순수 판정** — git 도 fs 도 `Date.now()` 도 없다 |
590
- | ✅ | `governance/policy.json` | 투표권자와 문턱. **팀 저장소 루트다** — `axmap/` 안이 아니다 (위 "자리"). 지금은 기본·개정 둘 다 `"majority"` |
591
- | ✅ | `governance/gate.mjs` | git 에서 재료를 모아 판정을 부르는 쪽. G2(정책은 타깃 브랜치에서)가 여기 산다 |
592
- | ✅ | `governance/vote.mjs` | 표를 쓰는 쪽 |
593
- | ✅ | `.gitlab-ci.yml` 의 `governance` 잡 | MR 파이프라인에서만 돈다. **`GIT_DEPTH: 0` 이 필요하다** — 아래 |
594
- | ✅ | 원격의 `axmap/votes` 브랜치 | 2026-08-26 에 실제로 생겼다 |
595
- | ✅ | `test/governance.test.mjs` · `test/gate.test.mjs` | 앞은 순수 판정, 뒤는 게이트가 재료를 모으는 부분 |
596
-
597
- 🔴 **`GIT_DEPTH: 0`** — GitLab 은 기본으로 얕은 클론(**최근 몇 커밋만 받아오는 것**)을
598
- 한다. 게이트는 소스와 타깃의 **갈림점**(merge-base — **두 브랜치가 갈라진 지점**)에서
599
- 바뀐 파일을 뽑는데, 얕으면 그 지점이 받아온 범위 밖에 있어 **바뀐 파일 목록이 통째로
600
- 틀린다.** 그러면 문턱을 엉뚱한 규칙에서 고른다. 이 한 줄이 빠지면 판정이 조용히 틀린다.
601
-
602
- **아직 없는 것은 사후 감사(`gate audit`)뿐이다** — 잠가 둔 브랜치의 머지 이력을 재생해
603
- "이 머지는 그때 정족수를 채웠는가" 를 다시 계산하는 것(위 "못 막는 것 셋" 의 1번).
604
- 그리고 MCP(**AI 도구가 외부 기능을 붙여 쓰는 규약**)에는 아직 판정을 **읽어서 보여주는**
605
- 도구가 없다. 투표 도구는 앞으로도 안 만든다 — D18 의 버린 대안 2.
606
-
607
- **판정을 순수 함수로 뺀 이유**는 이 저장소가 `src/protocol.mjs` 와 `bin/axmap.mjs` 를
608
- 가른 이유와 같다 — 판정을 검증하려고 저장소를 만들거나 승계 창 90일을 기다릴 수는 없다.
609
-
610
- ---
611
-
612
- ## 실측 기록 — 2026-08-26
613
-
614
- **저장소 실물과 실제 `policy.json` 로 끝까지 돌린 기록이다.** 손으로 만든 상황이 아니라
615
- `feat/S15P21E201-999-quorum-test` 라는 실제 브랜치에 실제 표를 던져서 얻었다.
616
-
617
- ### ① 세 단계가 전부 예상대로 나왔다
618
-
619
- 명단은 둘, 작성자는 그중 한 명이므로 문턱은 과반으로 **1 표**다.
620
-
621
- | 단계 | 종료 코드 | 출력 |
622
- |---|---|---|
623
- | 표 0장 | **2** | `합의 미달 — 유효 찬성 0 / 필요 1` |
624
- | 표 1장 (작성자가 아닌 사람) | **0** | `합의 충족` |
625
- | 그 뒤 커밋을 하나 더 올림 | **2** | 두 표 모두 `헤드가 바뀐 뒤라 효력 없음 (G3)` 으로 **보인 채** 안 세짐 |
626
-
627
- 세 번째 줄이 G3 의 *"효력을 잃는 것은 사라지는 것이 아니다"* 다. 표는 목록에 그대로
628
- 남아 있고 **왜 안 세졌는지가 화면에 적혀 있다.** 지워버렸다면 "어제 승인받았는데 왜
629
- 0표지" 를 아무도 못 풀었을 것이다.
630
-
631
- 실측용 표는 확인이 끝난 뒤 치웠다. `axmap/votes` 의 이력에는 던진 커밋과 치운 커밋이
632
- **둘 다 남아 있다** — 표 브랜치에서 무엇을 지워도 지운 사실이 남는 것이 이 층의 요점이다.
633
-
634
- ### ② 🔴 표를 쓰는 코드가 이 문서의 규칙을 안 지키고 있었다
635
-
636
- 이 기록이 위 표보다 값지다.
637
-
638
- `vote.mjs` 는 표의 `voter` 를 `git config user.name` 에서 가져왔다. 그런데 이 PC 의
639
- `user.name` 은 **`janghyojoon`** 이고 정책 명단의 `id` 는 **`rleaderjoon`**(GitLab 계정
640
- 이름)이었다. **email 은 같았다.**
641
-
642
- 결과가 고약하다. 표는 **정상적으로 쓰였고** `axmap/votes` 에 push 까지 됐는데, 게이트는
643
- `표의 이름과 email 이 명단과 어긋남` 으로 **그 표를 안 셌다.**
644
-
645
- > **던진 사람은 던졌다고 믿는데, 판정에는 들어가지 않는다.**
646
- > 실패가 조용하다 — 이 저장소가 락에서 가장 경계해 온 모양이다.
647
-
648
- **고친 방식.** email 이 유일 키(G4)이므로, `vote.mjs` 가 **타깃 브랜치의 정책에서 email
649
- 로 명단을 찾아 그 줄의 `id` 를 쓴다.** 바꿀 때는 사람에게 알린다 —
650
- `이름을 명단에 맞춥니다: janghyojoon → rleaderjoon`. 명단에 없으면(승계로 들어올 사람)
651
- `user.name` 을 그대로 둔다. 게이트도 그 경우엔 `id` 를 대조하지 않기 때문이다.
652
-
653
- **교훈은 규칙이 아니라 규칙이 사는 자리에 관한 것이다.**
654
-
655
- > *사람을 가리키는 것은 이름이 아니라 email 이다* — 이 규칙은 이 문서에 이미 적혀
656
- > 있었고(위 "표 한 장" 표의 `email` 칸), 판정 코드도 지키고 있었다.
657
- > **표를 쓰는 쪽만 안 지켰다.**
658
- > **규칙이 한 곳에만 적혀 있으면 다른 곳은 안 지킨다.**
659
-
660
- ---
661
-
662
- ## 검증
5
+ 이 기능은 axMap의 협업 기능이며 특정 서비스의 소스나 배포 설정은 포함하지 않는다. 규격은 [SPEC.md](../docs/SPEC.md), 불변식은 [INVARIANTS.md](../docs/INVARIANTS.md), 설계 이유는 [DECISIONS.md](../docs/DECISIONS.md)를 참고한다.
663
6
 
664
7
  ```bash
665
- npm --prefix axmap test
8
+ node governance/gate.mjs --help
9
+ node governance/vote.mjs --help
666
10
  ```
667
11
 
668
- 판정은 언제나 종료 코드다. **개수를 여기 적지 않는다** — 적어 두면 테스트를 늘릴
669
- 때마다 그 줄이 낡고, 낡은 기준은 없는 기준보다 나쁘다.
12
+ CI와 병합 제한은 사용하는 저장소가 설정한다. GitHub로 소스를 옮기는 것만으로 이전 GitLab의 병합 정책·승인·CI 설정이 적용되지는 않는다. 게이트를 적용하려면 해당 호스팅 환경에서 필수 검사를 구성하고 실패 시 병합이 거부되는지 확인한다.
670
13
 
671
- 🔴 **음성 테스트**(**막아야 하는 것이 실제로 막히는지 보는 테스트**)**가 이 층의 계약이다.**
672
- 한 번도 실패하지 않는 검사기는 검사기가 아니다.
673
- 자기 표 · email 중복 · 계층 위반 · 개정이 자기를 제외 · sha 불일치 · 채울 수 없는 문턱 ·
674
- 과반 분모에서 작성자가 빠지는 것 · 승계 발동 — **이 목록이 빠지면 테스트를 지운 것이다.**
675
- (개수를 적지 않는다. 하나 늘릴 때마다 그 숫자가 낡는다.)
14
+ 배포 예제는 `policy.example.json`이다. 사용하려는 저장소의 `governance/policy.json`으로 복사한 뒤 예제의 `example.com` 투표자를 실제 기여자와 정책으로 바꾼다. 예제 자체는 활성 정책이 아니다.