문서와 심볼

aidlc-docs 를 그대로 읽는다 · 읽기 전용
/home/sunny/enode-fixup-workshop2-visualizer연결 중
inception/application-design/unit-of-work.md565 줄

유닛 여섯 — 정의와 완료 조건

유닛은 계획 단위다. 배포 단위가 아니다 (unit-boundaries.md). 여섯 전부
같은 모듈 · 같은 실행파일들에 들어가고, 유닛이 나뉘는 이유는 브랜치 하나 ·
게이트 하나
로 진행을 재기 위해서다.

권장은 여섯이었고 다섯으로 왔다. U-화면을 U-관측API 와 U-제어판에 각각
합쳤고 근거는 aidlc-state.md 「실행 계획」 4절이다 — 요약하면 폭이 1 이라
따로 가를 사람이 없고, 안 합치면 CP1 이 초록이 될 수 없다.
합쳐도 파일은
ui/ 아래로 분리한다.

   U-바닥        스키마 · 상태 상수 · 조회 메서드 · 정책 자리 · 경계 검사     CP0
   U-관측API     GET /v1/runs · GET /v1/nodes · StepView.chosen
                 + 중앙 화면 S0 · S0b · S1 · S1b · S2                        CP1
   U-대기열      409 -> QUEUED · FIFO · WakeQueued · 202 · 기동 시 깨우기      CP2
   U-drain       정책 파일 · 광고에 싣기 · 후보 제외 · 통보 · 두 모드 · 해제   CP3
   U-제어판      enodectl serve · 프로세스 제어 · 신원 · 능력 · 현재 작업
                 · drain 토글 + 호스트 화면 S3 · S5                           CP4
   U-MCP         runctl mcp — stdio JSON-RPC · 도구 일곱                      CP5

여섯째는 2026-09-05 에 들어왔다. CP2 가 닫힌 뒤 진행자가 Mediator MCP 의
이월을 되돌렸고 근거는 requirements/decisions.md 5절이다. 앞의 다섯과
성격이 다르다 — dhseo 장면 밖이고, 딛는 것이 CP1 의 라우트뿐이라 지금
이미 착수 가능하며 U-drain · U-제어판과 병렬로 돈다.

읽는 순서 — 이 문서가 유닛마다의 무엇을 · 어디를 · 언제 끝났나이고,
유닛 사이의 접점과 순서는 unit-of-work-dependency.md, 요구사항과 화면과
게이트가 어느 유닛에 걸리는지는 unit-of-work-story-map.md 에 있다.

표기 — 파일 표의 NEW 는 신설, o 는 고친다, c 는 부르기만 하고 안 고친다.


U-바닥

0일차 직렬. 다른 넷이 전부 이것을 딛는다. 늦으면 전원이 선다.

하는 일

스키마와 타입과 조회를 놓는다. 라우트를 하나도 안 만든다 — 이 유닛이 끝난
시점에 밖에서 보이는 변화는 없고, 기존 라우트 15개가 그대로 돈다는 것이 이
유닛의 성질이다.

출처 FR
enode-features.md 3.1.1 FR-1 (GET /v1/nodes 의 저장 쪽) · FR-2 (GET /v1/runs 의 저장 쪽) · FR-3 (StepView.chosen)
enode-features.md 3.2.1 FR-6 (runs.state 어휘에 QUEUED)
enode-features.md 3.2.2 FR-13 (정책 자리 — nodes 열과 UpsertAdvert 시그니처)

딛는 것

없다. 이것이 바닥이다.

만지는 파일

   internal/store/schema.sql                       o    nodes 에 정책 열.  ADD COLUMN IF NOT EXISTS
   internal/store/store.go                         o    UpsertAdvert 가 정책을 받고 이전 정책을 돌려준다
   internal/store/observe.go                       o    StepView 에 Chosen.  DB 에 이미 있는 열이다
   internal/store/list.go                          NEW  ListNodes · ListRuns
   internal/store/queue.go                         NEW  QUEUED 상수 · DrainingNodes · ListQueued
   internal/contract/advert.go                     o    Policy{Drain}
   internal/match/match.go                         o    Reject 에 json 태그 셋.  Match 는 안 건드린다
   internal/boundary/imports_test.go               NEW  경계 검사.  이름은 Functional Design 이 바꿔도 된다

internal/match/match.go 가 여기 있는 것은 requirements.md §8.4 ①-b 의
결정이다. unit-boundaries.md §2 는 「아무 유닛도 안 만진다」로 적었고 그
문장이 이 한 줄만큼 바뀐다.
Match 의 시그니처와 로직은 그대로라 NFR-I4 는
지켜진다.

steps.chosen 은 스키마에 이미 있다 (schema.sql 의 ALTER TABLE steps ADD COLUMN IF NOT EXISTS chosen). FR-3 은 그것을 StepView 에 싣는 일이지
열을 만드는 일이 아니다.

완료 조건

   기존 테스트 전부 초록.  회귀 0
   기존 라우트 15개가 그대로 돈다
   nodes 에 정책 열이 있고 UpsertAdvert 가 이전 정책을 돌려준다
   store.QUEUED 상수가 있다.  runs.state 에 CHECK 가 없어 마이그레이션이 필요 없다
   ListNodes · ListRuns · DrainingNodes · ListQueued 가 있고 각각 테스트가 있다
   StepView 에 Chosen 이 실려 나온다
   contract.Policy{Drain} 이 있고 값은 "" · graceful · at-boundary 셋뿐이다
   match.Reject 가 {code, as, reason} 로 직렬화된다.  옛 DB 행이 그대로 읽힌다
   경계 검사 테스트가 금지 임포트 넷을 기계로 센다 (아래)
   커버리지 · 린트 · glyphscan 초록

경계 검사 테스트 — go/parser 로 각 패키지의 임포트를 읽어 넷을 확인한다
(unit-boundaries.md §5 · NFR-P1 ~ NFR-P5). 이것이 CP0 에 들어가면 겹침을
사람이 안 세도 된다.

   internal/panel   ->  internal/store        금지
   internal/panel   ->  internal/api          금지
   internal/api/ui  ->  internal/store        금지
   internal/enode   ->  internal/panel        금지

패키지가 아직 없을 때도 초록이어야 한다 — U-바닥 시점에 internal/panel 과
internal/api/ui 는 존재하지 않는다. 없으면 통과가 아니라 건너뛴다를
검사기가 구분해서 적는다.

게이트 — CP0 「기동이 안 깨졌다」

   go test ./... && go vet ./...
   go run ./scripts/glyphscan.go
   golangci-lint run
   커버리지 게이트 (.coverage-contract.yml)
   curl 로 기존 라우트 하나 — GET /v1/capabilities

U-관측API

중앙이 보이기 시작한다. 이 유닛이 화면 다섯을 함께 진다.

하는 일

출처 FR
enode-features.md 3.1.1 FR-1 · FR-2 · FR-4 (임대는 GET /v1/nodes 에 내장. 별도 표면 없음) · FR-5 (중앙 현황판 정적 파일)
requirements.md §8.4 ① · ①-a · ①-b 행마다 current[] 와 reject
enode-features.md 3.2.2 FR-16 의 화면 몫 — draining 배지

딛는 것

   U-바닥    store.ListNodes · store.ListRuns · StepView.Chosen · contract.Policy
             match.Reject 의 json 태그

만지는 파일

   internal/api/list.go                            NEW  두 라우트의 핸들러
   internal/api/ui/                                NEW  정적 파일 + embed.  S0 · S0b · S1 · S1b · S2
   internal/api/api.go                             o    등록 줄 셋.  /v1/nodes · /v1/runs · /ui/
   internal/store/list.go                          c

internal/api/api.go 는 등록 줄만 만진다. 핸들러는 새 파일이다.

CSP 에 unsafe-inline 을 안 쓰므로 화면의 JS 와 CSS 가 인라인이 아니라
별도 파일
이다 (requirements.md §3.3). 그것이 ui/ 의 파일 구성을 정한다.

완료 조건 — API

   GET /v1/nodes    ADR-065 모양 + draining.  만료 안 된 광고만.  principal 안 냄
   GET /v1/runs     {observed_at, runs:[…]}.  시간 역순
                    행 = {run_id, state, verdict, work_id, created_at, ended_at,
                          assigned, current[], reject}
   current[]        CLAIMED 단계마다 {node, step, attempt, started_at}.  없으면 안 낸다
   reject           {code, as, reason}.  GET /v1/runs/{id} 와 같은 글자다
   필터              state · since · work · limit.  기본 100 · 상한 1000 · 넘으면 자른다
   페이지네이션       없다.  limit 뿐
   StepView.chosen   GET /v1/runs/{id} 의 단계에 실려 나온다
   토큰              두 라우트가 기존 auth 미들웨어로 보호된다 (NFR-S4)
   입력 검증          state · draining 은 enum · since 는 RFC3339 · work 는 정확 일치 (NFR-S5)
   헤더              GET /ui/ 에 CSP · nosniff · DENY · Referrer-Policy 넷 (NFR-S6)
   오류              문구에 내부 경로 · 스택이 없다 (NFR-S9)

완료 조건 — 화면. 버튼 전부를 센다

「누르면 걸린다」만으로는 빠진 버튼이 통과된다 (scene-gates.md §2.1).
재료는 requirements.md §8.3 이다.

   S0    토큰 표시 · 숨김 토글 (눈 아이콘)
         현황판 열기
         토큰은 브라우저 세션 저장소에만 둔다

   S0b   위 둘이 그대로 산다
         입력 테두리가 붉다
         「401 — Mediator 가 이 토큰을 받지 않았다」가 인라인으로 붙는다

   S1    필터 칩 다섯 — 전체 · RUNNING · QUEUED · SUCCEEDED · FAILED
         Run 행의 열기 (>)  -> S2
         카드에는 조작이 없다.  중앙은 읽기 전용이다
         카드가 노드 수만큼 있다.  임대된 카드에 not_after 카운트다운
         카드에 observed_at 과 expires_at 을 함께 얹는다 (NFR-O5)
         expires_at 까지 30초 이하면 카드가 흐려진다 (NFR-O3)
         draining 카드에 배지.  진행 중(C4) 과 대기 중(owner)(C5) 을 가른다 (NFR-O6)
         at-boundary 카드는 카운트다운 대신 「경계에서 · 최대 <not_after>」
         마지막 갱신 시각이 있다 (NFR-O2)

   S1b   갱신이 연속 3회 실패하면 카운트다운이 멎고 실패 표시가 뜬다 (NFR-O4)
         필터 칩과 배지의 글자는 SUCCEEDED 다.  SEALED 가 아니다 (§8.6)

   S2    닫기 (X)  -> S1
         단계 노드 고르기 -> 오른쪽 상세
         「함대 격자의 이 노드로」 (->)  -> S1 의 그 카드
         단계 배지와 범례는 CLAIMED 다.  RUNNING 이 아니다 (§8.6)
         범례에 FAILED 와 ASKED 를 더한다.  원본의 넷으로는 색 없는 단계가 생긴다
         트랜스크립트가 아니라 단계 상세다.  produced 는 ledger 라우트를 조인한다

   폴링   5초.  SSE · WebSocket 없음

게이트 — CP1 「보인다」

   curl -H "Authorization: Bearer $T" $M/v1/nodes | jq '.nodes[] | {label, lease, draining}'
   curl -H "Authorization: Bearer $T" "$M/v1/runs?limit=5" | jq '.runs[] | {run_id, state, current}'
   브라우저로 GET /ui/ — S0 에 토큰을 넣으면 S1 이 뜬다
   Mediator 를 멈추면 S1b 로 간다

QUEUED 필터 칩은 CP1 시점에 값이 없는 채로 옳게 동작한다 — 어휘가 아직
안 들어왔기 때문이다. 칩이 존재하고 필터가 걸리는 것까지가 CP1 이고, 실제
QUEUED 행이 뜨는 것은 CP2 가 확인한다.


U-대기열

하는 일

출처 FR
enode-features.md 3.2.1 FR-6 (409 -> QUEUED, 202) · FR-7 (steps 를 제출 시점에) · FR-8 (WakeQueued 가 도착 순서로 전부) · FR-9 (깨우기 지점) · FR-10 (기동 시 한 번) · FR-11 (draining 노드 제외의 중앙 쪽)

값의 근거는 requirements.md §3.1 · §3.2 다.

딛는 것

   U-바닥    store.QUEUED 상수 · store.ListQueued · store.DrainingNodes

만지는 파일

   internal/api/queue.go                           NEW  승격 · 202 응답 조립
   internal/store/queue.go                         o    WakeQueued 를 더한다.  바닥이 만든 파일이다
   internal/api/api.go                             o    submit 의 QUEUED 분기 · busy 에 DrainingNodes 합침
                                                        postResult · postCancel · postNodes 재기동 감지 뒤 깨우기
   internal/store/release.go                       o    부분 반납 뒤 깨우기
   internal/store/reap.go                          o    Reap 뒤 깨우기
   cmd/mediator/main.go                            o    기동 시 한 번

internal/match/match.go 는 안 만진다. busy 는 api.go 의 submit 이
만들어 넘긴다 (NFR-I4 · ADR-014 결정 3).

완료 조건

   409 가 QUEUED 가 된다.  응답은 202.  422 는 그대로 FAILED
   같은 run_id 재제출은 200 + 기존 Run.  202 는 새로 QUEUED 가 되는 순간만 (NFR-I7)
   QUEUED Run 의 steps 가 제출 시점에 전부 PENDING 으로 있다.  node_id 는 빈 문자열
   runAcquires 와 raiseAsks 는 승격 시점으로 미뤄져 있다
   Records.Open 은 제출 시점에 한다.  대기 중 취소가 봉인까지 닿는다
   WakeQueued 가 created_at 오름차순으로 전부 훑는다.  앞이 못 가도 뒤를 안 막는다
   승격은 Run 하나마다 독립 트랜잭션.  하나가 실패해도 나머지를 계속 본다
   재진입 · 동시 호출 안전.  임대 기본키 충돌이 경쟁을 막는다
   깨우기가 다섯 지점에 있다 — postResult · postCancel · Reap · 재기동 감지 · 부분 반납
                              (여섯째인 drain 해제는 U-drain 이 단다)
   기동 시 WakeQueued 한 번.  재기동 후에도 QUEUED 가 참이다
   reaper 주기에 안 얹었다
   상태 READY 를 안 만들었다 (NFR-I2)
   dry-run 응답이 안 바뀌었다 (NFR-I6)
   화면 — S1 의 QUEUED 행이 어느 카드에도 안 얹혀 있다

runctl 은 고칠 것이 없다 — do 가 StatusCode/100 == 2 로 받아 202
가 통과하고, Terminal(state) 이 SUCCEEDED 와 FAILED 뿐이라 --wait 도
QUEUED 에서 계속 폴링한다 (requirements.md §2.1).

게이트 — CP2 「기다린다」

   runctl submit a.json                -> RUNNING
   runctl submit b.json                -> 202 · QUEUED
   runctl status <b>                   -> QUEUED
   a 가 끝난 뒤 runctl status <b>       -> RUNNING
   runctl submit c.json (없는 능력)     -> 422 · FAILED
   S1 에 QUEUED 행이 보이고 어느 카드에도 안 얹혀 있다

U-drain

하는 일

출처 FR
enode-features.md 3.2.2 FR-11 (후보 제외의 노드 쪽) · FR-12 (정책 파일) · FR-13 (경로 — 읽기 · 싣기 · 복사 · 되돌리기 · Worker 에 넘기기) · FR-14 (두 모드) · FR-15 (해제는 소유자 명시) · FR-16 (drain 중에도 광고)
enode-features.md 3.2.1 FR-9 의 여섯째 깨우기 — 해제를 받은 광고 처리

정책 파일의 위치와 형식은 이 유닛의 Functional Design 이 정한다 —
decisions.md 1절이 명시적으로 거기에 뒀다. 기존 enode 설정 파일 옆, 같은
yaml 이 시작값이다.

딛는 것

   U-바닥     contract.Policy{Drain} · nodes 정책 열 · UpsertAdvert 의 이전 정책 반환
   U-대기열   store.WakeQueued — 해제를 감지한 광고 처리가 부른다

만지는 파일

   internal/enode/policy.go                        NEW  Read · Write.  이 패키지가 정본이다
   internal/enode/advertise.go                     o    광고 직전에 정책을 읽어 싣는다
   internal/enode/claim.go                         o    Worker.SetDrain — 응답의 값으로 움직인다
   internal/store/store.go                         o    UpsertAdvert 가 정책 열을 채운다
   internal/api/api.go                             o    광고 응답에 policy 한 줄
                                                        postResult 끝의 at-boundary 종료 블록
                                                        해제를 감지하면 WakeQueued
   internal/store/reap.go                          c    Cancel 을 부르기만 한다

완료 조건

   정본은 노드의 파일이다.  중앙 라우트를 안 만들었다 (NFR-I3)
   정책 파일은 소유자만 쓸 수 있는 권한으로 만들어진다 (NFR-S3)
   데몬이 광고 직전에 파일을 읽어 광고에 싣는다
   UpsertAdvert 가 nodes 열에 복사본을 두고 이전 정책을 돌려준다
   광고 응답에 policy 가 실려 돌아오고 데몬이 그 값을 Worker 에 넘긴다
       — 파일을 직접 읽었는데도 응답의 값을 쓰는 이유는 §4.2 에 있다
   submit 이 BusyNodes 에 DrainingNodes 를 합쳐 넘긴다.  match.go 는 안 만진다
   graceful      새 임대만 막는다.  도는 것은 끝까지 간다
   at-boundary   도는 단계를 끝까지 두고, 결과 보고 뒤 임대를 놓고 더 안 집는다
                 Mediator 가 postResult 끝에서 store.Cancel(run, "drain:<node_id>")
                 다중 노드 Run 도 Run 전체가 FAILED 다
                 단계 도중에 안 끊는다 (NFR-I5)
   verdict 가 drain 취소와 진짜 실패를 가른다.  새 열 0 — Cancel 이 이미 쓴다
   해제는 소유자 명시.  도는 Run 이 끝나도 자동으로 안 풀린다
   풀면 다음 광고에서 후보로 돌아오고 WakeQueued 가 대기 Run 을 보낸다
   drain 중에도 광고를 계속 보낸다.  draining 을 실어서
   dry-run 은 draining 을 안 본다.  응답 변경 없음 (NFR-I6)
   모드 전이 graceful <-> at-boundary 는 파일로만 된다.  라우트는 CP4 에서 막는다

Reap 은 안 건드린다. 회수 UPDATE 가 임대를 쥔 Run 만 보므로 QUEUED 는
그 부분질의에 안 들어온다 — 「대기 상한 없음」이 코드에서 이미 참이다
(aidlc-state.md 실행 계획 2절).

게이트 — CP3 「돌려받는다」

화면이 없다. 정책 파일과 GET /v1/nodes 로 닫는다 — 제어판은 뒤에 온다.

   단계 둘 이상인 계약을 돌린다 — 경계가 있어야 at-boundary 를 잰다
   정책 파일에 at-boundary 를 쓴다
   GET /v1/nodes 에 draining: at-boundary
   도는 단계가 끝난 뒤 GET /v1/nodes 에 lease 없음
   새 계약을 내면 그 노드로 안 간다 (409 -> QUEUED)
   drain 을 푼다 -> 대기 Run 이 RUNNING

U-제어판

노드에 HTTP 서버가 처음 선다. 이 유닛이 화면 둘을 함께 진다.

하는 일

출처 FR
enode-features.md 3.1.2 FR-17 (enodectl serve) · FR-18 (프로세스 제어) · FR-19 (신원) · FR-20 (탐지 능력 읽기 전용) · FR-21 (현재 작업) · FR-23 (Mediator 연결 상태) · FR-24 (호스트 화면 S3 · S5)
enode-features.md 3.1.2 · 3.2.2 FR-22 (drain 토글)
requirements.md §8.4 ② · ③ · ③-a 노드 전부 서브 · ?node= · 마지막 관측 캐시 (FR-25)

딛는 것

   U-관측API   GET /v1/nodes · GET /v1/runs/{id} — 현재 작업과 마지막 관측을 조립한다
   U-drain     internal/enode 의 policy.Read · policy.Write.  자기 파서를 안 만든다

만지는 파일

   internal/panel/                                 NEW  Config · Server · New · 관측 캐시
                                                        Mediator 클라이언트 · 프로세스 제어
   internal/panel/ui/                              NEW  정적 파일.  S3 · S5
   cmd/enodectl/serve.go                           NEW  case "serve" 의 본체
   cmd/enodectl/main.go                            o    case "serve" 등록
                                                        names · pidOf · identityOf · ownsConfig
                                                        · signalStop 을 internal/panel 로 내린다
   cmd/enodectl/proc_unix.go · proc_windows.go     o    processAlive 를 같이 내린다
   internal/enode/policy.go                        c    Read · Write 를 부르기만 한다

internal/panel 은 internal/store 도 internal/api 도 임포트하지 않는다 —
HTTP 로만 Mediator 를 본다 (NFR-P1 · NFR-P2). U-바닥의 경계 검사가 센다.

라우트

   GET  /                     정적 파일.  S3 · S5
   GET  /api/nodes            이 기계의 노드 목록.  사이드바가 읽는다
   GET  /api/status?node=     신원 · 프로세스 · 능력 · 현재 작업
                              · Mediator 마지막 응답 시각 · observed · stale
   GET  /api/policy?node=     현재 정책
   PUT  /api/policy?node=     걸기와 풀기.  모드 바꾸기는 409
   POST /api/start?node=      데몬 띄우기
   POST /api/stop?node=       데몬 멈추기
   GET  /api/logs?node=       로그 꼬리

?node= 를 생략하면 names() 의 첫째이고, names() 에 없는 이름이면 404 다.

완료 조건 — 서버

   enodectl serve [name].  이 기계의 노드를 전부 서브한다.  이름은 처음 선택될 노드
   별도 프로세스.  새 실행파일 없음.  packaging/ 을 안 건드렸다
   바인딩 기본값 127.0.0.1:8081.  밖에서 안 붙는다 (NFR-S1 · SECURITY-08)
   --listen 이 127.0.0.1 밖이면 정책 파일의 panel_token 필수.  없으면 기동 거부
       Mediator 토큰을 재사용하지 않는다 (NFR-S2 · SECURITY-12)
   Mediator 를 부를 토큰은 그 기계의 기존 enode 설정에서 읽는다.  소스에 안 박는다 (NFR-S7)
   헤더 넷을 정적 표면에 건다 (NFR-S6).  CSP 가 인라인 JS · CSS 를 막는다
   오류 문구에 내부 경로 · 스택이 없다 (NFR-S9)
   폴링 — 5초마다 GET /v1/nodes 를 한 번.  Mediator 요청이 노드 수와 무관하다
   관측 캐시 — 노드마다 마지막 성공 관측.  serve 가 도는 동안만 산다
   /api/status 가 observed{seen_at, expires_at, lease, instance, at} 과 stale 을 낸다
       캐시가 비면 observed 를 안 내고 stale 이 true
   현재 작업의 출처는 Mediator 조회다.  데몬 로그를 안 읽는다
   PUT /api/policy 가 모드 전이(graceful <-> at-boundary)에 409 를 낸다
   탐지 능력에 편집 자리가 없다 (ADR-017 결정 3)

완료 조건 — 화면. 버튼 전부를 센다

   S3 (도는 노드)
         사이드바의 노드 고르기
         「로그」 · 「재시작」 · 「정지」        헤더 셋
                                            재시작은 stop 뒤 start 의 합성이다.  새 라우트 없음
         graceful (라디오) · at-boundary (라디오)
         「drain 걸기」
         [건 뒤 = C3]
           「drain 풀기 · 함대로 돌려주기」    이것이 빠지면 통과가 아니다
           모드 배지 (graceful | at-boundary).  라디오는 사라진다
           「지금 도는 작업 없음 — 되찾은 상태」
         트랜스크립트 (->)                   자리만 · 비활성
                                            헤더의 「로그」는 데몬 로그다.  다른 물건이다
         신원 블록 — node_id · label · principal · instance 전부
                    principal 은 이 화면에만 나온다
         임대 진행 막대 — lease.not_after 와 설정의 TTLSeconds 로 그린다
         Mediator 마지막 응답 시각.  실패가 이어지면 drain 통보가 늦어진다는 문구

   S5 (멈춘 노드)
         사이드바의 노드 고르기
         「마지막 로그」                      헤더 하나.  로그 · 재시작 · 정지가 아니다
         「시작」                             주 동작이다
         drain 블록 없음                     멈춘 노드는 이미 자원을 내놓은 상태다
         마지막 관측 — seen_at · expires_at · lease · instance
                      캐시가 비면 — 로 그린다

게이트 — CP4 「한 장면」

scene-gates.md §1 의 ① ~ ⑦ 을 끝까지 돈다. 현황판과 제어판이 같은 사실을
보인다.

   ①  dhseo 의 보드가 함대에 있다
   ②  taeels 가 계약을 낸다 — 보드의 LED 점멸 패턴을 바꾸는 프로그램.  보드가 잡힌다
   ③  둘째 계약이 온다.  보드가 하나라 QUEUED.  현황판에 둘 다 보인다
   ④  dhseo 가 제어판을 열어 at-boundary 로 drain 을 건다
   ⑤  도는 단계가 끝난다.  경계에서 임대가 풀린다.  현황판에 draining
   ⑥  dhseo 가 보드를 쓴다.  LED 가 새 패턴으로 깜빡인다
   ⑦  dhseo 가 drain 을 푼다.  대기 중이던 둘째 계약이 보드를 집는다

더해서 화면 검증 — S3 의 걸기 전과 건 뒤(C3), S1 배지가 「draining · 진행 중」
과 「draining · 대기 중(owner)」로 갈리는 것, S5 에서 start 가 뜨는 것.


U-MCP

하는 일

runctl 로 접수하던 것을 MCP 로도 접수한다. 그리고 현황판이 그리는 것과
같은 자료를 도구로 낸다.

   감싼다        기존 REST 일곱.  새 라우트 0 · Mediator 무변경
   전송          stdio JSON-RPC 2.0 하나.  streamable HTTP 를 안 연다
   인증          ENODE_MEDIATOR · ENODE_TOKEN 환경변수.  runctl 과 같은 규칙
   새 의존       0.  프로토콜을 손으로 짠다

새 의미가 0 개다 (ADR-022 §9.3). 이 유닛이 만드는 것은 어휘가 아니라
통로다 — 어휘가 늘면 그것은 이 유닛의 범위를 넘은 것이다.

딛는 것

   U-관측API    GET /v1/nodes · GET /v1/runs 가 있어야 fleet.list · runs.list 가 산다
                라우트만 딛는다.  패키지를 임포트하지 않는다

다른 유닛을 하나도 안 딛는다. U-바닥도 U-대기열도 U-drain 도 U-제어판도
아니다 — 이 유닛은 HTTP 클라이언트이고 store 도 api 도 안 부른다.

만지는 파일

   NEW   internal/mcp/                stdio 프레이밍 · initialize · tools/list · tools/call
   NEW   internal/mcp/tools.go        도구 일곱을 runctl.Client 에 잇는다
   o     internal/runctl/client.go    Nodes · Runs 두 메서드를 더한다 (FR-29)
   o     cmd/runctl/main.go           case "mcp" 한 줄

접점이 0 이다. 위 넷을 다른 유닛이 아무도 안 만진다
(unit-boundaries.md §2 의 MCP 열). 그래서 병합 순서에 자리를 안 잡는다.

완료 조건

    1   runctl mcp 가 stdio 로 서고 initialize 에 protocolVersion 을 낸다
    2   tools/list 가 일곱을 낸다 — 이름이 decisions.md 5.3 의 글자와 같다
    3   도구마다 inputSchema 가 있다.  필수 인자가 빠지면 오류를 낸다 (ADR-066)
    4   capabilities.list 가 GET /v1/capabilities 와 같은 자료를 낸다
    5   fleet.list 가 GET /v1/nodes 와 같은 자료를 낸다 — 글자로 대조한다
    6   runs.list 가 GET /v1/runs 와 같은 자료를 낸다.  필터 넷을 그대로 받는다
    7   run.submit 으로 낸 Run 이 현황판 S1 에 그대로 뜬다
    8   run.plan 이 dry-run 이다 — 점유를 안 한다
    9   run.cancel 이 200 과 상태를 낸다
   10   record.get 이 종료 전이면 409 를 그대로 사상한다 (I4 를 안 우회한다)
   11   토큰이 도구 인자에 없다.  ENODE_TOKEN 이 비면 initialize 에서 실패한다
   12   토큰이 응답 본문에도 로그에도 안 나온다 — 글자로 센다 (SECURITY-12)
   13   go.mod 가 안 바뀐다.  직접 의존이 셋 그대로다
   14   새 실행파일이 0 이다.  packaging/ 이 안 바뀐다
   15   cmd/mediator 와 internal/api 와 internal/store 를 한 줄도 안 고쳤다

게이트 — CP5 「감싼다」

scene-gates.md §2 의 CP5 다. dhseo 장면 밖이라 CP4 의 조건이 아니다 —
CP5 가 빨개도 CP4 는 초록일 수 있다.

   runctl mcp 를 띄우고 tools/list 가 일곱
   run.submit 으로 낸 Run 이 GET /v1/runs 와 화면 S1 에 똑같이 뜬다
   fleet.list 와 GET /v1/nodes 를 글자로 대조한다
   go.mod 무변경 · packaging/ 무변경 · Mediator 무변경

게이트가 그 유닛 밖의 이유로 빨간 경우

진행자가 사실을 aidlc-docs/audit.md 에 적고 조각을 보류로 표시한 뒤
넘어간다. 보류는 통과가 아니다 — CP4 전에 전부 초록이어야 한다
(scene-gates.md §4). 집행자는 그 유닛을 구현하지 않은 사람이다.