-- Phase 0 — Workstream A2: foundation schema for the agentic system. -- -- ADDITIVE AND REVERSIBLE ONLY (CLAUDE.md guardrail #3): this migration adds -- new tables and new nullable columns alongside the existing CRM. It never -- drops, renames, or rewrites existing data. Its reversal is 0001_phase0_foundation.down.sql. -- -- Applied once at startup by backend/core_migrations.py, tracked in the -- schema_migrations ledger. Safe to leave in place; the canonical layer it -- creates starts EMPTY and is populated later by entity resolution (A4/B3). -- ============================================================================ -- 1. canonical_entities — the single, model-agnostic identity for an LP / -- organization / person. Both the classic contacts/lp_profiles model and the -- fundraising_* grid map INTO this; neither existing model is demoted. -- IDs are full-length (e.g. 'lp_' + uuid4 hex), NOT the 8-char truncated -- UUIDs used elsewhere in the CRM, so they are safe as the index/payload key. -- ============================================================================ CREATE TABLE IF NOT EXISTS canonical_entities ( id TEXT PRIMARY KEY, entity_kind TEXT NOT NULL, -- 'lp' | 'organization' | 'person' display_name TEXT NOT NULL, primary_email TEXT, -- Phase-0 LP/prospect fields (model-agnostic home): thesis_fit TEXT, segment TEXT, accreditation_status TEXT, -- free-text until counsel defines the vocabulary (guardrail #6) qp_status TEXT, warmth_score REAL, source TEXT, owner_id TEXT REFERENCES users(id), last_touch_at TEXT, notes TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), deleted_at TEXT -- soft-delete (never hard-delete; guardrail #3) ); CREATE INDEX IF NOT EXISTS idx_canonical_kind ON canonical_entities(entity_kind); CREATE INDEX IF NOT EXISTS idx_canonical_email ON canonical_entities(primary_email); CREATE INDEX IF NOT EXISTS idx_canonical_owner ON canonical_entities(owner_id); -- ============================================================================ -- 2. entity_links — resolution map. Every source row (a contacts row, a -- fundraising_investors row, etc.) and every email/name variant points at the -- canonical entity it resolves to. This is how name variants collapse to one id. -- ============================================================================ CREATE TABLE IF NOT EXISTS entity_links ( id TEXT PRIMARY KEY, canonical_id TEXT NOT NULL REFERENCES canonical_entities(id) ON DELETE CASCADE, source_model TEXT NOT NULL, -- contacts|organizations|lp_profiles|fundraising_investors|fundraising_contacts|email_address|alias source_id TEXT, -- the local PK in that model (NULL for a bare email/name alias) match_value TEXT, -- normalized email or name variant match_kind TEXT NOT NULL, -- exact_email|name_variant|domain|manual confidence REAL DEFAULT 1.0, created_at TEXT DEFAULT (datetime('now')), UNIQUE(source_model, source_id, match_value) ); CREATE INDEX IF NOT EXISTS idx_entity_links_canonical ON entity_links(canonical_id); CREATE INDEX IF NOT EXISTS idx_entity_links_match ON entity_links(match_value); CREATE INDEX IF NOT EXISTS idx_entity_links_source ON entity_links(source_model, source_id); -- ============================================================================ -- 3. interaction_log — APPEND-ONLY record of every agent action and every human -- touch (guardrail #5). Distinct from audit_log (which is mutation-diff-only -- and has no actor/agent dimension). Nothing in this table is ever updated or -- deleted by convention. -- ============================================================================ CREATE TABLE IF NOT EXISTS interaction_log ( id TEXT PRIMARY KEY, ts TEXT NOT NULL DEFAULT (datetime('now')), -- event time actor_type TEXT NOT NULL, -- human | agent | system actor_id TEXT, -- users.id, or an agent name (Scout/Analyst/...) action TEXT NOT NULL, -- e.g. note.created | email.matched | enrichment.written | search.run target_type TEXT, -- canonical_entity | contact | communication | opportunity | ... target_id TEXT, -- canonical_entities.id where possible payload TEXT, -- JSON blob with the action detail source TEXT, -- crm_ui | mcp | ingest | scout | ... created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_interaction_target ON interaction_log(target_type, target_id); CREATE INDEX IF NOT EXISTS idx_interaction_ts ON interaction_log(ts); CREATE INDEX IF NOT EXISTS idx_interaction_actor ON interaction_log(actor_type, actor_id); -- ============================================================================ -- 4. relationship_edges — derived graph of who-knows-whom between canonical -- entities. Starts EMPTY; seeded later from email_investor_links + calendar + -- X follower overlap (Analyst, Phase 2). -- ============================================================================ CREATE TABLE IF NOT EXISTS relationship_edges ( id TEXT PRIMARY KEY, src_id TEXT NOT NULL REFERENCES canonical_entities(id) ON DELETE CASCADE, dst_id TEXT NOT NULL REFERENCES canonical_entities(id) ON DELETE CASCADE, edge_type TEXT NOT NULL, -- email_corr | calendar | x_follow | intro | colleague source TEXT NOT NULL, -- provenance of this edge strength REAL DEFAULT 0, directed INTEGER DEFAULT 0, evidence TEXT, -- JSON supporting detail first_seen_at TEXT, last_seen_at TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(src_id, dst_id, edge_type, source) ); CREATE INDEX IF NOT EXISTS idx_rel_src ON relationship_edges(src_id); CREATE INDEX IF NOT EXISTS idx_rel_dst ON relationship_edges(dst_id); -- ============================================================================ -- 5. Soft-delete columns on existing tables. Additive nullable columns; the CRM -- currently HARD-deletes everywhere (guardrail #3 gap). Adding the column is -- safe now; switching the DELETE handlers to set it instead of hard-deleting -- is a separate, reviewed code change. -- ============================================================================ ALTER TABLE contacts ADD COLUMN deleted_at TEXT; ALTER TABLE organizations ADD COLUMN deleted_at TEXT; ALTER TABLE opportunities ADD COLUMN deleted_at TEXT; ALTER TABLE communications ADD COLUMN deleted_at TEXT; ALTER TABLE lp_profiles ADD COLUMN deleted_at TEXT;