ERD (확장) — 새로 생기는 테이블과 삭제 사슬
갱신일 2026-08-12 · 기준: 현재 판 ERD(
V1V24·00010006) + 확장 개요 이 문서가 갖는 것: 물리 스키마 — 새 테이블·칼럼·FK·제약·삭제 사슬·마이그레이션 순서. 여기 없는 것: 소유권(DA) · 파이프라인 구조(AA §3) · 그래프(온톨로지) · 화면(IA).
⚠︎ 이건 계획이지 현황이 아니다. 지금 서 있는 23개 테이블은 현재 판 ERD가 갖는다. 여기서는 바뀌는 것과 새로 생기는 것만 적는다. 공통 규칙은 그대로다 — PK는 VARCHAR(13) TSID · created_at·updated_at(TIMESTAMP(6) WITH TIME ZONE) · enum은 VARCHAR + CHECK.
1. 23 → 47
heymoa 21 → 45(신규 25 · 드랍 1), heymoa_ai 는 테이블 수 그대로 칼럼만 바뀐다(§8). 기존 테이블 중 스키마가 바뀌는 것은 넷이다.
| 테이블 | 무엇이 바뀌나 | 어디 |
|---|---|---|
transcript_segments | note_id NOT NULL · speaker_id 추가 — APP-233이 남긴 구멍 | §2 |
workspaces | organization_id NOT NULL | §7 |
workspace_members | role 에 GUEST 추가 | §7 |
meeting_items | kind 에서 DECISION·ACTION_ITEM 제거 · meeting_item_evidence 드랍 (맨 마지막 판에서) | §5 · §12 |
토큰·사용량 넷은 워크스페이스에 매달리고(§6), 조직 테이블 둘은 organizations 에 매달린다(§7). deletion_outbox·projection_outbox 는 FK가 하나도 없고, audit_events·agent_card_suppressions 는 노트를 안 가리켜서 이 그림에 없다(§9).
2. 화자 — 세그먼트의 빈칸을 메운다
speaker_label VARCHAR 를 세그먼트에 직접 넣으면 교정 한 번에 수백 행 UPDATE 이고 발화 시간을 매번 집계해야 한다. 화자를 행으로 만든다. S1에서 김민수가 화자 4 → 최유진 을 누르면 바뀌는 것은 speaker_identities 한 행이고, 최유진의 발화는 손대지 않는다.
meeting_speakers (note_id FK CASCADE, label 'SPEAKER_1..N', pipeline_run_id FK, speaking_ms,
segment_count, UNIQUE(note_id,label), UNIQUE(note_id,id))
speaker_identities (meeting_speaker_id FK CASCADE, user_id FK nullable, display_name nullable,
match_score nullable, status CHECK(CANDIDATE·CONFIRMED·REJECTED),
source CHECK(VOICE_PRINT·USER), confirmed_by_user_id FK, confirmed_at)
transcript_segments (+ note_id NOT NULL, + speaker_id nullable)
세그먼트에 note_id 를 같이 넣는 이유는 복합 FK 다. 세그먼트는 세션 소유, 화자는 노트 소유라 다른 노트의 화자를 가리켜도 DB가 못 막는다. UNIQUE (note_id, id) 를 두고 두 칼럼으로 참조하면 소속 불일치가 스키마에서 불가능해진다. 같은 판단을 V23 이 meeting_items.note_id 에서 이미 했다.
ALTER TABLE transcript_segments ADD CONSTRAINT fk_transcript_segments_speaker
FOREIGN KEY (note_id, speaker_id) REFERENCES meeting_speakers (note_id, id)
ON DELETE SET NULL (speaker_id);
SET NULL 의 칼럼 목록이 핵심이다. 목록이 없으면 note_id 까지 NULL로 밀어 NOT NULL 위반으로 삭제가 실패한다 — 화자 둘을 합치는 교정이 그 경로다. PostgreSQL 15부터 되고 우리는 18.4 다.
| 화면 동작 | DB에서 | 실패하면 |
|---|---|---|
| 화자에 사람 지정 (S1) | speaker_identities 1행 UPSERT | 다른 화자가 이미 그 사람으로 확정 → 부분 UK 위반 → "이미 이서연으로 지정된 화자가 있다" |
| 계정 없는 이름표 (S2) | user_id NULL · display_name='이정훈(외부)' · source='USER' | — |
| 화자 둘 합치기 | 세그먼트 UPDATE 후 화자 1행 DELETE | 위 SET NULL 이 없으면 여기서 터진다 |
| 한 발화만 다른 화자로 | 세그먼트 1행 UPDATE | ②단계 재실행이 덮는다. 교정 이력 칼럼을 안 뒀으므로 재실행 전에 확인을 받는다 |
화자 4 는 결함이 아니라 상태다. 최유진은 meeting_speakers 행만 있고 확정 신원이 없다 — 후보가 임계 미달이면 CANDIDATE 행이 점수와 함께 남는다.
3. voice_prints — 스키마부터 다르다
다른 스키마에 두는 것은 격리가 아니라 사고 방지다. heymoa 를 통째로 덤프하는 스크립트·백업·로컬 복제가 생체정보를 자동으로 딸고 가지 못한다.
biometric.voice_prints (organization_id FK CASCADE, user_id FK CASCADE, embedding BYTEA nullable,
embedding_dim, model_version, consented_at NOT NULL, deleted_at, key_id)
vector 타입을 쓰지 않는다. pgvector + HNSW 면 ANN이 되지만 암호화한 칼럼에는 인덱스를 못 건다. 둘 중 하나를 버려야 하는데 조직 하나의 voice print 수가 작아서 ANN이 필요 없다 — 등록 10만 · 옵트인 30%(추정)면 3만이고 조직으로 자르면 수백이다. 매칭 서비스가 조직 것을 통째로 읽어 코사인을 돌린다. 인덱스를 버리고 암호화를 남겼다.
삭제는 소프트 삭제가 아니다. S7에서 최유진이 지우면 embedding = NULL·key_id = NULL 로 바이트가 실제로 사라진다. 껍데기와 deleted_at 만 남기는 이유는 동의·철회 이력이 감사 대상이고 재등록 때 "전에 지웠다"를 알아야 하기 때문이다. 매칭 쿼리는 언제나 WHERE deleted_at IS NULL AND embedding IS NOT NULL 이고, 살아 있는 것 하나는 (organization_id, user_id) WHERE deleted_at IS NULL 부분 유니크가 지킨다. 과거 노트의 화자 라벨은 남는다 — voice print를 가리키는 FK가 없으니 삭제가 전파될 곳이 없다.
4. 오디오와 파이프라인
meeting_audio (note_id FK CASCADE UNIQUE, s3_key, upload_id, bytes, expires_at,
status CHECK(UPLOADING·COMPLETED·ABORTED·DELETED))
meeting_audio_parts (meeting_audio_id FK CASCADE, part_number CHECK(>=1), etag, bytes,
UNIQUE(meeting_audio_id, part_number))
pipeline_runs (note_id FK CASCADE, status CHECK(PENDING·RUNNING·SUCCEEDED·FAILED),
trigger CHECK(MEETING_ENDED·USER_RETRY))
pipeline_steps (run_id FK CASCADE, step CHECK(5종), attempts, interrupt_count, next_attempt_at,
status CHECK(PENDING·RUNNING·SUCCEEDED·FAILED·SKIPPED), last_error_code,
last_error_message, idempotency_key UNIQUE 'run_id-step', UNIQUE(run_id,step))
파트를 JSONB 배열로 두지 않았다. 태스크가 죽으면 다른 태스크가 이어받는데(CA §8) JSONB append는 read-modify-write라 둘이 겹치면 파트 하나가 조용히 사라지고, CompleteMultipartUpload 가 그 회의 녹음을 통째로 버린다. 행 하나 + UNIQUE 면 겹침이 예외로 뜬다.
pipeline_steps 는 run 마다 5행을 미리 만든다. 진행하며 행을 만들면 "②가 아직인가 실패인가"를 구분할 수 없다. 노트당 진행 중 run 하나는 uk_pipeline_runs_note_open ON pipeline_runs (note_id) WHERE status IN ('PENDING','RUNNING') 이 지킨다 — 현재 판 uk_transcription_sessions_note_open 과 같은 장치다.
attempts 와 interrupt_count 는 다른 축이다. 잡이 틀려서 죽은 것과 인프라가 태스크를 회수한 것을 한 칼럼에 세면 Spot 중단 세 번에 멀쩡한 잡이 FAILED 로 떨어진다. 규칙은 §11에 적는다.
S4를 이 표로 읽는다 — 10:45에 12건 동시 종료, 그중 한 건의 GPU 태스크가 Spot 중단.
| 무엇 | 행에서 | 화면 |
|---|---|---|
| ②가 중단 | interrupt_count 0→1 · attempts 그대로 · PENDING — 재큐는 SQS 즉시 반환이 한다(CA §7) | "누가 말했는지 정리 중" 유지 |
| 3회 실패 | ② FAILED → ③ SKIPPED | 화자 없이 요약이 나온다 |
| ④는 독립 | ②③ 과 무관 | 요약은 예정대로 |
| 콜백이 두 번 | idempotency_key 가 같고 이미 SUCCEEDED → no-op | — |
5. 정본 — decisions · action_items · evidence · agent_cards
meeting_items(kind) 로 계속 가지 않는 이유는 칼럼이 아니라 수명이다. meeting_items 는 analysis_id 에 매달려 재분석마다 새 행이 생긴다. 그래프 노드 키가 재분석마다 바뀌면 SUPERSEDES 사슬이 매번 끊기고, 사람이 확인해 둔 관계가 재분석 한 번에 무효가 된다 — 그 순간 "그래프는 파생"(D7)을 지킬 수 없다. 그래서 결정·액션 아이템만 note_id 소유로 올린다. OVERVIEW 는 잡 산출물이 맞으니 meeting_items 에 남는다.
버린 대안: meeting_items 에 assignee_id·due_at·confirmed_by 를 nullable로 더하기. 칼럼 절반이 kind에 따라 의미가 없어지고, "액션 아이템만 담당자 필수" 를 CHECK로 쓰면 조건이 kind 마다 갈린다.
decisions (note_id FK CASCADE, content, origin CHECK(AI·USER), confidence, stated_at_ms,
status CHECK(PROPOSED·CONFIRMED·REJECTED))
action_items (note_id FK CASCADE, derived_from_decision_id FK nullable SET NULL, content,
assignee_user_id FK nullable, due_at, status CHECK(위 3종 + DONE))
decision_relations (from_decision_id FK CASCADE, to_decision_id FK CASCADE, decided_by_user_id FK,
decided_at, type CHECK(SUPERSEDES·RELATES_TO), status CHECK(위 3종),
CHECK(from <> to), UNIQUE(from,to,type))
evidence (decision_id FK nullable CASCADE, action_item_id FK nullable CASCADE,
transcript_segment_id FK CASCADE, position CHECK(0..2))
agent_cards (note_id FK CASCADE, condition CHECK(C1·C2·C3·FACT·MENTION), triggered_at_ms,
strength CHECK(QUIET·HIGHLIGHT), related_decision_id FK nullable SET NULL,
status CHECK(SHOWN·RECALLED),
resolution CHECK(ACCEPTED·REJECTED·DEFERRED) nullable)
REJECTED 관계 행을 남기는 것이 decision_relations 의 절반이다. S1의 SUPERSEDES 후보를 김민수가 "아니다"로 닫으면 행이 사라지는 게 아니라 REJECTED 로 굳는다. 지우면 다음 분석이 같은 후보를 또 올린다. 순회는 WHERE status='CONFIRMED' 만 본다(온톨로지).
evidence 의 다형 참조는 FK를 유지한 채로 푼다 — CHECK (num_nonnulls(decision_id, action_item_id) = 1) 과 소유자별 부분 유니크((decision_id, position) WHERE decision_id IS NOT NULL) 둘. owner_type+owner_id 로 갔으면 agent_chat_approvals.chat_id 가 치른 대가(현재 판 §11 ④)를 한 번 더 치른다. meeting_item_evidence 와 달리 다대다다 — 한 세그먼트가 결정과 액션 아이템 양쪽의 근거가 된다. S3에서 MCP가 되짚는 경로(Decision → 2026-07-22 회의 31:07)가 이 테이블의 역방향이다.
노출 상태와 사용자 조작은 다른 칼럼이다. status 는 카드가 화면에 살아 있나(SHOWN)와 회의 후 요약이 회수했나(RECALLED)만 본다. 사용자가 누른 것은 resolution 이고, 아무도 안 누르면 NULL 이다 — NULL이 기본값이고 그 카드는 요약에 남는다(실시간 §6). DISMISSED 를 뺀 이유는 resolution='REJECTED' 와 같은 말이어서다. 한 칼럼으로 합치면 "기각한 카드를 요약이 회수했다"를 적을 자리가 없다.
resolution='REJECTED' 는 카드 한 장의 이력이다. 다음 회의에 안 뜨게 하는 것은 다른 테이블이 진다.
agent_card_suppressions (workspace_id FK CASCADE, decision_id FK CASCADE, window_embedding BYTEA,
created_by_user_id FK, created_at, expires_at NOT NULL,
UNIQUE(workspace_id, decision_id, created_by_user_id))
억제 스코프가 워크스페이스이고 만료가 90일이라 카드 행으로는 셋 다 표현이 안 된다 — 스코프도, 만료도, 다음 회의 전파도. note_id FK를 안 건다. 억제는 워크스페이스 소유이고 노트가 지워져도 살아야 한다(deletion_outbox·audit_events 와 같은 판단 · §9). window_embedding 을 vector 로 두지 않는 것은 §3과 같은 이유다 — 워크스페이스당 살아 있는 억제가 수십이라 ANN이 필요 없다.
agent_cards 를 전사와 한 테이블에 넣지 않는 이유는 셋이다.
transcript_segments | agent_cards | |
|---|---|---|
| 성격 | 사실 — 사람 목소리에서 나온 것 | 추론 — 모델이 만든 것 |
| 소유 | 세션 | 노트 — 세션이 끊겨도 카드는 산다 |
| 키 | UNIQUE(session, sequence) append-only | 순번 없음 · 취소·재생성 |
제일 큰 것은 첫 줄이다. 추론을 전사 테이블에 섞으면 요약·근거·MCP가 매번 "이 행이 사실인가"를 걸러야 하고, 한 번 빠뜨리면 에이전트의 추측이 회의록에 사실로 들어간다. 두 홉 만에 그렇게 되는 경로는 DA 가 그림으로 갖는다.
채팅 턴
도구 왕복이 1~2분이라 그 사이 끊긴 스트림을 이어 붙이려면 턴이 행이어야 한다. 상태값·재개 규약은 실시간 §8.2 가 정했고 컬럼은 여기가 진다.
agent_chat_turns (chat_id FK CASCADE, note_id FK CASCADE, status CHECK(6종), last_seq,
started_at, ended_at, abandon_reason)
agent_chat_turn_events (turn_id FK CASCADE, seq, type CHECK(5종), payload JSONB,
UNIQUE(turn_id, seq))
status 여섯은 PENDING·STREAMING·AWAITING_APPROVAL·SUCCEEDED·FAILED·ABANDONED, type 다섯은 tool_call_start·tool_approval_request·tool_approval_resolved·tool_call_result·message_end 다.
| 규칙 | 왜 |
|---|---|
token 이벤트는 안 넣는다 | 토큰마다 행이면 저장 비용이 답이 없다. 재개는 그 시점까지의 부분 텍스트 한 건으로 준다 |
| 채팅당 진행 중 턴 하나 | 부분 UK (chat_id) WHERE status IN ('PENDING','STREAMING','AWAITING_APPROVAL') — uk_transcription_sessions_note_open 과 같은 장치다 |
| 이벤트는 24시간 뒤 삭제 | 재개 창이 지나면 히스토리는 메시지 본문이 갖는다. 턴 행은 남는다 |
note_id 를 chat_id 와 같이 드는 이유는 삭제 사슬이다 — 노트를 지우면 채팅을 거치지 않고도 턴에 닿는다(§9).
6. 토큰 · 사용량
api_tokens (workspace_id FK, created_by_user_id FK, name, token_digest UNIQUE sha256,
prefix, expires_at, last_used_at, revoked_at)
api_token_scopes (api_token_id FK CASCADE, scope CHECK, UNIQUE(api_token_id, scope))
api_token_usage (api_token_id FK CASCADE, bucket_started_at, tool, call_count, error_count,
UNIQUE(api_token_id, bucket_started_at, tool))
usage_counters (workspace_id FK CASCADE, period DATE, metric CHECK, value BIGINT,
UNIQUE(workspace_id, period, metric))
| 결정 | 버린 대안 |
|---|---|
token_digest sha256 · 원문 미보관 | 평문·가역 암호화. workspace_invitations 가 이미 같은 모양 |
scope 를 별도 테이블 | TEXT[] 한 칼럼. CHECK 로 값 집합 강제가 배열 원소보다 단순하고 부여 시각이 감사에 필요 |
사용량을 분 버킷 UPSERT · usage_counters 집계 | 호출 1건 = 1행이면 기계 속도에 행이 폭발한다(원시 로그는 Loki) · 매번 SUM(transcription_sessions) 하면 월말 스캔이 커지고 결제 근거가 조회 성능에 매인다 |
S3의 박준호 토큰은 api_tokens(expires_at = 발급 +90일) 한 행 + api_token_scopes('READ_DECISIONS') 한 행이다. 워크스페이스 한정은 workspace_id NOT NULL로 끝난다 — 여러 워크스페이스를 걸치는 토큰을 만들 수 없다. metric 은 TRANSCRIPTION_MS·ANALYSIS_RUNS·GPU_SECONDS·MCP_CALLS 넷이고, 증가는 ON CONFLICT DO UPDATE SET value = usage_counters.value + excluded.value 한 줄이다.
7. organizations — 기능은 10만 구간, 테이블은 지금
voice print의 대조 범위가 organization 이라(D4), 1만 구간에 voice_prints.workspace_id 로 시작하면 조직이 생겨 워크스페이스 셋을 묶는 순간 임베딩을 재대조해야 한다. 생체정보를 다시 만지는 마이그레이션이다.
| 시점 | 무엇 | FK 변화 |
|---|---|---|
1만 (V25·V26) | organizations(id, name) · organization_members · 워크스페이스마다 1:1 자동 생성 · workspace_members.role 에 GUEST | 기존 테이블이 조직을 가리키는 것은 workspaces.organization_id·voice_prints.organization_id 둘뿐 |
1만 (V38) | organization_policies — 기능 플래그의 조직 축 | 없다 |
| 10만 | 결제·SSO 칼럼 추가 | 없다 — api_tokens·usage_counters 는 워크스페이스에 남고 조직 청구는 롤업이다. 감사는 audit_events 가 이미 있다(§9) |
organization_members (organization_id FK CASCADE, user_id FK, role CHECK(OWNER·ADMIN·BILLING),
UNIQUE(organization_id, user_id))
organization_policies (organization_id FK CASCADE, feature CHECK(5종), enabled,
updated_by_user_id FK, updated_at, UNIQUE(organization_id, feature))
feature 다섯은 voice_print_matching·realtime_agent·graph_projection·mcp_edge·summary_pii_names 다. 행이 없으면 켜진 것으로 읽는다 — 조직마다 5행을 미리 만들지 않는다. pipeline_steps 와 반대 판단인데, 저기는 "아직인가 실패인가"를 갈라야 했고 여기는 기본값이 하나뿐이다.
조직 역할은 워크스페이스 권한이 아니다. organization_members.role 셋 다 노트를 열 권한을 함의하지 않는다(§11). 워크스페이스 접근은 workspace_members 만 판정하고, 거기 붙는 GUEST 는 계정이 있고 워크스페이스 하나에만 초대된 외부 사용자다. S2의 이정훈(외부) 는 게스트가 아니라 speaker_identities.display_name 이다(§2) — 계정이 없다.
8. heymoa_ai — 칼럼만 바뀐다
| 무엇 | 변경 | 왜 |
|---|---|---|
transcript_embeddings 에 speaker_label·workspace_id | 신규 nullable | 화자 없이는 "김민수가 뭐라고 했나"가 검색되지 않는다 · 스코프 필터가 인덱스 앞에 온다 |
note_id 폭 VARCHAR(32) → VARCHAR(13) | 정정 | 현재 판 §11 ② |
analysis_results legacy 3칼럼 | 드랍 (APP-316) | 오래 미룬 것 |
| LangGraph checkpoint | 손대지 않는다 | 라이브러리 소유. 회의 중 에이전트는 무상태(D13)라 여기 안 쌓인다 |
재적재는 그대로 note_id DELETE 후 INSERT · advisory lock 이다. 화자 교정마다 재적재가 필요해지는데 교정은 ③ 뒤 ④ 앞에 몰려 있어 ④ 시작 시 한 번이면 된다.
9. 삭제 사슬 — S6에서 어디까지 가나
DB 밖 넷은 CASCADE가 못 간다. 노트 삭제 트랜잭션 안에서 deletion_outbox (target CHECK('S3','NEO4J','VECTOR','AI_CACHE'), resource_id, payload JSONB, attempts, completed_at, UNIQUE(target, resource_id)) 에 네 행을 INSERT 하고 워커가 지운다. AI_CACHE 는 heymoa_ai 의 analysis_results 와 그 노트에 딸린 LangGraph 체크포인트다 — 다른 database라 CASCADE가 애초에 닿지 않는다(§8). 이 테이블에만 note_id FK를 걸지 않는다 — 노트 행이 사라진 뒤에도 살아 있어야 하기 때문이다. S3 키처럼 부모가 갖고 있던 값은 payload 에 복사해 둔다. 넷 다 즉시 시도하고 실패하면 아웃박스가 재시도한다. 오디오의 최후 방어선은 7일 lifecycle(D15), 그래프·벡터·분석 캐시는 주간 스윕이 고아 noteId를 훑는다(DA). 버린 대안: 아웃박스 없이 스윕만. 전사 본문이 임베딩에 들어 있어 "지웠다"와 실제 삭제 사이가 한 주가 되고, 삭제 확인 화면이 그걸 사용자에게 말할 수 없다.
voice_prints 는 노트 사슬에서 끊기고 유저 사슬에는 CASCADE로 붙는다. FK 두 개의 유무가 소유자를 그대로 적는다 — 노트가 아니라 사람이다. 삭제 확인 화면의 문장이 여기서 나온다 — "목소리 정보는 지워지지 않는다. 계정 설정에서 따로 지운다." speaker_identities.user_id·action_items.assignee_user_id 에는 유저 CASCADE를 걸지 않는다. 현재 판 §11 ⑤ 와 같은 이유로 유저 삭제 흐름이 생길 때 한꺼번에 정한다.
사슬 밖 — 노트를 안 따라가는 다섯
| 테이블 | 왜 사슬 밖인가 | 그럼 무엇이 지우나 |
|---|---|---|
deletion_outbox | 노트가 사라진 뒤에 일해야 한다 | 완료 행 정리 주기는 §14 미확인 6 |
audit_events | 삭제 기록이 삭제되면 안 된다 | 아무것도. 보존 기간은 DA |
agent_card_suppressions | 억제는 워크스페이스 소유다. 노트를 지웠다고 틀린 카드가 다시 떠서는 안 된다 | expires_at 지난 행을 주간 스윕이 |
projection_outbox | 투영 대기열이지 노트 자식이 아니다 | 워커가 처리하고 지운다. 사라진 엔티티는 no-op |
biometric.voice_prints | 사람 소유다(S7) | 계정 설정 · 유저 CASCADE |
audit_events (actor_user_id FK, actor_token_id FK nullable, action CHECK(8종), target_type,
target_id, workspace_id, organization_id, occurred_at, payload JSONB)
projection_outbox (entity_type, entity_id, attempts, next_attempt_at, parked_at,
UNIQUE(entity_type, entity_id))
action 여덟은 VOICE_PRINT_CONSENTED·VOICE_PRINT_DELETED·SPEAKER_CONFIRMED·NOTE_DELETED·TOKEN_ISSUED·TOKEN_USED·POLICY_CHANGED·OPS_ACTION 이다. 주체를 (actor_user_id, actor_token_id) 쌍으로 드는 이유는 S3의 MCP 호출이 사람 한 명이 아니라 사람이 발급한 토큰의 행위이기 때문이다 — 토큰을 회수할 때 무엇을 되짚을지가 여기서 나온다. notes·workspaces FK를 안 건다. workspace_id·target_id 는 맨 문자열이다. FK를 걸면 NOTE_DELETED 기록이 노트와 함께 사라진다. UPDATE·DELETE는 role로 막는다(§11).
projection_outbox 는 삭제 전용인 deletion_outbox 와 별개 테이블이다. 투영은 최신 상태 하나만 필요하니 UNIQUE(entity_type, entity_id) 로 접고(같은 결정이 세 번 바뀌어도 행은 하나), 삭제는 대상마다 반드시 한 번씩 일어나야 하니 접으면 안 된다. payload 도 없다 — 워커가 Postgres를 다시 읽는 것이 그래프를 파생으로 두는 방식이다(온톨로지). parked_at 은 재시도 상한을 넘겨 손을 놓은 행이고, 되살리는 것은 운영 API 다(AA).
10. 인덱스 — 어떤 조회가 실제로 오나
| 조회 | 인덱스 | 없으면 |
|---|---|---|
| 노트 전사 타임라인 | (note_id, started_at_ms) on transcript_segments | 세션 조인 후 정렬 |
| 화자별 발화 | (speaker_id) on transcript_segments | 화자 삭제·병합이 순차 스캔 |
| 근거 역참조 — 이 발화가 무엇의 근거인가 | (transcript_segment_id) on evidence | S3의 MCP 조회가 전량 스캔. CASCADE도 여기를 탄다 |
| 근거 정참조 · 무엇이 이 결정을 뒤집었나 | (decision_id)·(action_item_id) on evidence · (to_decision_id) on decision_relations | 결정 하나 여는 데 스캔 · 역방향만 색인에서 빠지기 쉽다 |
| 워치독 스캔 | (next_attempt_at) WHERE status IN ('PENDING','RUNNING') | 1분마다 전체 스캔. 성공 행이 쌓일수록 나빠진다 |
| 매칭 대상 로드 | (organization_id) WHERE deleted_at IS NULL AND embedding IS NOT NULL | 조직 것만 읽는 쿼리가 전체를 읽는다 |
| 살아 있는 억제 | (workspace_id, expires_at) on agent_card_suppressions | 카드 게이트가 만료된 억제까지 훑는다. 부분 인덱스로는 못 쓴다 — now() 가 IMMUTABLE이 아니라 index predicate에 안 들어간다 |
| 아웃박스 폴링 | (next_attempt_at) WHERE parked_at IS NULL on projection_outbox | park 해 둔 행을 폴링마다 다시 집는다 |
| 감사 조회 | (organization_id, occurred_at DESC) on audit_events | 조직 감사 화면이 전량 스캔 |
| 사용량 집계 | UNIQUE (workspace_id, period, metric) 이 곧 조회 인덱스 | UPSERT와 조회가 같은 키다. 따로 안 만든다 |
부분 인덱스가 반복되는 이유는 현재 판과 같다 — 끝난 행은 색인 대상이 아니다. token_digest UK는 요청마다 타고, speaker_identities (note_id) WHERE status='CANDIDATE' 는 교정 화면이 노트 전체를 훑지 않게 한다.
11. 제약 — DB가 지키는 것과 앱이 지는 것
| 불변식 | 누가 | 장치 |
|---|---|---|
| 세그먼트의 화자는 같은 노트의 화자 | DB | 복합 FK (note_id, speaker_id) |
| 화자 하나에 확정 신원 하나 | DB | UNIQUE (meeting_speaker_id) WHERE status='CONFIRMED' |
| 신원 행은 사용자든 이름표든 하나는 있다 | DB | CHECK (num_nonnulls(user_id, display_name) >= 1) |
| 근거는 결정 아니면 액션 아이템 하나에만 · 항목당 3개 · 정렬 결정적 | DB | num_nonnulls = 1 + 부분 UK + CHECK 0..2 (V23과 같은 쌍) |
| 노트당 진행 중 파이프라인 하나 · 채팅당 진행 중 턴 하나 · 관계가 자기 자신을 못 가리킴 | DB | 부분 UK 둘 · CHECK (from <> to) |
| 동의 없는 voice print 없음 · 조직당 사용자당 하나 | DB | consented_at NOT NULL · 부분 UK |
| 감사 행은 고쳐 쓰지 못한다 | DB | 앱 role에 audit_events 의 UPDATE·DELETE를 안 준다. INSERT·SELECT만 |
| 단계 순서 ①→⑤ | 앱 | CHECK로 쓰면 건너뛰기(②③ SKIP)가 표현이 안 된다 |
인프라 중단은 attempts 를 안 올린다 | 앱 | Spot 중단은 interrupt_count 만 올린다. 중단 전용 상한은 AA 가 갖는다 |
| 조직 관리자 role은 노트 조회 권한을 함의하지 않는다 | 앱 | organization_members.role 은 결제·정책·감사까지다. 워크스페이스 멤버십이 없으면 노트를 못 연다 |
| 조직 경계를 넘지 않는 대조 · 매칭 임계값 | 앱 | WHERE organization_id = ? 뿐. 쿼리 하나만 빠뜨려도 새는 자리 — 매칭 서비스만 이 스키마를 읽는 이유 · 임계값은 튜닝 대상이라 DB에 박으면 조정마다 마이그레이션 |
| 근거 세그먼트가 같은 노트인가 · 토큰 scope의 의미 | 앱 | 다형 FK라 복합 FK가 안 붙는다(콜백 검증) · READ_* 는 게이트웨이 판정 |
heymoa_ai 참조 무결성 | 없음 | DB 경계. 현재 판 §11 ① 과 같다 |
조직 경계 항목이 제일 위험하다 — DB가 지켜 줄 방법이 없다.
12. 마이그레이션 순서 — 앞으로만 호환되게
현재 판 §13이 적어 둔 사실: server 에는 롤백 경로가 없다. ddl-auto: validate 라 파괴적 마이그레이션 뒤 옛 JAR은 기동조차 안 된다. V23 이 create와 drop을 한 파일에 넣을 수 있었던 것은 운영 사용자가 없다는 전제 위에서였고, 그 전제는 1만 구간에서 사라진다. 그래서 ai가 이미 쓰는 방식(0004→0005)을 server 에도 적용한다 — 확장 판(추가·nullable·백필, 옛 코드가 그대로 돈다) → 코드 배포 → 축소 판(NOT NULL 승격·드랍, 최소 한 배포 뒤).
| # | 무엇 | 판 |
|---|---|---|
V25 | organizations · organization_members + workspaces.organization_id nullable + 1:1 백필 + workspace_members.role 에 GUEST | 확장 |
V26 | organization_id NOT NULL + FK | 축소 |
V27 | schema biometric + voice_prints | 추가만 |
V28 | meeting_speakers · speaker_identities · transcript_segments.note_id nullable + 배치 백필 | 확장 |
V29 | note_id NOT NULL + speaker_id + 복합 FK | 축소 |
V30 | meeting_audio · meeting_audio_parts · deletion_outbox · projection_outbox | 추가만 |
V31 | pipeline_runs · pipeline_steps | 추가만 |
V32 | decisions · action_items · evidence + meeting_items 백필 | 확장 |
V33~V36 | decision_relations · agent_cards · 토큰 3 · usage_counters | 추가만 |
V37 | agent_chat_turns · agent_chat_turn_events | 추가만 |
V38 | audit_events · organization_policies · agent_card_suppressions | 추가만 |
V3x | meeting_items.kind 축소 · meeting_item_evidence 드랍 | 축소 · V32 보다 두 배포 뒤 |
V28/V29 가 유일하게 아픈 자리다. transcript_segments 는 1만 구간이면 월 220만 행씩 는다(회의당 400줄 추정 × 5,500건/월). 한 트랜잭션 UPDATE는 락이 걸린다. V28 은 5만 행씩 커밋하며 백필하고, V29 는 스캔 없이 올린다.
ALTER TABLE transcript_segments ADD CONSTRAINT ck_ts_note_id
CHECK (note_id IS NOT NULL) NOT VALID;
ALTER TABLE transcript_segments VALIDATE CONSTRAINT ck_ts_note_id;
ALTER TABLE transcript_segments ALTER COLUMN note_id SET NOT NULL;
NOT VALID → VALIDATE 는 ACCESS EXCLUSIVE 락을 잡지 않고, 검증된 CHECK가 있으면 SET NOT NULL 이 전체 스캔을 건너뛴다(PG 12+).
V32 백필이 되돌릴 수 없는 지점이다. meeting_items 의 두 kind를 옮기면서 analysis_id 소속을 버린다 — "이 결정이 3차 재분석에서 나왔다"가 사라진다. 잃어도 되는 이유는 결정의 정체성이 잡이 아니라 노트에 있기 때문이고, 그게 §5의 전부다.
V32 백필이 끝나면 Neo4j 초기 투영을 워크스페이스 단위로 오래된 것부터 돌린다. 전량을 한 번에 밀지 않는 이유는 투영이 끝난 워크스페이스부터 맥락이 살아나기 때문이다.
13. 설계 전제
- 불변식은 되도록 DB가 진다. 앱이 지는 것은 §11에 이름을 적는다. 적히지 않은 앱 규칙은 없는 규칙이다.
- 다형 참조에도 FK를 남긴다. nullable FK 둘 +
num_nonnullsCHECK.owner_type/owner_id는 쓰지 않는다. - 부모가 바뀌면 자식 전부를 UPDATE 하는 스키마를 만들지 않는다. 화자가 행인 이유가 이것 하나다.
- 생체정보는 스키마를 나누고, 인덱스보다 암호화를 남긴다. 파생 삭제는 아웃박스가 진다 — CASCADE가 못 가는 곳은 행으로 만들어 재시도한다.
- 기록과 억제는 사슬 밖에 둔다. 감사·억제에
note_idFK를 걸면 지워져야 할 것이 지워지고 남아야 할 것이 사라진다. - 파괴적 마이그레이션은 두 판으로 나눈다. 롤백이 없는 한 어기면 되돌릴 방법이 없다. 큰 테이블에는 락 잡는 DDL 대신
NOT VALID→VALIDATE→SET NOT NULL.
14. 미확인
| # | 무엇 | 무엇이 걸려 있나 |
|---|---|---|
| 1 | voice print 임베딩 차원·모델 | 모델을 올릴 때 전원 재등록인지 병행 대조인지 |
| 2 | 매칭 점수 임계값 | match_score 를 어디서 자르나. 틀린 이름 vs 이름 없음의 교환비 |
| 3 | 회의당 세그먼트 수 | V28 백필 크기와 인덱스 비용. 400줄은 추정이고 실측이 없다 |
| 4 | SUPERSEDES 후보 탐지 방식 | 벡터 유사도면 heymoa_ai 에 결정 임베딩 테이블이 하나 는다. 그래프 이웃이면 안 는다 |
| 5 | 재분석 시 결정 병합 판정 | §5는 "같은 행을 갱신"이라 했는데 같은 결정인지 판정하는 규칙이 없다. 없으면 중복이 쌓인다 |
| 6 | 화자 교정 후 ② 재실행 정책 · deletion_outbox 보존 | 교정 이력 칼럼을 두는 게 맞는지 · 완료 행을 언제 지우나 |
| 7 | organization 병합·이전 | 워크스페이스를 다른 조직으로 옮기면 voice print 대조 범위가 바뀐다. §7이 그 경우를 안 다뤘다 |
| 8 | audit_events 보존 기간과 파티셔닝 | TOKEN_USED 를 집계행으로 접어도 10만 구간이면 커진다. 월 파티션을 언제 도입하나 |
| 9 | window_embedding 비교 위치 | 억제 판정을 server가 하면 BYTEA를 매번 끌어와야 한다. heymoa_ai 로 옮기면 cross-database 규칙에 걸린다 |
| 10 | BLOCKS 정본 · 관계 확신도 | decision_relations.type 은 SUPERSEDES·RELATES_TO 둘뿐이고 origin·confidence 는 decisions 에만 있다. BLOCKS 를 type 에 더할지 액션 아이템 관계 테이블을 따로 둘지, action_items 에 origin·confidence 를 둘지(온톨로지 §10) |