Skip to content

Feat/preflight migration review - #100

Open
notSumit25 wants to merge 17 commits into
mainfrom
feat/preflight-migration-review
Open

Feat/preflight migration review#100
notSumit25 wants to merge 17 commits into
mainfrom
feat/preflight-migration-review

Conversation

@notSumit25

Copy link
Copy Markdown
Collaborator

Pre-flight Migration Review (DDL safety)

Deterministic Postgres DDL risk analyzer, exposed as one MCP tool
(analyze_migration) and a deepsql migration analyze CLI command.
Answers "is this ALTER safe to run?" before it runs — no new UI.

Why deterministic

Follows the convention already in this codebase: the database (or arithmetic
over its catalog) decides the verdict, the LLM narrates. Same split as
IndexAdvisorService (hypopg + pg_class) and ExplainPlanService (Postgres
produces the plan, the model interprets it).

Verified in practice: asked the same question, DeepSQL Agent answered from
recall that DEFAULT gen_random_uuid() "does not rewrite the whole table."
It does. That's the failure this replaces.

Verification

Rules are proven against a real PostgreSQL 18 via Testcontainers —
pg_relation_filenode() for rewrites, pg_locks for lock modes. A rule that
disagrees with the engine means the rule is wrong. Confirmed non-vacuous: a
constant-returning classify() fails 4-5 of 9 cases.

Live-verified over HTTP: SAFE/DANGER verdicts, FK locking both tables,
fail-closed on garbage, and a real 403 for an unauthorized user.

Findings worth knowing

  • now() is STABLE, so DEFAULT now() does not rewrite. Volatile
    defaults (random(), gen_random_uuid(), clock_timestamp()) do.
  • ADD FOREIGN KEY takes ShareRowExclusiveLock on the referenced table
    too — hence locks is a per-table array.
  • JSqlParser 5.2 cannot parse NOT VALID or CREATE INDEX CONCURRENTLY
    the very forms this tool recommends. A pre-parse shim strips them into flags.

Fail closed

Unparseable SQL, multi-statement SQL, multi-clause ALTERs, unrecognised default
functions, DROP CONSTRAINT, and MySQL all yield UNKNOWN/CAUTION — never SAFE.

Known limits

  • ALTER COLUMN TYPE over-warns: same-family widening
    (varchar(50)varchar(100)) is reported as a rewrite when it isn't.
    DdlFacts carries no old type. Documented and covered by a test that asserts
    both engine truth and the rule's claim.
  • Postgres only. MySQL returns UNKNOWN rather than guessing.
  • Volatility uses hardcoded sets; the live pg_proc.provolatile lookup is
    follow-up work. Unrecognised functions yield CAUTION, so it fails safe.

Tests

55 Java (incl. Testcontainers engine verification), 291 JS,
ConnectionScopedAuthorizationSafetyTest green.

Every ruling made during implementation is recorded in
docs/superpowers/specs/2026-09-04-preflight-migration-review-decisions.md.

New

image ### Old image

notSumit25 and others added 16 commits September 4, 2026 22:57
Deterministic DDL risk analyzer exposed as an MCP tool, following the
codebase convention that the database decides verdicts and the LLM narrates.

Rule table measured against a live Postgres 18.4 via pg_locks and
pg_relation_filenode rather than recalled — which corrected the assumption
that DEFAULT now() forces a rewrite (now() is STABLE, not VOLATILE).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADD FOREIGN KEY takes ShareRowExclusiveLock on the referenced table as
well as the altered one — a statement can block writes on a table it does
not name. Output shape now reports locks per table rather than as a single
field.

NOT VALID surfaced as the safer path for both constraint types: same lock,
skips the validating scan, with VALIDATE CONSTRAINT running under
ShareUpdateExclusiveLock afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six TDD tasks. Records the JSqlParser 5.2 finding that NOT VALID and
CREATE INDEX CONCURRENTLY fail to parse — the safe forms this tool
recommends — and the verified normalization shim that fixes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…isclassification

- fromAlter refuses (Optional.empty()) when an ALTER TABLE has more than one
  clause, since a single DdlFacts cannot honestly represent two clauses and
  judging on the first alone let a table-rewriting clause hide behind a
  harmless one.
- ADD_CHECK is now detected structurally via AlterExpression.getIndex() != null
  instead of a rawSql.contains("CHECK") substring match, which misclassified
  ordinary columns like check_flag as ADD_CHECK.
- Added tests locking in that referencedTable is never populated outside the
  ADD_FOREIGN_KEY branch, plus DROP_COLUMN/RENAME_COLUMN coverage.
…fault functions

- addColumn now reports CAUTION (not SAFE) when a default function is
  neither a known-volatile nor known-stable function, since volatility
  cannot be verified without a live pg_proc lookup
- add STABLE_FALLBACK set and isStable helper
- reword alterType's reason text to not claim a check the code can't perform
- add tests for the new CAUTION branch, a constant-default SAFE case, and
  the five previously untested branches (RENAME_COLUMN, ALTER_COLUMN_TYPE,
  SET_NOT_NULL at both size thresholds, ADD_CHECK both notValid states,
  VALIDATE_CONSTRAINT)
Testcontainers-driven verification of PostgresMigrationRiskProvider against
postgres:18: rewrite behavior via pg_relation_filenode(), FK lock strength via
pg_locks, NOT NULL-without-default failure, and function volatility via
pg_proc. jtest.sh gains a Docker socket mount + TESTCONTAINERS_HOST_OVERRIDE
so the containerized Maven run can start a sibling Postgres container, and
drops -o since the new testcontainers deps were not yet cached locally.

Also documents (via a new test, not a rule-table change) a known limitation:
DdlFacts carries only the new column type, never the old one, so alterType
cannot tell a same-family widening ALTER (e.g. varchar(50)->varchar(100),
which does not rewrite) apart from a genuine rewrite, and conservatively
reports rewritesTable=true for both. Confirmed against real Postgres 18;
left unfixed since a correct fix needs schema-aware facts, out of scope here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…legate via safety-test exemption

MigrationRiskService.analyze already asserts assertCanReadConnectionContent before any
work. Duplicating it in the controller was the same mechanism with the same failure mode,
not defence-in-depth. Registers the controller in AUTHORIZED_ELSEWHERE and the service in
DELEGATED_CHECKS so everyDelegatedCheckStillExists fails the build if the service-layer
assert is ever removed.
Exposes Task 4's POST /migrations/analyze as an MCP tool
(analyze_migration) and a `deepsql migration analyze` CLI command, per
the MCP & CLI Release Rules: tool definition + handler + summarizer +
dispatch case, CLI dispatcher + help text + drift-guard registration,
SKILL_BODY.md/CLAUDE.md/README.md tool tables and counts, and a minor
version bump (0.28.0 -> 0.29.0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ring

Fixes four review findings, all on failure paths: the Long.MAX_VALUE
tableRows sentinel no longer renders as a huge fabricated number (both
surfaces now say "table size unknown - treated as large"); the CLI's
UNKNOWN path no longer prints "null" for an unparseable statement, now
mirroring the MCP summarizer's existing handling; DANGER/FAILS verdicts
get a plain-text "X " marker and CAUTION a "! " marker, following
growth.js's existing severity-marker convention; and migration.js now
wraps ApiError into friendly 403/404 messages like every sibling command.

Also fixes a separate bug found while adding tests: buildOpts() in
cli.js never wired --sql into opts at all, so the command was
non-functional from the real CLI entry point regardless of the above.

Adds unit tests for analyze_migration on both surfaces, covering the
multi-lock FK case, the null-table UNKNOWN case, and the row-count
sentinel (exact and JS-float-imprecise values).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stand

Three classification bugs in DdlStatementParser shared one root cause:
branches assumed rather than verified what the parse tree contained.

- Multi-statement SQL: parse() kept only the first statement and silently
  discarded the rest, so a hidden DROP TABLE riding after a harmless ALTER
  reported SAFE while Postgres executed both. Switch to parseStatements()
  and require exactly one statement, matching the existing multi-clause guard.
- DROP CONSTRAINT shared operation=DROP with DROP COLUMN and was reported as
  DROP_COLUMN (wrong operation, false "VACUUM reclaims it" reason, and locks
  missing the referenced table). Gate DROP_COLUMN on getColumnName() actually
  being set; anything else falls through to UNKNOWN.
- ALTER COLUMN ... DROP NOT NULL fell into the same weak ALTER branch as a
  real type change and was reported as ALTER_COLUMN_TYPE with rewritesTable=
  true, though it is metadata-only. No existing rule models it correctly
  (SET_NOT_NULL's rule is specific to adding the constraint), so it now
  returns UNKNOWN rather than a confident wrong answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every ruling made during subagent-driven execution, the deferred minors,
and the final whole-branch review verdict. Kept in-repo because the
rulings explain why several rules deliberately fail closed rather than
answer, which is otherwise invisible from the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CodeQL flagged NOT_VALID's regex (\s+NOT\s+VALID\s*;?\s*$ via find()) as
polynomial: the leading \s+ retries the match at every whitespace position
in the input, so 80KB of padding stalled a request thread for 35s. sql
arrives from POST /migrations/analyze, so it is attacker-controlled.

The NOT VALID clause can only ever be a bounded trailing tail, so replace
the regex with an index-based scan (notValidStart) that walks backward from
the end of the string instead of re-scanning the attacker-controlled prefix.
Verified equivalent to the old regex's behavior on all prior cases (case
insensitivity, multiple internal spaces, trailing semicolons, and the
existing negative cases including the trailing-comment non-match).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants