ouroboros · 이벤트 소싱 저장소와 Projection 읽기모델

한 줄 요약

ouroboros는 “현재 상태”를 따로 저장하지 않고 일어난 사건만 시간순으로 쌓은 장부 하나를 유일한 원본으로 삼은 뒤, 화면·평가·CLI가 보는 모든 요약본은 그 장부를 다시 읽어 그때그때 계산해 만든다. 왜 배우나 — 원본을 절대 건드리지 않으니 과거 세션을 완벽히 재구성하고, 요약 포맷을 자유롭게 바꿔도 데이터가 깨지지 않는 “감사 가능한 시스템”의 핵심 설계이기 때문이다.

그림

flowchart LR
  A["에이전트 행위<br/>LLM·도구·평가·directive"] -->|이벤트 생성| B["작업단위 UnitOfWork<br/>한 단계 동안 모음"]
  B -->|원자적 한 번에 커밋| C["(events 테이블<br/>고치지 않는 장부=원본)"]
  B -.->|단계별 안전지점 저장| D["CheckpointStore<br/>SHA-256·3단계 되돌리기"]
  C -->|시간순으로 다시 읽기 replay| E["ProjectionBuilder<br/>읽기 전용 변환"]
  E --> F["RunRecord<br/>실행 봉투"]
  E --> G["StageRecord<br/>단계"]
  E --> H["StepRecord<br/>최소 작업"]
  E --> I["ArtifactRecord<br/>산출물"]
  E --> J["VerdictRecord<br/>최종 판정"]
  F & G & H & I & J --> K["화면 · 평가 · CLI<br/>= 전부 요약본"]

쉽게 풀기

가계부 비유로 시작해 보자.

가계부에는 “5월 1일 커피 4천 원 지출”, “5월 3일 월급 입금” 같은 사건만 한 줄씩 적힌다. 한 번 적은 줄은 절대 지우거나 고치지 않는다. 잘못 적었으면 “정정: 커피는 3천 원이었음”이라는 새 줄을 추가할 뿐이다. 그러면 “지금 통장 잔액이 얼마야?”라는 질문의 답은 어디에도 직접 적혀 있지 않다. 가계부를 처음부터 끝까지 더하면 그때그때 나온다.

ouroboros가 정확히 이렇게 동작한다.

  1. 사건만 쌓는다 (이벤트 소싱). “도구를 호출했다”, “모델이 응답했다”, “평가가 통과했다” 같은 사건이 SQLite의 events 테이블에 한 줄씩 추가만 된다(append-only). 이 장부가 유일한 진짜 원본(SSOT, 단일 진실원천)이다.

  2. 사건은 못 고친다 (불변성). 이미 일어난 일은 사실이므로 수정·삭제하지 않는다. 잘못을 바로잡을 때도 가계부처럼 “정정 사건”을 새로 추가한다.

  3. 요약본은 그때그때 다시 만든다 (Projection·투영). 화면에 보이는 진행상황, 평가 결과, CLI 상태표는 전부 장부를 처음부터 다시 읽어(replay) 계산한 요약본이다. 이 요약본을 Projection 또는 읽기모델이라 부른다. 월말 잔액표를 가계부에서 새로 뽑는 것과 같다.

  4. 그래서 무엇이 좋은가. 요약 포맷(잔액표 모양)을 바꿔도 가계부 원본은 그대로다. 과거 세션도 장부만 있으면 완벽히 되살릴 수 있다. “왜 평가자가 재시도했나” 같은 인과도 런타임 로그 없이 장부만으로 따라갈 수 있다.

핵심 안전장치 — "정화(sanitize)"

사건을 저장하기 직전에 raw_*, subscribed_*event/payload 같은 다시 읽기에 위험한 원본 덩어리를 자동으로 걸러낸다. 구독한 런타임 스트림의 거대한 원본이 장부에 새어 들어가지 않게 막는 “거름망”이라고 보면 된다. 개발자가 직접 지울 필요 없이 저장 경계가 알아서 처리한다.

핵심 정리

저장의 최소 단위인 BaseEvent의 핵심 필드:

필드역할메모
type무슨 일이 일어났나점.표기.과거형 예: tool.call.returned
aggregate_type / aggregate_id어느 묶음의 사건인가replay(다시 읽기)할 때 묶는 키
data사건의 내용물저장 시 정화 거침
event_version내용물 형식 버전레거시 행은 0

장부에서 뽑아내는 읽기모델(요약본) 종류:

레코드의미비유
RunRecord한 목표 실행 전체 봉투한 달 가계부 묶음
StageRecord단계 묶음주별 합계
StepRecord최소 작업 단위거래 한 건
ArtifactRecord작업이 만든 산출물영수증
VerdictRecord최종 통과/실패 판정월말 결산 도장

절대 깨면 안 되는 규약

  • 모든 사건과 요약본은 frozen=True — 만든 뒤 수정 불가.
  • 기존 사건을 UPDATE/DELETE 하지 않는다. 정정도 새 사건으로.
  • 모든 요약본 행은 자기를 만든 사건으로 되짚어갈 수 있어야 한다. StepRecordsource_event_ids를 연결하거나 legacy_inferred=True를 명시하거나 둘 중 하나 강제.
  • 요약 포맷을 바꿀 땐 사건이 아니라 schema_version을 올리고 빌더를 고친다.

DB는 테이블 하나로 통합

모든 사건이 단일 events 테이블에 들어간다. 다시 읽기는 WHERE aggregate_type=? AND aggregate_id=? ORDER BY timestamp, id로 항상 같은 순서(결정적 순서)를 보장한다. 같은 시각의 사건이 섞여도 id로 순서가 고정된다.

실제 예시

1) 사건을 장부 형식으로 변환 (저장 직전 정화)

# /home/seunghyeong/harness-work/ouroboros/src/ouroboros/events/base.py
def to_db_dict(self) -> dict[str, Any]:
    """Convert event to dictionary for database insertion."""
    payload = sanitize_event_data_for_persistence(self.data)
    payload["event_version"] = self.event_version
    return {
        "id": self.id,
        "event_type": self.type,
        "timestamp": self.timestamp,
        "aggregate_type": self.aggregate_type,
        "aggregate_id": self.aggregate_id,
        "payload": payload,
        "consensus_id": self.consensus_id,
    }

2) 시간순으로 다시 읽기 (결정적 순서 보장)

# /home/seunghyeong/harness-work/ouroboros/src/ouroboros/persistence/event_store.py
async def replay(self, aggregate_type: str, aggregate_id: str) -> list[BaseEvent]:
    async with self._engine.begin() as conn:
        result = await conn.execute(
            select(events_table)
            .where(events_table.c.aggregate_type == aggregate_type)
            .where(events_table.c.aggregate_id == aggregate_id)
            # Order by timestamp + id for deterministic replay when
            # multiple events share the same timestamp resolution.
            .order_by(events_table.c.timestamp, events_table.c.id)
        )
        rows = result.mappings().all()
        return [BaseEvent.from_db_row(dict(row)) for row in rows]

3) 사건을 읽기모델로 투영 (도구 호출 짝 맞추기)

# /home/seunghyeong/harness-work/ouroboros/src/ouroboros/harness/projection_builder.py
def _handle_tool_returned(self, returned_event: BaseEvent) -> None:
    call_id = _extract_call_id(returned_event)
    if call_id is None:
        return
    start_event = self._tool_started.pop(call_id, None)   # started/returned를 call_id로 페어링
    tool_name = _extract_tool_name(start_event or returned_event)
    source_event_ids = tuple(
        event.id for event in (start_event, returned_event) if event is not None
    )
    is_error = _safe_bool(returned_event.data.get("is_error"))
    ok = (not is_error) if is_error is not None else None
    step = StepRecord(
        kind=kind, name=tool_name,
        started_at=(start_event or returned_event).timestamp,
        ended_at=returned_event.timestamp,
        ok=ok,
        source_event_ids=source_event_ids,   # 읽기모델 → 원본 이벤트 역추적 보장
    )
    self._steps[_slot_key("tool", call_id)] = step

4) 직접 만들 때 — 정의 → 저장 → 투영 한 바퀴

# 1) 이벤트 정의 — 불변, 점.표기.과거형
from ouroboros.events.base import BaseEvent
 
evt = BaseEvent(
    type="tool.call.returned",          # past_tense
    aggregate_type="execution",         # replay 키 종류
    aggregate_id="exec-2026-0615-01",   # replay 키 값
    data={
        "call_id": "01J...",            # started/returned 페어링 키 (ULID)
        "tool_name": "Bash",
        "is_error": False,
        "raw_event": {...},             # ← sanitize가 저장 시 자동 제거됨
    },
)
 
# 2) 저장 — append-only
store = EventStore("sqlite+aiosqlite:///ouroboros.db")
await store.initialize()
await store.append(evt)                 # 또는 append_batch([...]) 원자 커밋
 
# 3) 투영 — 이벤트를 읽기모델로
from ouroboros.harness.projection_builder import build_projection
 
events = await store.replay("execution", "exec-2026-0615-01")
bundle = build_projection(events, seed_id="seed-123", goal="...")
print(bundle.run.run_id, [s.name for s in bundle.steps], bundle.verdicts)

생명주기 한눈에

트리거 — LLM 호출/도구 디스패치(io.py), control-plane이 directive를 낼 때(control.py), 평가 종료 시 사건 생성. ② 누적 — 한 phase 동안 UnitOfWork.add_event()로 모았다가 phase 경계에서 commit()append_batch()원자적 단일 트랜잭션 기록, 동시에 CheckpointStore가 SHA-256 검증 체크포인트 저장(최대 3단계 롤백, NFR11). ③ 투영 — 다음 단계가 맥락이 필요할 때 replay()ProjectionBuilder. ④ 결과RunSnapshotRecord.safe_resume로 재개 가능 여부 판단, VerdictRecord로 증거 사건과 함께 통과/실패 확정.

요약 & 셀프체크

  • 3줄 요약

    1. 사건만 시간순으로 쌓는 events 테이블 하나가 유일한 원본이고, 한 번 적은 사건은 고치지 않는다.
    2. 화면·평가·CLI가 보는 모든 요약본(Run/Stage/Step/Artifact/Verdict)은 장부를 다시 읽어 계산한 Projection이다.
    3. 저장 직전 정화(sanitize)와 결정적 replay 순서, 그리고 “요약본→원본 사건 역추적” 규약이 신뢰성을 받친다.
  • 스스로 답해 보기

    1. “현재 잔액”을 따로 저장하지 않는데 왜 더 안전하고 유연한가? (힌트: 가계부와 잔액표)
    2. 잘못된 사건을 발견했다. 어떻게 바로잡아야 하나? (UPDATE? DELETE? 아니면?)
    3. 요약 화면의 표 모양을 바꾸고 싶다. 무엇을 건드리고 무엇은 절대 건드리면 안 되나?

연결

OB_개요 · _분석축_루브릭 · OB_40_orchestrator-execution-loop