@@ -412,6 +412,84 @@ than an `assertCan*` call — the case `Endpoint Authorization Rules` describes
412412- Usage belongs to ` QueryActorContextHolder ` first and the security principal second, so
413413 under ** View as** the spend is attributed to the target user, not the admin.
414414
415+ ## Pre-flight Migration Review
416+
417+ ` POST /migrations/analyze ` (` MigrationRiskController ` → ` MigrationRiskService ` →
418+ ` DatabaseDialect.migrationRisk() ` ) classifies a DDL statement's blast radius —
419+ verdict, locks (per table), whether it rewrites the table, a coarse duration bucket,
420+ and a safer alternative — before anyone runs it. It is ** deterministic by
421+ convention** , same as ` IndexAdvisorService ` and ` ExplainPlanService ` : the rule table
422+ and the target table's real size (` pg_class.reltuples ` / ` pg_total_relation_size ` )
423+ decide the verdict; nothing here asks an LLM to judge risk. An LLM may narrate the
424+ report in chat, but it never computes it.
425+
426+ - ** The Postgres rule table is engine-verified, not hand-derived.** A Testcontainers
427+ suite runs each rule's DDL against a real ` postgres:18 ` , reading
428+ ` pg_relation_filenode ` before/after to detect an actual rewrite and ` pg_locks ` to
429+ read the actual lock mode taken — not PostgreSQL documentation summarized from
430+ memory. If a rule disagrees with what the engine measured, the rule is wrong,
431+ full stop.
432+ - ** ` DEFAULT now() ` does NOT force a table rewrite — this is measured, not a bug.**
433+ ` now() ` is STABLE (it returns the same value for the whole transaction), and
434+ Postgres 11+ adds a column with a STABLE or constant default as a metadata-only
435+ operation. Only a VOLATILE default (` random() ` , ` gen_random_uuid() ` ,
436+ ` clock_timestamp() ` , ` uuid_generate_v4() ` ) forces a full rewrite, because a
437+ volatile function must be evaluated per row. Wrote this down after getting it
438+ wrong from memory twice before the Testcontainers run corrected it.
439+ - ** ` ADD FOREIGN KEY ` takes ` ShareRowExclusiveLock ` on the referenced table too** ,
440+ not just the table being altered — confirmed live: `ALTER TABLE child ADD
441+ CONSTRAINT fk FOREIGN KEY (t_id) REFERENCES t(id)` returns a ` locks` array with
442+ two entries, ` child ` and ` t ` . This is why ` MigrationRiskReport.locks ` is a
443+ per-table array rather than a single lock on the altered table — a statement can
444+ block writes on a table it never names, and that is the finding an operator is
445+ least likely to expect from reading the SQL alone.
446+ - ** The JSqlParser 5.2 shim exists because the library cannot parse the forms this
447+ tool exists to recommend.** JSqlParser has no grammar for ` NOT VALID ` or `CREATE
448+ INDEX CONCURRENTLY` — both fail to parse outright, which would make the analyzer
449+ unable to evaluate the very migration pattern (` ADD CONSTRAINT ... NOT VALID ` +
450+ ` VALIDATE CONSTRAINT ` , ` CREATE INDEX CONCURRENTLY ` ) it recommends as the safer
451+ alternative. ` DdlStatementParser ` pre-strips both into flags (` notValid ` ,
452+ ` concurrently ` ) before handing the rest to JSqlParser. Do not remove this step —
453+ removing it silently turns every NOT VALID / CONCURRENTLY statement into a parse
454+ failure, which fails closed (UNKNOWN) but defeats the point of the feature for
455+ exactly the statements it is meant to bless as safe.
456+ - ** Fail-closed, not best-effort.** Unparseable SQL, an ALTER with more than one
457+ clause (a single ` DdlFacts ` cannot honestly represent two clauses' worth of risk),
458+ an unrecognised default function DeepSQL cannot confirm the volatility of, and
459+ MySQL (no verified rule table exists yet — ` MySQLMigrationRiskProvider ` always
460+ returns ` UNKNOWN ` ) all report ` UNKNOWN ` or ` CAUTION ` rather than guessing ` SAFE ` .
461+ Verified live: ` {"sql":"not sql"} ` returns ` verdict: UNKNOWN ` , ` safeToRun: false ` .
462+ - ** Known limitation: ` ALTER COLUMN TYPE ` over-warns.** ` DdlFacts ` carries no old
463+ column type, only the new one, so the provider cannot tell a same-family widening
464+ (` varchar(50) ` → ` varchar(100) ` , which Postgres does NOT rewrite) from a genuine
465+ type change (which does). It reports the conservative rewrite verdict for both
466+ rather than risk a false SAFE.
467+ - ** The verification test ranks lock strength by an explicit ordering list, not
468+ ` max(mode) ` — the provider itself does not rank at all.**
469+ ` PostgresMigrationRiskProvider ` never computes a lock mode; each rule hardcodes
470+ the one it asserts (e.g. ` addForeignKey ` always reports ` ShareRowExclusiveLock ` ).
471+ The ranking lives only in ` PostgresMigrationRiskVerificationTest `
472+ (` LOCK_STRENGTH ` + ` strongestLock() ` ), which has to pick the strongest lock out
473+ of however many rows a query against the real ` pg_locks ` returns. It needs the
474+ ordering because Postgres lock mode names sort alphabetically in a way that has
475+ nothing to do with strength — ` "ShareLock".compareTo("AccessExclusiveLock") > 0 ` ,
476+ so a naive string-max would call ` ShareLock ` the stronger of the two, when
477+ ` AccessExclusiveLock ` is in fact the most exclusive mode Postgres has. Any future
478+ code that needs "the worst lock a query is holding" from ` pg_locks ` must rank
479+ against Postgres's real lock hierarchy, not compare mode names — but that need
480+ has not yet reached production code, only this test.
481+ - ** Authorization is asserted in the service, before parsing, credential
482+ decryption, or session opening** (` MigrationRiskService.analyze ` calls
483+ ` accessControlService.assertCanReadConnectionContent ` first). The controller is
484+ exempted from the static ` ConnectionScopedAuthorizationSafetyTest ` sweep
485+ (` AUTHORIZED_ELSEWHERE ` ) on that basis, same reasoning as
486+ ` DashboardWorkspaceController ` . Verified live, not just by the mocked unit test
487+ and the static scanner: a second user with zero grants on the target connection
488+ (confirmed empty in ` connection_access_grant ` ) received a genuine ` 403 ` from
489+ ` POST /migrations/analyze ` , with no stack trace in the logs — proof the
490+ controller's ` catch (ResponseStatusException e) { throw e; } ` before the
491+ catch-all is live and not swallowing the 403 into a 500.
492+
415493## Key Rules & Patterns
416494
417495### Backend Rules
0 commit comments