| """Migration safety chip — the shared-database constraint, enforced. |
| |
| Staging and prod share one PostgreSQL instance (belief |
| `belief_constraint_shared_db`, confidence 1.0). This chip classifies |
| every piece of SQL in the request as additive or destructive, checks |
| named tables against the actual schema (live or snapshot), and produces |
| the migration_report the discipline gate blocks on. |
| |
| Classification is code, not model judgment. The model can be talked out |
| of a rule; a regex cannot. |
| """ |
|
|
| import re |
|
|
| from kintsugi_core import ( |
| BaseSkillChip, |
| EFEWeights, |
| SkillCapability, |
| SkillContext, |
| SkillDomain, |
| SkillRequest, |
| SkillResponse, |
| ) |
|
|
| DESTRUCTIVE_SQL = [ |
| (r"\bDROP\s+(TABLE|COLUMN|INDEX|CONSTRAINT|SCHEMA|DATABASE)\b", |
| "DROP statement"), |
| (r"\bALTER\s+TABLE\s+[\w\".]+\s+DROP\b", "ALTER TABLE ... DROP"), |
| (r"\bALTER\s+TABLE\s+[\w\".]+\s+(ALTER|MODIFY)\s+COLUMN\s+\w+\s+(SET\s+DATA\s+)?TYPE\b", |
| "in-place column type change (needs multi-step expand/contract)"), |
| (r"\bALTER\s+(TABLE\s+[\w\".]+\s+)?RENAME\b", |
| "RENAME (old code breaks against the shared DB)"), |
| (r"\bTRUNCATE\b", "TRUNCATE"), |
| (r"\bDELETE\s+FROM\s+[\w\".]+\s*(;|$)", "unqualified DELETE"), |
| (r"\bALTER\s+TABLE\s+[\w\".]+\s+ADD\s+(COLUMN\s+)?\w+[^;]*\bNOT\s+NULL\b(?![^;]*DEFAULT)", |
| "NOT NULL column without DEFAULT (breaks old code's inserts)"), |
| ] |
|
|
| ADDITIVE_SQL = [ |
| r"\bCREATE\s+TABLE\b", |
| r"\bCREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY\b", |
| r"\bALTER\s+TABLE\s+[\w\".]+\s+ADD\s+(COLUMN\s+)?", |
| r"\bCREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|VIEW)\b", |
| ] |
|
|
| SQL_HINT = re.compile( |
| r"\b(CREATE|ALTER|DROP|TRUNCATE|DELETE|INSERT|UPDATE|SELECT)\b", re.I, |
| ) |
| TABLE_REF = re.compile( |
| r"\b(?:TABLE|FROM|INTO|UPDATE)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?" |
| r"(?:public\.)?([a-z_][\w]*)", re.I, |
| ) |
|
|
|
|
| def extract_sql(text: str) -> list: |
| """SQL from fenced code blocks, plus bare statements outside them. |
| |
| Prose that *describes* destructive SQL should not block; copy-pastable |
| SQL should. Fenced blocks are always inspected; outside fences only |
| lines that parse as statements count. |
| """ |
| chunks = [] |
| fenced = re.findall(r"```(?:\w*)\n(.*?)```", text, re.DOTALL) |
| for block in fenced: |
| if SQL_HINT.search(block): |
| chunks.append(block) |
| remainder = re.sub(r"```(?:\w*)\n.*?```", "", text, flags=re.DOTALL) |
| for line in remainder.splitlines(): |
| stripped = line.strip() |
| if re.match( |
| r"^(CREATE|ALTER|DROP|TRUNCATE|DELETE|INSERT|UPDATE)\s", stripped, re.I |
| ) and (stripped.endswith(";") or len(stripped.split()) >= 3): |
| chunks.append(stripped) |
| return chunks |
|
|
|
|
| def scan_destructive_anywhere(text: str) -> list: |
| """Scan the FULL text for destructive SQL patterns — including inline |
| code spans and prose. Returns a list of (match_text, label) tuples. |
| |
| This catches what extract_sql misses: decoded payloads, inline backtick |
| spans like `DROP COLUMN x`, and explanatory prose that still contains |
| copy-pastable destructive fragments. |
| """ |
| hits = [] |
| for pattern, label in DESTRUCTIVE_SQL: |
| for m in re.finditer(pattern, text, re.IGNORECASE): |
| hits.append((m.group(), label)) |
| return hits |
|
|
|
|
| def classify_sql(sql: str) -> dict: |
| violations = [] |
| for pattern, label in DESTRUCTIVE_SQL: |
| if re.search(pattern, sql, re.IGNORECASE): |
| violations.append(label) |
| additive = any(re.search(p, sql, re.IGNORECASE) for p in ADDITIVE_SQL) |
| return { |
| "sql": sql[:500], |
| "destructive": bool(violations), |
| "violations": violations, |
| "additive": additive and not violations, |
| "tables": list(dict.fromkeys( |
| t.lower() for t in TABLE_REF.findall(sql) |
| )), |
| } |
|
|
|
|
| class MigrationSafetyChip(BaseSkillChip): |
| name = "migration_safety" |
| description = "Classify SQL against the shared-db constraint and schema" |
| version = "2.0.0" |
| domain = SkillDomain.OPERATIONS |
| efe_weights = EFEWeights( |
| mission_alignment=0.15, stakeholder_benefit=0.35, |
| resource_efficiency=0.10, transparency=0.25, equity=0.15, |
| ) |
| capabilities = [SkillCapability.READ_DATA] |
| consensus_actions = ["destructive_migration"] |
|
|
| def __init__(self, schema_tools=None): |
| super().__init__() |
| self.schema_tools = schema_tools |
|
|
| async def handle(self, request: SkillRequest, |
| context: SkillContext) -> SkillResponse: |
| question = context.metadata.get("question", request.raw_input) |
| session = context.metadata.get("session") |
|
|
| statements = [classify_sql(s) for s in extract_sql(question)] |
| destructive = [s for s in statements if s["destructive"]] |
|
|
| schema_check = {"source": "none", "known_tables": [], |
| "unknown_tables": [], "migration_status": ""} |
| if self.schema_tools is not None: |
| info = self.schema_tools.inspect() |
| schema_check["source"] = info.source |
| schema_check["migration_status"] = info.migration_status |
| if info.tables: |
| mentioned = {t for s in statements for t in s["tables"]} |
| known = set(info.tables) |
| schema_check["known_tables"] = sorted(mentioned & known) |
| schema_check["unknown_tables"] = sorted(mentioned - known) |
| if session and info.source != "none": |
| session.record_evidence( |
| "schema", f"{info.source}:{info.table_count} tables", |
| self.name, |
| ) |
|
|
| report = { |
| "sql_found": bool(statements), |
| "statements": statements, |
| "destructive_count": len(destructive), |
| "schema_check": schema_check, |
| "shared_db_rule": ( |
| "Staging and prod share one PostgreSQL instance. Migrations " |
| "must be additive, backward-compatible, and reversible." |
| ), |
| } |
| summary = ( |
| f"{len(statements)} SQL statement(s), " |
| f"{len(destructive)} destructive; schema source: " |
| f"{schema_check['source']}" |
| ) |
| return SkillResponse( |
| content=summary, success=True, data=report, |
| requires_consensus=bool(destructive), |
| consensus_action="destructive_migration" if destructive else None, |
| ) |
|
|