2026년 8월 29일 · 5분 읽기
두 번 호출하면 증명이 바뀌었다: 실패 우선을 상태로 만든 기록
GREEN이 RED 파일의 존재만 확인하던 동안 이미 끝난 증명이 아무 파일로 교체됐다. 전이를 상태로 바꾸고 뒷정리 영수증을 완료 조건에 넣은 과정이다.
작업이 끝났다고 말하기 전에 실패를 먼저 잡아 두게 만드는 장치를 쓰고 있다. 기준마다 실패(RED)를 잡고, 같은 것을 통과시키고(GREEN), 사람이 볼 면으로 올린다(SURFACE). 그 장치를 점검하다가 이미 끝난 증명을 아무 파일로 갈아치우는 경로를 찾았다. 호출 두 번이면 됐다.
증명은 파일이 아니라 상태다
기준 하나는 네 값을 지난다. pending에서 시작해 red, green, surfaced로 나아가고, 되돌아오는 문은 하나뿐이다.
전이 규칙은 주석이 아니라 코드가 강제한다.
// plugins/justsend/mcp/contract.mjs:344-348
// red — only from pending or red (a re-capture is fine)
// green — only from red; the failing-first rule is a state, not a stored artifact
// surface — after green, or straight through when proof is "review"
// cleanup — a receipt, any status, needs a note or a path
// reopen — from surfaced only, with a reason; archives the cycle and returns to pending세 번째 줄이 이 글의 주제다. "상태이고 저장된 artifact가 아니다"라는 문장은 처음부터 있었던 것이 아니라, 아래의 우회로를 막으면서 적힌 것이다.
두 번 호출로 열려 있던 문
고치기 전의 green은 RED 파일이 있는지만 봤다. 그러면 이미 surfaced인 기준에 green을 부르고 곧바로 surface를 부르는 것으로 증명이 교체된다. 새로 잡은 실패는 없고, 다른 변경을 위해 잡아 둔 옛 RED가 그 자리에서 엄호가 된다.
이 우회로를 확인하려던 점검이 직전 계약의 증거를 그 경로로 덮어 버렸다. 이론이 아니라 관측이다.
고친 방향은 조건을 파일에서 상태로 옮기는 것이었다. 재증명은 정당한 일이므로 따로 문을 만들었다.
// plugins/justsend/mcp/contract.mjs:416-423
criterion.status === "surfaced"
? `Criterion "${criterionId}" is already surfaced. If its subject changed, reopen it with `
+ "justsend_evidence(kind: \"reopen\", note: what changed) and earn RED again; "
+ "overwriting GREEN here would reuse the old RED as cover."
: `Failing-first violation: criterion "${criterionId}" is ${criterion.status}. `
+ "Capture the failure before implementing."거절 문구는 다음 행동을 말해야 한다
거절은 "안 된다"로 끝나면 우회를 유도한다. 그래서 문구가 다음 호출을 지시한다. 임시 폴더에서 직접 불러 보면 이렇게 나온다.
$ node -e "callTool(cwd, justsend_evidence, {kind: green})" # RED 없이 GREEN
Error: Failing-first violation: criterion "c1" is pending. Capture the failure before implementing.
$ node -e "red -> green -> surface, 그리고 다시 green"
Error: Criterion "c1" is already surfaced. If its subject changed, reopen it with justsend_evidence(kind: "reopen", note: what changed) and earn RED again; overwriting GREEN here would reuse the old RED as cover.
Error: Reopen needs a note saying what changed, so the archived cycle explains itself.
세 번째 줄까지 읽으면 재증명 경로도 공짜가 아니라는 것이 보인다. reopen은 이유를 요구하고, 끝난 주기를 지우지 않고 보관한다.
뒷정리 영수증이 없으면 완료가 잠긴다
같은 점검에서 두 번째 구멍이 나왔다. 완료 판정이 "모든 기준이 surfaced인가"만 보고 있었다. 그래서 검증이 띄운 프로세스나 임시 자원이 그대로 남아도 완료가 허용됐다.
// plugins/justsend/mcp/contract.mjs:242-248
const unreceipted = (contract) =>
contract.criteria.filter(
(c) => c.status === "surfaced" && c.proof !== "review" && c.cleanup_receipts.length === 0,
);
const isDone = (contract) =>
contract.criteria.length > 0 && unproven(contract).length === 0 && unreceipted(contract).length === 0;이제 상태 보고가 그 자리를 이름으로 부른다.
Contract t [LIGHT]: prove it
- [surfaced] c1: run the thing -> exit code is 0 (RED:... GREEN:... SURFACE:...)
No teardown receipt 1: c1review 기준은 면제다. 읽고 판단한 것에는 치울 자원이 없고, 빈 영수증을 요구하면 형식만 남는다.
영수증은 한 줄로도 성립한다
영수증이 요구하는 것은 청소 자체가 아니라 무엇을 띄웠는지 아는 사람의 진술이다. 검증이 프로세스를 띄웠으면 죽이고 그 사실을 적고, 아무것도 띄우지 않았으면 "아무것도 띄우지 않았다"를 적으면 끝난다. 그 한 줄이 없을 때만 완료가 잠긴다.
이 구분이 중요한 이유는 자원을 남기는 검증이 다음 검증을 오염시키기 때문이다. 시뮬레이터 한 대, 임시 데이터베이스 하나, 살아 있는 포트 하나가 다음 기준의 RED를 가짜로 만든다. 그래서 영수증은 완료 조건에 들어가 있고, 판정만 하는 기준에서는 빠져 있다.
같은 사실을 훅이 다시 읽는다
상태 하나를 여러 입구가 읽는다. 완료 도구 호출 전에 한 번, 턴이 끝날 때 한 번이다.
// plugins/justsend/hooks/post/justsend.ts:68-79
export function guardComplete(taskKey: string, cwd: string): HookResult {
const result = run("contract.sh", ["gate", taskKey], undefined, cwd);
...
(result.status === 2
? "The JustSend contract still has unproven criteria."
: "The JustSend verification gate could not run; completion remains blocked."),
}플랫폼 계약이 이것을 가능하게 한다. 공식 문서는 PreToolUse가 "도구 호출이 실행되기 전에 발동하고 그것을 막을 수 있다"고 적고, Stop은 "Claude가 응답을 마쳤을 때" 발동한다고 적는다. 두 자리가 같은 종료코드를 읽으므로, 도구를 우회해도 턴이 조용히 끝나지 않는다.
남은 제약
전이 규칙을 지키는 스위트는 두 파일에서 116건이 통과한다. 계약 스위트만 돌리면 78건이고 1309.00 ms가 든다. 이 수치는 규칙이 코드에 있다는 것만 말한다. 기준 자체가 옳게 쓰였는지는 어떤 테스트도 판정하지 않는다.
$ bun test plugins/justsend/tests/
116 pass
0 fail
454 expect() calls
Ran 116 tests across 2 files. [3.60s]
설치본과 소스 트리가 어긋나는 문제도 같은 점검에서 나왔다. 증거를 잡은 시점의 트리가 그 뒤 재설치(0.9.5 → 0.9.6)로 바뀌면, 그 증거가 증명한 대상은 이미 존재하지 않는다. 지금은 그럴 때 reopen으로 다시 잡게 하지만, 트리가 바뀌었다는 사실 자체를 자동으로 감지하지는 않는다. 그 판단은 여전히 사람이 한다.
두 입구가 같은 종료코드를 읽는다는 것은 우회 경로가 하나 줄었다는 뜻일 뿐, 기준이 무엇을 증명해야 하는지까지 정해 주지는 않는다. 잘못 쓴 기준은 실패를 잡고 통과시키고 면으로 올리는 세 단계를 모두 지나면서도 사용자에게 닿는 것을 하나도 증명하지 않을 수 있다. 그 판단은 기준을 쓰는 사람이 지고, 장치가 대신 지지 않는다.
그래서 이 장치가 실제로 막는 것과 막지 않는 것을 구분해 두는 편이 낫다. 막는 것은 순서를 건너뛰는 일, 끝난 증명을 조용히 덮는 일, 치우지 않은 자원을 남긴 채 닫는 일이다. 막지 않는 것은 기준의 선택과 관측 대상의 타당성이다.