전이 통제

전이 통제

상태는 값이 아니다. 상태는 어떤 경로를 거쳤는지다.


왜 전이를 별도로 통제하는가

task.status = "completed"는 값을 바꾼다.

task.complete()는 다르다. 현재 상태가 전이를 허용하는지 확인하고, 허용된 경우에만 상태를 바꾸고, 전이에 따르는 부수 효과를 실행하고, 이벤트를 발행한다.

겉으로는 같아 보인다. 결과도 같을 수 있다. 하지만 두 코드가 하는 일은 다르다.

이게 전이 통제가 존재하는 이유다. 값을 바꾸는 것과 상태가 이동하는 것은 다른 일이다. 상태 변경 우회(Atlas)와 상태-UI 바인딩 우회(Glif)는 모두 이 구분이 사라질 때 발생한다.


문제: AI는 상태를 값으로 취급한다

AI가 작업을 수행할 때 가장 먼저 찾는 것은 결과가 가장 빨리 바뀌는 경로다.

tag.color = "#ff0000"
save(tag)

기능은 동작할 수 있다. 하지만 이 경로는 다음을 보장하지 않는다.

  • 색상 변경이 허용된 상태에서 일어났는가
  • validate -> normalize -> publish 절차를 통과했는가
  • 관련 이벤트가 발행됐는가
  • 다른 변경과 동시에 발생할 때 어떤 순서로 처리되는가

값은 바뀌었다. 하지만 상태가 이동한 것은 아니다.


1단계: 허용된 전이를 정의한다

전이 통제의 시작은 “어떤 상태에서 어떤 상태로 이동할 수 있는가"를 코드로 선언하는 것이다.

type DocumentStatus = "draft" | "review" | "published" | "archived" | "rejected";

const allowedTransitions: Record<DocumentStatus, DocumentStatus[]> = {
  draft: ["review"],
  review: ["draft", "published", "rejected"],
  published: ["archived"],
  rejected: ["draft"],
  archived: [],
};

function assertTransition(from: DocumentStatus, to: DocumentStatus): void {
  if (!allowedTransitions[from].includes(to)) {
    throw new Error(`Cannot transition from '${from}' to '${to}'.`);
  }
}

Atlas에서는 enum과 dictionary, Glif에서는 union type과 map을 주로 쓴다. 표현은 달라도 핵심은 같다. 허용된 전이가 코드 밖 문서가 아니라 실행 경로 안에 있어야 한다.


2단계: 직접 변경 경로를 닫는다

정의만으로는 부족하다. status 필드를 직접 바꿀 수 있는 한, 전이 규칙은 우회된다.

class Document {
  #status: DocumentStatus;

  constructor(status: DocumentStatus) {
    this.#status = status;
  }

  get status(): DocumentStatus {
    return this.#status;
  }

  transition(to: DocumentStatus): Document {
    assertTransition(this.#status, to);
    return new Document(to);
  }
}

핵심은 세 가지다.


3단계: 실행과 전이를 결합한다

전이 검증이 실행 바깥에 있으면, 실행 함수가 전이를 건너뛰는 순간 다시 우회가 열린다.

async function publishDocument(id: string): Promise<void> {
  const doc = await repository.get(id);
  const published = doc.transition("published");

  await repository.save(published);
  await eventBus.emit("document.published", { id: published.id });
}

전이는 helper가 아니라 실행 절차의 일부여야 한다. 호출자가 save()만 선택하고 transition()을 생략할 수 있으면 구조는 다시 값 수정 패턴으로 무너진다.


4단계: 오류가 방향을 가리키게 만든다

전이 실패 오류는 최소한 세 가지를 포함해야 한다.

  1. 현재 상태
  2. 요청한 전이
  3. 허용된 전이 목록
Cannot transition from 'draft' to 'published'.
Allowed: [review].
Use the approved command or action path instead of direct mutation.
See:
- harness/atlas/state-mutation-bypass
- harness/glif/state-ui-binding-bypass

방향이 없는 오류 메시지는 다시 추측을 만든다. AI와 사람 모두 “어디로 돌아가야 하는가"를 오류 메시지에서 바로 읽을 수 있어야 한다.


테스트에서도 같은 경로를 따른다

테스트에서 published 상태를 초기값으로 넣거나, private field를 직접 바꾸거나, store를 바로 조작하면 전이 경로는 검증되지 않는다. 더 큰 문제는 AI가 그 테스트를 정본 예시로 학습한다는 점이다.

테스트 구조 정렬은 테스트 하네스 강제에서 따로 다루고, 전이 사례 자체는 Atlas와 Glif 하네스 문서에서 이어서 본다.


환경별 사례

Atlas

Glif


체크리스트

  • 허용된 전이 목록이 코드로 정의되어 있는가?
  • 상태 필드가 직접 변경될 수 없도록 보호되어 있는가?
  • 전이 메서드만 상태 변경 권한을 가지는가?
  • 직접 변경이 빌드 타임과 런타임에서 함께 차단되는가?
  • 실행 함수 안에서 전이 호출이 빠질 수 없는가?
  • 전이 위반 오류가 합법 경로를 직접 가리키는가?
  • 테스트에서도 직접 상태 설정이 금지되어 있는가?
  • 환경별 구현 세부사항이 하네스 leaf에 모여 있는가?