문서와 심볼

aidlc-docs 를 그대로 읽는다 · 읽기 전용
/home/sunny/enode-fixup-workshop2-visualizer연결 중
inception/reverse-engineering/architecture.md663 줄

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:66 Match 는 입력 셋 · 출력
    둘 · 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:234 sealInput — 입력을 0444/0555 로 봉인한다
  • internal/enode/env.go:97 harnessEnv — 블랙리스트가 아니라 빈 것에서
    쌓아 올리는 화이트리스트
    다. 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:325 submit
    (RESOLVING · ALLOCATING), internal/store/reap.go:235 SettleIfDone
    (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:267 postResult → internal/store/claim.go:762
    ReportStep → :851 afterStep → :864 applyStepEffects
    (expands → dispatch → release 순서와 그 순서를 그렇게 둔 이유)
  • §2 "RUNNING → RUNNING 만 멱등이 아니다 · 재실행하지 않고 Run 을 실패시킨다" →
    internal/store/claim.go:565 FailRestarted,
    internal/store/reap.go:235 SettleIfDone(broke > 0 분기),
    internal/store/claim.go:507 redeliver(재전달은 재실행이 아니라는 구분)
  • §2 * → FAILED(취소 · 임대 만료), 멱등 → internal/store/reap.go:170 Cancel,
    internal/store/reap.go:19 Reap
  • §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:97 harnessEnv
  • I2 (종료 시 점유 장부가 빈다) → 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:98 Verify(순수 함수, DB 도 시계도 안 본다).
    세 갈래: 조건 불만족(:150~), _unmet 목표 미달 보고(:107-135,
    contract.UnmetName = "_unmet" at internal/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:156 seal(파일 0444 ·
    디렉터리 0555, 순서 이유 포함), :76 Sealed,
    internal/store/seal.go:180 sealRecord(멱등). 봉인 뒤 쓰기 거절:
    internal/api/api.go:639-642(putLog 410) · :686-689(putBlob 410) ·
    :795-798(getRecord 409)
  • I5 (전부 아니면 전무) → internal/store/store.go:233 CreateRun
    (단일 트랜잭션 · 기본키 충돌 → 전체 롤백). ADR-024 가 넓힌 "한 획득 요청
    범위" 쪽은 internal/store/acquire.go:34 runAcquires · :69 doAcquire ·
    :130 tryGrab
  • §3.1 점유 구간 비용 · 조기 해제 → internal/store/release.go:32 applyRelease
  • §4 "임대의 키 = (노드)" → schema.sql 의 leases 기본키. "임대 갱신 · 취소의
    전달 = POST /v1/nodes 응답의 유효 임대 목록(델타 아님)" →
    internal/store/claim.go:38 RenewLeases + internal/api/api.go:207-216 +
    노드 쪽 internal/enode/leases.go(Held.Set 이 병합이 아니라 교체)
  • §4 "Record 의 계약 = 계약의 열, len >= 1, 상한 없음" →
    internal/store/seal.go:78 ContractVersion · :190-199 Manifest.Contract
    조립(v1 은 언제나 runs.contract 제출 전문). 열의 성장 지점은
    internal/store/expand.go:52 applyExpands, 상한은
    internal/store/expand.go:341 maxContractVersions(설정 밖 · 기본 2,
    config.Default() 는 4)
  • §4 "단계의 종류 = agent / run, 둘 다거나 둘 다 아니면 400" →
    internal/contract/contract.go:796 (s Step) Kind(),
    internal/contract/contract.go:400-418 StepKind
  • §4 "exit_code 는 명령 단계에만. agent 단계에 쓰면 400" →
    internal/contract/contract.go:954 Validate 안(테스트
    internal/store/verdict_test.go:277 TestExitCodeConditionsAreCommandStepsOnly
    가 그 자리를 가리킨다)
  • §4 "agent 산출물의 스키마는 계약의 1급 필드 · 어기면 PUT blob 이 422" →
    internal/api/api.go:717-731(schema.Validate → 422 → 저장 안 함),
    internal/api/api.go:777 schemaFor
  • §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:234 sealInput(0444/0555)과
    internal/enode/env.go:97 harnessEnv
  • §4 "단계를 가리키는 경로 = 두 세그먼트" → internal/api/api.go:57,63,67,68
    (라우트 패턴)과 :260-266(그 이유 주석)
  • §4 "산출물의 최신 = (회차, 순번)이 가장 큰 것 · 이름에 회차가 들어간다
    (01.1-name)" → internal/record/record.go:260 blobPath · :294
    parseBlobName · :316 OpenBlob
  • §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:59 Contract
    구조체(전 필드) · :441 Step 구조체 · :954 Validate()(612줄, 문법
    전체의 단일 검증 지점) · :1679 Warnings
  • 단계 사이의 의존 간선(needs)과 기본값(직전 단계) →
    internal/contract/contract.go:764 NeedsOf,
    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(비대칭 이유 주석) · :668 putBlob · :754
    getBlob, 노드 쪽은 internal/enode/claim.go:170 PutBlob · :198 GetBlob
  • §4.5 count > 1 미구현 → internal/store/store.go:283
    (nodeOf[a.As] = a.Nodes[0].Node // count>1 은 아직)
  • expands(계획 위임)와 재계획 → internal/store/expand.go:52 applyExpands
    전체, internal/contract/contract.go:480~(plan 형태),
    internal/store/claim.go:596-663 fillFromContract 의 Roles · Owed ·
    Goal 적재
  • dispatch(분기)와 건너뜀 전파 → internal/store/dispatch.go:28
    applyDispatch · :118 propagateSkips · :159 readDispatchValue
    (점 경로만, 식 언어가 아니다)
  • loop(구간 반복)과 validate_with(되먹임 재시도) →
    internal/store/rollback.go:23 rollBack · :42 loopBack · :88
    validateBack · :152 untilMet
  • acquire / release(실행 중 자원 증감) → internal/store/acquire.go ·
    internal/store/release.go 전체
  • ask(되묻기)와 목표 위임 채택 → internal/store/ask.go:25 raiseAsks ·
    :193 PendingAsks · :265 AnswerStep · :479 ExpireAsks · :519
    retirePlan. 계약 쪽 형태는 internal/contract/contract.go:122 Ask ·
    :174 AskTimeout · :676 checkAskForm
  • ledger / see(시야의 세 축: 발견 · 주입 · 축소)와 워터마크 →
    internal/store/observe.go:96 Ledger · :180 priorRuns · :206
    entriesOf, internal/store/claim.go:453 stampLedger(steps.ledger_at
    기록), internal/contract/contract.go:182 See · :216 Ledger · :727
    Scope. work_id 유도는 internal/contract/contract.go:280 (w Work) Key()
  • success_when 조건 형식과 fleet_has →
    internal/contract/contract.go:844 Condition · :884 IsFleet · :888
    Want, 평가는 internal/store/verdict.go:98 Verify, 함대 스냅샷 적재는
    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:800 writeChangedNote
  • _unmet 예약 이름과 밑줄 예약 규칙 → internal/contract/contract.go:94
    UnmetName, internal/store/expand.go:392 unmetReported,
    internal/enode/claim.go:99 ledgerFile = "_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:66 RunIdentity · :79
    Credentials · :84 Transparent(자리만 · 아무도 안 본다). 요청자 값이
    흘러오는 경로는 internal/store/claim.go 의 Claimed.Requester ←
    runs.principal, internal/api/api.go:82-92(X-Enode-Principal)
  • R3 (스폰은 Harness 인터페이스 뒤 · 어댑터에 exec 이 없다) →
    internal/enode/runner.go:54 runHarness(유일한 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:954 Validate 안. 실제 되묻기는 ADR-032
    경로로 별도 구현: internal/store/ask.go · internal/api/api.go:473,497
  • R5 (산출물 세 갈래) → ① $OUT 직접 배출:
    internal/enode/claim.go:728 uploadProduced · :854 harvest
    ② 워크스페이스 diff: internal/enode/diff.go
    ②' git 이 못 보는 것: internal/enode/changed.go(stampNow · CheckChanged)
    ③ 종료 훅: internal/enode/hook.go:95 RunStopHook ·
    cmd/enode/hook.go:20 runHookCmd. 선언적 수거는
    internal/enode/collect.go:44 collectDeclared
  • R6 (플러그인 · 스킬 · 컨텍스트 주입 · 훅이 곧 enode 자신) →
    internal/enode/hook.go:290 WriteHookSettings
    (--settings <path> --setting-sources "" 를 돌려준다, :331-335) ·
    cmd/enode/hook.go(어떤 실패에도 종료코드 0 — :53). 개인 설정 차단의 두 번째
    장치는 internal/enode/claude.go:69 Fixed()
    (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 — 이름이 아니라 값으로 박은 이유가 주석에
    있다)
  • R7 (샌드박스는 하네스가 주는 만큼 · 강도를 광고) → 결정 ①③(권한 모드)은
    internal/enode/claude.go:212-224 에 있다(2026-08-22 ADR-042 개정 뒤의
    bypassPermissions, 주석이 뒤집힘의 근거를 적는다). 결정 ②
    (sandbox: none|tools|os 광고 속성)를 볼 자리는 internal/enode/detect.go:20
    Detect 이며, 그 함수가 채우는 속성 목록에 sandbox 키가 없다
    — 자세한
    관측은 아래 「관측 2」
  • 하네스 종료 사유의 정규화(어댑터가 한다 · 계약에 안 적는다) →
    internal/enode/harness.go:1-50(Reason · Completed) · :101 ParseClaude.
    자백(_cannot, ADR-038) 읽기는 internal/enode/runner.go:147 readCannot
  • 되먹임 봉투와 열쇠(ADR-050) → internal/enode/agent.go:424 envelopeIntro ·
    :453 envelope, 열쇠 발급은 internal/store/claim.go:220 newEnvelopeKey
    • schema.sql 의 steps.envelope_key
  • 프롬프트 조립 전체 → internal/enode/agent.go:186 buildPrompt(210줄)

mediator-api.md

  • §1.1 인증 · 식별 → internal/api/api.go:83 auth(상수시간 비교 · 15개 라우트
    전부를 감싼다) · :97 principal
  • §1.2 에러 코드 체계(400/401/404/409/410/413/422/503) →
    internal/api/api.go:110 fail + 각 핸들러. 409/422 의 발원지는
    internal/match/match.go:23-25
  • §1.3 "표면에서 비멱등인 곳은 claim 하나뿐" → internal/api/api.go:226
    postClaim → internal/store/claim.go:246 ClaimStep
  • §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-166 advertResponse · :207-216, 수용은
    internal/enode/advertise.go:148-157
  • POST /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-138 runView · :141 view →
    internal/store/observe.go:45 Steps
  • GET /v1/runs/{id}/ledger 는 목록이지 본문이 아니다 →
    internal/api/api.go:558 getLedger → internal/store/observe.go:96 Ledger
  • GET /v1/capabilities 는 존재만 답하고 여유는 안 답한다 →
    internal/api/api.go:580 getCapabilities → 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:38 RenewLeases 와 internal/api/api.go:618 putLog
  • §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:66 Match(입력 셋 · 출력 둘 · I/O 0),
    internal/store/schema.sql 머리 주석("매칭은 SQL 이 하지 않는다")
  • §5 구현 상태 표 — "구현됨" 칸의 각 항목이 대응하는 자리:
    • Mediator 표면 → internal/api/api.go:55-69
    • R1~R7 1회차 → 위 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:507 redeliver · :565 FailRestarted ·
      schema.sql 의 nodes.instance · steps.claimed_instance · 노드 쪽
      cmd/enode/main.go:120-128(기동마다 새 instance) ·
      internal/enode/claim.go:309 report(닿을 때까지 재시도)
    • "닫힌 것 — 매처가 속성 적은 노드를 먼저 준다 (ADR-027)" →
      internal/match/match.go:34 attrCount 와 :66 Match 의 정렬
    • "닫힌 것 — 광고 주기를 두 곳에서 정하던 것 (ADR-028)" →
      internal/api/api.go:161-166 · internal/enode/advertise.go:148-157
  • §5 "설계만" 칸: 바깥 표면 어댑터(S1 MCP · S2 A2A) — 코드 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 키가 그 목록에 없다. 정본 조항과 구현 사이의 이 간극을 여기 기록하며,
정본을 고칠지 구현을 채울지는 이 문서가 판정하지 않는다.