<a id="db-table-constraints"></a>
# PostgreSQL table-level constraint matrix

`db/schema.sql`의 fresh `CREATE TABLE` 블록에 있는 table-level clause 24개를 17개 table에서 전수 분리한다. Column-level PK/UNIQUE/FK/CHECK와 startup `ALTER` 제약은 이 분모 밖이며, 설치된 DB를 실행 관찰한 결과가 아니다. [DDL 근거](evidence:db-table-constraints)

## 판독 규칙

- Composite column은 DDL 순서를 그대로 쓴다. PK/UNIQUE는 PostgreSQL unique index를 만들므로 동시 충돌을 직렬화하지만, 애플리케이션이 `ON CONFLICT`를 쓰는지는 [production access matrix](access-matrix.md#db-access-matrix)에서 별도로 본다.
- PostgreSQL UNIQUE는 기본적으로 null끼리를 같은 값으로 보지 않는다. Nullable key를 포함한 `company_scope` 두 제약은 null row 전체를 하나로 제한하지 않는다.
- `ON DELETE`는 table-level clause가 아니라 관련 column FK의 효과를 함께 적었다. Composite invariant가 FK 대상 소속 일치까지 보장한다고 확대 해석하지 않는다.
- Fresh 제약과 legacy migration 결과가 다른 경우는 [migration 경계](functions-triggers.md#schema-migrations)가 권위다.

## 24/24 matrix

| table / clause | 정확한 column 순서·식 | 보장하는 invariant | delete·동시성·남는 경계 |
|---|---|---|---|
| <a id="constraint-company-scope"></a>[`company_scope`](access-matrix.md#access-company-scope) `uq_sv2_domain` | `UNIQUE (company_id, domain)` | 같은 company의 동일 non-null domain 중복 금지 | company delete는 row CASCADE; null domain은 여러 row 가능; 동시 insert는 unique index에서 충돌 |
| `company_scope` `uq_sv2_net` | `UNIQUE (company_id, net)` | 같은 company의 동일 non-null CIDR 중복 금지 | company delete CASCADE; null net은 여러 row 가능 |
| `company_scope` `ck_company_scope_payload` | kind별 `(domain, net, value)` XOR: domain은 domain만, ip/cidr은 net만, icp/keyword는 value만 non-null | kind와 payload column 조합 일치 | raw/reason 내용과 canonicalization은 보장하지 않음; startup이 drop/add하며 legacy row 전수 재검증 |
| <a id="constraint-exploration-nodes"></a>[`exploration_nodes`](access-matrix.md#access-exploration-nodes) `ck_node_kind` | `kind IN (begin,goal,intent,fact,finding,hint,digest)` | node kind allowlist | exploration delete CASCADE; conditional legacy replacement의 탐지 한계는 migration 참조 |
| `exploration_nodes` `ck_node_state` | kind별 state 조합 | 예: intent만 running/paused/done/blocked/exhausted/stopped/deleted, digest만 active/superseded | 상태 전이 순서 자체는 보장하지 않음; legacy row가 새 식을 위반하면 replacement 실패 |
| <a id="constraint-exploration-edges"></a>[`exploration_edges`](access-matrix.md#access-exploration-edges) primary key | `(exploration_id, src_id, rel, dst_id)` | 같은 exploration 안 동일 방향·relation edge 중복 금지 | exploration/src/dst delete CASCADE; src/dst가 같은 exploration 소속인지는 FK만으로 보장하지 않음 |
| `exploration_edges` `ck_edge_noself` | `src_id <> dst_id` | 동일 node self edge 금지 | 반대 방향 cycle과 장거리 cycle은 허용 |
| `exploration_edges` `ck_edge_rel` | `rel IN (spawns,derived_from,yields,proves,covers)` | relation allowlist | 동적 legacy replacement가 여러 old check 중 하나만 drop할 수 있음 |
| <a id="constraint-exploration-anchors"></a>[`exploration_anchors`](access-matrix.md#access-exploration-anchors) primary key | `(node_id, asset_id)` | 동일 node↔asset anchor 중복 금지 | node/asset delete 모두 CASCADE; node와 asset의 task/exploration 소속 일치는 별도 코드 책임 |
| <a id="constraint-main-sessions"></a>[`main_sessions`](access-matrix.md#access-main-sessions) primary key | `(exploration_id, seq)` | exploration별 segment sequence 중복 금지 | exploration delete CASCADE; `seq >= 1`이라는 설명은 DB CHECK로 보장하지 않음 |
| <a id="constraint-task-relations"></a>[`task_relations`](access-matrix.md#access-task-relations) primary key | `(task_id, source_task_id)` | 동일 direct inheritance edge 중복 금지 | 양쪽 task delete CASCADE; relation은 direct이며 재귀/cycle 금지는 보장하지 않음 |
| `task_relations` `ck_task_relation_not_self` | `task_id <> source_task_id` | 직접 self relation 금지 | 두 task 이상의 cycle은 허용 |
| <a id="constraint-task-asset-links"></a>[`task_asset_links`](access-matrix.md#access-task-asset-links) primary key | `(task_id, asset_id)` | task↔asset provenance row 하나 | task/asset delete CASCADE, source node delete SET NULL; competing insert는 PK 충돌/`ON CONFLICT` 경로로 조율 |
| <a id="constraint-task-llm-profiles"></a>[`task_llm_profiles`](access-matrix.md#access-task-llm-profiles) primary key | `(task_id, profile_id)` | 같은 profile을 task chain에 두 번 넣지 않음 | task/profile delete CASCADE |
| `task_llm_profiles` unique | `(task_id, position)` | task chain에서 position 하나당 row 하나 | 서로 다른 task는 같은 position 가능; 위치 연속성은 보장하지 않음 |
| <a id="constraint-agents"></a>[`agents`](access-matrix.md#access-agents) `agents_role_ck` | `role IN (goals,main,planner,worker,assistant)` | agent role allowlist | agent delete의 prompt/visibility 효과는 각 FK를 따름; trigger mode fresh/upgrade CHECK 차이는 별도 |
| <a id="constraint-agent-prompts"></a>[`agent_prompts`](access-matrix.md#access-agent-prompts) unique | `(agent_id, version)` | agent별 prompt version 중복 금지 | agent delete CASCADE; `agents.current_prompt_id`는 후속 FK로 SET NULL |
| <a id="constraint-agent-prompt-vars"></a>[`agent_prompt_vars`](access-matrix.md#access-agent-prompt-vars) unique | `(agent_id, var_name)` | agent별 variable 이름 중복 금지 | agent delete CASCADE |
| <a id="constraint-mcp-tools-cache"></a>[`mcp_tools_cache`](access-matrix.md#access-mcp-tools-cache) unique | `(server_id, tool_name)` | MCP server별 discovered tool 이름 중복 금지 | server delete CASCADE; discovery refresh의 최신성은 보장하지 않음 |
| <a id="constraint-agent-visibility"></a>[`agent_visibility`](access-matrix.md#access-agent-visibility) primary key | `(agent_id, resource_kind, resource_id, mcp_tool_name)` | agent별 MCP resource/tool visibility row 중복 금지 | agent delete CASCADE; `resource_id`에는 FK가 없어 target 삭제 무결성을 DB가 보장하지 않음 |
| <a id="constraint-agent-skill-visibility"></a>[`agent_skill_visibility`](access-matrix.md#access-agent-skill-visibility) primary key | `(agent_id, skill_name)` | agent별 Skill visibility row 중복 금지 | agent delete CASCADE; filesystem Skill 존재는 DB가 보장하지 않음 |
| <a id="constraint-finding-traffic-bindings"></a>[`finding_traffic_bindings`](access-matrix.md#access-finding-traffic-bindings) unique | `(finding_id, snapshot_id)` | 같은 finding에 같은 snapshot 중복 binding 금지 | finding delete CASCADE; snapshot delete는 기본 NO ACTION이라 참조 중 삭제를 거부 |
| <a id="constraint-side-question-sessions"></a>[`side_question_sessions`](access-matrix.md#access-side-question-sessions) check | `(conversation_id non-null AND task/exploration/intent null) OR (conversation_id null AND task/exploration non-null)` | standalone conversation와 task/exploration parent 형식을 배타적으로 선택 | intent는 task branch에서 optional이며 해당 exploration 소속임을 보장하지 않음; 참조 대상 delete는 CASCADE |
| <a id="constraint-side-question-requests"></a>[`side_question_requests`](access-matrix.md#access-side-question-requests) unique | `(session_key, generation, client_id)` | 한 session generation에서 client request idempotency key 중복 금지 | session delete CASCADE; running 1개 제한은 별도 partial unique index가 담당 |

## 의미 판정

| 분모 | C | A/U |
|---|---:|---:|
| fresh CREATE table-level clause | 24/24 | 0 |
| invariant·composite 순서·FK delete 해석 | 24/24 정적 해석 | runtime isolation, 실제 충돌 결과와 installed drift는 U |
| legacy와 동일한 설치 결과 | — | trigger mode/task action divergence, conditional replacement, repeated validation 때문에 A/U |
