enode — 아키텍처
f46e187시점의 구현을 읽고 적은 지도다. 적합성 판정은 여기 없다 —
정본 조항과의 대조는 다음 스테이지가 한다. 이 문서는 그 대조가 딛고 설
좌표만 제공한다.
시스템 개요
세 컴포넌트가 하나의 중앙을 향한다. 중앙은 상태를 소유하고, 노드는 자원을
소유하고, CLI 는 무상태다.
runctl ──HTTP──▶ Mediator ◀──HTTP── enode (기계 N대)
iapadapter ─────▶ (중앙 하나) │
│ └─exec─▶ 하네스 (claude CLI)
PostgreSQL 17
파일시스템 Run Record
핵심은 연결의 방향이다. 노드는 서버를 열지 않고 자기가 연결을 건다.
internal/enode/ 전체에 net.Listen · http.Server 가 0건이고, 저장소에서
포트를 여는 자리는 cmd/mediator/main.go:112 하나뿐이다. 방화벽 뒤에 있는
개발보드나 남의 노트북이 아무 설정 없이 함대에 들어올 수 있는 것이 이 방향의
직접적 결과다.
아키텍처 스타일
중앙 조정자 + 당기기(pull) 워커 다. 마이크로서비스도 모놀리스도 아닌,
분산 작업 대기열의 형태에 가깝다. 근거:
- 배포 단위가 다섯 개의 독립 실행파일이지만 서비스 경계가 아니라 역할 경계다.
Mediator 하나만 상태를 갖고 나머지는 전부 그것을 향한다 - 노드는 대기열에서 단계를 당긴다. Mediator 가 노드에 밀어 넣지 않는다
(POST /v1/nodes/{id}/claim롱폴,internal/api/api.go:226) - 상태 저장소가 하나(PostgreSQL)이고 배분의 원자성을 SQL 이 준다
(FOR UPDATE OF s SKIP LOCKED,internal/store/claim.go:292) - 매칭은 순수 함수다.
internal/match/match.go:66Match는 입력 셋 · 출력
둘 · I/O 0 이고,POST /v1/runs와POST /v1/runs/dry-run이 같은 함수를
부른다. Mediator 는 추론을 하지 않는다는 경계가 여기서 코드로 선다 - 프레임워크가 없다. 표준
net/http의 Go 1.22+ServeMux패턴만 쓰고,
라우팅 전체가internal/api/api.go:55-69한 함수에 있다
컴포넌트 관계
flowchart LR
subgraph sgUser["사용자 쪽"]
RC["runctl CLI"]
IAP["cmd/iapadapter"]
end
subgraph sgMed["중앙 — Mediator"]
MED["cmd/mediator"]
API["internal/api"]
ST["internal/store"]
MT["internal/match"]
RD["internal/record"]
SC["internal/schema"]
CT["internal/contract"]
CF["internal/config"]
DB[("PostgreSQL 17")]
FS[("파일시스템 Run Record")]
end
subgraph sgNode["노드 쪽 — 기계 N대"]
ENC["cmd/enode"]
ENL["internal/enode"]
HN["하네스 claude CLI"]
end
RC -->|"HTTP /v1"| API
IAP -->|"HTTP /v1"| API
MED --> API
ENC --> ENL
ENL -->|"아웃바운드 HTTP /v1 만"| API
API --> ST
API --> MT
API --> SC
API --> RD
API --> CT
API --> CF
ST --> RD
ST --> CT
ST --> MT
ST --> DB
RD --> FS
ENL --> CT
ENL -->|"exec"| HN
ST -.->|"asks 웹훅 POST"| IAP
경계 하나가 코드로 서 있다: internal/enode 는 internal/store 도
internal/api 도 import 하지 않는다. 노드가 Mediator 내부를 못 보는 것이 규약이
아니라 컴파일러가 지키는 사실이다. 공유하는 것은 internal/contract 하나뿐이다.
Interaction Diagrams
세 개의 업무 트랜잭션을 컴포넌트를 가로질러 그린다.
1. Run 제출 → 매칭 → 배정
sequenceDiagram
autonumber
actor U as 사용자
participant RC as runctl
participant API as internal/api
participant CT as internal/contract
participant MT as internal/match
participant ST as internal/store
participant RD as internal/record
participant DB as PostgreSQL
U->>RC: runctl submit contract.json
RC->>API: POST /v1/runs
API->>API: auth Bearer 상수시간 비교 + X-Enode-Principal
API->>CT: Validate 612줄 문법 검증
CT-->>API: 문법 위반이면 400
API->>ST: LiveAdverts
ST->>DB: SELECT nodes
DB-->>ST: 살아 있는 광고 목록
API->>MT: Match 요구 대 광고, 순수 함수
alt 후보 없음 — 영구 거절
MT-->>API: 422 no_candidate
API-->>RC: 422
else 전부 사용 중 — 일시 거절
MT-->>API: 409 all_busy
API-->>RC: 409
else 배정 성립
MT-->>API: 배정표, 이름에서 node_id 로
API->>ST: CreateRun 단일 트랜잭션
ST->>RD: Records.Open runID
ST->>DB: INSERT runs 그리고 steps 그리고 leases
Note over DB: leases.node_id PRIMARY KEY 가 I1 을 강제한다
DB-->>ST: 충돌하면 duplicate key
ST-->>API: ErrNodeTaken 이면 전체 롤백 — I5
API-->>RC: 201 run_id
end
2. claim 롱폴과 단계 실행 루프
sequenceDiagram
autonumber
participant EN as enode 노드
participant API as internal/api
participant ST as internal/store
participant DB as PostgreSQL
participant RD as Run Record
participant HN as 하네스 claude
EN->>API: POST /v1/nodes/ID/claim 롱폴
activate API
loop 1초 티커, 최대 7200초
API->>ST: ClaimStep
ST->>DB: SELECT FOR UPDATE OF s SKIP LOCKED
DB-->>ST: 게이트가 열린 단계 또는 없음
end
alt 줄 단계가 없다
API-->>EN: 204 No Content
else 단계를 집었다
ST->>ST: commit 뒤 stampStanding 그리고 stampRejections 그리고 stampRoleAttrs 그리고 stampLedger
Note over ST: 커밋 밖 부수효과 — 실패를 삼킨다, 의도된 것
API-->>EN: 200 Claimed 프롬프트와 argv 포함
end
deactivate API
EN->>EN: 워크스페이스 준비, IN 적재, sealInput 0444 그리고 0555
EN->>HN: runHarness — 유일한 exec 지점, harnessEnv 화이트리스트
HN-->>EN: 종료 봉투를 ParseClaude 가 정규화
EN->>API: PUT /v1/runs/RUN/steps/SEQ/log
EN->>API: PUT /v1/runs/RUN/steps/SEQ/blob/NAME
API->>API: schema.Validate 실패면 422 이고 저장하지 않는다
API->>RD: WriteBlob 회차와 순번이 이름에 들어간다
EN->>API: POST /v1/runs/RUN/steps/SEQ/result
API->>ST: ReportStep 다음 afterStep 다음 applyStepEffects
Note over ST: expands 다음 dispatch 다음 release 순서
ST->>DB: 다음 단계를 만든다 — RUNNING 에서 RUNNING
3. 임대 · 하트비트 갱신 경로 (S4 위험 구간)
sequenceDiagram
autonumber
participant AD as advertise.go 광고 고루틴
participant LS as leases.go Held
participant WD as claim.go 임대 워치독
participant API as internal/api
participant ST as internal/store
participant RP as reap.go 리퍼 고루틴
Note over AD,ST: ADR-016 — 하트비트가 임대를 나른다. 별도 하트비트 표면은 없다
loop 광고 주기 기본 60초
AD->>API: POST /v1/nodes 광고
API->>ST: RenewLeases node
ST->>ST: 끝난 Run 은 갱신하지 않는다 — WHERE r.state NOT IN
ST-->>API: 유효 임대 목록 전체, 델타가 아니다
API-->>AD: 200 leases 그리고 renew_seconds
AD->>LS: Held.Set 목록 — 병합이 아니라 교체
end
WD->>LS: 실행 중 보유 임대를 확인한다
alt 내 임대가 목록에서 사라졌다
WD->>WD: 실행 중인 단계를 접는다
end
loop 리퍼 주기
RP->>ST: Reap — DELETE leases USING runs
RP->>ST: sealExpired — 최근 1시간 창만 본다
RP->>ST: SettleIfDone — DELETE FROM leases
Note over RP,ST: I2 — 종료 시 점유 장부가 빈다
end
데이터 흐름
세 저장소가 있고 각자 다른 것을 담는다.
| 저장소 | 담는 것 | 소유자 |
|---|---|---|
| PostgreSQL 17 | Run · Step · Node · Lease · Ask · 상태 전이 | internal/store |
| 파일시스템 | Run Record — 로그 · blob · manifest · tar | internal/record |
| 노드 로컬 디스크 | 워크스페이스 · $IN/$OUT · 잠금 파일 |
internal/enode |
산출물이 단계 사이를 건너가는 경로가 별 모양이다. 노드 A 가 만든 blob 은
노드 B 로 직접 가지 않고 Mediator 를 경유한다. 올릴 때는 단계를 두 세그먼트로
가리키고(PUT /v1/runs/{run}/steps/{seq}/blob/{name}), 내릴 때는 Run 단위로
가리킨다(GET /v1/runs/{run}/blob/{name}). 이 비대칭의 이유가
internal/api/api.go:653-666 주석에 적혀 있다.
blob 의 「최신」은 (회차, 순번)이 가장 큰 것이고, 회차가 파일 이름에 들어간다
(01.1-name 꼴 — internal/record/record.go:260 blobPath · :294
parseBlobName).
주요 설계 결정 — 코드에서 관측되는 것
라우팅 · 프레임워크 부재
라우터 라이브러리가 없다. 표준 ServeMux 의 메서드+경로 패턴만 쓰고 15개 라우트
전부가 internal/api/api.go:55-69 한 함수에 등록된다. 흩어진 라우트 정의가 없어
표면 전수 확인이 grep 한 번으로 끝난다.
인증은 예외 경로 0개
15개 라우트가 전부 s.auth(...) 로 감싸여 있다. 인증은
Authorization: Bearer 의 crypto/subtle 상수시간 비교이고, 식별은
X-Enode-Principal 헤더인데 검증하지 않는다 — 인증과 식별을 일부러 분리했다
(internal/api/api.go:83 auth · :97 principal).
마이그레이션 도구 없음 — 매 기동 schema.sql 재실행
internal/store/schema.sql 을 //go:embed 로 바이너리에 넣고 Store.Migrate
가 매 기동마다 통째로 Exec 한다. 모든 DDL 이 IF NOT EXISTS /
ADD COLUMN IF NOT EXISTS 로 멱등하게 쓰여 있다. store.go:71 주석이 의도를
적는다 — "MVP 는 마이그레이션 도구를 안 쓴다 — 스키마가 굳으면 그때 넣는다".
ADR-042 — 단계별 파일시스템 경계가 제거되었다
internal/enode/claude.go:212-224 는 하네스를
--permission-mode bypassPermissions 로 띄운다. 정본은 이것을 2026-08-22 개정
(ADR-042)으로 기록한다 — 단계별 파일시스템 경계를 어느 단계에서도 없앤
것이고, 코드 주석이 그 뒤집기의 근거를 적는다. --add-dir 은 의도 기록용으로만
남아 있다.
남는 두 개의 봉쇄 장치는 다음과 같다. 리뷰 대상 코드베이스에 기록된 설계
결정이며, 여기서는 사실로만 적는다.
internal/enode/collect.go:234sealInput— 입력을 0444/0555 로 봉인한다internal/enode/env.go:97harnessEnv— 블랙리스트가 아니라 빈 것에서
쌓아 올리는 화이트리스트다.ENODE_TOKEN이 하네스 프로세스로 넘어가지
않는 것이I1전제절의 우회로 하나를 막는다
롱폴의 비용을 지금은 폴링으로 낸다
internal/api/api.go:225 주석: "MVP 는 짧은 주기로 DB 를 다시 본다. ADR-015 가
적어둔 LISTEN/NOTIFY 는 … 최적화다. 지금은 넷이다." claim 롱폴이 1초 티커로
폴링하며 최대 7,200초 대기한다. 서버 쪽에서 WriteTimeout 을 일부러 걸지 않는
것이 그 짝이다(cmd/mediator/main.go:114-117), 클라이언트 쪽도 타임아웃 없는
&http.Client{} 를 쓴다(cmd/enode/main.go:132-136).
정본 대조를 위한 관찰 — 조항에서 코드로 가는 지도
판정이 아니라 지도다. 다음 스테이지가 정본 조항을 코드에 대볼 때 가야 할
자리를 문서별로 적는다. 이 절이 없으면 다음 스테이지는 3,860줄의 정본을
27,211줄의 구현에 손으로 다시 대야 한다.
INVARIANTS.md
- §1.1 Run 상태 어휘(
CREATED/RESOLVING/ALLOCATING/RUNNING/
VERIFYING/SUCCEEDED/FAILED) →internal/store/store.go:145-152
(상수 정의). 지나가는 자리는internal/api/api.go:325submit
(RESOLVING·ALLOCATING),internal/store/reap.go:235SettleIfDone
(RUNNING→VERIFYING→SUCCEEDED/FAILED) - §1 단계 상태 어휘(
PENDING/CLAIMED/DONE/FAILED/SKIPPED+
ASKED) →internal/store/verdict.go:19-32(상수와 "이 열거를 늘리면 reap 의
집계를 반드시 다시 본다" 주석),internal/store/schema.sql의steps.state
컬럼과CHECK을 안 거는 이유 - §2 전이표 전반, 특히
RUNNING → RUNNING이 "Mediator 가 다음 단계를 만든다" →
internal/api/api.go:267postResult→internal/store/claim.go:762
ReportStep→:851afterStep→:864applyStepEffects
(expands→dispatch→release순서와 그 순서를 그렇게 둔 이유) - §2 "
RUNNING → RUNNING만 멱등이 아니다 · 재실행하지 않고 Run 을 실패시킨다" →
internal/store/claim.go:565FailRestarted,
internal/store/reap.go:235SettleIfDone(broke > 0분기),
internal/store/claim.go:507redeliver(재전달은 재실행이 아니라는 구분) - §2
* → FAILED(취소 · 임대 만료), 멱등 →internal/store/reap.go:170Cancel,
internal/store/reap.go:19Reap - §2 정정(
422영구 /409일시) →internal/match/match.go:23-25
(CodeNoCandidate = 422·CodeAllBusy = 409),internal/api/api.go:365-368 I1(노드 하나는 동시에 한 Run) →internal/store/schema.sql의
leases (node_id text PRIMARY KEY)와 그 위 주석 블록. 강제 지점은
internal/store/store.go:255-262(isUniqueViolation→ErrNodeTaken)와
internal/store/acquire.go:170-183(세이브포인트 안 INSERT). 전제절의 우회로
하나(토큰 유출)를 막는 자리는internal/enode/env.go:97harnessEnvI2(종료 시 점유 장부가 빈다) →internal/store/reap.go:315-318
(SettleIfDone의DELETE FROM leases),:203(Cancel의 같은 문장),
:88(Reap의DELETE ... USING runs),internal/store/claim.go:38
RenewLeases(끝난 Run 의 임대를 갱신 안 하는WHERE r.state NOT IN)I3(성공은 계약의 기계적 조건만 · 실패는 세 갈래) →
internal/store/verdict.go:98Verify(순수 함수, DB 도 시계도 안 본다).
세 갈래: 조건 불만족(:150~),_unmet목표 미달 보고(:107-135,
contract.UnmetName="_unmet"atinternal/contract/contract.go:94),
고른 뒤SKIPPED(verdict.go:170~+schema.sql의steps.chosen컬럼 +
internal/store/dispatch.go:76-83의SET chosen = true). 판정을 코드가
가로채지 않는 지점:internal/api/api.go:296(completed := res.Error == "")와
internal/enode/claim.go:585-593(exit code 를 판정에 안 쓴다)I4(봉인 불변) →internal/record/record.go:156seal(파일 0444 ·
디렉터리 0555, 순서 이유 포함),:76Sealed,
internal/store/seal.go:180sealRecord(멱등). 봉인 뒤 쓰기 거절:
internal/api/api.go:639-642(putLog410) ·:686-689(putBlob410) ·
:795-798(getRecord409)I5(전부 아니면 전무) →internal/store/store.go:233CreateRun
(단일 트랜잭션 · 기본키 충돌 → 전체 롤백).ADR-024가 넓힌 "한 획득 요청
범위" 쪽은internal/store/acquire.go:34runAcquires·:69doAcquire·
:130tryGrab- §3.1 점유 구간 비용 · 조기 해제 →
internal/store/release.go:32applyRelease - §4 "임대의 키 = (노드)" →
schema.sql의leases기본키. "임대 갱신 · 취소의
전달 =POST /v1/nodes응답의 유효 임대 목록(델타 아님)" →
internal/store/claim.go:38RenewLeases+internal/api/api.go:207-216+
노드 쪽internal/enode/leases.go(Held.Set이 병합이 아니라 교체) - §4 "Record 의 계약 = 계약의 열,
len >= 1, 상한 없음" →
internal/store/seal.go:78ContractVersion·:190-199Manifest.Contract
조립(v1 은 언제나runs.contract제출 전문). 열의 성장 지점은
internal/store/expand.go:52applyExpands, 상한은
internal/store/expand.go:341maxContractVersions(설정 밖 · 기본 2,
config.Default()는 4) - §4 "단계의 종류 = agent / run, 둘 다거나 둘 다 아니면 400" →
internal/contract/contract.go:796(s Step) Kind(),
internal/contract/contract.go:400-418StepKind - §4 "
exit_code는 명령 단계에만. agent 단계에 쓰면 400" →
internal/contract/contract.go:954Validate안(테스트
internal/store/verdict_test.go:277TestExitCodeConditionsAreCommandStepsOnly
가 그 자리를 가리킨다) - §4 "agent 산출물의 스키마는 계약의 1급 필드 · 어기면 PUT blob 이 422" →
internal/api/api.go:717-731(schema.Validate→ 422 → 저장 안 함),
internal/api/api.go:777schemaFor - §4 "스키마가 제약할 수 있는 것은 형식만" →
internal/schema/schema.go:46
CheckBoundary(minimum·minLength를 판정으로 보고 거절) - §4 "단계의 파일시스템 경계는 어느 단계에도 없다(2026-08-22 개정,
ADR-042)" →
internal/enode/claude.go:212-224(--permission-mode bypassPermissions·
--add-dir은 의도 기록용으로 유지). 남는 두 장치는
internal/enode/collect.go:234sealInput(0444/0555)과
internal/enode/env.go:97harnessEnv - §4 "단계를 가리키는 경로 = 두 세그먼트" →
internal/api/api.go:57,63,67,68
(라우트 패턴)과:260-266(그 이유 주석) - §4 "산출물의 최신 = (회차, 순번)이 가장 큰 것 · 이름에 회차가 들어간다
(01.1-name)" →internal/record/record.go:260blobPath·:294
parseBlobName·:316OpenBlob - §4 "Mediator 상태 저장소는 PostgreSQL ·
SKIP LOCKED가 단계 배분" →
internal/store/claim.go:292(FOR UPDATE OF s SKIP LOCKED).
"LISTEN/NOTIFY가 롱폴을 공짜로" 부분은internal/api/api.go:223-231
(1초 티커 폴링 · 주석이 그 차이를 적는다) - §4 "Run Record 저장소는 파일시스템 · blob 은 Record 안" →
internal/record/record.go전체,internal/config/config.go:29
(Artifacts.Root·MaxBlobBytes기본 10 MiB)
run-contract.md
- 계약 스키마 전체와 그 검증 →
internal/contract/contract.go:59Contract
구조체(전 필드) ·:441Step구조체 ·:954Validate()(612줄, 문법
전체의 단일 검증 지점) ·:1679Warnings - 단계 사이의 의존 간선(
needs)과 기본값(직전 단계) →
internal/contract/contract.go:764NeedsOf,
internal/store/store.go:290(CreateRun이 정규화해steps.needs에 박는다),
internal/store/schema.sql의needs text[]컬럼과 백필 블록, 게이트 술어는
internal/store/claim.go:277-289 - §4 별 모양(단계 간 산출물은 Mediator 경유 · 비대칭 경로) →
internal/api/api.go:653-666(비대칭 이유 주석) ·:668putBlob·:754
getBlob, 노드 쪽은internal/enode/claim.go:170PutBlob·:198GetBlob - §4.5
count > 1미구현 →internal/store/store.go:283
(nodeOf[a.As] = a.Nodes[0].Node // count>1 은 아직) expands(계획 위임)와 재계획 →internal/store/expand.go:52applyExpands
전체,internal/contract/contract.go:480~(plan 형태),
internal/store/claim.go:596-663fillFromContract의Roles·Owed·
Goal적재dispatch(분기)와 건너뜀 전파 →internal/store/dispatch.go:28
applyDispatch·:118propagateSkips·:159readDispatchValue
(점 경로만, 식 언어가 아니다)loop(구간 반복)과validate_with(되먹임 재시도) →
internal/store/rollback.go:23rollBack·:42loopBack·:88
validateBack·:152untilMetacquire/release(실행 중 자원 증감) →internal/store/acquire.go·
internal/store/release.go전체ask(되묻기)와 목표 위임 채택 →internal/store/ask.go:25raiseAsks·
:193PendingAsks·:265AnswerStep·:479ExpireAsks·:519
retirePlan. 계약 쪽 형태는internal/contract/contract.go:122Ask·
:174AskTimeout·:676checkAskFormledger/see(시야의 세 축: 발견 · 주입 · 축소)와 워터마크 →
internal/store/observe.go:96Ledger·:180priorRuns·:206
entriesOf,internal/store/claim.go:453stampLedger(steps.ledger_at
기록),internal/contract/contract.go:182See·:216Ledger·:727
Scope.work_id유도는internal/contract/contract.go:280(w Work) Key()success_when조건 형식과fleet_has→
internal/contract/contract.go:844Condition·:884IsFleet·:888
Want, 평가는internal/store/verdict.go:98Verify, 함대 스냅샷 적재는
internal/store/reap.go:314(hasFleetCondition→LiveAdverts)changed조건(ADR-037) →internal/enode/changed.go전체 +
internal/store/claim.go:76-79(CheckChanged를 Mediator 가 뽑아 싣는다) +
internal/enode/claim.go:800writeChangedNote_unmet예약 이름과 밑줄 예약 규칙 →internal/contract/contract.go:94
UnmetName,internal/store/expand.go:392unmetReported,
internal/enode/claim.go:99ledgerFile = "_ledger.json"
agent-runtime.md
R1(자격증명 화이트리스트) →internal/enode/env.go전체 —
baseAllow(:35) ·windowsAllow(:53) ·harnessEnv(:97, 블랙리스트가 아니라
빈 것에서 쌓는다) ·droppedNotable(:155). 명령 단계에도 같은 규칙을 거는
자리는internal/enode/claim.go:559-561(commandEnv+step.Env)R2(요청자 신원) →internal/enode/env.go:66RunIdentity·:79
Credentials·:84Transparent(자리만 · 아무도 안 본다). 요청자 값이
흘러오는 경로는internal/store/claim.go의Claimed.Requester←
runs.principal,internal/api/api.go:82-92(X-Enode-Principal)R3(스폰은 Harness 인터페이스 뒤 · 어댑터에 exec 이 없다) →
internal/enode/runner.go:54runHarness(유일한 exec 지점) ·
internal/enode/harness.go:217-230(harnesses레지스트리 ·harnessFor) ·
internal/enode/claude.go(유일한 구현).Argv/Decode/Instrument/
Probe/Env/Fixed만 물어보는 형태R4(되묻기는 자리만 ·--interactive는 400) →
internal/contract/contract.go:954Validate안. 실제 되묻기는ADR-032
경로로 별도 구현:internal/store/ask.go·internal/api/api.go:473,497R5(산출물 세 갈래) → ①$OUT직접 배출:
internal/enode/claim.go:728uploadProduced·:854harvest
② 워크스페이스 diff:internal/enode/diff.go
②' git 이 못 보는 것:internal/enode/changed.go(stampNow·CheckChanged)
③ 종료 훅:internal/enode/hook.go:95RunStopHook·
cmd/enode/hook.go:20runHookCmd. 선언적 수거는
internal/enode/collect.go:44collectDeclaredR6(플러그인 · 스킬 · 컨텍스트 주입 · 훅이 곧 enode 자신) →
internal/enode/hook.go:290WriteHookSettings
(--settings <path> --setting-sources ""를 돌려준다,:331-335) ·
cmd/enode/hook.go(어떤 실패에도 종료코드 0 —:53). 개인 설정 차단의 두 번째
장치는internal/enode/claude.go:69Fixed()
(CLAUDE_CODE_DISABLE_AUTO_MEMORY=1— 이름이 아니라 값으로 박은 이유가 주석에
있다)R7(샌드박스는 하네스가 주는 만큼 · 강도를 광고) → 결정 ①③(권한 모드)은
internal/enode/claude.go:212-224에 있다(2026-08-22ADR-042개정 뒤의
bypassPermissions, 주석이 뒤집힘의 근거를 적는다). 결정 ②
(sandbox: none|tools|os광고 속성)를 볼 자리는internal/enode/detect.go:20
Detect이며, 그 함수가 채우는 속성 목록에sandbox키가 없다 — 자세한
관측은 아래 「관측 2」- 하네스 종료 사유의 정규화(어댑터가 한다 · 계약에 안 적는다) →
internal/enode/harness.go:1-50(Reason·Completed) ·:101ParseClaude.
자백(_cannot,ADR-038) 읽기는internal/enode/runner.go:147readCannot - 되먹임 봉투와 열쇠(
ADR-050) →internal/enode/agent.go:424envelopeIntro·
:453envelope, 열쇠 발급은internal/store/claim.go:220newEnvelopeKeyschema.sql의steps.envelope_key
- 프롬프트 조립 전체 →
internal/enode/agent.go:186buildPrompt(210줄)
mediator-api.md
- §1.1 인증 · 식별 →
internal/api/api.go:83auth(상수시간 비교 · 15개 라우트
전부를 감싼다) ·:97principal - §1.2 에러 코드 체계(400/401/404/409/410/413/422/503) →
internal/api/api.go:110fail+ 각 핸들러. 409/422 의 발원지는
internal/match/match.go:23-25 - §1.3 "표면에서 비멱등인 곳은 claim 하나뿐" →
internal/api/api.go:226
postClaim→internal/store/claim.go:246ClaimStep - §2 runctl 표면 5개 →
internal/api/api.go:58-60,65-66등록 +
internal/runctl/client.go:118,177,187,197 - §3 enode 표면 6개 →
internal/api/api.go:55-57,67-69등록 +
internal/enode/advertise.go:57·internal/enode/claim.go:114,143,170,198,242 POST /v1/nodes응답 형태(leases+renew_seconds,ADR-028) →
internal/api/api.go:160-166advertResponse·:207-216, 수용은
internal/enode/advertise.go:148-157POST /v1/nodes/{id}/claim롱폴과 204 →internal/api/api.go:226-256,
클라이언트 쪽 무타임아웃 정책은cmd/enode/main.go:132-136
(Poll: &http.Client{})과cmd/mediator/main.go:114-117(서버WriteTimeout
미설정)GET /v1/runs/{id}가steps를 함께 준다(ADR-025) →
internal/api/api.go:120-138runView·:141view→
internal/store/observe.go:45StepsGET /v1/runs/{id}/ledger는 목록이지 본문이 아니다 →
internal/api/api.go:558getLedger→internal/store/observe.go:96LedgerGET /v1/capabilities는 존재만 답하고 여유는 안 답한다 →
internal/api/api.go:580getCapabilities→internal/store/store.go:392
Capabilities(주석이 "함대 전체의 합집합" 한계를 적고 dry-run 으로 넘긴다)POST /v1/runs/dry-run이 같은 파서 · 같은 매처 →
internal/api/api.go:318,323이 같은submit(w, r, dry)를 부른다- §6.3 대응표가 찾아낸 G1(임대 갱신 통로) · G3(
PUT log) → 각각
internal/store/claim.go:38RenewLeases와internal/api/api.go:618putLog - §7 "없는 것 — 일부러" →
GET /v1/nodes계열이internal/api/api.go:55-69에
등록돼 있지 않다./retry·/queue도 없다
system.md
- §1 아키텍처(노드가 연결을 건다 · 노드끼리 안 만난다 · 중앙이 하나) → 노드가
포트를 안 여는 것은internal/enode/전체에http.Server/net.Listen이
0건인 것으로 확인된다(서버는cmd/mediator/main.go:112하나). 노드 간 직접
전송이 없는 것은internal/enode/claim.go의 모든 HTTP 호출이c.Base
(Mediator)로만 가는 것으로 확인된다 - §2 컴포넌트 경계("Mediator 는 추론을 안 한다 · 매칭은 순수 함수") →
internal/match/match.go:66Match(입력 셋 · 출력 둘 · I/O 0),
internal/store/schema.sql머리 주석("매칭은 SQL 이 하지 않는다") - §5 구현 상태 표 — "구현됨" 칸의 각 항목이 대응하는 자리:
- Mediator 표면 →
internal/api/api.go:55-69 R1~R71회차 → 위agent-runtime.md항목- 계약의 열 →
internal/store/seal.go:78·internal/store/expand.go - 단계 건너뜀 · 분기 →
internal/store/dispatch.go - 병렬(
needs간선 + 건너뜀 전파) →internal/store/claim.go:277-289·
internal/store/dispatch.go:118 - 계획 위임 →
internal/store/expand.go:52 - 실행 중 관측 →
internal/store/observe.go:45·internal/api/api.go:141 - 원장(발견 · 주입 · 축소 · 이전 Run) →
internal/store/observe.go:96,180 - 자원 조기 해제 · 실행 중 획득 →
internal/store/release.go·
internal/store/acquire.go - 구간 반복 →
internal/store/rollback.go:42 - 재계획 · 깊이 상한 →
internal/store/expand.go:112-121·:341 - 되묻기(인박스 정본 · 웹훅 보조) →
internal/store/ask.go·
internal/api/api.go:473,497 - 목표 위임(저자 · 승인자 · 판정자 분리) →
internal/store/expand.go:230-236
(Proposed는 효력 없음) ·internal/store/ask.go:320~(답이 채택) - "닫힌 위험 — claim 응답 유실 → 영구
CLAIMED(ADR-029·ADR-030)" →
internal/store/claim.go:507redeliver·:565FailRestarted·
schema.sql의nodes.instance·steps.claimed_instance· 노드 쪽
cmd/enode/main.go:120-128(기동마다 새 instance) ·
internal/enode/claim.go:309report(닿을 때까지 재시도) - "닫힌 것 — 매처가 속성 적은 노드를 먼저 준다 (
ADR-027)" →
internal/match/match.go:34attrCount와:66Match의 정렬 - "닫힌 것 — 광고 주기를 두 곳에서 정하던 것 (
ADR-028)" →
internal/api/api.go:161-166·internal/enode/advertise.go:148-157
- Mediator 표면 →
- §5 "설계만" 칸: 바깥 표면 어댑터(
S1MCP ·S2A2A) — 코드 0건.
요청자 신원(R2) —internal/enode/env.go:66의 빈 자리.
하네스 팩(ADR-034) · 능력 탐색(ADR-035) — 저장소 전체에서 그 번호가 0건 - §3 "두 종류의 단계" · "순서는 목록이 아니라 간선" · "자원도 실행 중에 늘고
준다" · "계약은 실행 중에 자랄 수 있다" — 각각 위run-contract.md항목과
같은 자리
ADR-INDEX.md
- 색인이 아는 62개 중 55개가 Go 소스에서 번호로 인용된다(계측 확인:
grep -rhoE 'ADR-[0-9]{3}' --include='*.go' | sort -u | wc -l= 55).
저장소 어디에도 안 나오는 7개:ADR-001·ADR-003·ADR-006·
ADR-034·ADR-035·ADR-039·ADR-041 - 그중 둘은 내용은 구현돼 있고 번호만 안 붙어 있다:
ADR-003(점유는
Mediator 가 소유)은internal/store/schema.sql의leases주석과
internal/store/store.go가 구현하고,ADR-039(완주만 판정)는
internal/store/claim.go:737-748과internal/api/api.go:294-297이 구현하되
ADR-004·I3를 인용한다 ADR-034·ADR-035는system.md§5 가 「설계만」으로 분류한 것과 일치한다
관측 1 — Mediator 표면의 개수
판정이 아니라 관측이다.
구현이 서빙하는 라우트는 15개이고, 전부 internal/api/api.go:55-69 의 한
ServeMux 에 등록되며, 전부 s.auth(...) 로 감싸여 있다(예외 경로 0개).
mediator-api.md 를 메서드+경로로 grep 하면 21개의 서로 다른 문자열이
나오는데, 그 구성은 다음과 같다.
| 수 | 무엇 |
|---|---|
| 15 | 구현이 서빙하는 라우트와 1:1 대응 |
| 2 | ADR-021 이전의 한 세그먼트 표기가 § 제목에 남은 것 — POST /v1/steps/{id}/result(line 385 · 564), PUT /v1/steps/{id}/log(line 418 · 563). 같은 문서 line 238-241 의 목록은 두 세그먼트이고 구현도 두 세그먼트다 |
| 2 | 자리표시자 이름만 다른 변형 — {id} 대 {run}(blob 라우트), 그리고 § 제목 line 432 가 GET/PUT 을 한 줄로 묶으면서 생긴 표기 |
| 1 | §7 "없는 것 — 일부러" 에서 명시 기각된 것 — GET /v1/nodes?free=…(line 672) |
| 1 | line 529 가 "자리만 잡는다" 라고 적은 예약 자리 — GET /v1/runs?state=&since= |
15 + 2 + 2 + 1 + 1 = 21. 기각 · 예약 · 표기중복을 제거한 문서의 실제 표면
집합은 구현이 서빙하는 15개와 일치한다.
별개로, 표면 개수를 말하는 문장 셋이 서로 다른 수를 든다:
system.md§5 — "Mediator 표면 12개 전부"mediator-api.md§6.3 G3 — "표면 12 → 13"enode/README.md— "표면 13개를 먼저 고정했다"
셋 다 여기 기록한다. 어느 것이 맞는지, 문서를 고칠지 코드를 고칠지는 이 문서가
판정하지 않는다.
관측 2 — R7 의 sandbox 광고 속성
판정이 아니라 관측이다.
agent-runtime.md 의 R7 결정 ②는 샌드박스 강도를 sandbox: none|tools|os 로
광고한다고 규정한다.
계측: 문자열 sandbox 는 Go 소스 전체에서 0건이다
(grep -rn 'sandbox' --include='*.go' . → 0).
Detect()(internal/enode/detect.go:20)가 실제로 채우는 광고 속성은 다음이다.
os · host_arch · harness · ws · repo · arch · board · tag
+ 사람이 붙인 라벨
sandbox 키가 그 목록에 없다. 정본 조항과 구현 사이의 이 간극을 여기 기록하며,
정본을 고칠지 구현을 채울지는 이 문서가 판정하지 않는다.