블로그로 돌아가기

OpenCairn Agentic Workflow Ledger: AI 작업을 기록 가능한 action으로 만들기

2026-05-06personal

문제

AI agent를 제품에 넣을 때 제일 위험한 구현은 이것입니다.

LLM response
-> parse
-> execute

처음에는 빠릅니다. 하지만 기능이 조금만 늘어나면 바로 문제가 생깁니다.

  • 누가 실행했는지 남지 않는다.
  • 어떤 workspace/project/page를 바꾸는지 불명확하다.
  • destructive action과 low-risk action이 같은 경로를 탄다.
  • 실패했을 때 재시도 가능한지 알 수 없다.
  • UI에서 preview, approval, history를 만들 수 없다.

OpenCairn에서는 이 문제를 agent_actions substrate로 풀었습니다. 모델이 "파일을 지워줘"라고 말한다고 바로 지우지 않습니다. 먼저 typed action이 만들어지고, action의 risk와 status가 기록됩니다.

핵심 schema

공유 계약은 packages/shared/src/agent-actions.ts에 있습니다.

여기서 action status는 단순히 pending/done이 아닙니다.

export const agentActionStatusSchema = z.enum([
  "draft",
  "approval_required",
  "queued",
  "running",
  "completed",
  "failed",
  "cancelled",
  "expired",
  "reverted",
]);

이 상태들이 필요한 이유는 각각 다릅니다.

draft
-> 모델이 제안했지만 아직 실행하면 안 됨

approval_required
-> 사용자 승인이 필요한 위험 작업

queued/running
-> worker나 API 실행 경로에 들어감

completed/failed
-> 결과가 ledger에 남음

expired
-> 오래된 preview나 stale action

reverted
-> 실행 후 되돌림까지 기록

risk도 따로 둡니다.

export const agentActionRiskSchema = z.enum([
  "low",
  "write",
  "destructive",
  "external",
  "expensive",
]);

이 risk가 있어야 UI와 API가 같은 기준으로 행동할 수 있습니다.

low
-> 바로 preview 가능

write
-> 변경은 있지만 복구 가능

destructive
-> 삭제/영구 변경

external
-> 외부 API, 외부 계정, webhook

expensive
-> 비용이 큰 LLM/tool 실행

action kind는 제품 surface다

OpenCairn의 action kind는 단순 command 문자열이 아닙니다. shared schema에 명시된 제품 surface입니다.

예시는 이런 식입니다.

export const agentActionKindSchema = z.enum([
  "note.create",
  "note.rename",
  "note.move",
  "note.delete",
  "note.restore",
  "note.update",
  "file.export",
  "import.retry",
  "code_project.command.run",
]);

이렇게 action kind를 enum으로 고정하면 장점이 있습니다.

  • API route에서 kind별 validation을 할 수 있다.
  • UI가 action별 preview component를 고를 수 있다.
  • worker가 실행 가능한 action만 받을 수 있다.
  • audit log에서 "무슨 일이 일어났는지"를 query할 수 있다.
  • approval policy를 kind/risk 기준으로 만들 수 있다.

반대로 action을 free-form JSON으로 두면 agent가 새로운 명령을 계속 만들어냅니다. 그 순간 제품은 통제 가능한 workflow가 아니라 prompt interpreter가 됩니다.

scope field를 nested input에서 금지하는 이유

agent-actions.ts에서 재미있는 부분은 nested scope field를 금지하는 규칙입니다.

요지는 이겁니다.

const forbiddenNestedScopeKeys = [
  "workspaceId",
  "projectId",
  "userId",
  "actorUserId",
];

왜 이런 금지가 필요할까요?

잘못된 action input은 이렇게 생길 수 있습니다.

{
  "kind": "note.update",
  "workspaceId": "safe-workspace",
  "input": {
    "noteId": "target-note",
    "workspaceId": "other-workspace"
  }
}

outer scope와 inner scope가 충돌하면, 어떤 값을 믿어야 하는지 모호해집니다. 공격자가 inner input에 다른 workspace를 넣는 경우도 생각해야 합니다.

그래서 OpenCairn에서는 action의 권한 경계를 top-level scope로 고정하고, nested input 안에 scope를 다시 넣지 못하게 막습니다.

actor
workspace
project/page/note target
kind
input

이 순서가 action identity입니다. input은 "작업에 필요한 값"이지 권한 경계를 다시 정의하는 곳이 아닙니다.

note.update는 preview/apply로 분리된다

OpenCairn에서 특히 중요한 action은 note.update입니다.

문서를 직접 바꾸는 작업이기 때문입니다. 여기서는 agent가 만든 patch를 바로 적용하지 않고 preview와 apply를 분리합니다.

공유 schema에는 이런 경계가 있습니다.

export const noteUpdatePreviewSchema = z.object({
  actionId: z.string(),
  noteId: z.string(),
  baseStateVector: z.string(),
  operations: z.array(noteUpdateOperationSchema),
});

export const noteUpdateApplyRequestSchema = z.object({
  actionId: z.string(),
  previewId: z.string(),
});

export const noteUpdateApplyResultSchema = z.object({
  actionId: z.string(),
  noteId: z.string(),
  applied: z.boolean(),
  stale: z.boolean().optional(),
});

핵심 flow는 다음입니다.

LLM proposes note update
-> validate as note.update action
-> create preview against current Yjs state
-> user reviews diff
-> apply request
-> check state vector
-> apply update
-> mark action completed or stale

preview를 보는 동안 다른 사용자가 같은 note를 수정할 수 있습니다. 그래서 apply 시점에는 base state vector가 아직 유효한지 다시 확인해야 합니다.

ledger가 UI를 가능하게 만든다

agent action ledger가 있으면 UI는 단순 채팅창을 넘어설 수 있습니다.

Action card
-> preview
-> approval buttons
-> run state
-> error detail
-> retry/revert
-> history

OpenCairn의 Workflow Console도 이 ledger 위에서 동작합니다. chat에서 시작한 작업, document generation, code workspace command가 서로 다른 기능이어도 모두 "기록 가능한 workflow"로 보입니다.

이게 중요한 이유는 AI 기능이 실패했을 때 사용자가 다음 질문을 하기 때문입니다.

무슨 작업이 실행됐지?
어디까지 됐지?
왜 실패했지?
다시 실행해도 되나?
되돌릴 수 있나?

ledger가 없으면 이 질문에 답할 수 없습니다.

구현하며 배운 점

agentic workflow에서 핵심은 모델이 얼마나 똑똑한지가 아니라, 모델 output이 제품 상태로 들어오는 경계입니다.

OpenCairn에서는 그 경계를 이렇게 잡았습니다.

LLM text
-> typed action
-> schema validation
-> permission check
-> risk classification
-> preview/approval
-> execution
-> ledger result

이 구조가 있어야 AI agent가 실제 제품 안에서 협업 가능한 작업자가 됩니다.