sonmat v0.9 — 증인을 세우다

손맛(sonmat) 만들기 · 5편 중 5편


결론부터. sonmat에 검증자 하나를 새로 붙였다. 이름은 sonmat-witness. 메인 세션이 내놓은 결과물이 사용자가 진짜 시킨 거랑 맞는지를, 메인 머릿속은 안 본 채로 본다.

거기까진 깔끔한 설계 얘기다. 그런데 진짜 일은 만드는 동안에 벌어졌다. 한 세션 안에서 같은 자리에 세 번 자빠졌고, 매번 /devil 한 번 돌리니까 가정이 무너졌다. 이 글은 그 둘 다에 대한 얘기다.


왜 witness가 필요했나

sonmat은 원래 의심해야 한다 는 한 줄에서 출발했다. AI든 사람이든 자기가 쓴 걸 그대로 믿지 말고, 한 번 더 의심해서 보라는 디시플린. 그게 sonmat 전체의 출발점이다.

이번 v0.9에서 부딪힌 자리는, 그 의심 이라는 동작 자체가 자기 자신한테는 잘 안 먹힌다는 점이었다.

사람도 마찬가지다. 자기 글에서 자기 오타는 안 보인다. 수술실 Time Out은 집도의 본인이 안 한다. 다른 사람이 한다. 비행기 조종실에서 “FLAPS — TWO” 같은 콜아웃이 오면, 다른 조종사가 그냥 입으로 “two”라고 답하는 걸로는 검증이 안 된다. 그 스위치 자체를 눈으로 봐야 검증이다.

자기 자신을 의심하라고 시켜봤자 잘 안 된다. 그러니 밖에서 의심하는 자리 를 따로 만들어야 한다. 거기 들어갈 게 witness였다.

이름은 사실 훈수꾼 이 먼저 떠올랐다. 옆에서 같이 보면서 한 수 짚어주는 그 자리. 한국어로는 직관적인데, 시리즈를 영문판도 같이 가다 보니 그대로 옮기기가 어려웠다. 결국 더 보편적으로 통하는 witness 라는 단어를 차용했다.

이 자리가 제대로 작동하려면 따라붙어야 할 게 두 개다.

  1. 검증자가 실행자의 머릿속을 보면 안 된다. 보면 실행자의 합리화를 그대로 받아서 “고무도장” 찍는 기계가 된다.
  2. 검증자한테 들어가는 입력은 사용자가 친 그 문장이어야 한다. 메인이 “이 사람이 원한 건 이거지”로 해석한 버전이 아니라.

기존 guard 스킬로는 이 둘 다 안 풀렸다. guard는 메인 세션 안에서 같이 도는 규칙 체크라, 메인이 보는 맥락을 그대로 본다. 테스트 돌리기, 민감 파일 막기, 디시플린 안 어겼나 보기 — 이런 운영 레벨 체크는 잘한다. 그런데 “사용자가 시킨 거랑 결과가 맞나?”는 못 한다. 격리가 없어서.

그래서 witness를 따로 뽑기로 했다. 어떤 모양으로 떨어졌는지는 뒤에서 보고, 먼저 이걸 만드는 동안 벌어진 얘기부터.


한 세션에 세 번 자빠졌다

witness를 짜는 동안 같은 자리에서 세 번 자빠졌다. 매번 그럴듯한 이름 하나를 먼저 받아들이고, 그 프레임 안에서 설계를 굴렸다. 그러고 나서 /devil을 돌렸다. 매번, 그 이름이 떠받치고 있던 핵심 가정 — load-bearing이라고 부르는 거 — 이 사실은 약했다. 세 번 다 뒤집혔다.

1차: “session-orchestrator-worker 3층”

sonmat memory에 한 달 전부터 박혀 있던 한 줄이 있었다.

“역전된 3층 구조 설계 진행.”

메인은 대화자, 그 아래 오케스트레이터, 또 그 아래 워커. 한 달 동안 이 그림을 머릿속에 두고 굴렸다. 이번에 witness를 만들면서도 자연스럽게 이 3층 위에 얹는 모양을 그렸다. 검증자가 오케스트레이터 자리에 들어가서 워커가 산출한 결과물을 보는 구도. 깔끔했다. 그림이 너무 깔끔해서 의심이 안 들었다.

마지막으로 한 번 확인이나 하자 싶어서 devil CCT를 돌렸다. CCT는 /devil 안에서 도는 discovery 단계다. 주장이 어디 매달려 있는지 한 줄을 먼저 찾는 절차.

  • Claim-crux: 이 구조 전체가 “Claude Code가 nested subagent delegation을 지원한다” 라는 가정에 매달려 있다.
  • Counter-fit: 공식 문서를 안 읽고 구조부터 그렸다. 그 가정은 검증된 적이 없다.

이 한 줄이 떨어지자마자 등이 좀 시려웠다. 한 달짜리 그림이 검증 안 된 한 줄에 매달려 있었다는 얘기니까. 공식 문서를 깠다. Claude Code multi-agent 문서:

“Only one level of delegation is supported: the coordinator can call other agents, but those agents cannot call agents of their own.”

명시적 금지. 3층은 구조적으로 불가능이었다. “역전된 3층”이라는 이름이 한 달 전 메모리에 박혔고, 그 순간부터 이후 모든 설계 논의가 그 프레임 안에서만 굴러갔다. 그동안 “이거 플랫폼이 받쳐주냐?” 를 아무도 안 물었다.

2층(witness-pair)이 Claude Code의 천장이다. 우리는 이미 거기 닿아 있었다. 3층은 처음부터 그냥 그림이었다.

2차: “PreToolUse hook이 witness 스폰해서 tool call deny”

3층 retract하고 다음 안을 짜는데, 뒷통수가 좀 간지러웠다. 한 시간 전까지 멀쩡해 보이던 게 깨졌으니까. 이번엔 검증해가면서 가자, 싶어서 좀 더 조심스럽게 그렸다.

“autoloop 커밋 단계에서 PreToolUse hook이 witness를 agent-type 훅으로 스폰. witness가 BLOCK 내면 hook이 tool call을 deny한다.”

이게 짠 다음 안이었다. 솔깃했다. 메인이 witness를 우회할 수 없는, 플랫폼 레벨 강제니까. 디시플린에 의존하지 않고 hook이 알아서 막아주면, witness가 신뢰 가능한 게이트가 된다.

근데 1차에서 데여서 그런가 — 안을 정리해놓고 보니 “이번엔 진짜로 문서에 있나?” 가 먼저 들어왔다. 공식 가이드를 다시 깠다.

  • agent 타입 hook은 있다. 문서화돼 있다.
  • 그런데 PreToolUse 이벤트에서 agent hook + verdict-based deny 예시는 0개. 공식 예시는 전부 Stop 이벤트에서 테스트 통과 확인하는 용도뿐.
  • “hook이 서브에이전트를 synchronous하게 기다렸다가 verdict로 deny를 채운다”는 semantics 자체가 문서에 없다.

CCT 한 번 더.

  • Claim-crux: witness가 hook 레이어 강제를 필요로 한다는 전제.
  • Counter-fit: autoloop의 다른 phase([Plan], [Define], [Execute], [Evaluate])는 다 디시플린 기반으로 돈다. witness gate만 유독 플랫폼 레벨 강제가 필요하다고 가정할 근거가 없다.
  • Cause-chain: “hook 강제 → 신뢰 가능한 게이팅” 인과는 hook 자체가 실재할 때만 성립. 지금은 안 실재한다. 그럼 대안은 autoloop 디시플린뿐 — 그리고 autoloop는 이미 그렇게 돌고 있다.

뒤집혔다. witness는 hook 필요 없다. Task tool로 [Judge] 단계 안에서 스폰하면 끝이다. Task + subagent_type은 잘 문서화된 primitive. autoloop가 다른 phase 돌리는 방식 그대로 witness도 돌린다. 강제력은 autoloop discipline — 이미 받아들인 그 디시플린에서 안 빠지는 만큼.

그런데 이번에도 패턴이 같았다. 매력적인 이름(“PreToolUse agent hook + deny”)이 박혔고, 그 프레임 안에서 설계를 굴렸고, 플랫폼 지원 여부는 안 물었고, 뒤늦게 무너졌다. 1차랑 똑같은 모양으로.

3차: 그래서 witness 자체를 의심하기 시작했다

여기까지 오니 witness는 shippable해 보였다. 실행 격리 박혔다. 입력은 raw user turn이다. citation rule도 있다. 두 번 데였으니 이제는 진짜 끝일 것 같았다.

근데 두 번 데인 게 마음에 걸렸다. 두 번 다 “매력적인 이름 안에서 굴린 설계” 가 무너진 거였는데, witness라는 이름 자체는 검증된 적이 있나?

마지막으로 한 번만 더. 이번엔 witness 자체를 공격해봤다. 주장을 평소답지 않게 강하게 박았다.

“witness는 우리가 설계한 대로, 비교만 하고 reasoning은 안 하는 결정론적 comparator처럼 작동할 것이다.”

CCT가 집어낸 게 의외였다.

  • Claim-crux: 프롬프트에 “do not reason, compare”라고 적으면 LLM이 진짜 그렇게 행동한다는 가정.
  • Counter-fit: witness는 agent file 한 장이 아니다. agent file + 입력 + 모델의 기본 경향 조합으로 행동한다. 그리고 메인을 돌리는 LLM과 witness를 돌리는 LLM이 같은 클래스다. 메인이 자기 검증할 때 rationalization에 빠지는 그 failure mode가, witness한테도 똑같이 적용될 수 있다. 우리가 agent file에 “비교만 해라” 적어뒀다고 안 빠진다는 보장은 없다.
  • Cause-chain: “agent file 규칙 → 엄격한 comparator 행동”의 인과가 약하다. enforcement 메커니즘이, witness가 제약해야 하는 바로 그 메커니즘(프롬프트 지시에 대한 instruction-following) 자체다. 순환.

여기서 잠깐 멈췄다. 1차랑 2차는 플랫폼이 안 받쳐줘서 깨진 거였다 — 검색 가능한 사실의 문제. 3차는 다르다. witness 설계 그 자체 가 self-referential한 자리에 들어가 있다는 거다. enforcement를 instruction-following으로 해야 하는데, witness가 제약해야 하는 그 instruction-following이 동시에 enforcement 메커니즘이다.

이런 자리에선 strong form은 못 세운다. 그런데 의외로 weak form은 살아남는다.

Strong form — “witness는 결정론적 comparator다” — 는 약해졌다. 그런데 Weak form — “witness는 그래도 메인 self-check보단 낫다” — 는 그대로 살았다. 이유는 layer가 갈리기 때문이다.

Layer 1 — 실행 격리 (harness-enforced) ───── 진짜 구조적 보장
Layer 2 — spawn prompt 규율 ─────────────── aspirational contract
Layer 3 — citation rule ──────────────────── aspirational contract

Layer 1은 플랫폼이 강제로 잡아준다. witness가 sloppy해진다 한들, 메인의 합리화 컨텍스트에 접근할 길 자체가 없다. confirmation-rubber-stamp failure mode에 빠질 수가 없다. input isolation 하나만으로도 메인 self-check보다 낫다고 말할 수 있는 이유.

Layer 2와 3은 다르다. LLM 프롬프트 수준의 행동 계약일 뿐이다. 런타임에 안 잡아준다. witness가 안에서 “이 finding §2긴 한데 좀 약해 보이니까 WARN으로 가자” 결정해버리면 그걸 구조적으로 막을 수단이 없다.

이 차이를 문서에 honest하게 박은 게 v0.9.1이다. witness.md§Isolation stack이 세 layer를 enforced vs aspirational로 명시적으로 갈라놨다. 그리고 early use 동안엔 사람이 직접 verdict를 sample해서 layer 2-3이 진짜 버티는지 봐라는 지침을 박았다. drift 보이면 agent file 조정. scribe가 이미 witness verdict를 journal에 적고 있으니 drift 감지 채널은 이미 깔려 있다.

witness가 완벽한 검증자는 아니다. 그래도 메인보다는 낫고, 그 차이만은 layer 1 덕분에 포장 없이 보장된다. 나머지는 굴려가면서 맞춰갈 영역.


그래서 살아남은 게 이런 모양

세 번 깨지고 자리잡은 witness의 최종 모양은 이렇다.

  • Task tool로 띄우는 서브에이전트. 1차에서 깨졌던 3층 그림 대신, 2층이 천장이라는 걸 받아들인 자리.
  • autoloop [Judge] 단계 안에서 스폰. 2차에서 깨졌던 hook 강제 대신, 다른 phase랑 똑같이 디시플린으로 돈다.
  • 입력은 raw user turn하고 산출물 둘뿐. 메인의 설명, chain-of-thought, 코멘트, 커밋 메시지는 못 읽거나 읽혀도 인용할 수 없다. 이게 layer 1, harness가 강제하는 구조.
  • citation은 무조건. 모든 발견은 user turn N: "exact quote"하고 file:line 쌍을 달아야 한다. 근거 없이 “느낌이 이상하다”는 발견은 그냥 버려진다. 이건 layer 3, 프롬프트 수준 계약.
  • verdict는 source 기반. BLOCK/WARN을 witness가 느낌으로 정하지 않는다. 어느 체크(§1 intent-scope, §2 intent-content, §3 framing-derived, §4 ground truth)가 그 발견을 냈느냐로 자동 결정. §1/§2/§4BLOCK, §3WARN. 강도 판정 자체를 뺐다.
  • 도는 범위 세 가지. 커밋 한 개 단위(commit gate), 세션에서 건드린 파일 전체(session forest), 그리고 사용자가 “전체적으로”, “모든 X에”, “시스템 전반” 식으로 말한 경우의 principle coverage. 마지막이 제일 날카로운 모드다. 여러 파일에 걸쳐 박혀야 할 원칙이 일부만 박혔는지를 witness가 직접 grep으로 훑는다.

세 번 깨진 자리마다 한 줄이 박힌 셈이다.


메타 한 마디 — discovery-led depth가 자기 자신에게 걸렸다

이 세션에 같이 들어간 변화가 하나 더 있다. inspect, devil, punch 셋의 설계 원칙을 discovery-led로 다시 정렬했다. 원래는 “cascade 원칙”이라고 부르고 있었는데, 방향이 잘 안 전달돼서 이름을 갈았다.

원리는 간단하다. 깊이는 발견 다음에 온다. 얼마나 파볼지를 미리 정해놓고 뭘 찾는 게 아니라, 뭔가 잡힌 다음에 그 잡힌 게 깊이를 끌어당기는 식이다. 체스에서 긴 변화수 계산 전에 Checks/Captures/Threats부터 훑는 CCT. 수술 전 Time Out. 항공 challenge-and-response에서 PM이 스위치 위치를 “본다”. 다섯 갈래 검증 전통이 같은 구조를 쓴다.

devil에도 CCT 이름을 빌려 적용했다 — Claim-crux / Counter-fit / Cause-chain. 주장을 네 축에서 평행하게 때리는 대신, load-bearing 가정 하나를 먼저 찾는다. 그게 Evidence 축이냐, Logic 축이냐, Alternatives 축이냐에 따라 그 한 축에만 깊이를 쏟는다. 나머지 축은 가볍게 패스.

오늘 돌린 세 번의 devil이 이 원칙의 첫 실전 적용이었다. 평행 공격이었다면 “witness 괜찮나? inspect는? punch는? 3층은? hook은?” 을 동시에 때렸을 거다. 결론이 이렇게 선명하게는 안 나왔을 거다. CCT는 매번 정확히 load-bearing 가정 하나를 집어냈다. 그 하나만 깊게 파자 전체가 무너졌다. 우리가 만든 원칙이, 그 원칙을 만든 사람한테 그대로 적용됐고, 작동했다.


v0.9에 들어간 것들 (전체)

  • sonmat-witness: 외부 intent-artifact comparator. 3 scope scale(commit / session forest / principle coverage). Source-based verdict(BLOCK/WARN/PASS/INSUFFICIENT_GROUND_TRUTH).
  • guard / scribe 분리: guard는 순수 검증(운영 체크 + 발견), scribe는 사후 persistence(project rule 기록, novel trap memory, journal, bridge note, witness verdict 로그).
  • discovery-led 원칙 재정렬: inspect는 trigger-reactive depth, devil은 CCT로 load-bearing 찾아서 asymmetric attack, punch는 user invocation 자체가 discovery인 모드 시스템.
  • punch refactor-residue check: 구조적 제거/renaming 후에 살아남은 stale reference를 mechanical grep으로 찾는다. Section/function/file/terminology/example/enum/template 7가지 패턴.
  • Isolation stack 솔직한 framing: layer 1(harness-enforced) vs layer 2-3(aspirational). 강한 용어로 포장하지 않고, 어느 부분이 보장이고 어느 부분이 희망인지 문서에 직접 갈라놨다.
  • Feature request 문서: 지금 플랫폼이 안 주는 4가지 primitive(input channel 제한, nested delegation, session layer, 문서화된 hook pattern)를 정리. docs/feature-requests/claude-code-isolation.md.

v0.9.0 — witness + 재구조. v0.9.1 — honest framing pass.


교훈 한 줄

그럴듯한 아키텍처 이름이 떠오를 때, 설계 들어가기 전에 플랫폼이 받쳐주는지부터 봐라. “역전된 3층”, “PreToolUse agent hook + deny”, “결정론적 comparator” — 셋 다 이름은 그럴듯했다. 셋 다 실제론 성립 안 했거나 반쪽이었다. devil CCT가 매번 정확히 그 틈을 집어냈다.

더 큰 얘기로: 원칙을 문서에 박아둔다고 끝이 아니다. 그 원칙이 자기 자신한테 어떻게 적용되는지를 같이 본다. 오늘 세션이 그 첫 장이었다.


릴리스 노트: v0.9.0, v0.9.1 리포: https://github.com/jun0-ds/sonmat


시리즈 손맛 (sonmat) 만들기 다섯 번째 글. 이전 글: 세종대왕은 아이패드를 던지지 않았다

GitHub · LinkedIn