<a id="db-sqlite"></a>
# Traffic SQLite와 파일 저장 계약

traffic recorder의 SQLite 일반 table 3개, 선택적 FTS5 virtual table 1개와 body/blob/file 경계를 설명한다.

각 객체의 exact production Go symbol, SQL verb와 query 조건은 [DB access matrix](access-matrix.md#access-sqlite)에서 역추적한다.

> **기준** · core `db/schema.sql`은 PostgreSQL 49개 table·최종 column 합집합 520개·명시 index 97개·function 4개·trigger 22개다. startup 보조 ledger `llm_records`와 `llm_usage`가 2개 table·31개 column·5개 index를 더해 선언 총계는 51개 table·551개 column·102개 index다. `Ensure*Table` 실패는 로그 후 계속되므로 보조 ledger 존재는 core startup 보장이 아니다. Traffic은 별도 SQLite 3개 일반 table·FTS5 virtual table 1개·index 6개다. 아래 DDL은 고정 source revision의 선언이며 실제 운영 DB 적용 상태는 관찰하지 않았다.

## 객체 색인

| 객체 | 저장소 | 목적 |
|---|---|---|
| [`exchanges`](#table-exchanges) | SQLite traffic index | 캡처한 HTTP exchange의 검색/목록 metadata를 저장한다. |
| [`exchange_bodies`](#table-exchange-bodies) | SQLite traffic index | 작은 request/response body inline 값 또는 큰 body blob 참조를 저장한다. |
| [`blob_refs`](#table-blob-refs) | SQLite traffic index | content-addressed body blob과 exchange 참조를 연결한다. |
| [`ex_fts`](#table-ex-fts) | SQLite FTS5 virtual table | body text의 contentless trigram 검색 index다. 적용 실패 시 기능이 비활성화될 수 있다. |

<a id="table-exchanges"></a>
## `exchanges`

캡처한 HTTP exchange의 검색/목록 metadata를 저장한다.

정의 원본은 `traffic/traffic.go`다. [등록 근거](evidence:traffic-schema)

```sql
CREATE TABLE IF NOT EXISTS exchanges (
  id           TEXT PRIMARY KEY,
  ts           INTEGER,
  host         TEXT,
  method       TEXT,
  url_template TEXT,
  url          TEXT,
  status       INTEGER,
  content_type TEXT,
  req_len      INTEGER,
  resp_len     INTEGER,
  path         TEXT
);
```

### 인덱스

- `CREATE INDEX IF NOT EXISTS idx_ex_host ON exchanges(host);`
- `CREATE INDEX IF NOT EXISTS idx_ex_tmpl ON exchanges(host, url_template);`
- `CREATE INDEX IF NOT EXISTS idx_ex_ts ON exchanges(ts);`
- `CREATE INDEX IF NOT EXISTS idx_ex_status ON exchanges(status);`
- `CREATE INDEX IF NOT EXISTS idx_ex_resp ON exchanges(resp_len);`

### 필드·접근 의미

| 필드 | null/default | 의미·encoding | 민감도 |
|---|---|---|---|
| `id TEXT` | schema상 null 허용 | ordinary rowid table의 non-`INTEGER` `TEXT PRIMARY KEY`라 implicit `NOT NULL`이 아니다; 앱 recorder는 항상 ID를 공급 | 일반 |
| `ts INTEGER` | null 허용 | `time.Now().Unix()`의 Unix seconds capture 시각 | 일반 |
| `host TEXT` | null 허용 | `URL.Hostname()` 결과라 port를 제외한 host | 민감 가능 |
| `method TEXT` | null 허용 | HTTP method | 일반 |
| `url_template TEXT` | null 허용 | query를 버린 `URL.EscapedPath()`에 `TemplatePath`를 적용해 numeric/UUID/긴 hex·token path segment를 치환한 template | 민감 가능 |
| `url TEXT` | null 허용 | 원 요청 URL | 민감/원문 |
| `status INTEGER` | null 허용 | 응답 status code | 일반 |
| `content_type TEXT` | null 허용 | 응답 Content-Type | 일반 |
| `req_len`, `resp_len INTEGER` | null 허용 | request/response byte 길이 | 일반 |
| `path TEXT` | null 허용 | 새 capture는 빈 값; non-empty 값은 legacy host/method tree의 상대 경로 | 민감 가능 |

`Traffic.record`가 같은 transaction에서 이 row와 body/blob reference/FTS row를 쓴다. public `Traffic.Search`는 host exact filter와 page만 제공한다. UI의 `Page`는 metadata·FTS filter를, agent tool이 호출하는 private `query`는 host/URL/body filter를 사용한다. `Get`, `Hosts`, `Count`와 deletion staging·GC도 읽고, `deleteWhere`가 관련 세 table과 FTS row를 함께 삭제한다. 다섯 index는 host/template/time/status/response-size filter와 정렬을 돕지만 capture마다 유지 비용을 낸다. FK가 없으므로 네 객체의 동일 ID 무결성은 이 transaction과 delete 순서가 유지한다. [traffic store](evidence:traffic-schema)

ID는 `<Unix-second>-<atomic sequence mod 10000>`다. Sequence는 process 재시작 시 0에서 다시 시작하고 한 초에 10,000건을 넘으면 돌기 때문에 same-second restart·고속 capture에서 collision이 가능하다. `INSERT OR REPLACE`는 collision 시 기존 `exchanges`/`exchange_bodies` row를 대체하지만 구 `blob_refs`와 구 FTS row를 선행 정리하지 않는다. 충돌 빈도와 잔존 reference/index 영향은 실행 검증 전 U다.


<a id="table-exchange-bodies"></a>
## `exchange_bodies`

작은 request/response body inline 값 또는 큰 body blob 참조를 저장한다.

정의 원본은 `traffic/traffic.go`다. [등록 근거](evidence:traffic-schema)

```sql
CREATE TABLE IF NOT EXISTS exchange_bodies (
  id        TEXT PRIMARY KEY,
  req_head  TEXT NOT NULL DEFAULT '',
  req_body  BLOB,
  req_blob  TEXT,
  resp_head TEXT NOT NULL DEFAULT '',
  resp_body BLOB,
  resp_blob TEXT
);
```

### 필드·접근 의미

| 필드 | null/default | 의미·encoding | 민감도 |
|---|---|---|---|
| `id TEXT` | schema상 null 허용 | ordinary rowid table의 `TEXT PRIMARY KEY`; 앱 writer는 `exchanges.id`를 공급해 논리 연결하지만 FK는 없음 | 일반 |
| `req_head`, `resp_head TEXT` | null 불가, `''` | 원 HTTP header text | 비밀/원문 |
| `req_body`, `resp_body BLOB` | null 허용 | 256 KiB 이하 body 전체 또는 큰 body의 8 KiB preview | 비밀/원문 |
| `req_blob`, `resp_blob TEXT` | null 허용 | 큰 body의 SHA-256 blob key | 민감 가능 |

`Traffic.record`가 `INSERT OR REPLACE`하고 `Get`이 `exchanges`와 join해 읽는다. blob file은 SQL transaction **전에** 기록되므로 SQL rollback만으로 filesystem write가 되돌아가지 않는다. `deleteWhere`는 body row를 exchange보다 먼저 지운다. body/header redaction은 이 storage layer에 없으며 문서화한 크기 경계도 실행 환경의 row 내용을 검증한 결과는 아니다. [traffic store](evidence:traffic-schema)


<a id="table-blob-refs"></a>
## `blob_refs`

content-addressed body blob과 exchange 참조를 연결한다.

정의 원본은 `traffic/traffic.go`다. [등록 근거](evidence:traffic-schema)

```sql
CREATE TABLE IF NOT EXISTS blob_refs (
  hash        TEXT NOT NULL,
  exchange_id TEXT NOT NULL,
  PRIMARY KEY (hash, exchange_id)
);
```

### 인덱스

- `CREATE INDEX IF NOT EXISTS idx_blob_refs_ex ON blob_refs(exchange_id);`

### 필드·접근 의미

| 필드 | null/default | 의미·encoding | 민감도 |
|---|---|---|---|
| `hash TEXT` | composite PK, null 불가 | `_blobs/sha256` content key | 민감 가능 |
| `exchange_id TEXT` | composite PK, null 불가 | 참조 exchange ID; FK는 없음 | 일반 |

`Traffic.record`가 body를 spill할 때 `INSERT OR IGNORE`하고, `gcBlobs`가 distinct hash를 읽어 filesystem sweep의 live set으로 쓴다. `deleteWhere`가 exchange별 reference를 지운다. `idx_blob_refs_ex`는 exchange 삭제 조회를 빠르게 하지만 hash별 GC는 composite PK의 leading `hash`를 사용한다. 자동 기간 보존은 없고 API의 host/all deletion과 task archive staging이 retention을 결정한다. [traffic store](evidence:traffic-schema)

<a id="table-ex-fts"></a>
## `ex_fts` virtual table

request/response 본문 검색을 위한 contentless FTS5 index다. 원문은 크기에 따라 `exchange_bodies` inline body 또는 `_blobs/sha256` CAS blob에 남고, FTS row는 `rowid`와 trigram index만 유지한다. [등록 근거](evidence:traffic-schema)

```sql
CREATE VIRTUAL TABLE IF NOT EXISTS ex_fts USING fts5(
  content, tokenize='trigram', content='', contentless_delete=1
);
```

### 적용과 fallback

`ftsSchema`는 일반 index schema와 별도로 적용된다. build/runtime SQLite가 FTS5 또는 해당 option을 지원하지 않으면 traffic store는 초기화 자체를 실패시키지 않고 FTS 기능을 비활성화한다. 따라서 선언상 virtual table이 있어도 실제 DB에 존재한다고 단정할 수 없다. 일반 table 3개와 명시 index 6개는 별도 경로다.

`content` 한 필드는 URL, request/response header 전체, text로 판정된 request body와 response body의 각각 최대 4 MiB를 합쳐 만든다. 두 body만 합쳐도 잠재적으로 약 8 MiB이며 URL/header는 별도다. contentless이므로 원문을 보관하지 않고 rowid로 `exchanges`에 논리 연결된다. UI `Page`의 broad/body filter와 agent tool private `query`의 `body_contains`가 3자 이상 검색에 이 index를 쓴다. public `Traffic.Search`는 FTS를 사용하지 않는 host-only 목록 API다. `deleteWhere`와 background merge/reclaim은 tombstone을 정리한다. 원문에 credential·cookie·token이 있으면 trigram에도 파생 정보가 남을 수 있다. FTS availability, 실제 index 크기와 merge 비용은 운영 파일 관찰 전에는 U다.

Archive export는 spilled body의 CAS blob 전체를 함께 복사한다. `traffic.json`의 inline body는 256 KiB 이하 body이면 전체이고, spill된 body이면 8 KiB preview(이진 body는 짧은 표시 문자열)다. Import는 blob을 복원한 뒤에도 FTS를 이 URL/header/inline 값으로만 재생성하므로, 원래 capture에서 8 KiB를 넘어 4 MiB까지 색인된 spilled text는 restore 후 8 KiB 밖이 재색인되지 않는다. body bytes 복원과 FTS 검색 커버리지는 다른 일관성 경계다.

## SQLite 의미 판정

| 축 | 결과 | 미확인 |
|---|---:|---|
| 일반 table/field | 3 table / 20 field (C) | runtime file drift |
| FTS virtual table/field | 1 / 1 선언(C), 실제 가용성 A | build의 FTS5 option과 `ftsSchema` 적용 결과 |
| 명시 index | 6/6 (C) | 실제 query plan·cardinality |
| writer/reader와 transaction 경계 | `Traffic.record`, query/get/delete/GC 경로 정적 확인(C/A) | crash injection과 동시 부하 |
| retention/role | API deletion·archive staging만 확인(A) | 운영 보존 기간, filesystem ACL·backup |

<a id="filesystem"></a>
## Filesystem 경계

`Traffic.Open`은 root 아래 `_index/index.sqlite`, `_blobs/sha256/...`, `_ca`를 사용한다. 현재 `Traffic.record`는 새 capture를 SQLite에 기록하고, 256 KiB를 넘는 body만 SHA-256 content-addressed blob으로 spill하며 8 KiB preview를 SQLite에 남긴다. 새 row의 `exchanges.path`는 빈 문자열이고 per-request `.http`/host-method tree를 만들지 않는다. non-empty `path`와 host/method tree는 이전 저장 형식의 조회·삭제·archive/recovery 호환 경계다. [traffic store](evidence:traffic-schema)

SQLite transaction은 index/body/blob reference를 묶지만 blob file write는 transaction을 열기 전에 일어난다. `spill`과 archive import는 final CAS path에 직접 write하고 temp+fsync+rename이나 destination hash 재검증을 하지 않는다. Crash/I/O로 partial file이 남으면 현재 capture는 빈 hash+8 KiB clip으로 commit할 수 있고, 뒤의 동일 body는 `os.Stat`만으로 그 path를 신뢰해 hash reference를 저장할 수 있다. 성공한 spill 뒤 SQL이 실패하면 orphan CAS file도 남는다. Blob directory 생성·file write 실패로 SQLite commit만 계속되면 원문이 비가역적으로 잘릴 수 있다. `index.sqlite`만 백업하면 큰 body가 복원되지 않는다. 현재 capture에 legacy host tree는 필요 없지만 non-empty `exchanges.path`가 남은 설치에서는 그 tree도 함께 보존해야 한다. HTTPS MITM이 proxy/protocol 원인으로 실패한 host는 이후 transparent tunnel로 fail-open하며 그 트래픽은 기록되지 않는다.

### 초기화·commit·recovery matrix

| 경로 | 순서·보장 | 실패/운영 경계 |
|---|---|---|
| SQLite open | DSN과 pinned init connection에 WAL, 5 s busy timeout 설정 | index path에 `?`가 있으면 bare DSN fallback이므로 busy timeout은 init connection에만 적용되고 pool의 후속 connection에는 보장되지 않음 |
| Auto-vacuum | `sqlite_master` table 0개인 최초 DB에서만 `auto_vacuum=incremental`+`VACUUM` | 기존 mode 0 DB는 ordinary delete로 file이 줄지 않을 수 있고, `DeleteAll` 후 full compaction이 conversion path |
| Capture | body가 256 KiB를 넘으면 final CAS path에 직접 write → SQLite metadata/body/ref/FTS transaction | temp+fsync+rename/hash 재검증이 없어 partial destination을 다음 동일-body capture가 `os.Stat`만으로 신뢰할 수 있음; CAS 성공 뒤 SQLite 실패는 orphan, write 실패 뒤 DB 성공은 빈 hash+8 KiB clip과 비가역 truncation을 남김 |
| Archive export/import | small body 전체 또는 spilled body 8 KiB preview+CAS blob을 package화; import는 `snapshot.Blobs` 각 entry의 형식과 incoming bytes SHA-256을 검증/copy한 뒤 SQLite transaction | destination CAS path가 이미 있으면 기존 file은 overwrite·재검증하지 않음. `traffic.json`의 `ReqBlob`/`RespBlob`은 `snapshot.Blobs` membership·hash format·destination 존재를 교차 검증하지 않아 내부 불일치 archive가 missing/invalid reference를 commit할 수 있고, outer package checksum도 이 cross-reference invariant를 보장하지 않음. DB 실패 또는 duplicate-only restore는 선행 copied blob을 orphan으로 남길 수 있으며 FTS는 inline 값으로만 재생성 |
| Host/task delete | legacy tree를 same-filesystem rename으로 stage → SQLite delete; commit 실패면 tree restore, commit 후 unlink/GC/reclaim | unlink/GC/reclaim은 best-effort라 committed row delete 후 file garbage가 남을 수 있음 |
| Task archive | PostgreSQL archive/compaction commit → staged SQLite delete commit | 두 commit 사이 crash gap은 persistent journal과 startup `RecoverHostDeleteStages`가 PG state를 조회해 complete/restore; atomic cross-store transaction은 아님 |
| Requested modes | traffic root/index/CAS/CA directory `0755`, CAS blob `0644`; archive blob dir/staging `0700`, archive/journal file `0600` | umask, inherited ACL, container volume/mount option이 effective permission을 바꿀 수 있음 |
