# v0.2.10 — Cut C Design (raw_text 수정 + revision history)

**작성일:** 2026-05-09
**선행 문서:**

- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F20)
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut C

**Cut 라벨:** v0.2.10 — load-bearing invariant 변경 (raw_text 불변 폐기 + revision history). semver 엄밀히 minor 이지만 v0.2.x 관습.

---

## 1. Cut 정체성

메모리 정책 `raw_text 불변` invariant 폐기 + 변경 이력 (revision) 보존. 사용자가 raw_text 자유 수정 + 옛 버전 회수 가능.

**load-bearing 정책 변경**:

- 옛: `raw_text 불변` (capture 시점 원본 영구 보존)
- 새: `raw_text 가변` + `note_revisions 테이블` (옛 버전 모두 보존, rollback 가능)

이는 F1 / F4 / F17 / F19 의 raw_text 가정에 영향 — 모두 current latest raw_text 기준으로 동작 (시간 경과 시 정정된 값 사용).

---

## 2. 범위

| 항목 | 결정 |
|---|---|
| **F20** | C 옵션 — raw_text 수정 허용 + `note_revisions` 테이블 + 옛 버전 회수 UI. AI 재실행 input = current latest raw_text (B 옵션). |

---

## 3. Schema 마이그레이션 (m006)

> 메모: 본 스펙 작성 시점에는 m005 로 예상했으나 Cut B (v0.2.9) 에서 m005 (ai_disabled CHECK relax) 가 선점됨 → 실제 번호는 **m006**.

```sql
CREATE TABLE note_revisions (
  rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
  note_id TEXT NOT NULL,
  raw_text TEXT NOT NULL,
  edited_at TEXT NOT NULL,
  edited_by TEXT NOT NULL DEFAULT 'user',  -- 'user' or 'capture' (최초 캡처)
  FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);

CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC);

-- 기존 notes 의 모든 raw_text 를 첫 revision 으로 backfill
INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
  SELECT id, raw_text, created_at, 'capture' FROM notes;
```

`note_revisions.rev_id = AUTOINCREMENT` — chronological 순서 보장. `edited_by` = 'user' (사용자 정정) 또는 'capture' (최초).

`notes.raw_text` 컬럼 그대로 — current latest 값. 검색 인덱스 (F19 FTS5) 가 이걸 source 로 사용 → revision 검색 X (latest only). YAGNI.

---

## 4. NoteRepository 메서드

```ts
class NoteRepository {
  // 기존
  insert(input: ...): Note;  // 내부에서 note_revisions INSERT (edited_by='capture')

  // 신규
  updateRawText(id: string, newText: string, now: Date): void {
    const tx = this.db.transaction(() => {
      this.db.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`).run(newText, now.toISOString(), id);
      this.db.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) VALUES (?, ?, ?, 'user')`).run(id, newText, now.toISOString());
    });
    tx();
  }

  listRevisions(id: string): NoteRevision[] {
    return this.db.prepare(`SELECT * FROM note_revisions WHERE note_id=? ORDER BY edited_at DESC`).all(id) as NoteRevision[];
  }

  restoreRevision(id: string, revId: number, now: Date): void {
    const rev = this.db.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`).get(revId, id) as { raw_text: string } | undefined;
    if (!rev) throw new Error(`revision ${revId} not found`);
    this.updateRawText(id, rev.raw_text, now);  // 새 revision 으로 복원 (linear history 유지)
  }
}
```

`restoreRevision` 은 옛 raw_text 를 **새 revision** 으로 INSERT — chain 끊지 않고 latest = restored. timestamp/순서 명확.

---

## 5. UI — NoteCard 수정 흐름

### 5-1. raw_text 편집 UI

기존 NoteCard 의 "원문 보기" 펼침 → 추가 "편집" 버튼:

```tsx
{rawOpen && (
  <div>
    {editingRaw ? (
      <>
        <textarea value={draftRaw} onChange={e => setDraftRaw(e.target.value)} />
        <button onClick={onSaveRaw}>저장</button>
        <button onClick={() => setEditingRaw(false)}>취소</button>
      </>
    ) : (
      <>
        <pre>{local.rawText}</pre>
        <button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }}>편집</button>
        <button onClick={() => setShowRevisions(true)}>이력</button>
      </>
    )}
  </div>
)}
```

### 5-2. Revision 회수 UI

"이력" 클릭 → modal 또는 확장 panel:

```
이력 (3 buah)
[2026-05-12 14:30 사용자]  본문...           [회수]
[2026-05-10 09:15 사용자]  옛 본문...        [회수]
[2026-05-09 11:00 캡처]    최초 캡처 본문... [회수]
```

회수 클릭 → confirm dialog ("이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.") → `restoreRevision()` 호출.

---

## 6. AI 재실행 정책

**입력 = current notes.raw_text (latest)**. 옛 revision 은 AI 재실행 input X. 정책 일관 (사용자 정정 의도 반영).

`AiWorker` 의 input 추출 코드는 변경 없음 — `notes.raw_text` 그대로 사용.

---

## 7. F1 (Due Date) / F4 (Aha Moment) / F17 / F19 영향

| 영역 | 영향 |
|---|---|
| F1 Due Date 파서 | input = current raw_text. 사용자 정정 후 due 갱신 가능 — 정책 충실 (수정 시 의도 반영) |
| F4 Aha Moment | capture 카운트 = notes 갯수. revision 갯수 무관 |
| F17 status | 영향 X (raw_text 수정과 status 분기 독립) |
| F19 search FTS5 | 인덱스 source = notes.raw_text (latest). revision 검색 미지원. 향후 cut 에서 옵션 |

---

## 8. IPC + types

```ts
// 신규
'inbox:update-raw-text': (id: string, newText: string) => Promise<{ ok: true }>
'inbox:list-revisions': (id: string) => Promise<NoteRevision[]>
'inbox:restore-revision': (id: string, revId: number) => Promise<{ ok: true }>

interface NoteRevision {
  revId: number;
  noteId: string;
  rawText: string;
  editedAt: string;
  editedBy: 'user' | 'capture';
}
```

---

## 9. 테스트 전략

| 영역 | 단위 |
|---|---|
| m006 마이그레이션 | 기존 notes → revision backfill (edited_by='capture') |
| `updateRawText` | notes.raw_text 갱신 + 새 revision INSERT atomic |
| `listRevisions` | DESC 순 + edited_by 정확 |
| `restoreRevision` | 옛 raw_text 가 새 revision 으로 INSERT + notes.raw_text 갱신 |
| 편집 UI | textarea 입력 + 저장 → IPC 호출 + store 갱신 |
| 이력 modal | revision 목록 표시 + 회수 클릭 → confirm + IPC |
| AiWorker input | current notes.raw_text 사용 (revision X) 회귀 |

**목표**: 단위 548 → 약 567 (+19, m006 5 + create rev 1 + updateRawText 2 + listRevisions 1 + restoreRevision 2 + IPC 4 + NoteCard 편집 1 + RevisionHistoryModal 2 + findById 회귀 1), typecheck 0.

---

## 10. Risk

| Risk | 대응 |
|---|---|
| revision 무한 누적 (메모 1개당 100+ revision 시 DB bloat) | 향후 cut 에서 N개 cap 정책 (예: 최근 50개만 보존). 본 cut 은 unlimited |
| 사용자가 실수로 옛 revision 회수 | confirm dialog 강제 |
| F1 Due Date 가 raw_text 변경 시 재추출 안 함 | 별도 cut. 본 cut 은 raw_text 갱신 + 기존 due 잔류 (사용자 의도 보존) |
| 메모리 정책 갱신 필수 | `project_inkling_status.md` 의 load-bearing invariant 갱신 |

---

## 11. 메모리 정책 갱신 (Cut C 머지 후 필수)

`raw_text 불변` → `raw_text 가변 + revision 보존`. 메모 갱신:

```
- ~~raw_text 불변~~ → raw_text 가변 (사용자 편집 가능, note_revisions 테이블에 변경 이력 보존)
- AI 재실행 input = current latest raw_text (옛 revision X)
```
