Planner 통제
Planner가 있어도 우회 가능하면 AI는 우회한다. Planner 통제는 Planner 없이는 실행이 시작되지 않게 만드는 것이다.
왜 Planner를 별도로 통제하는가
전이 통제는 상태 이동 경로를 고정한다. 빌드 타임 강제는 잘못된 코드를 작성 시점에 차단한다. 그런데도 AI 작업 흐름에서 반복적으로 발생하는 문제가 있다.
AI는 작업을 완료하기 위해 가장 짧은 경로를 선택한다. Planner가 있어도, Executor를 직접 호출할 수 있으면 AI는 Planner를 건너뛴다. 이건 AI의 결함이 아니다. Planner가 선택 가능한 구조이기 때문이다.
// AI가 생성한 코드 — Atlas
await _documentService.SaveAsync(doc);
await _documentService.PublishAsync(doc);// AI가 생성한 코드 — Glif
await saveDocument(doc);
await publishDocument(doc);두 코드 모두 Planner를 거치지 않는다. 기능은 동작한다. 하지만 다음이 보장되지 않는다.
- 실행 순서가 검증됐는가
- validation이 통과됐는가
- side-effect가 올바른 순서로 실행됐는가
- 실패 시 어떻게 처리되는가
Planner 통제는 이 문제를 “더 잘 설명하거나 지시하는” 방식으로 해결하지 않는다. Executor를 숨겨서 직접 호출 경로 자체를 없앤다.
1단계: Executor를 외부에서 볼 수 없게 만든다
Planner 통제의 핵심은 간단하다. Executor를 외부에 노출하지 않는다.
Atlas (C#)
// 위반: Executor가 public으로 노출됨
public sealed class DocumentExecutor
{
public async Task SaveAsync(Document doc) { ... }
public async Task PublishAsync(Document doc) { ... }
}
// 어디서든 직접 호출 가능
var executor = new DocumentExecutor();
await executor.SaveAsync(doc);// 합법: Executor를 internal로 숨김
internal sealed class DocumentExecutor
{
internal async Task SaveAsync(Document doc, ExecutionContext ctx) { ... }
internal async Task PublishAsync(Document doc, ExecutionContext ctx) { ... }
}
// 외부에는 Planner만 노출
public sealed class DocumentPlanner
{
private readonly DocumentExecutor _executor = new();
public async Task ExecuteAsync(DocumentCommand command)
{
var plan = BuildPlan(command);
ValidatePlan(plan);
await RunPlan(plan);
}
private async Task RunPlan(ExecutionPlan plan)
{
var ctx = new ExecutionContext(source: "planner", planId: plan.Id);
foreach (var step in plan.Steps)
await _executor.ExecuteStep(step, ctx);
}
}internal 키워드가 어셈블리 경계에서 접근을 차단한다. Application 레이어 외부에서는 DocumentExecutor를 참조조차 할 수 없다.
Glif (TypeScript)
// 위반: 실행 함수들이 export됨
export async function saveDocument(doc: Document) { ... }
export async function publishDocument(doc: Document) { ... }
// 합법: 실행 함수를 숨기고 dispatch만 노출
const executor = {
save: async (doc: Document, ctx: ExecutionContext) => { ... },
publish: async (doc: Document, ctx: ExecutionContext) => { ... },
};
// executor는 export 없음
export async function dispatch(command: DocumentCommand): Promise<void> {
const plan = buildPlan(command);
validatePlan(plan);
await runPlan(plan, executor);
}ESLint 규칙으로 executor 모듈 직접 import도 차단한다.
{
"no-restricted-imports": ["error", {
"patterns": [{
"group": ["@/executor/*", "@/executors/*"],
"message": "Executors must not be imported directly. Use dispatch()."
}]
}]
}2단계: 단일 진입점으로 수렴시킨다
Executor를 숨기는 것만으로는 부족하다. 진입점이 여러 개면 하나를 막아도 다른 경로가 사용된다.
// 위반: 여러 진입점
public Task SaveDocumentAsync(SaveDocumentCommand command) { ... }
public Task QuickSaveAsync(string docId, string content) { ... }
public Task AutoSaveAsync(Document doc) { ... }이 구조에서 AI는 가장 편리한 것을 선택한다. 세 함수가 내부적으로 다른 검증을 거치면 일관성이 깨진다.
// 합법: 단일 진입점
public sealed class DocumentPlanner
{
public async Task ExecuteAsync(DocumentCommand command) { ... }
}
// Command 타입이 의도를 명시
public abstract record DocumentCommand;
public sealed record SaveDocumentCommand(string DocId, string Content) : DocumentCommand;
public sealed record PublishDocumentCommand(string DocId) : DocumentCommand;
public sealed record ArchiveDocumentCommand(string DocId, string Reason) : DocumentCommand;// 합법: 단일 진입점
type DocumentCommand =
| { type: "save"; docId: string; content: string }
| { type: "publish"; docId: string }
| { type: "archive"; docId: string; reason: string };
export async function dispatch(command: DocumentCommand): Promise<void> {
const plan = buildPlan(command);
validatePlan(plan);
await runPlan(plan);
}Command 기반 인터페이스는 두 가지를 동시에 달성한다. 허용된 작업의 목록이 타입으로 정의되고, 진입점이 하나로 수렴된다.
3단계: 런타임에서 Planner 우회를 차단한다
빌드 타임 제약을 우회하는 코드가 있을 수 있다. 런타임 검증이 최후의 방어선이다.
Atlas (C#)
internal sealed class DocumentExecutor
{
internal async Task ExecuteStep(
ExecutionStep step,
ExecutionContext ctx)
{
// Planner를 통한 호출인지 검증
if (ctx?.Source != "planner")
throw new UnauthorizedExecutionException(
"Execution must originate from DocumentPlanner. " +
"Direct executor calls are forbidden.");
await ExecuteStepInternal(step, ctx);
}
}async function runPlan(
plan: ExecutionPlan,
ctx: ExecutionContext
): Promise<void> {
if (ctx.source !== "planner") {
throw new Error(
"Execution must originate from dispatch(). " +
"Direct executor calls are forbidden."
);
}
for (const step of plan.steps) {
await executeStep(step, ctx);
}
}이 가드는 ExecutionContext가 Planner에서 생성된 것인지 확인한다. 직접 호출에서는 올바른 컨텍스트를 만들 수 없다.
4단계: 오류가 방향을 가리키게 만든다
Planner 통제의 실패 메시지는 “무엇이 잘못됐는가"와 “어떻게 해야 하는가"를 동시에 전달한다.
public sealed class UnauthorizedExecutionException : DomainException
{
public UnauthorizedExecutionException(string detail)
: base(
$"Unauthorized execution: {detail}\n" +
$"Use DocumentPlanner.ExecuteAsync(command) instead.\n" +
$"See: enforcement/planner-control") { }
}// Glif — 구체적인 방향 포함
throw new Error(
`Direct execution is forbidden.\n` +
`Use: dispatch({ type: "${command.type}", ... })\n` +
`See: enforcement/planner-control`
);방향이 없는 오류 메시지는 다시 추측을 만든다. AI가 “planner를 통해야 한다"는 것을 오류 메시지에서 읽어야 다음 시도가 올바른 방향으로 수렴한다.
AI 작업 흐름에서의 작동
Planner 통제가 완성되면 AI의 작업 흐름이 바뀐다.
이전 (통제 없음):
- AI가
saveDocument(doc)생성 - 동작함 — 문제 없음
- 같은 패턴 반복
이후 (통제 있음):
- AI가
saveDocument(doc)생성 - ESLint:
Executors must not be imported directly - AI가
dispatch({ type: "save", docId, content })로 수정 - 동작함 — Planner 경유 확인됨
핵심은 AI가 “올바른 방식을 설명해줬기 때문에” 바꾸는 것이 아니다. 잘못된 경로가 차단됐기 때문에 합법 경로를 선택한다.
AI 하네스 계층과의 관계
AI 통제 계층의 작업 계약과 Acceptance Gate는 “무엇이 완료인가"를 정의한다. Planner 통제는 그것의 강제 구현이다.
작업 계약이 “이 작업은 Planner를 통해야 한다"고 명시하고, Planner 통제가 그것을 코드 수준에서 강제한다. 두 계층이 함께 작동할 때 AI가 Planner를 우회하는 경로가 실질적으로 닫힌다.
체크리스트
- Executor가 external에서 참조 불가능한가? (internal/비공개)
- 진입점이 단일 dispatch 함수로 수렴됐는가?
- Command 타입이 허용된 작업 목록을 명시하는가?
- ESLint/Roslyn이 executor 직접 import를 차단하는가?
- 런타임 컨텍스트 검증이 Planner 우회를 차단하는가?
- 실패 메시지가 합법 경로를 직접 가리키는가?
- 테스트에서도 dispatch를 통해서만 실행이 가능한가?